better structure
This commit is contained in:
2
config/quickshell/Assets/Config/.gitignore
vendored
Normal file
2
config/quickshell/Assets/Config/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# some sensitive files
|
||||
GeoInfoToken.txt
|
||||
1
config/quickshell/Assets/Config/LyricsOffset.txt
Normal file
1
config/quickshell/Assets/Config/LyricsOffset.txt
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
8
config/quickshell/Assets/Config/Settings.json
Normal file
8
config/quickshell/Assets/Config/Settings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"location": "Munich",
|
||||
"notifications": {
|
||||
"doNotDisturb": false
|
||||
},
|
||||
"primaryColor": "#89b4fa",
|
||||
"showLyricsBar": false
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
Tabler Licenses - Detailed Usage Rights and Guidelines
|
||||
|
||||
This is a legal agreement between you, the Purchaser, and Tabler. Purchasing or downloading of any Tabler product (Tabler Admin Template, Tabler Icons, Tabler Emails, Tabler Illustrations), constitutes your acceptance of the terms of this license, Tabler terms of service and Tabler private policy.
|
||||
|
||||
Tabler Admin Template and Tabler Icons License*
|
||||
Tabler Admin Template and Tabler Icons are available under MIT License.
|
||||
|
||||
Copyright (c) 2018-2025 Tabler
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
See more at Tabler Admin Template MIT License See more at Tabler Icons MIT License
|
||||
BIN
config/quickshell/Assets/Fonts/tabler/tabler-icons.ttf
Normal file
BIN
config/quickshell/Assets/Fonts/tabler/tabler-icons.ttf
Normal file
Binary file not shown.
BIN
config/quickshell/Assets/Images/Avatar.jpg
Normal file
BIN
config/quickshell/Assets/Images/Avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
26
config/quickshell/Constants/Color.qml
Normal file
26
config/quickshell/Constants/Color.qml
Normal file
@@ -0,0 +1,26 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Compatibility colors for noctalia modules
|
||||
property color mPrimary: Colors.primary
|
||||
property color mOnPrimary: Colors.base
|
||||
property color mSecondary: Colors.primary
|
||||
property color mOnSecondary: Colors.base
|
||||
property color mTertiary: Colors.primary
|
||||
property color mOnTertiary: Colors.base
|
||||
property color mError: Colors.red
|
||||
property color mOnError: Colors.base
|
||||
property color mSurface: Colors.base
|
||||
property color mOnSurface: Colors.text
|
||||
property color mSurfaceVariant: Colors.surface
|
||||
property color mOnSurfaceVariant: Colors.overlay1
|
||||
property color mOutline: Colors.primary
|
||||
property color mShadow: Colors.crust
|
||||
property color transparent: "transparent"
|
||||
}
|
||||
40
config/quickshell/Constants/Colors.qml
Normal file
40
config/quickshell/Constants/Colors.qml
Normal file
@@ -0,0 +1,40 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Services
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property color transparent: "transparent"
|
||||
readonly property color rosewater: "#f5e0dc"
|
||||
readonly property color flamingo: "#f2cdcd"
|
||||
readonly property color pink: "#f5c2e7"
|
||||
readonly property color mauve: "#cba6f7"
|
||||
readonly property color red: "#f38ba8"
|
||||
readonly property color maroon: "#eba0ac"
|
||||
readonly property color peach: "#fab387"
|
||||
readonly property color yellow: "#f9e2af"
|
||||
readonly property color green: "#a6e3a1"
|
||||
readonly property color teal: "#94e2d5"
|
||||
readonly property color sky: "#89dceb"
|
||||
readonly property color sapphire: "#74c7ec"
|
||||
readonly property color blue: "#89b4fa"
|
||||
readonly property color lavender: "#b4befe"
|
||||
readonly property color text: "#cdd6f4"
|
||||
readonly property color subtext1: "#bac2de"
|
||||
readonly property color subtext0: "#a6adc8"
|
||||
readonly property color overlay2: "#9399b2"
|
||||
readonly property color overlay1: "#7f849c"
|
||||
readonly property color overlay0: "#6c7086"
|
||||
readonly property color surface2: "#585b70"
|
||||
readonly property color surface1: "#45475a"
|
||||
readonly property color surface0: "#313244"
|
||||
readonly property color surface: "#292a3c"
|
||||
readonly property color base: "#1e1e2e"
|
||||
readonly property color mantle: "#181825"
|
||||
readonly property color crust: "#11111b"
|
||||
property color primary: SettingsService.primaryColor
|
||||
readonly property color distroColor: "#74c7ec"
|
||||
readonly property var cavaList: ["#b4befe", "#89b4fa", "#74c7ec", "#89dceb", "#94e2d5", "#a6e3a1", "#f9e2af", "#fab387"]
|
||||
}
|
||||
16
config/quickshell/Constants/Fonts.qml
Normal file
16
config/quickshell/Constants/Fonts.qml
Normal file
@@ -0,0 +1,16 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string primary: "Sour Gummy Light"
|
||||
readonly property string nerd: "Meslo LGM Nerd Font Mono"
|
||||
readonly property string sans: "Noto Sans"
|
||||
readonly property int small: Style.fontSizeS
|
||||
readonly property int medium: Style.fontSizeM
|
||||
readonly property int large: Style.fontSizeL
|
||||
readonly property int icon: 14 // for nerd font
|
||||
}
|
||||
105
config/quickshell/Constants/Icons.qml
Normal file
105
config/quickshell/Constants/Icons.qml
Normal file
@@ -0,0 +1,105 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Nerd fonts icons
|
||||
readonly property string distro: ""
|
||||
readonly property string tray: ""
|
||||
readonly property string idleInhibitorActivated: ""
|
||||
readonly property string idleInhibitorDeactivated: ""
|
||||
readonly property string powerMenu: ""
|
||||
readonly property string volumeHigh: ""
|
||||
readonly property string volumeMedium: ""
|
||||
readonly property string volumeLow: ""
|
||||
readonly property string volumeMuted: ""
|
||||
readonly property string brightness: ""
|
||||
readonly property string charging: ""
|
||||
readonly property string battery100: ""
|
||||
readonly property string battery75: ""
|
||||
readonly property string battery50: ""
|
||||
readonly property string battery25: ""
|
||||
readonly property string battery00: ""
|
||||
readonly property string cpu: ""
|
||||
readonly property string memory: ""
|
||||
readonly property string tempHigh: ""
|
||||
readonly property string tempMedium: ""
|
||||
readonly property string tempLow: ""
|
||||
readonly property string global: ""
|
||||
readonly property string upload: ""
|
||||
readonly property string download: ""
|
||||
readonly property string speedSlower: ""
|
||||
readonly property string speedFaster: ""
|
||||
readonly property string speedReset: ""
|
||||
readonly property string reset: ""
|
||||
readonly property string lines: ""
|
||||
readonly property string record: ""
|
||||
// Tabler icons
|
||||
// Expose the font family name for easy access
|
||||
readonly property string fontFamily: currentFontLoader ? currentFontLoader.name : ""
|
||||
readonly property string defaultIcon: TablerIcons.defaultIcon
|
||||
readonly property var icons: TablerIcons.icons
|
||||
readonly property var aliases: TablerIcons.aliases
|
||||
readonly property string fontPath: "/Assets/Fonts/tabler/tabler-icons.ttf"
|
||||
// Current active font loader
|
||||
property FontLoader currentFontLoader: null
|
||||
property int fontVersion: 0
|
||||
// Create a unique cache-busting path
|
||||
readonly property string cacheBustingPath: Quickshell.shellDir + fontPath + "?v=" + fontVersion + "&t=" + Date.now()
|
||||
|
||||
// Signal emitted when font is reloaded
|
||||
signal fontReloaded()
|
||||
|
||||
// ---------------------------------------
|
||||
function get(iconName) {
|
||||
// Check in aliases first
|
||||
if (aliases[iconName] !== undefined)
|
||||
iconName = aliases[iconName];
|
||||
|
||||
// Find the appropriate codepoint
|
||||
return icons[iconName];
|
||||
}
|
||||
|
||||
function loadFontWithCacheBusting() {
|
||||
// Destroy old loader first
|
||||
if (currentFontLoader) {
|
||||
currentFontLoader.destroy();
|
||||
currentFontLoader = null;
|
||||
}
|
||||
// Create new loader with cache-busting URL
|
||||
currentFontLoader = Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
FontLoader {
|
||||
source: "${cacheBustingPath}"
|
||||
}
|
||||
`, root, "dynamicFontLoader_" + fontVersion);
|
||||
// Connect to the new loader's status changes
|
||||
currentFontLoader.statusChanged.connect(function() {
|
||||
if (currentFontLoader.status === FontLoader.Ready)
|
||||
fontReloaded();
|
||||
else if (currentFontLoader.status === FontLoader.Error)
|
||||
Logger.error("Icons", "Font failed to load (version " + fontVersion + ")");
|
||||
});
|
||||
}
|
||||
|
||||
function reloadFont() {
|
||||
fontVersion++;
|
||||
loadFontWithCacheBusting();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
loadFontWithCacheBusting();
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onReloadCompleted() {
|
||||
reloadFont();
|
||||
}
|
||||
|
||||
target: Quickshell
|
||||
}
|
||||
|
||||
}
|
||||
68
config/quickshell/Constants/Style.qml
Normal file
68
config/quickshell/Constants/Style.qml
Normal file
@@ -0,0 +1,68 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
/*
|
||||
Preset sizes for font, radii, ?
|
||||
*/
|
||||
|
||||
id: root
|
||||
|
||||
// Font size
|
||||
property real fontSizeXXS: 8
|
||||
property real fontSizeXS: 9
|
||||
property real fontSizeS: 10
|
||||
property real fontSizeM: 11
|
||||
property real fontSizeL: 13
|
||||
property real fontSizeXL: 16
|
||||
property real fontSizeXXL: 18
|
||||
property real fontSizeXXXL: 24
|
||||
// Font weight
|
||||
property int fontWeightRegular: 400
|
||||
property int fontWeightMedium: 500
|
||||
property int fontWeightSemiBold: 600
|
||||
property int fontWeightBold: 700
|
||||
// Radii
|
||||
property int radiusXXS: 4
|
||||
property int radiusXS: 8
|
||||
property int radiusS: 12
|
||||
property int radiusM: 16
|
||||
property int radiusL: 20
|
||||
//screen Radii
|
||||
property int screenRadius: 20
|
||||
// Border
|
||||
property int borderS: 2
|
||||
property int borderM: 3
|
||||
property int borderL: 4
|
||||
// Margins (for margins and spacing)
|
||||
property int marginXXS: 2
|
||||
property int marginXS: 4
|
||||
property int marginS: 8
|
||||
property int marginM: 12
|
||||
property int marginL: 16
|
||||
property int marginXL: 24
|
||||
// Opacity
|
||||
property real opacityNone: 0
|
||||
property real opacityLight: 0.25
|
||||
property real opacityMedium: 0.5
|
||||
property real opacityHeavy: 0.75
|
||||
property real opacityAlmost: 0.95
|
||||
property real opacityFull: 1
|
||||
// Animation duration (ms)
|
||||
property int animationFast: 150
|
||||
property int animationNormal: 300
|
||||
property int animationSlow: 450
|
||||
property int animationSlowest: 1000
|
||||
// Delays
|
||||
property int tooltipDelay: 300
|
||||
property int tooltipDelayLong: 1200
|
||||
property int pillDelay: 500
|
||||
// Settings widgets base size
|
||||
property real baseWidgetSize: 33
|
||||
property real sliderWidth: 200
|
||||
// Bar Dimensions
|
||||
property real barHeight: 45
|
||||
property real capsuleHeight: 35
|
||||
}
|
||||
6169
config/quickshell/Constants/TablerIcons.qml
Normal file
6169
config/quickshell/Constants/TablerIcons.qml
Normal file
File diff suppressed because it is too large
Load Diff
256
config/quickshell/Modules/Bar/Bar.qml
Normal file
256
config/quickshell/Modules/Bar/Bar.qml
Normal file
@@ -0,0 +1,256 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.UPower
|
||||
import Quickshell.Wayland
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar.Components
|
||||
import qs.Modules.Bar.Misc
|
||||
import qs.Modules.Misc
|
||||
import qs.Services
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
Item {
|
||||
property var modelData
|
||||
|
||||
PanelWindow {
|
||||
id: panel
|
||||
|
||||
screen: modelData
|
||||
WlrLayershell.namespace: "quickshell-bar"
|
||||
color: Colors.transparent
|
||||
implicitHeight: Style.barHeight
|
||||
|
||||
anchors {
|
||||
left: true
|
||||
right: true
|
||||
top: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: barBackground
|
||||
|
||||
anchors.fill: parent
|
||||
color: Niri.noFocus ? null : Colors.base
|
||||
|
||||
gradient: Gradient {
|
||||
GradientStop {
|
||||
position: 0
|
||||
color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0.8 : 1)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationSlowest
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
GradientStop {
|
||||
position: 1
|
||||
color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0 : 1)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationSlowest
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: leftLayout
|
||||
|
||||
height: parent.height - 10
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
verticalCenter: parent.verticalCenter
|
||||
leftMargin: 5
|
||||
}
|
||||
|
||||
SymbolButton {
|
||||
symbol: Icons.distro
|
||||
buttonColor: Colors.distroColor
|
||||
onClicked: {
|
||||
PanelService.getPanel("controlCenterPanel")?.toggle(this)
|
||||
}
|
||||
onRightClicked: {
|
||||
if (action.running) {
|
||||
action.signal(15);
|
||||
return ;
|
||||
}
|
||||
action.exec(["rofi", "-show", "drun"]);
|
||||
}
|
||||
}
|
||||
|
||||
Separator {
|
||||
}
|
||||
|
||||
Workspace {
|
||||
screen: modelData
|
||||
}
|
||||
|
||||
Separator {
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 10
|
||||
}
|
||||
|
||||
CavaBar {
|
||||
count: 6
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 10
|
||||
}
|
||||
|
||||
Separator {
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 10
|
||||
}
|
||||
|
||||
FocusedWindow {
|
||||
maxWidth: 400
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: middleLayout
|
||||
|
||||
height: parent.height - 10
|
||||
|
||||
anchors {
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Time {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: rightLayout
|
||||
|
||||
height: parent.height - 10
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
verticalCenter: parent.verticalCenter
|
||||
rightMargin: 5
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: monitorsLayout
|
||||
visible: !SettingsService.showLyricsBar
|
||||
|
||||
height: parent.height
|
||||
NetworkSpeed {
|
||||
}
|
||||
|
||||
Separator {
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 10
|
||||
}
|
||||
|
||||
RecordIndicator {
|
||||
}
|
||||
|
||||
Ip {
|
||||
showCountryCode: true
|
||||
}
|
||||
|
||||
CpuTemp {
|
||||
}
|
||||
|
||||
MemUsage {
|
||||
}
|
||||
|
||||
CpuUsage {
|
||||
}
|
||||
|
||||
Battery {
|
||||
}
|
||||
|
||||
Brightness {
|
||||
screen: modelData
|
||||
}
|
||||
|
||||
Volume {
|
||||
}
|
||||
}
|
||||
|
||||
LyricsBar {
|
||||
id: lyricsBar
|
||||
visible: SettingsService.showLyricsBar
|
||||
width: 600
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 5
|
||||
}
|
||||
|
||||
Separator {
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 5
|
||||
}
|
||||
|
||||
TrayExpander {
|
||||
screen: modelData
|
||||
}
|
||||
|
||||
SymbolButton {
|
||||
symbol: Caffeine.isInhibited ? Icons.idleInhibitorActivated : Icons.idleInhibitorDeactivated
|
||||
buttonColor: Caffeine.isInhibited ? Colors.peach : Colors.yellow
|
||||
onClicked: {
|
||||
Caffeine.manualToggle();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
SymbolButton {
|
||||
symbol: Icons.powerMenu
|
||||
buttonColor: Colors.red
|
||||
onClicked: {
|
||||
if (action.running) {
|
||||
action.signal(15);
|
||||
return ;
|
||||
}
|
||||
Quickshell.execDetached(["wlogout"]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Process {
|
||||
id: action
|
||||
|
||||
running: false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
23
config/quickshell/Modules/Bar/Components/Battery.qml
Normal file
23
config/quickshell/Modules/Bar/Components/Battery.qml
Normal file
@@ -0,0 +1,23 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.UPower
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar.Misc
|
||||
import qs.Services
|
||||
|
||||
MonitorItem {
|
||||
readonly property var battery: UPower.displayDevice
|
||||
readonly property bool isReady: (battery && battery.ready && battery.isLaptopBattery && battery.isPresent)
|
||||
readonly property real percent: (isReady ? (battery.percentage * 100) : 0)
|
||||
readonly property bool charging: (isReady ? battery.state === UPowerDeviceState.Charging : false)
|
||||
property int lowBatteryThreshold: 20
|
||||
|
||||
symbol: {
|
||||
return charging ? Icons.charging : percent >= 80 ? Icons.battery100 : percent >= 60 ? Icons.battery75 : percent >= 40 ? Icons.battery50 : percent >= 20 ? Icons.battery25 : Icons.battery00;
|
||||
}
|
||||
fillColor: Colors.sapphire
|
||||
value: percent
|
||||
critical: isReady && !charging && percent <= lowBatteryThreshold
|
||||
maxValue: 100
|
||||
textSuffix: "%"
|
||||
pointerCursor: false
|
||||
}
|
||||
35
config/quickshell/Modules/Bar/Components/Brightness.qml
Normal file
35
config/quickshell/Modules/Bar/Components/Brightness.qml
Normal file
@@ -0,0 +1,35 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar.Misc
|
||||
import qs.Services
|
||||
|
||||
MonitorItem {
|
||||
property ShellScreen screen: null
|
||||
|
||||
function getMonitor() {
|
||||
return BrightnessService.getMonitorForScreen(screen) || null;
|
||||
}
|
||||
|
||||
symbol: Icons.brightness
|
||||
fillColor: Colors.blue
|
||||
value: {
|
||||
const monitor = getMonitor();
|
||||
return monitor ? Math.round(monitor.brightness * 100) : "N/A";
|
||||
}
|
||||
maxValue: 100
|
||||
textSuffix: "%"
|
||||
expandOnValueChange: true
|
||||
onWheelUp: {
|
||||
const monitor = getMonitor();
|
||||
if (monitor)
|
||||
monitor.increaseBrightness();
|
||||
|
||||
}
|
||||
onWheelDown: {
|
||||
const monitor = getMonitor();
|
||||
if (monitor)
|
||||
monitor.decreaseBrightness();
|
||||
|
||||
}
|
||||
}
|
||||
73
config/quickshell/Modules/Bar/Components/CavaBar.qml
Normal file
73
config/quickshell/Modules/Bar/Components/CavaBar.qml
Normal file
@@ -0,0 +1,73 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int count: 6
|
||||
property int barWidth: 5
|
||||
property int barSpacing: 3
|
||||
|
||||
implicitWidth: root.barWidth * root.count + root.barSpacing * (root.count - 1)
|
||||
implicitHeight: parent.height - 10
|
||||
|
||||
Cava {
|
||||
id: cavaProcess
|
||||
|
||||
count: root.count
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: root.barSpacing
|
||||
|
||||
anchors {
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: cavaProcess.values
|
||||
|
||||
Rectangle {
|
||||
width: root.barWidth
|
||||
implicitHeight: Math.max(1, modelData * (parent.height - 10))
|
||||
color: Colors.cavaList[Math.min(Math.floor(modelData * (Colors.cavaList.length - 1)), Colors.cavaList.length - 1)]
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: 100
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.LeftButton)
|
||||
MusicManager.playPause();
|
||||
else if (mouse.button === Qt.RightButton)
|
||||
SettingsService.showLyricsBar = !SettingsService.showLyricsBar;
|
||||
}
|
||||
onWheel: function(wheel) {
|
||||
if (wheel.angleDelta.y > 0)
|
||||
MusicManager.previous();
|
||||
else if (wheel.angleDelta.y < 0)
|
||||
MusicManager.next();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
28
config/quickshell/Modules/Bar/Components/CpuTemp.qml
Normal file
28
config/quickshell/Modules/Bar/Components/CpuTemp.qml
Normal file
@@ -0,0 +1,28 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar.Misc
|
||||
import qs.Services
|
||||
|
||||
MonitorItem {
|
||||
symbol: SystemStatService.cpuTemp > 80 ? Icons.tempHigh : SystemStatService.cpuTemp > 50 ? Icons.tempMedium : Icons.tempLow
|
||||
fillColor: Colors.yellow
|
||||
critical: SystemStatService.cpuTemp > 80
|
||||
value: Math.round(SystemStatService.cpuTemp)
|
||||
maxValue: 100
|
||||
textSuffix: "°C"
|
||||
onClicked: {
|
||||
if (action.running) {
|
||||
action.signal(15);
|
||||
return ;
|
||||
}
|
||||
action.exec(["ghostty", "-e", "btop"]);
|
||||
}
|
||||
|
||||
Process {
|
||||
id: action
|
||||
|
||||
running: false
|
||||
}
|
||||
|
||||
}
|
||||
28
config/quickshell/Modules/Bar/Components/CpuUsage.qml
Normal file
28
config/quickshell/Modules/Bar/Components/CpuUsage.qml
Normal file
@@ -0,0 +1,28 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar.Misc
|
||||
import qs.Services
|
||||
|
||||
MonitorItem {
|
||||
symbol: Icons.cpu
|
||||
fillColor: Colors.teal
|
||||
critical: SystemStatService.cpuUsage > 90
|
||||
value: Math.round(SystemStatService.cpuUsage)
|
||||
maxValue: 100
|
||||
textSuffix: "%"
|
||||
onClicked: {
|
||||
if (action.running) {
|
||||
action.signal(15);
|
||||
return ;
|
||||
}
|
||||
action.exec(["ghostty", "-e", "btop"]);
|
||||
}
|
||||
|
||||
Process {
|
||||
id: action
|
||||
|
||||
running: false
|
||||
}
|
||||
|
||||
}
|
||||
129
config/quickshell/Modules/Bar/Components/FocusedWindow.qml
Normal file
129
config/quickshell/Modules/Bar/Components/FocusedWindow.qml
Normal file
@@ -0,0 +1,129 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real maxWidth: 250
|
||||
property string fallbackIcon: "application-x-executable"
|
||||
|
||||
function getAppIcon(appId) {
|
||||
try {
|
||||
if (appId) {
|
||||
try {
|
||||
const normalizedId = (typeof appId === 'string') ? appId : String(appId);
|
||||
const iconResult = ThemeIcons.iconForAppId(normalizedId.toLowerCase());
|
||||
if (iconResult && iconResult !== "")
|
||||
return iconResult;
|
||||
|
||||
} catch (iconError) {
|
||||
Logger.warn("FocusedWindow", "Error getting icon from CompositorService: " + iconError);
|
||||
}
|
||||
}
|
||||
return ThemeIcons.iconFromName(root.fallbackIcon);
|
||||
} catch (e) {
|
||||
Logger.warn("FocusedWindow", "Error in getAppIcon:", e);
|
||||
return ThemeIcons.iconFromName(root.fallbackIcon);
|
||||
}
|
||||
}
|
||||
|
||||
implicitHeight: parent.height
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
visible: Niri.focusedWindowId !== -1
|
||||
|
||||
Item {
|
||||
// Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
id: iconContainer
|
||||
|
||||
implicitWidth: 18
|
||||
implicitHeight: 18
|
||||
|
||||
IconImage {
|
||||
id: windowIcon
|
||||
|
||||
anchors.fill: parent
|
||||
source: getAppIcon(Niri.focusedWindowAppId)
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
visible: source !== ""
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Item {
|
||||
id: titleContainer
|
||||
|
||||
implicitWidth: root.maxWidth
|
||||
implicitHeight: parent.height
|
||||
// Layout.alignment: Qt.AlignVCenter
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
id: windowTitle
|
||||
|
||||
text: Niri.focusedWindowTitle
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
font.pointSize: Fonts.medium
|
||||
font.family: Fonts.primary
|
||||
color: Colors.primary
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
||||
onEntered: {
|
||||
if (windowTitle.implicitWidth > titleContainer.width)
|
||||
windowTitle.x = titleContainer.width - windowTitle.implicitWidth;
|
||||
|
||||
}
|
||||
onExited: {
|
||||
windowTitle.x = 0;
|
||||
}
|
||||
onClicked: function(mouse) {
|
||||
if (mouse.button === Qt.MiddleButton)
|
||||
Quickshell.execDetached(["niri", "msg", "action", "close-window"]);
|
||||
else if (mouse.button === Qt.LeftButton)
|
||||
Quickshell.execDetached(["niri", "msg", "action", "center-window"]);
|
||||
}
|
||||
onWheel: function(wheel) {
|
||||
if (wheel.angleDelta.y > 0)
|
||||
Quickshell.execDetached(["niri", "msg", "action", "set-column-width", "+10%"]);
|
||||
else if (wheel.angleDelta.y < 0)
|
||||
Quickshell.execDetached(["niri", "msg", "action", "set-column-width", "-10%"]);
|
||||
else if (wheel.angleDelta.x > 0)
|
||||
Quickshell.execDetached(["niri", "msg", "action", "focus-column-left"]);
|
||||
else if (wheel.angleDelta.x < 0)
|
||||
Quickshell.execDetached(["niri", "msg", "action", "focus-column-right"]);
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on x {
|
||||
NumberAnimation {
|
||||
duration: 1000
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
85
config/quickshell/Modules/Bar/Components/Ip.qml
Normal file
85
config/quickshell/Modules/Bar/Components/Ip.qml
Normal file
@@ -0,0 +1,85 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
// Text {
|
||||
// id: ipText
|
||||
// anchors.verticalCenter: parent.verticalCenter
|
||||
// text: Icons.global + " " + (showCountryCode ? IpService.countryCode : IpService.ip)
|
||||
// font.pixelSize: Fonts.medium
|
||||
// color: Colors.peach
|
||||
// }
|
||||
|
||||
id: root
|
||||
|
||||
property bool showCountryCode: true
|
||||
|
||||
implicitHeight: parent.height
|
||||
implicitWidth: layout.width + 10
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
text: Icons.global
|
||||
font.pointSize: Fonts.icon + 6
|
||||
color: Colors.peach
|
||||
}
|
||||
|
||||
Item {
|
||||
id: expander
|
||||
|
||||
implicitWidth: mouseArea.containsMouse ? ipText.implicitWidth + 10 : 0
|
||||
implicitHeight: parent.height
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
id: ipText
|
||||
|
||||
text: showCountryCode ? IpService.countryCode : IpService.ip
|
||||
font.pointSize: showCountryCode ? Fonts.medium : Fonts.small
|
||||
font.family: Fonts.primary
|
||||
color: Colors.peach
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 5
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
WriteClipboard.write(showCountryCode ? IpService.countryCode : IpService.ip);
|
||||
SendNotification.show("Copied to clipboard", showCountryCode ? IpService.countryCode : IpService.ip);
|
||||
} else if (mouse.button === Qt.RightButton)
|
||||
showCountryCode = !showCountryCode;
|
||||
else if (mouse.button === Qt.MiddleButton)
|
||||
IpService.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
89
config/quickshell/Modules/Bar/Components/LyricsBar.qml
Normal file
89
config/quickshell/Modules/Bar/Components/LyricsBar.qml
Normal file
@@ -0,0 +1,89 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
import qs.Noctalia
|
||||
import qs.Services
|
||||
|
||||
Rectangle {
|
||||
implicitHeight: parent.height
|
||||
radius: Style.radiusS
|
||||
color: Colors.base
|
||||
border.color: Colors.primary
|
||||
border.width: Style.borderS
|
||||
|
||||
Connections {
|
||||
function onShowLyricsBarChanged() {
|
||||
visible = SettingsService.showLyricsBar;
|
||||
if (visible)
|
||||
LyricsService.startSyncing();
|
||||
else
|
||||
LyricsService.stopSyncing();
|
||||
}
|
||||
|
||||
target: SettingsService
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
Item {
|
||||
implicitWidth: parent.width - slowerButton.implicitWidth * 3 - parent.spacing * 3 - parent.anchors.leftMargin - parent.anchors.rightMargin
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
|
||||
NText {
|
||||
text: LyricsService.lyrics[LyricsService.currentIndex] || ""
|
||||
family: Fonts.sans
|
||||
pointSize: Style.fontSizeS
|
||||
maximumLineCount: 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
id: slowerButton
|
||||
|
||||
baseSize: 24
|
||||
colorBg: Color.transparent
|
||||
colorBgHover: Colors.blue
|
||||
colorFg: Colors.blue
|
||||
icon: "rotate-2"
|
||||
onClicked: {
|
||||
LyricsService.increaseOffset();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
id: playPauseButton
|
||||
|
||||
baseSize: 24
|
||||
colorBg: Color.transparent
|
||||
colorBgHover: Colors.yellow
|
||||
colorFg: Colors.yellow
|
||||
icon: "rotate-clockwise-2"
|
||||
onClicked: {
|
||||
LyricsService.decreaseOffset();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
id: nextButton
|
||||
|
||||
baseSize: 24
|
||||
colorBg: Color.transparent
|
||||
colorBgHover: Colors.green
|
||||
colorFg: Colors.green
|
||||
icon: "rotate-clockwise"
|
||||
onClicked: {
|
||||
LyricsService.resetOffset();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
34
config/quickshell/Modules/Bar/Components/MemUsage.qml
Normal file
34
config/quickshell/Modules/Bar/Components/MemUsage.qml
Normal file
@@ -0,0 +1,34 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar.Misc
|
||||
import qs.Services
|
||||
|
||||
MonitorItem {
|
||||
property bool showPercent: false
|
||||
|
||||
symbol: Icons.memory
|
||||
fillColor: Colors.green
|
||||
critical: SystemStatService.memPercent > 90
|
||||
value: Math.round(SystemStatService.memPercent)
|
||||
maxValue: 100
|
||||
textValue: showPercent ? SystemStatService.memPercent : SystemStatService.memGb
|
||||
textSuffix: showPercent ? "%" : "GB"
|
||||
onClicked: {
|
||||
if (action.running) {
|
||||
action.signal(15);
|
||||
return ;
|
||||
}
|
||||
action.exec(["ghostty", "-e", "btop"]);
|
||||
}
|
||||
onRightClicked: {
|
||||
showPercent = !showPercent;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: action
|
||||
|
||||
running: false
|
||||
}
|
||||
|
||||
}
|
||||
52
config/quickshell/Modules/Bar/Components/NetworkSpeed.qml
Normal file
52
config/quickshell/Modules/Bar/Components/NetworkSpeed.qml
Normal file
@@ -0,0 +1,52 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
implicitHeight: parent.height
|
||||
implicitWidth: layout.width + 10
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
text: Icons.download
|
||||
font.pointSize: Fonts.icon - 3
|
||||
color: Colors.primary
|
||||
Layout.leftMargin: 10
|
||||
}
|
||||
|
||||
Text {
|
||||
text: SystemStatService.formatSpeed(SystemStatService.rxSpeed)
|
||||
font.pointSize: Fonts.medium
|
||||
font.family: Fonts.primary
|
||||
color: Colors.primary
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 5
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Icons.upload
|
||||
font.pointSize: Fonts.icon - 3
|
||||
color: Colors.primary
|
||||
}
|
||||
|
||||
Text {
|
||||
text: SystemStatService.formatSpeed(SystemStatService.txSpeed)
|
||||
font.pointSize: Fonts.medium
|
||||
font.family: Fonts.primary
|
||||
color: Colors.primary
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
107
config/quickshell/Modules/Bar/Components/RecordIndicator.qml
Normal file
107
config/quickshell/Modules/Bar/Components/RecordIndicator.qml
Normal file
@@ -0,0 +1,107 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property color fillColor: Colors.red
|
||||
property color _actualColor: Colors.red
|
||||
|
||||
visible: RecordService.isRecording
|
||||
implicitHeight: parent.height
|
||||
implicitWidth: layout.width + 10
|
||||
|
||||
SequentialAnimation {
|
||||
id: blinkAnimation
|
||||
|
||||
running: RecordService.isRecording
|
||||
loops: Animation.Infinite
|
||||
|
||||
ColorAnimation {
|
||||
target: root
|
||||
property: "_actualColor"
|
||||
to: Qt.rgba(fillColor.r, fillColor.g, fillColor.b, 0)
|
||||
duration: Style.animationSlowest
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
ColorAnimation {
|
||||
target: root
|
||||
property: "_actualColor"
|
||||
to: fillColor
|
||||
duration: Style.animationSlowest
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
text: Icons.record
|
||||
font.pointSize: Fonts.icon + 6
|
||||
color: _actualColor
|
||||
}
|
||||
|
||||
Item {
|
||||
id: expander
|
||||
|
||||
implicitWidth: mouseArea.containsMouse ? ipText.implicitWidth + 10 : 0
|
||||
implicitHeight: parent.height
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
id: ipText
|
||||
|
||||
text: RecordService.recordingDisplay
|
||||
font.pointSize: Fonts.medium
|
||||
font.family: Fonts.primary
|
||||
color: fillColor
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 5
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.LeftButton)
|
||||
RecordService.startOrStop();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on _actualColor {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
18
config/quickshell/Modules/Bar/Components/Separator.qml
Normal file
18
config/quickshell/Modules/Bar/Components/Separator.qml
Normal file
@@ -0,0 +1,18 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Constants
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
implicitHeight: parent.height
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: 1.5
|
||||
height: parent.height * 0.32
|
||||
color: Colors.text
|
||||
}
|
||||
|
||||
}
|
||||
22
config/quickshell/Modules/Bar/Components/Time.qml
Normal file
22
config/quickshell/Modules/Bar/Components/Time.qml
Normal file
@@ -0,0 +1,22 @@
|
||||
import QtQuick
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
|
||||
Text {
|
||||
text: TimeService.time + " | " + TimeService.dateString
|
||||
font.pointSize: Fonts.medium
|
||||
font.family: Fonts.primary
|
||||
color: Colors.primary
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.LeftButton)
|
||||
PanelService.getPanel("calendarPanel")?.toggle(this)
|
||||
else if (mouse.button === Qt.RightButton)
|
||||
PanelService.getPanel("notificationHistoryPanel")?.toggle(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
61
config/quickshell/Modules/Bar/Components/TrayExpander.qml
Normal file
61
config/quickshell/Modules/Bar/Components/TrayExpander.qml
Normal file
@@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar.Misc
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
implicitHeight: parent.height
|
||||
implicitWidth: layout.implicitWidth
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
|
||||
SymbolButton {
|
||||
symbol: Icons.tray
|
||||
buttonColor: Colors.green
|
||||
disabledHover: true
|
||||
}
|
||||
|
||||
Item {
|
||||
id: trayContainer
|
||||
|
||||
implicitHeight: parent.height
|
||||
implicitWidth: mouseArea.containsMouse ? expandedTray.implicitWidth : 0
|
||||
clip: true
|
||||
|
||||
SystemTray {
|
||||
id: expandedTray
|
||||
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
}
|
||||
26
config/quickshell/Modules/Bar/Components/Volume.qml
Normal file
26
config/quickshell/Modules/Bar/Components/Volume.qml
Normal file
@@ -0,0 +1,26 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar.Misc
|
||||
import qs.Services
|
||||
|
||||
MonitorItem {
|
||||
symbol: AudioService.muted ? Icons.volumeMuted : (AudioService.volume >= 0.5 ? Icons.volumeHigh : (AudioService.volume >= 0.2 ? Icons.volumeMedium : Icons.volumeLow))
|
||||
fillColor: Colors.lavender
|
||||
value: Math.round(AudioService.volume * 100)
|
||||
maxValue: 100
|
||||
textSuffix: "%"
|
||||
expandOnValueChange: true
|
||||
onWheelUp: {
|
||||
AudioService.increaseVolume();
|
||||
}
|
||||
onWheelDown: {
|
||||
AudioService.decreaseVolume();
|
||||
}
|
||||
onClicked: {
|
||||
AudioService.toggleMute();
|
||||
}
|
||||
onRightClicked: {
|
||||
Quickshell.execDetached(["sh", "-c", "pkill -x -n pavucontrol || pavucontrol"]);
|
||||
}
|
||||
}
|
||||
299
config/quickshell/Modules/Bar/Components/Workspace.qml
Normal file
299
config/quickshell/Modules/Bar/Components/Workspace.qml
Normal file
@@ -0,0 +1,299 @@
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ShellScreen screen
|
||||
property bool hovered: false
|
||||
property ListModel localWorkspaces
|
||||
property real masterProgress: 0
|
||||
property bool effectsActive: false
|
||||
property color effectColor: Colors.primary
|
||||
property int horizontalPadding: 16
|
||||
property int spacingBetweenPills: 8
|
||||
property bool isDestroying: false
|
||||
|
||||
signal workspaceChanged(int workspaceId, color primaryColor)
|
||||
|
||||
function triggerUnifiedWave() {
|
||||
effectColor = Colors.primary;
|
||||
masterAnimation.restart();
|
||||
}
|
||||
|
||||
function updateWorkspaceFocus() {
|
||||
for (let i = 0; i < localWorkspaces.count; i++) {
|
||||
const ws = localWorkspaces.get(i);
|
||||
if (ws.isFocused === true) {
|
||||
root.triggerUnifiedWave();
|
||||
root.workspaceChanged(ws.id, Colors.primary);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
implicitWidth: {
|
||||
let total = 0;
|
||||
for (let i = 0; i < localWorkspaces.count; i++) {
|
||||
const ws = localWorkspaces.get(i);
|
||||
if (ws.isFocused)
|
||||
total += 44;
|
||||
else if (ws.isActive)
|
||||
total += 28;
|
||||
else
|
||||
total += 16;
|
||||
}
|
||||
total += Math.max(localWorkspaces.count - 1, 0) * spacingBetweenPills;
|
||||
total += horizontalPadding * 2;
|
||||
return total;
|
||||
}
|
||||
height: parent.height
|
||||
Component.onCompleted: {
|
||||
localWorkspaces.clear();
|
||||
for (let i = 0; i < WorkspaceManager.workspaces.count; i++) {
|
||||
const ws = WorkspaceManager.workspaces.get(i);
|
||||
if (ws.output.toLowerCase() === screen.name.toLowerCase())
|
||||
localWorkspaces.append(ws);
|
||||
|
||||
}
|
||||
workspaceRepeater.model = localWorkspaces;
|
||||
updateWorkspaceFocus();
|
||||
}
|
||||
Component.onDestruction: {
|
||||
root.isDestroying = true;
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onWorkspacesChanged() {
|
||||
localWorkspaces.clear();
|
||||
for (let i = 0; i < WorkspaceManager.workspaces.count; i++) {
|
||||
const ws = WorkspaceManager.workspaces.get(i);
|
||||
if (ws.output.toLowerCase() === screen.name.toLowerCase())
|
||||
localWorkspaces.append(ws);
|
||||
|
||||
}
|
||||
workspaceRepeater.model = localWorkspaces;
|
||||
updateWorkspaceFocus();
|
||||
}
|
||||
|
||||
target: WorkspaceManager
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: masterAnimation
|
||||
|
||||
PropertyAction {
|
||||
target: root
|
||||
property: "effectsActive"
|
||||
value: true
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
target: root
|
||||
property: "masterProgress"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: 1000
|
||||
easing.type: Easing.OutQuint
|
||||
}
|
||||
|
||||
PropertyAction {
|
||||
target: root
|
||||
property: "effectsActive"
|
||||
value: false
|
||||
}
|
||||
|
||||
PropertyAction {
|
||||
target: root
|
||||
property: "masterProgress"
|
||||
value: 0
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: workspaceBackground
|
||||
|
||||
width: parent.width - 15
|
||||
height: 26
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 12
|
||||
color: Colors.transparent
|
||||
layer.enabled: true
|
||||
|
||||
layer.effect: DropShadow {
|
||||
color: "black"
|
||||
radius: 12
|
||||
samples: 24
|
||||
verticalOffset: 0
|
||||
horizontalOffset: 0
|
||||
opacity: 0.1
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Row {
|
||||
id: pillRow
|
||||
|
||||
spacing: spacingBetweenPills
|
||||
anchors.verticalCenter: workspaceBackground.verticalCenter
|
||||
width: root.width - horizontalPadding * 2
|
||||
x: horizontalPadding
|
||||
|
||||
Repeater {
|
||||
id: workspaceRepeater
|
||||
|
||||
model: localWorkspaces
|
||||
|
||||
Item {
|
||||
id: workspacePillContainer
|
||||
|
||||
height: 12
|
||||
width: {
|
||||
if (model.isFocused)
|
||||
return 44;
|
||||
else if (model.isActive)
|
||||
return 28;
|
||||
else
|
||||
return 16;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// half of focused height (if you want to animate this too)
|
||||
|
||||
id: workspacePill
|
||||
|
||||
anchors.fill: parent
|
||||
radius: {
|
||||
if (model.isFocused)
|
||||
return 12;
|
||||
else
|
||||
return 6;
|
||||
}
|
||||
color: {
|
||||
if (model.isFocused)
|
||||
return Colors.primary;
|
||||
|
||||
if (model.isActive)
|
||||
return Colors.primary.lighter(130);
|
||||
|
||||
if (model.isUrgent)
|
||||
return Theme.error;
|
||||
|
||||
return Colors.surface2;
|
||||
}
|
||||
scale: model.isFocused ? 1 : 0.9
|
||||
z: 0
|
||||
|
||||
MouseArea {
|
||||
id: pillMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
WorkspaceManager.switchToWorkspace(model.idx);
|
||||
}
|
||||
z: 20
|
||||
hoverEnabled: true
|
||||
}
|
||||
// Material 3-inspired smooth animation for width, height, scale, color, opacity, and radius
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: 350
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: 350
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: 300
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on radius {
|
||||
NumberAnimation {
|
||||
duration: 350
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pillBurst
|
||||
|
||||
anchors.centerIn: workspacePillContainer
|
||||
width: workspacePillContainer.width + 18 * root.masterProgress
|
||||
height: workspacePillContainer.height + 18 * root.masterProgress
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.color: root.effectColor
|
||||
border.width: 2 + 6 * (1 - root.masterProgress)
|
||||
opacity: root.effectsActive && model.isFocused ? (1 - root.masterProgress) * 0.7 : 0
|
||||
visible: root.effectsActive && model.isFocused
|
||||
z: 1
|
||||
}
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: 350
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: 350
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
localWorkspaces: ListModel {
|
||||
}
|
||||
|
||||
}
|
||||
193
config/quickshell/Modules/Bar/Misc/MonitorItem.qml
Normal file
193
config/quickshell/Modules/Bar/Misc/MonitorItem.qml
Normal file
@@ -0,0 +1,193 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string symbol
|
||||
property real maxValue: 100
|
||||
property real value: 100
|
||||
property string textValue: "" // override value in textDisplay if set
|
||||
property color fillColor: Colors.primary
|
||||
property string textSuffix: ""
|
||||
property bool pointerCursor: true
|
||||
property bool expandOnValueChange: false
|
||||
property real hideTimeOut: 2000 // ms
|
||||
property bool forceExpand: false
|
||||
property bool _expand: forceExpand || mouseArea.containsMouse
|
||||
property bool _isFirst: true
|
||||
property bool disableHover: false
|
||||
property bool critical: false
|
||||
property color criticalColor: Colors.red
|
||||
readonly property real ratio: value / maxValue
|
||||
property color realColor: critical ? criticalColor : fillColor
|
||||
|
||||
signal wheelUp()
|
||||
signal wheelDown()
|
||||
signal clicked()
|
||||
signal rightClicked()
|
||||
|
||||
implicitHeight: parent.height - 5
|
||||
implicitWidth: parent.height + (_expand ? textDisplay.width : 0)
|
||||
|
||||
Loader {
|
||||
id: connectionLoader
|
||||
|
||||
active: expandOnValueChange
|
||||
|
||||
sourceComponent: Connections {
|
||||
function onValueChanged() {
|
||||
// No need to expand (again) if already hovering
|
||||
if (mouseArea.containsMouse)
|
||||
return ;
|
||||
|
||||
// Skip first change (which is most likely initialization)
|
||||
if (root._isFirst) {
|
||||
root._isFirst = false;
|
||||
return ;
|
||||
}
|
||||
root.forceExpand = true;
|
||||
hideTimer.restart();
|
||||
}
|
||||
|
||||
target: root
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
|
||||
interval: parent.hideTimeOut
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.forceExpand = false;
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: !disableHover
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: pointerCursor ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.LeftButton)
|
||||
root.clicked();
|
||||
else if (mouse.button === Qt.RightButton)
|
||||
root.rightClicked();
|
||||
}
|
||||
onWheel: (wheel) => {
|
||||
if (wheel.angleDelta.y > 0)
|
||||
root.wheelUp();
|
||||
else if (wheel.angleDelta.y < 0)
|
||||
root.wheelDown();
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
id: progressDisplay
|
||||
|
||||
Layout.preferredHeight: parent.height
|
||||
Layout.preferredWidth: parent.height
|
||||
|
||||
Canvas {
|
||||
id: progressCircle
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.centerIn: parent
|
||||
onPaint: {
|
||||
var ctx = getContext("2d");
|
||||
ctx.reset();
|
||||
var centerX = width / 2;
|
||||
var centerY = height / 2;
|
||||
var radius = width / 2 - 3;
|
||||
var startAngle = -Math.PI / 2;
|
||||
var endAngle = startAngle - (2 * Math.PI * root.ratio);
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, endAngle, startAngle, false);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = root.realColor;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onRatioChanged() {
|
||||
progressCircle.requestPaint();
|
||||
}
|
||||
|
||||
function onRealColorChanged() {
|
||||
progressCircle.requestPaint();
|
||||
}
|
||||
|
||||
target: root
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Text {
|
||||
id: symbolText
|
||||
|
||||
anchors.fill: parent
|
||||
text: symbol
|
||||
font.family: Fonts.nerd
|
||||
font.pointSize: Fonts.icon
|
||||
color: root.realColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Item {
|
||||
id: textDisplay
|
||||
|
||||
implicitHeight: parent.height
|
||||
implicitWidth: root._expand ? textLabel.implicitWidth + 10 : 0
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
id: textLabel
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 5
|
||||
text: (textValue || Math.round(root.value)) + root.textSuffix
|
||||
font.pointSize: Fonts.small
|
||||
font.family: Fonts.primary
|
||||
color: root.realColor
|
||||
opacity: root._expand ? 1 : 0
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on realColor {
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
64
config/quickshell/Modules/Bar/Misc/SymbolButton.qml
Normal file
64
config/quickshell/Modules/Bar/Misc/SymbolButton.qml
Normal file
@@ -0,0 +1,64 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Constants
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string symbol
|
||||
property color buttonColor: Colors.distroColor
|
||||
readonly property alias hovered: mouseArea.containsMouse
|
||||
property real iconSize: Fonts.icon
|
||||
property real radius: Style.radiusS
|
||||
property bool disabledHover: false
|
||||
|
||||
signal clicked()
|
||||
signal rightClicked()
|
||||
|
||||
implicitHeight: parent.height
|
||||
implicitWidth: parent.height
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: !disabledHover
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
root.rightClicked();
|
||||
else if (mouse.button === Qt.LeftButton)
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
text: symbol
|
||||
font.family: Fonts.nerd
|
||||
font.pointSize: iconSize
|
||||
font.bold: false
|
||||
color: buttonColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: parent.hovered ? buttonColor : Colors.transparent
|
||||
opacity: 0.3
|
||||
radius: root.radius
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
160
config/quickshell/Modules/Bar/Misc/SystemTray.qml
Normal file
160
config/quickshell/Modules/Bar/Misc/SystemTray.qml
Normal file
@@ -0,0 +1,160 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Services.SystemTray
|
||||
import Quickshell.Widgets
|
||||
import qs.Modules.Bar.Misc
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
implicitWidth: trayFlow.implicitWidth + 20
|
||||
implicitHeight: parent.height
|
||||
radius: 0
|
||||
color: Colors.transparent
|
||||
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
Flow {
|
||||
id: trayFlow
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
flow: Flow.LeftToRight
|
||||
|
||||
Repeater {
|
||||
id: repeater
|
||||
model: SystemTray.items
|
||||
|
||||
delegate: Item {
|
||||
width: 18
|
||||
height: 18
|
||||
visible: modelData
|
||||
|
||||
IconImage {
|
||||
id: trayIcon
|
||||
|
||||
property ShellScreen screen: root.screen
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: 14
|
||||
height: 14
|
||||
smooth: false
|
||||
asynchronous: true
|
||||
backer.fillMode: Image.PreserveAspectFit
|
||||
source: {
|
||||
let icon = modelData?.icon || ""
|
||||
if (!icon) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Process icon path
|
||||
if (icon.includes("?path=")) {
|
||||
// Seems qmlfmt does not support the following ES6 syntax: const[name, path] = icon.split
|
||||
const chunks = icon.split("?path=")
|
||||
const name = chunks[0]
|
||||
const path = chunks[1]
|
||||
const fileName = name.substring(name.lastIndexOf("/") + 1)
|
||||
return `file://${path}/${fileName}`
|
||||
}
|
||||
return icon
|
||||
}
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
onClicked: mouse => {
|
||||
if (!modelData) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
// Close any open menu first
|
||||
trayPanel.close()
|
||||
|
||||
if (!modelData.onlyMenu) {
|
||||
modelData.activate()
|
||||
}
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
// Close any open menu first
|
||||
trayPanel.close()
|
||||
|
||||
modelData.secondaryActivate && modelData.secondaryActivate()
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
// Close the menu if it was visible
|
||||
if (trayPanel && trayPanel.visible) {
|
||||
trayPanel.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (modelData.hasMenu && modelData.menu && trayMenu.item) {
|
||||
trayPanel.open()
|
||||
|
||||
// Position menu based on bar position
|
||||
let menuX, menuY
|
||||
// For horizontal bars: center horizontally and position below
|
||||
menuX = (width / 2) - (trayMenu.item.width / 2)
|
||||
menuY = root.height
|
||||
trayMenu.item.menu = modelData.menu
|
||||
trayMenu.item.showAt(parent, menuX, menuY)
|
||||
} else {
|
||||
Logger.log("Tray", "No menu available for", modelData.id, "or trayMenu not set")
|
||||
}
|
||||
}
|
||||
}
|
||||
onEntered: {
|
||||
trayPanel.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: trayPanel
|
||||
anchors.top: true
|
||||
anchors.left: true
|
||||
anchors.right: true
|
||||
anchors.bottom: true
|
||||
visible: false
|
||||
color: Colors.transparent
|
||||
screen: root.screen
|
||||
|
||||
function open() {
|
||||
visible = true
|
||||
PanelService.willOpenPanel(trayPanel)
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible = false
|
||||
if (trayMenu.item) {
|
||||
trayMenu.item.hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// Clicking outside of the rectangle to close
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: trayPanel.close()
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: trayMenu
|
||||
Component.onCompleted: {
|
||||
setSource("../Misc/TrayMenu.qml", {
|
||||
"screen": root.screen
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
config/quickshell/Modules/Bar/Misc/TrayMenu.qml
Normal file
253
config/quickshell/Modules/Bar/Misc/TrayMenu.qml
Normal file
@@ -0,0 +1,253 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
import qs.Utils
|
||||
import qs.Noctalia
|
||||
|
||||
PopupWindow {
|
||||
id: root
|
||||
property QsMenuHandle menu
|
||||
property var anchorItem: null
|
||||
property real anchorX
|
||||
property real anchorY
|
||||
property bool isSubMenu: false
|
||||
property bool isHovered: rootMouseArea.containsMouse
|
||||
property ShellScreen screen
|
||||
|
||||
readonly property int menuWidth: 180
|
||||
|
||||
implicitWidth: menuWidth
|
||||
|
||||
// Use the content height of the Flickable for implicit height
|
||||
implicitHeight: Math.min(screen ? screen.height * 0.9 : Screen.height * 0.9, flickable.contentHeight + (Style.marginS * 2))
|
||||
visible: false
|
||||
color: Colors.transparent
|
||||
anchor.item: anchorItem
|
||||
anchor.rect.x: anchorX
|
||||
anchor.rect.y: anchorY - (isSubMenu ? 0 : 4)
|
||||
|
||||
function showAt(item, x, y) {
|
||||
if (!item) {
|
||||
Logger.warn("TrayMenu", "AnchorItem is undefined, won't show menu.");
|
||||
return
|
||||
}
|
||||
|
||||
if (!opener.children || opener.children.values.length === 0) {
|
||||
Qt.callLater(() => showAt(item, x, y))
|
||||
return
|
||||
}
|
||||
|
||||
anchorItem = item
|
||||
anchorX = x
|
||||
anchorY = y
|
||||
|
||||
visible = true
|
||||
forceActiveFocus()
|
||||
|
||||
// Force update after showing.
|
||||
Qt.callLater(() => {
|
||||
root.anchor.updateAnchor()
|
||||
})
|
||||
}
|
||||
|
||||
function hideMenu() {
|
||||
visible = false
|
||||
|
||||
// Clean up all submenus recursively
|
||||
for (var i = 0; i < columnLayout.children.length; i++) {
|
||||
const child = columnLayout.children[i]
|
||||
if (child?.subMenu) {
|
||||
child.subMenu.hideMenu()
|
||||
child.subMenu.destroy()
|
||||
child.subMenu = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-sized, transparent MouseArea to track the mouse.
|
||||
MouseArea {
|
||||
id: rootMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
Keys.onEscapePressed: root.hideMenu()
|
||||
}
|
||||
|
||||
QsMenuOpener {
|
||||
id: opener
|
||||
menu: root.menu
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Colors.base
|
||||
border.color: Colors.primary
|
||||
border.width: 2
|
||||
radius: Style.radiusM
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: flickable
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
contentHeight: columnLayout.implicitHeight
|
||||
interactive: true
|
||||
|
||||
// Use a ColumnLayout to handle menu item arrangement
|
||||
ColumnLayout {
|
||||
id: columnLayout
|
||||
width: flickable.width
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: opener.children ? [...opener.children.values] : []
|
||||
|
||||
delegate: Rectangle {
|
||||
id: entry
|
||||
required property var modelData
|
||||
|
||||
Layout.preferredWidth: parent.width
|
||||
Layout.preferredHeight: {
|
||||
if (modelData?.isSeparator) {
|
||||
return 8
|
||||
} else {
|
||||
// Calculate based on text content
|
||||
const textHeight = text.contentHeight || (Style.fontSizeS * 1.2)
|
||||
return Math.max(28, textHeight + (Style.marginS * 2))
|
||||
}
|
||||
}
|
||||
|
||||
color: Colors.transparent
|
||||
property var subMenu: null
|
||||
|
||||
NDivider {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - (Style.marginM * 2)
|
||||
visible: modelData?.isSeparator ?? false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: mouseArea.containsMouse ? Colors.primary : Colors.transparent
|
||||
radius: Style.radiusS
|
||||
visible: !(modelData?.isSeparator ?? false)
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NText {
|
||||
id: text
|
||||
Layout.fillWidth: true
|
||||
color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) : Color.mOnSurfaceVariant
|
||||
text: modelData?.text !== "" ? modelData?.text.replace(/[\n\r]+/g, ' ') : "..."
|
||||
pointSize: Style.fontSizeS
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
wrapMode: Text.WordWrap
|
||||
family: Fonts.sans
|
||||
}
|
||||
|
||||
Image {
|
||||
Layout.preferredWidth: Style.marginL
|
||||
Layout.preferredHeight: Style.marginL
|
||||
source: modelData?.icon ?? ""
|
||||
visible: (modelData?.icon ?? "") !== ""
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: modelData?.hasChildren ? "menu" : ""
|
||||
pointSize: Style.fontSizeS
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
visible: modelData?.hasChildren ?? false
|
||||
color: (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface)
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: (modelData?.enabled ?? true) && !(modelData?.isSeparator ?? false) && root.visible
|
||||
|
||||
onClicked: {
|
||||
if (modelData && !modelData.isSeparator && !modelData.hasChildren) {
|
||||
modelData.triggered()
|
||||
root.hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onEntered: {
|
||||
if (!root.visible)
|
||||
return
|
||||
|
||||
// Close all sibling submenus
|
||||
for (var i = 0; i < columnLayout.children.length; i++) {
|
||||
const sibling = columnLayout.children[i]
|
||||
if (sibling !== entry && sibling?.subMenu) {
|
||||
sibling.subMenu.hideMenu()
|
||||
sibling.subMenu.destroy()
|
||||
sibling.subMenu = null
|
||||
}
|
||||
}
|
||||
|
||||
// Create submenu if needed
|
||||
if (modelData?.hasChildren) {
|
||||
if (entry.subMenu) {
|
||||
entry.subMenu.hideMenu()
|
||||
entry.subMenu.destroy()
|
||||
}
|
||||
|
||||
// Need a slight overlap so that menu don't close when moving the mouse to a submenu
|
||||
const submenuWidth = menuWidth // Assuming a similar width as the parent
|
||||
const overlap = 4 // A small overlap to bridge the mouse path
|
||||
|
||||
// Position with overlap
|
||||
const anchorX = -submenuWidth + overlap
|
||||
|
||||
// Create submenu
|
||||
entry.subMenu = Qt.createComponent("TrayMenu.qml").createObject(root, {
|
||||
"menu": modelData,
|
||||
"anchorItem": entry,
|
||||
"anchorX": anchorX,
|
||||
"anchorY": 0,
|
||||
"isSubMenu": true,
|
||||
"screen": root.screen
|
||||
})
|
||||
|
||||
if (entry.subMenu) {
|
||||
entry.subMenu.showAt(entry, anchorX, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
Qt.callLater(() => {
|
||||
if (entry.subMenu && !entry.subMenu.isHovered) {
|
||||
entry.subMenu.hideMenu()
|
||||
entry.subMenu.destroy()
|
||||
entry.subMenu = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (subMenu) {
|
||||
subMenu.destroy()
|
||||
subMenu = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
config/quickshell/Modules/Misc/Corner.qml
Normal file
87
config/quickshell/Modules/Misc/Corner.qml
Normal file
@@ -0,0 +1,87 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.Constants
|
||||
|
||||
Shape {
|
||||
id: root
|
||||
|
||||
property string position: "topleft" // Corner position: topleft/topright/bottomleft/bottomright
|
||||
property real size: 1 // Scale multiplier for entire corner
|
||||
property int concaveWidth: 100 * size
|
||||
property int concaveHeight: 60 * size
|
||||
property int offsetX: -20
|
||||
property int offsetY: -20
|
||||
property color fillColor: Colors.base
|
||||
property int arcRadius: 20 * size
|
||||
property var modelData: null
|
||||
// Position flags derived from position string
|
||||
property bool _isTop: position.includes("top")
|
||||
property bool _isLeft: position.includes("left")
|
||||
property bool _isRight: position.includes("right")
|
||||
property bool _isBottom: position.includes("bottom")
|
||||
// Shift the path vertically if offsetY is negative to pull shape up
|
||||
property real pathOffsetY: Math.min(offsetY, 0)
|
||||
// Base coordinates for left corner shape, shifted by pathOffsetY vertically
|
||||
property real _baseStartX: 30 * size
|
||||
property real _baseStartY: (_isTop ? 20 * size : 0) + pathOffsetY
|
||||
property real _baseLineX: 30 * size
|
||||
property real _baseLineY: (_isTop ? 0 : 20 * size) + pathOffsetY
|
||||
property real _baseArcX: 50 * size
|
||||
property real _baseArcY: (_isTop ? 20 * size : 0) + pathOffsetY
|
||||
// Mirror coordinates for right corners
|
||||
property real _startX: _isRight ? (concaveWidth - _baseStartX) : _baseStartX
|
||||
property real _startY: _baseStartY
|
||||
property real _lineX: _isRight ? (concaveWidth - _baseLineX) : _baseLineX
|
||||
property real _lineY: _baseLineY
|
||||
property real _arcX: _isRight ? (concaveWidth - _baseArcX) : _baseArcX
|
||||
property real _arcY: _baseArcY
|
||||
// Arc direction varies by corner to maintain proper concave shape
|
||||
property int _arcDirection: {
|
||||
if (_isTop && _isLeft)
|
||||
return PathArc.Counterclockwise;
|
||||
|
||||
if (_isTop && _isRight)
|
||||
return PathArc.Clockwise;
|
||||
|
||||
if (_isBottom && _isLeft)
|
||||
return PathArc.Clockwise;
|
||||
|
||||
if (_isBottom && _isRight)
|
||||
return PathArc.Counterclockwise;
|
||||
|
||||
return PathArc.Counterclockwise;
|
||||
}
|
||||
|
||||
width: concaveWidth
|
||||
height: concaveHeight
|
||||
// Position relative to parent based on corner type
|
||||
x: _isLeft ? offsetX : (parent ? parent.width - width + offsetX : 0)
|
||||
y: _isTop ? offsetY : (parent ? parent.height - height + offsetY : 0)
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
layer.enabled: true
|
||||
layer.samples: 4
|
||||
|
||||
ShapePath {
|
||||
strokeWidth: 0
|
||||
fillColor: root.fillColor
|
||||
strokeColor: root.fillColor
|
||||
startX: root._startX
|
||||
startY: root._startY
|
||||
|
||||
PathLine {
|
||||
x: root._lineX
|
||||
y: root._lineY
|
||||
}
|
||||
|
||||
PathArc {
|
||||
x: root._arcX
|
||||
y: root._arcY
|
||||
radiusX: root.arcRadius
|
||||
radiusY: root.arcRadius
|
||||
useLargeArc: false
|
||||
direction: root._arcDirection
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
178
config/quickshell/Modules/Misc/Corners.qml
Normal file
178
config/quickshell/Modules/Misc/Corners.qml
Normal file
@@ -0,0 +1,178 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Constants
|
||||
import qs.Modules.Misc
|
||||
import qs.Services
|
||||
|
||||
Scope {
|
||||
id: rootScope
|
||||
|
||||
property var shell
|
||||
property string namespace: "quickshell-corners"
|
||||
property int topMargin: 45
|
||||
property int cornerHeight: 20
|
||||
property real cornerSize: 1
|
||||
property real opacity: Niri.noFocus ? 0 : 1
|
||||
|
||||
Item {
|
||||
id: cornersRootItem
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
Item {
|
||||
property var modelData
|
||||
|
||||
PanelWindow {
|
||||
id: fakeBar
|
||||
|
||||
anchors.top: true
|
||||
anchors.left: true
|
||||
anchors.right: true
|
||||
color: "transparent"
|
||||
screen: modelData
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
visible: true
|
||||
WlrLayershell.layer: WlrLayer.Background
|
||||
aboveWindows: false
|
||||
WlrLayershell.namespace: namespace
|
||||
implicitHeight: topMargin
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Colors.base
|
||||
opacity: rootScope.opacity
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: topLeftPanel
|
||||
|
||||
anchors.top: true
|
||||
anchors.left: true
|
||||
color: "transparent"
|
||||
screen: modelData
|
||||
margins.top: topMargin
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
visible: true
|
||||
WlrLayershell.layer: WlrLayer.Background
|
||||
aboveWindows: false
|
||||
WlrLayershell.namespace: namespace
|
||||
implicitHeight: cornerHeight
|
||||
|
||||
Corner {
|
||||
id: topLeftCorner
|
||||
|
||||
position: "bottomleft"
|
||||
size: rootScope.cornerSize
|
||||
offsetX: -32
|
||||
offsetY: 0
|
||||
anchors.top: parent.top
|
||||
opacity: rootScope.opacity
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: topRightPanel
|
||||
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
color: "transparent"
|
||||
screen: modelData
|
||||
margins.top: topMargin
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
visible: true
|
||||
WlrLayershell.layer: WlrLayer.Background
|
||||
aboveWindows: false
|
||||
WlrLayershell.namespace: namespace
|
||||
implicitHeight: cornerHeight
|
||||
|
||||
Corner {
|
||||
id: topRightCorner
|
||||
|
||||
position: "bottomright"
|
||||
size: rootScope.cornerSize
|
||||
offsetX: 32
|
||||
offsetY: 0
|
||||
anchors.top: parent.top
|
||||
opacity: rootScope.opacity
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: bottomLeftPanel
|
||||
|
||||
anchors.bottom: true
|
||||
anchors.left: true
|
||||
color: "transparent"
|
||||
screen: modelData
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
visible: true
|
||||
WlrLayershell.layer: WlrLayer.Background
|
||||
aboveWindows: false
|
||||
WlrLayershell.namespace: namespace
|
||||
implicitHeight: cornerHeight
|
||||
|
||||
Corner {
|
||||
id: bottomLeftCorner
|
||||
|
||||
position: "topleft"
|
||||
size: rootScope.cornerSize
|
||||
offsetX: -32
|
||||
offsetY: 0
|
||||
anchors.top: parent.top
|
||||
opacity: rootScope.opacity
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: bottomRightPanel
|
||||
|
||||
anchors.bottom: true
|
||||
anchors.right: true
|
||||
color: "transparent"
|
||||
screen: modelData
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
visible: true
|
||||
WlrLayershell.layer: WlrLayer.Background
|
||||
aboveWindows: false
|
||||
WlrLayershell.namespace: namespace
|
||||
implicitHeight: cornerHeight
|
||||
|
||||
Corner {
|
||||
id: bottomRightCorner
|
||||
|
||||
position: "topright"
|
||||
size: rootScope.cornerSize
|
||||
offsetX: 32
|
||||
offsetY: 0
|
||||
anchors.top: parent.top
|
||||
opacity: rootScope.opacity
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: 1000
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
439
config/quickshell/Modules/Misc/Notification.qml
Normal file
439
config/quickshell/Modules/Misc/Notification.qml
Normal file
@@ -0,0 +1,439 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.Notifications
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import qs.Constants
|
||||
import qs.Noctalia
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
// Simple notification popup - displays multiple notifications
|
||||
Variants {
|
||||
// Force removal without animation as fallback
|
||||
|
||||
// If no notification display activated in settings, then show them all
|
||||
model: Quickshell.screens
|
||||
|
||||
delegate: Loader {
|
||||
id: root
|
||||
|
||||
required property ShellScreen modelData
|
||||
property real scaling: 1
|
||||
// Access the notification model from the service
|
||||
property ListModel notificationModel: NotificationService.activeList
|
||||
|
||||
// Loader is active when there are notifications
|
||||
active: notificationModel.count > 0 || delayTimer.running
|
||||
|
||||
// Keep loader active briefly after last notification to allow animations to complete
|
||||
Timer {
|
||||
id: delayTimer
|
||||
|
||||
interval: Style.animationSlow + 200 // Animation duration + buffer
|
||||
repeat: false
|
||||
}
|
||||
|
||||
// Start delay timer when last notification is removed
|
||||
Connections {
|
||||
function onCountChanged() {
|
||||
if (notificationModel.count === 0 && root.active)
|
||||
delayTimer.restart();
|
||||
|
||||
}
|
||||
|
||||
target: notificationModel
|
||||
}
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
readonly property string location: "top_right"
|
||||
readonly property bool isTop: (location === "top") || (location.length >= 3 && location.substring(0, 3) === "top")
|
||||
readonly property bool isBottom: (location === "bottom") || (location.length >= 6 && location.substring(0, 6) === "bottom")
|
||||
readonly property bool isLeft: location.indexOf("_left") >= 0
|
||||
readonly property bool isRight: location.indexOf("_right") >= 0
|
||||
readonly property bool isCentered: (location === "top" || location === "bottom")
|
||||
// Store connection for cleanup
|
||||
property var animateConnection: null
|
||||
|
||||
screen: modelData
|
||||
WlrLayershell.namespace: "noctalia-notifications"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
color: Color.transparent
|
||||
// Anchor selection based on location (window edges)
|
||||
anchors.top: isTop
|
||||
anchors.bottom: isBottom
|
||||
anchors.left: isLeft
|
||||
anchors.right: isRight
|
||||
// Margins depending on bar position and chosen location
|
||||
margins.top: Style.barHeight + Style.marginM
|
||||
margins.bottom: 0
|
||||
margins.left: 0
|
||||
margins.right: Style.marginM
|
||||
implicitWidth: 360
|
||||
implicitHeight: notificationStack.implicitHeight
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
// Connect to animation signal from service
|
||||
Component.onCompleted: {
|
||||
animateConnection = NotificationService.animateAndRemove.connect(function(notificationId) {
|
||||
// Find the delegate by notification ID
|
||||
var delegate = null;
|
||||
if (notificationStack && notificationStack.children && notificationStack.children.length > 0) {
|
||||
for (var i = 0; i < notificationStack.children.length; i++) {
|
||||
var child = notificationStack.children[i];
|
||||
if (child && child.notificationId === notificationId) {
|
||||
delegate = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (delegate && delegate.animateOut)
|
||||
delegate.animateOut();
|
||||
else
|
||||
NotificationService.dismissActiveNotification(notificationId);
|
||||
});
|
||||
}
|
||||
// Disconnect when destroyed to prevent memory leaks
|
||||
Component.onDestruction: {
|
||||
if (animateConnection) {
|
||||
NotificationService.animateAndRemove.disconnect(animateConnection);
|
||||
animateConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Main notification container
|
||||
ColumnLayout {
|
||||
id: notificationStack
|
||||
|
||||
// Anchor the stack inside the window based on chosen location
|
||||
anchors.top: parent.isTop ? parent.top : undefined
|
||||
anchors.bottom: parent.isBottom ? parent.bottom : undefined
|
||||
anchors.left: parent.isLeft ? parent.left : undefined
|
||||
anchors.right: parent.isRight ? parent.right : undefined
|
||||
anchors.horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined
|
||||
spacing: Style.marginS
|
||||
width: 360
|
||||
visible: true
|
||||
|
||||
// Multiple notifications display
|
||||
Repeater {
|
||||
model: notificationModel
|
||||
|
||||
delegate: Rectangle {
|
||||
id: card
|
||||
|
||||
// Store the notification ID and data for reference
|
||||
property string notificationId: model.id
|
||||
property var notificationData: model
|
||||
// Animation properties
|
||||
property real scaleValue: 0.8
|
||||
property real opacityValue: 0
|
||||
property bool isRemoving: false
|
||||
|
||||
// Animate out when being removed
|
||||
function animateOut() {
|
||||
if (isRemoving)
|
||||
return ;
|
||||
|
||||
// Prevent multiple animations
|
||||
isRemoving = true;
|
||||
scaleValue = 0.8;
|
||||
opacityValue = 0;
|
||||
}
|
||||
|
||||
Layout.preferredWidth: 360
|
||||
Layout.preferredHeight: notificationLayout.implicitHeight + (Style.marginL * 2)
|
||||
Layout.maximumHeight: Layout.preferredHeight
|
||||
radius: Style.radiusL
|
||||
border.color: Colors.overlay0
|
||||
border.width: Math.max(1, Style.borderS)
|
||||
color: Color.mSurface
|
||||
// Scale and fade-in animation
|
||||
scale: scaleValue
|
||||
opacity: opacityValue
|
||||
// Animate in when the item is created
|
||||
Component.onCompleted: {
|
||||
scaleValue = 1;
|
||||
opacityValue = 1;
|
||||
}
|
||||
// Check if this notification is being removed
|
||||
onIsRemovingChanged: {
|
||||
if (isRemoving)
|
||||
removalTimer.start();
|
||||
|
||||
}
|
||||
|
||||
// Optimized progress bar container
|
||||
Rectangle {
|
||||
id: progressBarContainer
|
||||
|
||||
// Pre-calculate available width for the progress bar
|
||||
readonly property real availableWidth: parent.width - (2 * parent.radius)
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 2
|
||||
color: Color.transparent
|
||||
|
||||
// Actual progress bar - centered and symmetric
|
||||
Rectangle {
|
||||
id: progressBar
|
||||
|
||||
height: parent.height
|
||||
// Center the bar and make it shrink symmetrically
|
||||
x: parent.parent.radius + (parent.availableWidth * (1 - model.progress)) / 2
|
||||
width: parent.availableWidth * model.progress
|
||||
color: {
|
||||
if (model.urgency === NotificationUrgency.Critical || model.urgency === 2)
|
||||
return Colors.red;
|
||||
else if (model.urgency === NotificationUrgency.Low || model.urgency === 0)
|
||||
return Colors.green;
|
||||
else
|
||||
return Colors.primary;
|
||||
}
|
||||
antialiasing: true
|
||||
|
||||
// Smooth progress animation
|
||||
Behavior on width {
|
||||
enabled: !card.isRemoving // Disable during removal animation
|
||||
|
||||
NumberAnimation {
|
||||
duration: 100 // Quick but smooth
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on x {
|
||||
enabled: !card.isRemoving
|
||||
|
||||
NumberAnimation {
|
||||
duration: 100
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Right-click to dismiss
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: (mouse) => {
|
||||
if (mouse.button === Qt.RightButton)
|
||||
animateOut();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Timer for delayed removal after animation
|
||||
Timer {
|
||||
id: removalTimer
|
||||
|
||||
interval: Style.animationSlow
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
NotificationService.dismissActiveNotification(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: notificationLayout
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
anchors.rightMargin: (Style.marginM + 32) // Leave space for close button
|
||||
spacing: Style.marginM
|
||||
|
||||
// Main content section
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
ColumnLayout {
|
||||
// For real-time notification always show the original image
|
||||
// as the cached version is most likely still processing.
|
||||
NImageCircled {
|
||||
Layout.preferredWidth: 40
|
||||
Layout.preferredHeight: 40
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.topMargin: 30
|
||||
imagePath: model.originalImage || ""
|
||||
borderColor: Color.transparent
|
||||
borderWidth: 0
|
||||
fallbackIcon: "bell"
|
||||
fallbackIconSize: 24
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Text content
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
// Header section with app name and timestamp
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 6
|
||||
Layout.preferredHeight: 6
|
||||
radius: Style.radiusXS
|
||||
color: {
|
||||
if (model.urgency === NotificationUrgency.Critical || model.urgency === 2)
|
||||
return Color.mError;
|
||||
else if (model.urgency === NotificationUrgency.Low || model.urgency === 0)
|
||||
return Color.mOnSurface;
|
||||
else
|
||||
return Color.mPrimary;
|
||||
}
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: `${model.appName || I18n.tr("system.unknown-app")} · ${Time.formatRelativeTime(model.timestamp)}`
|
||||
color: Color.mSecondary
|
||||
pointSize: Style.fontSizeXS
|
||||
family: Fonts.sans
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.summary || I18n.tr("general.no-summary")
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: Color.mOnSurface
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
Layout.fillWidth: true
|
||||
maximumLineCount: 3
|
||||
elide: Text.ElideRight
|
||||
family: Fonts.sans
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.body || ""
|
||||
pointSize: Style.fontSizeM
|
||||
color: Color.mOnSurface
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
Layout.fillWidth: true
|
||||
maximumLineCount: 5
|
||||
elide: Text.ElideRight
|
||||
family: Fonts.sans
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
// Notification actions
|
||||
Flow {
|
||||
// Store the notification ID for access in button delegates
|
||||
property string parentNotificationId: notificationId
|
||||
// Parse actions from JSON string
|
||||
property var parsedActions: {
|
||||
try {
|
||||
return model.actionsJson ? JSON.parse(model.actionsJson) : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
Layout.topMargin: Style.marginM
|
||||
flow: Flow.LeftToRight
|
||||
layoutDirection: Qt.LeftToRight
|
||||
visible: parsedActions.length > 0
|
||||
|
||||
Repeater {
|
||||
model: parent.parsedActions
|
||||
|
||||
delegate: NButton {
|
||||
property var actionData: modelData
|
||||
|
||||
text: {
|
||||
var actionText = actionData.text || "Open";
|
||||
// If text contains comma, take the part after the comma (the display text)
|
||||
if (actionText.includes(","))
|
||||
return actionText.split(",")[1] || actionText;
|
||||
|
||||
return actionText;
|
||||
}
|
||||
fontFamily: Fonts.sans
|
||||
fontSize: Style.fontSizeS
|
||||
backgroundColor: Color.mPrimary
|
||||
textColor: hovered ? Color.mOnTertiary : Color.mOnPrimary
|
||||
hoverColor: Color.mTertiary
|
||||
outlined: false
|
||||
implicitHeight: 24
|
||||
onClicked: {
|
||||
NotificationService.invokeAction(parent.parentNotificationId, actionData.identifier);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Close button positioned absolutely
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
baseSize: Style.baseWidgetSize * 0.6
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: Style.marginM
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.marginM
|
||||
onClicked: {
|
||||
animateOut();
|
||||
}
|
||||
}
|
||||
|
||||
// Animation behaviors
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutExpo
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
526
config/quickshell/Modules/Panel/CalendarPanel.qml
Normal file
526
config/quickshell/Modules/Panel/CalendarPanel.qml
Normal file
@@ -0,0 +1,526 @@
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Constants
|
||||
import qs.Noctalia
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
NPanel {
|
||||
id: root
|
||||
|
||||
preferredWidth: 400
|
||||
preferredHeight: 520
|
||||
|
||||
panelContent: ColumnLayout {
|
||||
id: content
|
||||
|
||||
readonly property int firstDayOfWeek: Qt.locale().firstDayOfWeek
|
||||
property bool isCurrentMonth: checkIsCurrentMonth()
|
||||
readonly property bool weatherReady: (LocationService.data.weather !== null)
|
||||
|
||||
function checkIsCurrentMonth() {
|
||||
return (Time.date.getMonth() === grid.month) && (Time.date.getFullYear() === grid.year);
|
||||
}
|
||||
|
||||
function getISOWeekNumber(date) {
|
||||
const target = new Date(date.getTime());
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const dayOfWeek = target.getDay() || 7;
|
||||
target.setDate(target.getDate() + 4 - dayOfWeek);
|
||||
const yearStart = new Date(target.getFullYear(), 0, 1);
|
||||
const weekNumber = Math.ceil(((target - yearStart) / 8.64e+07 + 1) / 7);
|
||||
return weekNumber;
|
||||
}
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
Connections {
|
||||
function onDateChanged() {
|
||||
isCurrentMonth = checkIsCurrentMonth();
|
||||
}
|
||||
|
||||
target: Time
|
||||
}
|
||||
|
||||
// Combined blue banner with date/time and weather summary
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: blueColumn.implicitHeight + Style.marginM * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: blueColumn
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: 0
|
||||
|
||||
// Combined layout for weather icon, date, and weather text
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 60
|
||||
spacing: Style.marginS
|
||||
|
||||
// Weather icon and temperature
|
||||
ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: Style.marginXXS
|
||||
|
||||
NIcon {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
icon: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode) : "cloud"
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: Colors.text
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: {
|
||||
if (!weatherReady)
|
||||
return "";
|
||||
|
||||
var temp = LocationService.data.weather.current_weather.temperature;
|
||||
var suffix = "C";
|
||||
temp = Math.round(temp);
|
||||
return `${temp}°${suffix}`;
|
||||
}
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.text
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Today day number
|
||||
NText {
|
||||
visible: content.isCurrentMonth
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
|
||||
text: Time.date.getDate()
|
||||
pointSize: Style.fontSizeXXXL * 1.5
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.text
|
||||
}
|
||||
|
||||
Item {
|
||||
visible: !content.isCurrentMonth
|
||||
}
|
||||
|
||||
// Month, year, location
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: false
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
|
||||
spacing: -Style.marginXS
|
||||
|
||||
RowLayout {
|
||||
spacing: 0
|
||||
|
||||
NText {
|
||||
text: Qt.locale().monthName(grid.month, Locale.LongFormat).toUpperCase()
|
||||
pointSize: Style.fontSizeXL * 1.2
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.text
|
||||
Layout.alignment: Qt.AlignBaseline
|
||||
Layout.maximumWidth: 150
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
NText {
|
||||
text: ` ${grid.year}`
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Qt.alpha(Colors.text, 0.7)
|
||||
Layout.alignment: Qt.AlignBaseline
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: 0
|
||||
|
||||
NText {
|
||||
text: {
|
||||
if (!weatherReady)
|
||||
return "Weather unavailable";
|
||||
|
||||
const chunks = LocationService.data.name.split(",");
|
||||
return chunks[0];
|
||||
}
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: Colors.text
|
||||
Layout.maximumWidth: 150
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
NText {
|
||||
text: weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : ""
|
||||
pointSize: Style.fontSizeXS
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: Qt.alpha(Colors.text, 0.7)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Spacer between date and clock
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// Digital clock with circular progress
|
||||
Item {
|
||||
width: Style.fontSizeXXXL * 1.9
|
||||
height: Style.fontSizeXXXL * 1.9
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
// Seconds circular progress
|
||||
Canvas {
|
||||
id: secondsProgress
|
||||
|
||||
property real progress: Time.date.getSeconds() / 60
|
||||
|
||||
anchors.fill: parent
|
||||
onProgressChanged: requestPaint()
|
||||
onPaint: {
|
||||
var ctx = getContext("2d");
|
||||
var centerX = width / 2;
|
||||
var centerY = height / 2;
|
||||
var radius = Math.min(width, height) / 2 - 3;
|
||||
ctx.reset();
|
||||
// Background circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.strokeStyle = Qt.alpha(Colors.text, 0.15);
|
||||
ctx.stroke();
|
||||
// Progress arc
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI);
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.strokeStyle = Colors.text;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onDateChanged() {
|
||||
secondsProgress.progress = Time.date.getSeconds() / 60;
|
||||
}
|
||||
|
||||
target: Time
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Digital clock
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: -Style.marginXXS
|
||||
|
||||
NText {
|
||||
text: {
|
||||
var t = Qt.locale().toString(new Date(), "HH");
|
||||
return t.split(" ")[0];
|
||||
}
|
||||
pointSize: Style.fontSizeXS
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.text
|
||||
family: Fonts.sans
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: Qt.formatTime(Time.date, "mm")
|
||||
pointSize: Style.fontSizeXXS
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Colors.text
|
||||
family: Fonts.sans
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 6-day forecast (outside blue banner)
|
||||
RowLayout {
|
||||
visible: weatherReady
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: Style.marginL
|
||||
|
||||
Repeater {
|
||||
model: weatherReady ? Math.min(6, LocationService.data.weather.daily.time.length) : 0
|
||||
|
||||
delegate: ColumnLayout {
|
||||
Layout.preferredWidth: 0
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: Style.marginS
|
||||
|
||||
NText {
|
||||
text: {
|
||||
var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/"));
|
||||
return Qt.locale().toString(weatherDate, "ddd");
|
||||
}
|
||||
color: Color.mOnSurfaceVariant
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightMedium
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NIcon {
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
|
||||
icon: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index])
|
||||
pointSize: Style.fontSizeXXL * 1.5
|
||||
color: LocationService.weatherColorFromCode(LocationService.data.weather.daily.weathercode[index])
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: {
|
||||
var max = LocationService.data.weather.daily.temperature_2m_max[index];
|
||||
var min = LocationService.data.weather.daily.temperature_2m_min[index];
|
||||
max = Math.round(max);
|
||||
min = Math.round(min);
|
||||
return `${max}°/${min}°`;
|
||||
}
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Colors.text
|
||||
font.weight: Style.fontWeightMedium
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Loading indicator for weather
|
||||
RowLayout {
|
||||
visible: !weatherReady
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
NBusyIndicator {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Spacer
|
||||
Item {
|
||||
}
|
||||
|
||||
// Navigation and divider
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "chevron-left"
|
||||
colorBg: Color.transparent
|
||||
colorBorder: Color.transparent
|
||||
colorBorderHover: Color.transparent
|
||||
onClicked: {
|
||||
let newDate = new Date(grid.year, grid.month - 1, 1);
|
||||
grid.year = newDate.getFullYear();
|
||||
grid.month = newDate.getMonth();
|
||||
content.isCurrentMonth = content.checkIsCurrentMonth();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "calendar"
|
||||
colorBg: Color.transparent
|
||||
colorBorder: Color.transparent
|
||||
colorBorderHover: Color.transparent
|
||||
onClicked: {
|
||||
grid.month = Time.date.getMonth();
|
||||
grid.year = Time.date.getFullYear();
|
||||
content.isCurrentMonth = true;
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "chevron-right"
|
||||
colorBg: Color.transparent
|
||||
colorBorder: Color.transparent
|
||||
colorBorderHover: Color.transparent
|
||||
onClicked: {
|
||||
let newDate = new Date(grid.year, grid.month + 1, 1);
|
||||
grid.year = newDate.getFullYear();
|
||||
grid.month = newDate.getMonth();
|
||||
content.isCurrentMonth = content.checkIsCurrentMonth();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Names of days of the week
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 7
|
||||
rows: 1
|
||||
columnSpacing: 0
|
||||
rowSpacing: 0
|
||||
|
||||
Repeater {
|
||||
model: 7
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Style.baseWidgetSize * 0.6
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
text: {
|
||||
let dayIndex = (content.firstDayOfWeek + index) % 7;
|
||||
const dayNames = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
return dayNames[dayIndex];
|
||||
}
|
||||
color: Color.mPrimary
|
||||
pointSize: Style.fontSizeS
|
||||
font.weight: Style.fontWeightBold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Grid with weeks and days
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 0
|
||||
|
||||
// Column of week numbers
|
||||
ColumnLayout {
|
||||
Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0
|
||||
Layout.fillHeight: true
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: 6
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
color: Color.mOutline
|
||||
pointSize: Style.fontSizeXXS
|
||||
font.weight: Style.fontWeightMedium
|
||||
text: {
|
||||
let firstOfMonth = new Date(grid.year, grid.month, 1);
|
||||
let firstDayOfWeek = content.firstDayOfWeek;
|
||||
let firstOfMonthDayOfWeek = firstOfMonth.getDay();
|
||||
let daysBeforeFirst = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7;
|
||||
if (daysBeforeFirst === 0)
|
||||
daysBeforeFirst = 7;
|
||||
|
||||
let gridStartDate = new Date(grid.year, grid.month, 1 - daysBeforeFirst);
|
||||
let rowStartDate = new Date(gridStartDate);
|
||||
rowStartDate.setDate(gridStartDate.getDate() + (index * 7));
|
||||
let thursday = new Date(rowStartDate);
|
||||
if (firstDayOfWeek === 0) {
|
||||
thursday.setDate(rowStartDate.getDate() + 4);
|
||||
} else if (firstDayOfWeek === 1) {
|
||||
thursday.setDate(rowStartDate.getDate() + 3);
|
||||
} else {
|
||||
let daysToThursday = (4 - firstDayOfWeek + 7) % 7;
|
||||
thursday.setDate(rowStartDate.getDate() + daysToThursday);
|
||||
}
|
||||
return `${getISOWeekNumber(thursday)}`;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Days Grid
|
||||
MonthGrid {
|
||||
id: grid
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: Style.marginXXS
|
||||
month: Time.date.getMonth()
|
||||
year: Time.date.getFullYear()
|
||||
locale: Qt.locale()
|
||||
|
||||
delegate: Item {
|
||||
Rectangle {
|
||||
width: Style.baseWidgetSize * 0.9
|
||||
height: Style.baseWidgetSize * 0.9
|
||||
anchors.centerIn: parent
|
||||
radius: Style.radiusM
|
||||
color: model.today ? Color.mSecondary : Color.transparent
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
text: model.day
|
||||
color: {
|
||||
if (model.today)
|
||||
return Color.mOnSecondary;
|
||||
|
||||
if (model.month === grid.month)
|
||||
return Color.mOnSurface;
|
||||
|
||||
return Color.mOnSurfaceVariant;
|
||||
}
|
||||
opacity: model.month === grid.month ? 1 : 0.4
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: model.today ? Style.fontWeightBold : Style.fontWeightMedium
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
46
config/quickshell/Modules/Panel/Cards/LyricsCard.qml
Normal file
46
config/quickshell/Modules/Panel/Cards/LyricsCard.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
96
config/quickshell/Modules/Panel/Cards/LyricsControl.qml
Normal file
96
config/quickshell/Modules/Panel/Cards/LyricsControl.qml
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
458
config/quickshell/Modules/Panel/Cards/MediaCard.qml
Normal file
458
config/quickshell/Modules/Panel/Cards/MediaCard.qml
Normal 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() : {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
61
config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml
Normal file
61
config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
175
config/quickshell/Modules/Panel/Cards/TopLeftCard.qml
Normal file
175
config/quickshell/Modules/Panel/Cards/TopLeftCard.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
87
config/quickshell/Modules/Panel/ControlCenterPanel.qml
Normal file
87
config/quickshell/Modules/Panel/ControlCenterPanel.qml
Normal file
@@ -0,0 +1,87 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Constants
|
||||
import qs.Modules.Panel.Cards
|
||||
import qs.Noctalia
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
NPanel {
|
||||
id: root
|
||||
|
||||
// Positioning
|
||||
readonly property string controlCenterPosition: "top_left"
|
||||
property real topCardHeight: 120
|
||||
property real middleCardHeight: 100
|
||||
property real bottomCardHeight: 200
|
||||
|
||||
preferredWidth: 480
|
||||
preferredHeight: topCardHeight + middleCardHeight + bottomCardHeight + Style.marginL * 4
|
||||
panelKeyboardFocus: false
|
||||
panelAnchorHorizontalCenter: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_center")
|
||||
panelAnchorVerticalCenter: false
|
||||
panelAnchorLeft: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_left")
|
||||
panelAnchorRight: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_right")
|
||||
panelAnchorBottom: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("bottom_")
|
||||
panelAnchorTop: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("top_")
|
||||
|
||||
panelContent: Item {
|
||||
id: content
|
||||
|
||||
property real cardSpacing: Style.marginL
|
||||
|
||||
// Layout content
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: content.cardSpacing
|
||||
spacing: content.cardSpacing
|
||||
|
||||
// Top Card: profile + utilities
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: topCardHeight
|
||||
|
||||
TopLeftCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumHeight: topCardHeight
|
||||
}
|
||||
|
||||
LyricsControl {
|
||||
Layout.preferredHeight: topCardHeight
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
LyricsCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: middleCardHeight
|
||||
}
|
||||
|
||||
// Media + stats column
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: bottomCardHeight
|
||||
spacing: content.cardSpacing
|
||||
|
||||
SystemMonitorCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: bottomCardHeight
|
||||
}
|
||||
|
||||
MediaCard {
|
||||
Layout.preferredWidth: 270
|
||||
Layout.preferredHeight: bottomCardHeight
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
54
config/quickshell/Modules/Panel/Misc/MonitorSlider.qml
Normal file
54
config/quickshell/Modules/Panel/Misc/MonitorSlider.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
341
config/quickshell/Modules/Panel/NotificationHistoryPanel.qml
Normal file
341
config/quickshell/Modules/Panel/NotificationHistoryPanel.qml
Normal file
@@ -0,0 +1,341 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.Notifications
|
||||
import Quickshell.Wayland
|
||||
import qs.Constants
|
||||
import qs.Noctalia
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
// Notification History panel
|
||||
NPanel {
|
||||
id: root
|
||||
|
||||
preferredWidth: 380
|
||||
preferredHeight: 480
|
||||
|
||||
panelContent: Rectangle {
|
||||
id: notificationRect
|
||||
|
||||
color: Color.transparent
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
// Header section
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: "bell"
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: "Notifications"
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: SettingsService.notifications.doNotDisturb ? "bell-off" : "bell"
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: SettingsService.notifications.doNotDisturb = !SettingsService.notifications.doNotDisturb
|
||||
colorFg: SettingsService.notifications.doNotDisturb ? Colors.base : Colors.green
|
||||
colorBg: SettingsService.notifications.doNotDisturb ? Colors.green : Color.transparent
|
||||
colorFgHover: Colors.base
|
||||
colorBgHover: Colors.green
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "trash"
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: {
|
||||
NotificationService.clearHistory();
|
||||
// Close panel as there is nothing more to see.
|
||||
root.close();
|
||||
}
|
||||
colorFg: Colors.red
|
||||
colorBg: Color.transparent
|
||||
colorFgHover: Colors.base
|
||||
colorBgHover: Colors.red
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: root.close()
|
||||
colorFg: Colors.blue
|
||||
colorBg: Color.transparent
|
||||
colorFgHover: Colors.base
|
||||
colorBgHover: Colors.blue
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// Empty state when no notifications
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: NotificationService.historyList.count === 0
|
||||
spacing: Style.marginL
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: "bell-off"
|
||||
pointSize: 64
|
||||
color: Color.mOnSurfaceVariant
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: "No Notifications"
|
||||
pointSize: Style.fontSizeL
|
||||
color: Color.mOnSurfaceVariant
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Notification list
|
||||
NListView {
|
||||
id: notificationList
|
||||
|
||||
// Track which notification is expanded
|
||||
property string expandedId: ""
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
verticalPolicy: ScrollBar.AsNeeded
|
||||
model: NotificationService.historyList
|
||||
spacing: Style.marginM
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
visible: NotificationService.historyList.count > 0
|
||||
|
||||
delegate: NBox {
|
||||
property string notificationId: model.id
|
||||
property bool isExpanded: notificationList.expandedId === notificationId
|
||||
|
||||
width: notificationList.width
|
||||
height: notificationLayout.implicitHeight + (Style.marginM * 2)
|
||||
|
||||
// Click to expand/collapse
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
// Don't capture clicks on the delete button
|
||||
anchors.rightMargin: 48
|
||||
enabled: (summaryText.truncated || bodyText.truncated)
|
||||
onClicked: {
|
||||
if (notificationList.expandedId === notificationId)
|
||||
notificationList.expandedId = "";
|
||||
else
|
||||
notificationList.expandedId = notificationId;
|
||||
}
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: notificationLayout
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
ColumnLayout {
|
||||
NImageCircled {
|
||||
Layout.preferredWidth: 40
|
||||
Layout.preferredHeight: 40
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.topMargin: 20
|
||||
imagePath: model.cachedImage || model.originalImage || ""
|
||||
borderColor: Color.transparent
|
||||
borderWidth: 0
|
||||
fallbackIcon: "bell"
|
||||
fallbackIconSize: 24
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Notification content column
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: Style.marginXS
|
||||
Layout.rightMargin: -(Style.marginM + Style.baseWidgetSize * 0.6)
|
||||
|
||||
// Header row with app name and timestamp
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
// Urgency indicator
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 6
|
||||
Layout.preferredHeight: 6
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: 3
|
||||
visible: model.urgency !== 1
|
||||
color: {
|
||||
if (model.urgency === 2)
|
||||
return Color.mError;
|
||||
else if (model.urgency === 0)
|
||||
return Color.mOnSurfaceVariant;
|
||||
else
|
||||
return Color.transparent;
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.appName || "Unknown App"
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mSecondary
|
||||
family: Fonts.sans
|
||||
}
|
||||
|
||||
NText {
|
||||
text: Time.formatRelativeTime(model.timestamp)
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mSecondary
|
||||
family: Fonts.sans
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Summary
|
||||
NText {
|
||||
id: summaryText
|
||||
|
||||
text: model.summary || "No Summary"
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Font.Medium
|
||||
color: Color.mOnSurface
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
maximumLineCount: isExpanded ? 999 : 2
|
||||
family: Fonts.sans
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Body
|
||||
NText {
|
||||
id: bodyText
|
||||
|
||||
text: model.body || ""
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
maximumLineCount: isExpanded ? 999 : 3
|
||||
elide: Text.ElideRight
|
||||
family: Fonts.sans
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
// Spacer for expand indicator
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: (!isExpanded && (summaryText.truncated || bodyText.truncated)) ? (Style.marginS) : 0
|
||||
}
|
||||
|
||||
// Expand indicator
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: !isExpanded && (summaryText.truncated || bodyText.truncated)
|
||||
spacing: Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NText {
|
||||
text: "Click to expand"
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mPrimary
|
||||
family: Fonts.sans
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: "chevron-down"
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Delete button
|
||||
NIconButton {
|
||||
icon: "trash"
|
||||
baseSize: Style.baseWidgetSize * 0.7
|
||||
Layout.alignment: Qt.AlignTop
|
||||
onClicked: {
|
||||
// Remove from history using the service API
|
||||
NotificationService.removeFromHistory(notificationId);
|
||||
}
|
||||
colorFg: Colors.red
|
||||
colorBg: Color.transparent
|
||||
colorFgHover: Colors.base
|
||||
colorBgHover: Colors.red
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Smooth color transition on hover
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
28
config/quickshell/Noctalia/NBox.qml
Normal file
28
config/quickshell/Noctalia/NBox.qml
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
53
config/quickshell/Noctalia/NBusyIndicator.qml
Normal file
53
config/quickshell/Noctalia/NBusyIndicator.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
183
config/quickshell/Noctalia/NButton.qml
Normal file
183
config/quickshell/Noctalia/NButton.qml
Normal file
@@ -0,0 +1,183 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// Public properties
|
||||
property string text: ""
|
||||
property string icon: ""
|
||||
property string tooltipText
|
||||
property color backgroundColor: Color.mPrimary
|
||||
property color textColor: Color.mOnPrimary
|
||||
property color hoverColor: Color.mTertiary
|
||||
property bool enabled: true
|
||||
property real fontSize: Style.fontSizeM * scaling
|
||||
property int fontWeight: Style.fontWeightBold
|
||||
property string fontFamily: Fonts.primary
|
||||
property real iconSize: Style.fontSizeL * scaling
|
||||
property bool outlined: false
|
||||
// Internal properties
|
||||
property bool hovered: false
|
||||
property bool pressed: false
|
||||
|
||||
// Signals
|
||||
signal clicked()
|
||||
signal rightClicked()
|
||||
signal middleClicked()
|
||||
|
||||
// Dimensions
|
||||
implicitWidth: contentRow.implicitWidth + (Style.marginL * 2 * scaling)
|
||||
implicitHeight: Math.max(Style.baseWidgetSize * scaling, contentRow.implicitHeight + (Style.marginM * scaling))
|
||||
// Appearance
|
||||
radius: Style.radiusS * scaling
|
||||
color: {
|
||||
if (!enabled)
|
||||
return outlined ? Color.transparent : Qt.lighter(Color.mSurfaceVariant, 1.2);
|
||||
|
||||
if (hovered)
|
||||
return hoverColor;
|
||||
|
||||
return outlined ? Color.transparent : backgroundColor;
|
||||
}
|
||||
border.width: outlined ? Math.max(1, Style.borderS * scaling) : 0
|
||||
border.color: {
|
||||
if (!enabled)
|
||||
return Color.mOutline;
|
||||
|
||||
if (pressed || hovered)
|
||||
return backgroundColor;
|
||||
|
||||
return outlined ? backgroundColor : Color.transparent;
|
||||
}
|
||||
opacity: enabled ? 1 : 0.6
|
||||
|
||||
// Content
|
||||
RowLayout {
|
||||
id: contentRow
|
||||
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.marginXS * scaling
|
||||
|
||||
// Icon (optional)
|
||||
NIcon {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: root.icon !== ""
|
||||
icon: root.icon
|
||||
pointSize: root.iconSize
|
||||
color: {
|
||||
if (!root.enabled)
|
||||
return Color.mOnSurfaceVariant;
|
||||
|
||||
if (root.outlined) {
|
||||
if (root.pressed || root.hovered)
|
||||
return root.backgroundColor;
|
||||
|
||||
return root.backgroundColor;
|
||||
}
|
||||
return root.textColor;
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Text
|
||||
NText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: root.text !== ""
|
||||
text: root.text
|
||||
pointSize: root.fontSize
|
||||
font.weight: root.fontWeight
|
||||
family: root.fontFamily
|
||||
color: {
|
||||
if (!root.enabled)
|
||||
return Color.mOnSurfaceVariant;
|
||||
|
||||
if (root.outlined) {
|
||||
if (root.hovered)
|
||||
return root.textColor;
|
||||
|
||||
return root.backgroundColor;
|
||||
}
|
||||
return root.textColor;
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Mouse interaction
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
enabled: root.enabled
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onEntered: {
|
||||
root.hovered = true;
|
||||
if (tooltipText)
|
||||
TooltipService.show(Screen, root, root.tooltipText);
|
||||
|
||||
}
|
||||
onExited: {
|
||||
root.hovered = false;
|
||||
if (tooltipText)
|
||||
TooltipService.hide();
|
||||
|
||||
}
|
||||
onPressed: (mouse) => {
|
||||
if (tooltipText)
|
||||
TooltipService.hide();
|
||||
|
||||
if (mouse.button === Qt.LeftButton)
|
||||
root.clicked();
|
||||
else if (mouse.button == Qt.RightButton)
|
||||
root.rightClicked();
|
||||
else if (mouse.button == Qt.MiddleButton)
|
||||
root.middleClicked();
|
||||
}
|
||||
onCanceled: {
|
||||
root.hovered = false;
|
||||
if (tooltipText)
|
||||
TooltipService.hide();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
122
config/quickshell/Noctalia/NCircleStat.qml
Normal file
122
config/quickshell/Noctalia/NCircleStat.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
124
config/quickshell/Noctalia/NContextMenu.qml
Normal file
124
config/quickshell/Noctalia/NContextMenu.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
35
config/quickshell/Noctalia/NDivider.qml
Normal file
35
config/quickshell/Noctalia/NDivider.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
28
config/quickshell/Noctalia/NIcon.qml
Normal file
28
config/quickshell/Noctalia/NIcon.qml
Normal 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
|
||||
}
|
||||
92
config/quickshell/Noctalia/NIconButton.qml
Normal file
92
config/quickshell/Noctalia/NIconButton.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
85
config/quickshell/Noctalia/NImageCircled.qml
Normal file
85
config/quickshell/Noctalia/NImageCircled.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
103
config/quickshell/Noctalia/NImageRounded.qml
Normal file
103
config/quickshell/Noctalia/NImageRounded.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
217
config/quickshell/Noctalia/NListView.qml
Normal file
217
config/quickshell/Noctalia/NListView.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
459
config/quickshell/Noctalia/NPanel.qml
Normal file
459
config/quickshell/Noctalia/NPanel.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
config/quickshell/Noctalia/NSlider.qml
Normal file
152
config/quickshell/Noctalia/NSlider.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
20
config/quickshell/Noctalia/NText.qml
Normal file
20
config/quickshell/Noctalia/NText.qml
Normal 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
|
||||
}
|
||||
146
config/quickshell/Services/AudioService.qml
Normal file
146
config/quickshell/Services/AudioService.qml
Normal file
@@ -0,0 +1,146 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Utils
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => {
|
||||
if (!node.isStream) {
|
||||
if (node.isSink) {
|
||||
acc.sinks.push(node)
|
||||
} else if (node.audio) {
|
||||
acc.sources.push(node)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {
|
||||
"sources": [],
|
||||
"sinks": []
|
||||
})
|
||||
|
||||
readonly property PwNode sink: Pipewire.defaultAudioSink
|
||||
readonly property PwNode source: Pipewire.defaultAudioSource
|
||||
readonly property list<PwNode> sinks: nodes.sinks
|
||||
readonly property list<PwNode> sources: nodes.sources
|
||||
|
||||
// Volume [0..1] is readonly from outside
|
||||
readonly property alias volume: root._volume
|
||||
property real _volume: sink?.audio?.volume ?? 0
|
||||
|
||||
readonly property alias muted: root._muted
|
||||
property bool _muted: !!sink?.audio?.muted
|
||||
|
||||
// Input volume [0..1] is readonly from outside
|
||||
readonly property alias inputVolume: root._inputVolume
|
||||
property real _inputVolume: source?.audio?.volume ?? 0
|
||||
|
||||
readonly property alias inputMuted: root._inputMuted
|
||||
property bool _inputMuted: !!source?.audio?.muted
|
||||
|
||||
readonly property real stepVolume: 5 / 100.0
|
||||
|
||||
PwObjectTracker {
|
||||
objects: [...root.sinks, ...root.sources]
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: sink?.audio ? sink?.audio : null
|
||||
|
||||
function onVolumeChanged() {
|
||||
var vol = (sink?.audio.volume ?? 0)
|
||||
if (isNaN(vol)) {
|
||||
vol = 0
|
||||
}
|
||||
root._volume = vol
|
||||
}
|
||||
|
||||
function onMutedChanged() {
|
||||
root._muted = (sink?.audio.muted ?? true)
|
||||
Logger.log("AudioService", "OnMuteChanged:", root._muted)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: source?.audio ? source?.audio : null
|
||||
|
||||
function onVolumeChanged() {
|
||||
var vol = (source?.audio.volume ?? 0)
|
||||
if (isNaN(vol)) {
|
||||
vol = 0
|
||||
}
|
||||
root._inputVolume = vol
|
||||
}
|
||||
|
||||
function onMutedChanged() {
|
||||
root._inputMuted = (source?.audio.muted ?? true)
|
||||
Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted)
|
||||
}
|
||||
}
|
||||
|
||||
function increaseVolume() {
|
||||
setVolume(volume + stepVolume)
|
||||
}
|
||||
|
||||
function decreaseVolume() {
|
||||
setVolume(volume - stepVolume)
|
||||
}
|
||||
|
||||
function setVolume(newVolume: real) {
|
||||
if (sink?.ready && sink?.audio) {
|
||||
// Clamp it accordingly
|
||||
sink.audio.muted = false
|
||||
sink.audio.volume = Math.max(0, Math.min(1.0, newVolume))
|
||||
} else {
|
||||
Logger.warn("AudioService", "No sink available")
|
||||
}
|
||||
}
|
||||
|
||||
function setOutputMuted(muted: bool) {
|
||||
if (sink?.ready && sink?.audio) {
|
||||
sink.audio.muted = muted
|
||||
} else {
|
||||
Logger.warn("AudioService", "No sink available")
|
||||
}
|
||||
}
|
||||
|
||||
function setInputVolume(newVolume: real) {
|
||||
if (source?.ready && source?.audio) {
|
||||
// Clamp it accordingly
|
||||
source.audio.muted = false
|
||||
source.audio.volume = Math.max(0, Math.min(1.0, newVolume))
|
||||
} else {
|
||||
Logger.warn("AudioService", "No source available")
|
||||
}
|
||||
}
|
||||
|
||||
function setInputMuted(muted: bool) {
|
||||
if (source?.ready && source?.audio) {
|
||||
source.audio.muted = muted
|
||||
} else {
|
||||
Logger.warn("AudioService", "No source available")
|
||||
}
|
||||
}
|
||||
|
||||
function setAudioSink(newSink: PwNode): void {
|
||||
Pipewire.preferredDefaultAudioSink = newSink
|
||||
// Volume is changed by the sink change
|
||||
root._volume = newSink?.audio?.volume ?? 0
|
||||
root._muted = !!newSink?.audio?.muted
|
||||
}
|
||||
|
||||
function setAudioSource(newSource: PwNode): void {
|
||||
Pipewire.preferredDefaultAudioSource = newSource
|
||||
// Volume is changed by the source change
|
||||
root._inputVolume = newSource?.audio?.volume ?? 0
|
||||
root._inputMuted = !!newSource?.audio?.muted
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
setOutputMuted(!muted)
|
||||
}
|
||||
}
|
||||
308
config/quickshell/Services/BrightnessService.qml
Normal file
308
config/quickshell/Services/BrightnessService.qml
Normal file
@@ -0,0 +1,308 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property list<var> ddcMonitors: []
|
||||
readonly property list<Monitor> monitors: variants.instances
|
||||
property bool appleDisplayPresent: false
|
||||
|
||||
function getMonitorForScreen(screen: ShellScreen): var {
|
||||
return monitors.find(m => m.modelData === screen)
|
||||
}
|
||||
|
||||
// Signal emitted when a specific monitor's brightness changes, includes monitor context
|
||||
signal monitorBrightnessChanged(var monitor, real newBrightness)
|
||||
|
||||
function getAvailableMethods(): list<string> {
|
||||
var methods = []
|
||||
if (monitors.some(m => m.isDdc))
|
||||
methods.push("ddcutil")
|
||||
if (monitors.some(m => !m.isDdc))
|
||||
methods.push("internal")
|
||||
if (appleDisplayPresent)
|
||||
methods.push("apple")
|
||||
return methods
|
||||
}
|
||||
|
||||
// Global helpers for IPC and shortcuts
|
||||
function increaseBrightness(): void {
|
||||
monitors.forEach(m => m.increaseBrightness())
|
||||
}
|
||||
|
||||
function decreaseBrightness(): void {
|
||||
monitors.forEach(m => m.decreaseBrightness())
|
||||
}
|
||||
|
||||
function getDetectedDisplays(): list<var> {
|
||||
return detectedDisplays
|
||||
}
|
||||
|
||||
reloadableId: "brightness"
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.log("Brightness", "Service started")
|
||||
}
|
||||
|
||||
onMonitorsChanged: {
|
||||
ddcMonitors = []
|
||||
ddcProc.running = true
|
||||
}
|
||||
|
||||
Variants {
|
||||
id: variants
|
||||
model: Quickshell.screens
|
||||
Monitor {}
|
||||
}
|
||||
|
||||
// Check for Apple Display support
|
||||
Process {
|
||||
running: true
|
||||
command: ["sh", "-c", "which asdbctl >/dev/null 2>&1 && asdbctl get || echo ''"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.appleDisplayPresent = text.trim().length > 0
|
||||
}
|
||||
}
|
||||
|
||||
// Detect DDC monitors
|
||||
Process {
|
||||
id: ddcProc
|
||||
property list<var> ddcMonitors: []
|
||||
command: ["ddcutil", "detect", "--sleep-multiplier=0.5"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var displays = text.trim().split("\n\n")
|
||||
ddcProc.ddcMonitors = displays.map(d => {
|
||||
|
||||
var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/)
|
||||
var modelMatch = d.match(/Model:\s*(.*)/)
|
||||
var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)
|
||||
var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false
|
||||
var model = modelMatch ? modelMatch[1] : "Unknown"
|
||||
var bus = busMatch ? busMatch[1] : "Unknown"
|
||||
Logger.log("Brigthness", "Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel)
|
||||
return {
|
||||
"model": model,
|
||||
"busNum": bus,
|
||||
"isDdc": !ddcModel
|
||||
}
|
||||
})
|
||||
root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Monitor: QtObject {
|
||||
id: monitor
|
||||
|
||||
required property ShellScreen modelData
|
||||
readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model)
|
||||
readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? ""
|
||||
readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay")
|
||||
readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal")
|
||||
|
||||
property real brightness
|
||||
property real lastBrightness: 0
|
||||
property real queuedBrightness: NaN
|
||||
|
||||
// For internal displays - store the backlight device path
|
||||
property string backlightDevice: ""
|
||||
property string brightnessPath: ""
|
||||
property string maxBrightnessPath: ""
|
||||
property int maxBrightness: 100
|
||||
property bool ignoreNextChange: false
|
||||
|
||||
// Signal for brightness changes
|
||||
signal brightnessUpdated(real newBrightness)
|
||||
|
||||
// Execute a system command to get the current brightness value directly
|
||||
readonly property Process refreshProc: Process {
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var dataText = text.trim()
|
||||
if (dataText === "") {
|
||||
return
|
||||
}
|
||||
|
||||
var lines = dataText.split("\n")
|
||||
if (lines.length >= 2) {
|
||||
var current = parseInt(lines[0].trim())
|
||||
var max = parseInt(lines[1].trim())
|
||||
if (!isNaN(current) && !isNaN(max) && max > 0) {
|
||||
var newBrightness = current / max
|
||||
// Only update if it's actually different (avoid feedback loops)
|
||||
if (Math.abs(newBrightness - monitor.brightness) > 0.01) {
|
||||
// Update internal value to match system state
|
||||
monitor.brightness = newBrightness
|
||||
monitor.brightnessUpdated(monitor.brightness)
|
||||
root.monitorBrightnessChanged(monitor, monitor.brightness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to actively refresh the brightness from system
|
||||
function refreshBrightnessFromSystem() {
|
||||
if (!monitor.isDdc && !monitor.isAppleDisplay) {
|
||||
// For internal displays, query the system directly
|
||||
refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath]
|
||||
refreshProc.running = true
|
||||
} else if (monitor.isDdc) {
|
||||
// For DDC displays, get the current value
|
||||
refreshProc.command = ["ddcutil", "-b", monitor.busNum, "getvcp", "10", "--brief"]
|
||||
refreshProc.running = true
|
||||
} else if (monitor.isAppleDisplay) {
|
||||
// For Apple displays, get the current value
|
||||
refreshProc.command = ["asdbctl", "get"]
|
||||
refreshProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
// FileView to watch for external brightness changes (internal displays only)
|
||||
readonly property FileView brightnessWatcher: FileView {
|
||||
id: brightnessWatcher
|
||||
// Only set path for internal displays with a valid brightness path
|
||||
path: (!monitor.isDdc && !monitor.isAppleDisplay && monitor.brightnessPath !== "") ? monitor.brightnessPath : ""
|
||||
watchChanges: path !== ""
|
||||
onFileChanged: {
|
||||
// When a file change is detected, actively refresh from system
|
||||
// to ensure we get the most up-to-date value
|
||||
Qt.callLater(() => {
|
||||
monitor.refreshBrightnessFromSystem()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize brightness
|
||||
readonly property Process initProc: Process {
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var dataText = text.trim()
|
||||
if (dataText === "") {
|
||||
return
|
||||
}
|
||||
|
||||
if (monitor.isAppleDisplay) {
|
||||
var val = parseInt(dataText)
|
||||
if (!isNaN(val)) {
|
||||
monitor.brightness = val / 101
|
||||
Logger.log("Brightness", "Apple display brightness:", monitor.brightness)
|
||||
}
|
||||
} else if (monitor.isDdc) {
|
||||
var parts = dataText.split(" ")
|
||||
if (parts.length >= 4) {
|
||||
var current = parseInt(parts[3])
|
||||
var max = parseInt(parts[4])
|
||||
if (!isNaN(current) && !isNaN(max) && max > 0) {
|
||||
monitor.brightness = current / max
|
||||
Logger.log("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Internal backlight - parse the response which includes device path
|
||||
var lines = dataText.split("\n")
|
||||
if (lines.length >= 3) {
|
||||
monitor.backlightDevice = lines[0]
|
||||
monitor.brightnessPath = monitor.backlightDevice + "/brightness"
|
||||
monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness"
|
||||
|
||||
var current = parseInt(lines[1])
|
||||
var max = parseInt(lines[2])
|
||||
if (!isNaN(current) && !isNaN(max) && max > 0) {
|
||||
monitor.maxBrightness = max
|
||||
monitor.brightness = current / max
|
||||
Logger.log("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness)
|
||||
Logger.log("Brightness", "Using backlight device:", monitor.backlightDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always update
|
||||
monitor.brightnessUpdated(monitor.brightness)
|
||||
root.monitorBrightnessChanged(monitor, monitor.brightness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property real stepSize: 5.0 / 100.0
|
||||
|
||||
// Timer for debouncing rapid changes
|
||||
readonly property Timer timer: Timer {
|
||||
interval: 100
|
||||
onTriggered: {
|
||||
if (!isNaN(monitor.queuedBrightness)) {
|
||||
monitor.setBrightness(monitor.queuedBrightness)
|
||||
monitor.queuedBrightness = NaN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setBrightnessDebounced(value: real): void {
|
||||
monitor.queuedBrightness = value
|
||||
timer.start()
|
||||
}
|
||||
|
||||
function increaseBrightness(): void {
|
||||
const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness
|
||||
setBrightnessDebounced(value + stepSize)
|
||||
}
|
||||
|
||||
function decreaseBrightness(): void {
|
||||
const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness
|
||||
setBrightnessDebounced(value - stepSize)
|
||||
}
|
||||
|
||||
function setBrightness(value: real): void {
|
||||
value = Math.max(0, Math.min(1, value))
|
||||
var rounded = Math.round(value * 100)
|
||||
|
||||
if (timer.running) {
|
||||
monitor.queuedBrightness = value
|
||||
return
|
||||
}
|
||||
|
||||
// Update internal value and trigger UI feedback
|
||||
monitor.brightness = value
|
||||
monitor.brightnessUpdated(value)
|
||||
root.monitorBrightnessChanged(monitor, monitor.brightness)
|
||||
|
||||
if (isAppleDisplay) {
|
||||
monitor.ignoreNextChange = true
|
||||
Quickshell.execDetached(["asdbctl", "set", rounded])
|
||||
} else if (isDdc) {
|
||||
monitor.ignoreNextChange = true
|
||||
Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded])
|
||||
} else {
|
||||
monitor.ignoreNextChange = true
|
||||
Quickshell.execDetached(["set-brightness", rounded + "%"])
|
||||
}
|
||||
|
||||
if (isDdc) {
|
||||
timer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
function initBrightness(): void {
|
||||
if (isAppleDisplay) {
|
||||
initProc.command = ["asdbctl", "get"]
|
||||
} else if (isDdc) {
|
||||
initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]
|
||||
} else {
|
||||
// Internal backlight - find the first available backlight device and get its info
|
||||
// This now returns: device_path, current_brightness, max_brightness (on separate lines)
|
||||
initProc.command = ["sh", "-c", "for dev in /sys/class/backlight/*; do " + " if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then " + " echo \"$dev\"; " + " cat \"$dev/brightness\"; " + " cat \"$dev/max_brightness\"; " + " break; " + " fi; " + "done"]
|
||||
}
|
||||
initProc.running = true
|
||||
}
|
||||
|
||||
onBusNumChanged: initBrightness()
|
||||
Component.onCompleted: initBrightness()
|
||||
}
|
||||
}
|
||||
32
config/quickshell/Services/CacheService.qml
Normal file
32
config/quickshell/Services/CacheService.qml
Normal file
@@ -0,0 +1,32 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string cacheDir: Quickshell.env("HOME") + "/.cache/quickshell/"
|
||||
property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/"
|
||||
property var cacheFiles: ["Location.json", "Ip.json", "Notifications.json", "LyricsOffset.txt"]
|
||||
property bool loaded: false
|
||||
property string locationCacheFile: cacheDir + "Location.json"
|
||||
property string ipCacheFile: cacheDir + "Ip.json"
|
||||
property string notificationsCacheFile: cacheDir + "Notifications.json"
|
||||
property string lyricsOffsetCacheFile: cacheDir + "LyricsOffset.txt"
|
||||
|
||||
Process {
|
||||
id: process
|
||||
|
||||
running: true
|
||||
command: ["sh", "-c", `mkdir -p ${cacheDir} && mkdir -p ${recordingDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`]
|
||||
onExited: (code, status) => {
|
||||
if (code === 0)
|
||||
root.loaded = true;
|
||||
else
|
||||
Logger.error("CacheService", `Failed to create cache files: ${command.join(" ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
143
config/quickshell/Services/Caffeine.qml
Normal file
143
config/quickshell/Services/Caffeine.qml
Normal file
@@ -0,0 +1,143 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string reason: "Application request"
|
||||
property bool isInhibited: false
|
||||
property var activeInhibitors: []
|
||||
// Different inhibitor strategies
|
||||
property string strategy: "systemd"
|
||||
|
||||
// Auto-detect the best strategy
|
||||
function detectStrategy() {
|
||||
if (strategy === "auto") {
|
||||
// Check if systemd-inhibit is available
|
||||
try {
|
||||
var systemdResult = Quickshell.execDetached(["which", "systemd-inhibit"]);
|
||||
strategy = "systemd";
|
||||
return ;
|
||||
} catch (e) {
|
||||
}
|
||||
try {
|
||||
var waylandResult = Quickshell.execDetached(["which", "wayhibitor"]);
|
||||
strategy = "wayland";
|
||||
return ;
|
||||
} catch (e) {
|
||||
}
|
||||
strategy = "systemd"; // Fallback to systemd even if not detected
|
||||
}
|
||||
}
|
||||
|
||||
// Add an inhibitor
|
||||
function addInhibitor(id, reason = "Application request") {
|
||||
if (activeInhibitors.includes(id))
|
||||
return false;
|
||||
|
||||
activeInhibitors.push(id);
|
||||
updateInhibition(reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove an inhibitor
|
||||
function removeInhibitor(id) {
|
||||
const index = activeInhibitors.indexOf(id);
|
||||
if (index === -1) {
|
||||
console.log("Inhibitor not found:", id);
|
||||
return false;
|
||||
}
|
||||
activeInhibitors.splice(index, 1);
|
||||
updateInhibition();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update the actual system inhibition
|
||||
function updateInhibition(newReason = reason) {
|
||||
const shouldInhibit = activeInhibitors.length > 0;
|
||||
if (shouldInhibit === isInhibited)
|
||||
return ;
|
||||
|
||||
// No change needed
|
||||
if (shouldInhibit)
|
||||
startInhibition(newReason);
|
||||
else
|
||||
stopInhibition();
|
||||
}
|
||||
|
||||
// Start system inhibition
|
||||
function startInhibition(newReason) {
|
||||
reason = newReason;
|
||||
if (strategy === "systemd")
|
||||
startSystemdInhibition();
|
||||
else if (strategy === "wayland")
|
||||
startWaylandInhibition();
|
||||
else
|
||||
return ;
|
||||
isInhibited = true;
|
||||
}
|
||||
|
||||
// Stop system inhibition
|
||||
function stopInhibition() {
|
||||
if (!isInhibited)
|
||||
return ;
|
||||
|
||||
// SIGTERM
|
||||
if (inhibitorProcess.running)
|
||||
inhibitorProcess.signal(15);
|
||||
|
||||
isInhibited = false;
|
||||
}
|
||||
|
||||
// Systemd inhibition using systemd-inhibit
|
||||
function startSystemdInhibition() {
|
||||
inhibitorProcess.command = ["systemd-inhibit", "--what=idle", "--why=" + reason, "--mode=block", "sleep", "infinity"];
|
||||
inhibitorProcess.running = true;
|
||||
}
|
||||
|
||||
// Wayland inhibition using wayhibitor or similar
|
||||
function startWaylandInhibition() {
|
||||
inhibitorProcess.command = ["wayhibitor"];
|
||||
inhibitorProcess.running = true;
|
||||
}
|
||||
|
||||
// Manual toggle for user control
|
||||
function manualToggle() {
|
||||
if (activeInhibitors.includes("manual")) {
|
||||
removeInhibitor("manual");
|
||||
return false;
|
||||
} else {
|
||||
addInhibitor("manual", "Manually activated by user");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
detectStrategy();
|
||||
}
|
||||
// Clean up on shutdown
|
||||
Component.onDestruction: {
|
||||
stopInhibition();
|
||||
}
|
||||
|
||||
// Process for maintaining the inhibition
|
||||
Process {
|
||||
id: inhibitorProcess
|
||||
|
||||
running: false
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if (isInhibited)
|
||||
isInhibited = false;
|
||||
|
||||
Logger.log("Caffeine", "Inhibitor process exited with code:", exitCode, "status:", exitStatus);
|
||||
}
|
||||
onStarted: function() {
|
||||
Logger.log("Caffeine", "Inhibitor process started with PID:", inhibitorProcess.processId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
51
config/quickshell/Services/IPCService.qml
Normal file
51
config/quickshell/Services/IPCService.qml
Normal file
@@ -0,0 +1,51 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
|
||||
Item {
|
||||
IpcHandler {
|
||||
function setPrimary(color: color) {
|
||||
SettingsService.primaryColor = color;
|
||||
}
|
||||
|
||||
target: "colors"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function toggleCalendar() {
|
||||
calendarPanel.toggle();
|
||||
}
|
||||
|
||||
function toggleControlCenter() {
|
||||
controlCenterPanel.toggle();
|
||||
}
|
||||
|
||||
target: "panels"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function toggleBarLyrics() {
|
||||
SettingsService.showLyricsBar = !SettingsService.showLyricsBar;
|
||||
}
|
||||
|
||||
target: "lyrics"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function toggleInhibitor() {
|
||||
Caffeine.manualToggle();
|
||||
}
|
||||
|
||||
target: "idleInhibitor"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function startOrStopRecording() {
|
||||
RecordService.startOrStop();
|
||||
}
|
||||
|
||||
target: "recording"
|
||||
}
|
||||
|
||||
}
|
||||
165
config/quickshell/Services/IpService.qml
Normal file
165
config/quickshell/Services/IpService.qml
Normal file
@@ -0,0 +1,165 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property alias ip: cacheFileAdapter.ip
|
||||
property string cacheFilePath: CacheService.ipCacheFile
|
||||
property string countryCode: "N/A"
|
||||
property real fetchInterval: 120 // in s
|
||||
property real fetchTimeout: 10 // in s
|
||||
property string ipURL: "https://api.uyanide.com/ip"
|
||||
property string geoURL: "https://api.ipinfo.io/lite/"
|
||||
property string geoURLToken: ""
|
||||
|
||||
function fetchIP() {
|
||||
curl.fetch(ipURL, function(success, data) {
|
||||
if (success) {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
if (response && response.ip) {
|
||||
let newIP = response.ip;
|
||||
Logger.log("IpService", "Fetched IP: " + newIP);
|
||||
if (newIP !== ip) {
|
||||
ip = newIP;
|
||||
countryCode = "N/A";
|
||||
fetchGeoInfo(true); // Fetch geo info only if IP has changed
|
||||
}
|
||||
} else {
|
||||
Logger.error("IpService", "IP response does not contain 'ip' field");
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("IpService", "Failed to parse IP response: " + e);
|
||||
}
|
||||
} else {
|
||||
Logger.error("IpService", "Failed to fetch IP");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetchGeoInfo(notify) {
|
||||
if (!ip || ip === "N/A") {
|
||||
countryCode = "N/A";
|
||||
return ;
|
||||
}
|
||||
let url = geoURL + ip;
|
||||
if (geoURLToken)
|
||||
url += "?token=" + geoURLToken;
|
||||
|
||||
cacheFileAdapter.geoInfo = null
|
||||
curl.fetch(url, function(success, data) {
|
||||
if (success) {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
if (response && (response.country_code || response.country)) {
|
||||
let newCountryCode = response.country_code || response.country;
|
||||
Logger.log("IpService", "Fetched country code: " + newCountryCode);
|
||||
countryCode = newCountryCode;
|
||||
} else {
|
||||
Logger.error("IpService", "Geo response does not contain 'country_code' field");
|
||||
}
|
||||
cacheFileAdapter.geoInfo = response;
|
||||
} catch (e) {
|
||||
Logger.error("IpService", "Failed to parse geo response: " + e);
|
||||
}
|
||||
} else {
|
||||
Logger.error("IpService", "Failed to fetch geo info");
|
||||
}
|
||||
SendNotification.show("New IP", `IP: ${ip}\nCountry: ${countryCode}`);
|
||||
cacheFile.writeAdapter();
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetchTimer.stop();
|
||||
ip = "N/A";
|
||||
fetchIP();
|
||||
fetchTimer.start();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
}
|
||||
|
||||
NetworkFetch {
|
||||
id: curl
|
||||
}
|
||||
|
||||
Process {
|
||||
id: ipMonitor
|
||||
|
||||
command: ["ip", "monitor", "address", "route"]
|
||||
running: true
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: {
|
||||
ipMonitorDebounce.restart();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: ipMonitorDebounce
|
||||
|
||||
interval: 1000
|
||||
repeat: false
|
||||
running: false
|
||||
onTriggered: {
|
||||
fetchIP();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: fetchTimer
|
||||
|
||||
interval: fetchInterval * 1000
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: {
|
||||
fetchTimer.stop();
|
||||
fetchIP();
|
||||
fetchTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: tokenFile
|
||||
|
||||
path: Qt.resolvedUrl("../Assets/Config/GeoInfoToken.txt")
|
||||
onLoaded: {
|
||||
geoURLToken = tokenFile.text();
|
||||
if (!geoURLToken)
|
||||
Logger.warn("IpService", "No token found for geoIP service, assuming none is required");
|
||||
|
||||
fetchIP();
|
||||
fetchTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: cacheFile
|
||||
|
||||
path: cacheFilePath
|
||||
watchChanges: false
|
||||
onLoaded: {
|
||||
Logger.log("IpService", "Loaded IP from cache file: " + cacheFileAdapter.ip);
|
||||
if (cacheFileAdapter.geoInfo) {
|
||||
countryCode = cacheFileAdapter.geoInfo.country_code || cacheFileAdapter.country || "N/A";
|
||||
Logger.log("IpService", "Loaded country code from cache file: " + countryCode);
|
||||
}
|
||||
}
|
||||
|
||||
JsonAdapter {
|
||||
id: cacheFileAdapter
|
||||
|
||||
property string ip: "N/A"
|
||||
property var geoInfo: null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
318
config/quickshell/Services/LocationService.qml
Normal file
318
config/quickshell/Services/LocationService.qml
Normal file
@@ -0,0 +1,318 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
// Weather logic and caching with stable UI properties
|
||||
Singleton {
|
||||
//console.log(JSON.stringify(weatherData))
|
||||
|
||||
id: root
|
||||
|
||||
property string locationName: SettingsService.location
|
||||
property string locationFile: CacheService.locationCacheFile
|
||||
property int weatherUpdateFrequency: 30 * 60 // 30 minutes expressed in seconds
|
||||
property bool isFetchingWeather: false
|
||||
readonly property alias data: adapter // Used to access via LocationService.data.xxx from outside, best to use "adapter" inside the service.
|
||||
// Stable UI properties - only updated when location is fully resolved
|
||||
property bool coordinatesReady: false
|
||||
property string stableLatitude: ""
|
||||
property string stableLongitude: ""
|
||||
property string stableName: ""
|
||||
// Helper property for UI components (outside JsonAdapter to avoid binding loops)
|
||||
readonly property string displayCoordinates: {
|
||||
if (!root.coordinatesReady || root.stableLatitude === "" || root.stableLongitude === "")
|
||||
return "";
|
||||
|
||||
const lat = parseFloat(root.stableLatitude).toFixed(4);
|
||||
const lon = parseFloat(root.stableLongitude).toFixed(4);
|
||||
return `${lat}, ${lon}`;
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function init() {
|
||||
// does nothing but ensure the singleton is created
|
||||
// do not remove
|
||||
Logger.log("Location", "Service started");
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function resetWeather() {
|
||||
Logger.log("Location", "Resetting weather data");
|
||||
// Mark as changing to prevent UI updates
|
||||
root.coordinatesReady = false;
|
||||
// Reset stable properties
|
||||
root.stableLatitude = "";
|
||||
root.stableLongitude = "";
|
||||
root.stableName = "";
|
||||
// Reset core data
|
||||
adapter.latitude = "";
|
||||
adapter.longitude = "";
|
||||
adapter.name = "";
|
||||
adapter.weatherLastFetch = 0;
|
||||
adapter.weather = null;
|
||||
// Try to fetch immediately
|
||||
updateWeather();
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function updateWeather() {
|
||||
if (isFetchingWeather) {
|
||||
Logger.warn("Location", "Weather is still fetching");
|
||||
return ;
|
||||
}
|
||||
if ((adapter.weatherLastFetch === "") || (adapter.weather === null) || (adapter.latitude === "") || (adapter.longitude === "") || (adapter.name !== root.locationName) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency))
|
||||
getFreshWeather();
|
||||
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function getFreshWeather() {
|
||||
isFetchingWeather = true;
|
||||
// Check if location name has changed
|
||||
const locationChanged = data.name !== root.locationName;
|
||||
if (locationChanged) {
|
||||
root.coordinatesReady = false;
|
||||
Logger.log("Location", "Location changed from", adapter.name, "to", root.locationName);
|
||||
}
|
||||
if ((adapter.latitude === "") || (adapter.longitude === "") || locationChanged)
|
||||
_geocodeLocation(root.locationName, function(latitude, longitude, name, country) {
|
||||
Logger.log("Location", "Geocoded", root.locationName, "to:", latitude, "/", longitude);
|
||||
// Save location name
|
||||
adapter.name = root.locationName;
|
||||
// Save GPS coordinates
|
||||
adapter.latitude = latitude.toString();
|
||||
adapter.longitude = longitude.toString();
|
||||
root.stableName = `${name}, ${country}`;
|
||||
_fetchWeather(latitude, longitude, errorCallback);
|
||||
}, errorCallback);
|
||||
else
|
||||
_fetchWeather(adapter.latitude, adapter.longitude, errorCallback);
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function _geocodeLocation(locationName, callback, errorCallback) {
|
||||
Logger.log("Location", "Geocoding location name");
|
||||
var geoUrl = "https://assets.noctalia.dev/geocode.php?city=" + encodeURIComponent(locationName) + "&language=en&format=json";
|
||||
curl.fetch(geoUrl, function(success, data) {
|
||||
if (success) {
|
||||
try {
|
||||
var geoData = JSON.parse(data);
|
||||
if (geoData.lat != null)
|
||||
callback(geoData.lat, geoData.lng, geoData.name, geoData.country);
|
||||
else
|
||||
errorCallback("Location", "could not resolve location name");
|
||||
} catch (e) {
|
||||
errorCallback("Location", "Failed to parse geocoding data: " + e);
|
||||
}
|
||||
} else {
|
||||
errorCallback("Location", "Geocoding error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function _fetchWeather(latitude, longitude, errorCallback) {
|
||||
Logger.log("Location", "Fetching weather from api.open-meteo.com");
|
||||
var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto";
|
||||
curl.fetch(url, function(success, fetchedData) {
|
||||
if (success) {
|
||||
try {
|
||||
var weatherData = JSON.parse(fetchedData);
|
||||
// Save core data
|
||||
data.weather = weatherData;
|
||||
data.weatherLastFetch = Time.timestamp;
|
||||
// Update stable display values only when complete and successful
|
||||
root.stableLatitude = data.latitude = weatherData.latitude.toString();
|
||||
root.stableLongitude = data.longitude = weatherData.longitude.toString();
|
||||
root.coordinatesReady = true;
|
||||
isFetchingWeather = false;
|
||||
Logger.log("Location", "Cached weather to disk - stable coordinates updated");
|
||||
} catch (e) {
|
||||
errorCallback("Location", "Failed to parse weather data: " + e);
|
||||
}
|
||||
} else {
|
||||
errorCallback("Location", "Weather fetch error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function errorCallback(module, message) {
|
||||
Logger.error(module, message);
|
||||
isFetchingWeather = false;
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function weatherSymbolFromCode(code) {
|
||||
if (code === 0)
|
||||
return "weather-sun";
|
||||
|
||||
if (code === 1 || code === 2)
|
||||
return "weather-cloud-sun";
|
||||
|
||||
if (code === 3)
|
||||
return "weather-cloud";
|
||||
|
||||
if (code >= 45 && code <= 48)
|
||||
return "weather-cloud-haze";
|
||||
|
||||
if (code >= 51 && code <= 67)
|
||||
return "weather-cloud-rain";
|
||||
|
||||
if (code >= 71 && code <= 77)
|
||||
return "weather-cloud-snow";
|
||||
|
||||
if (code >= 71 && code <= 77)
|
||||
return "weather-cloud-snow";
|
||||
|
||||
if (code >= 85 && code <= 86)
|
||||
return "weather-cloud-snow";
|
||||
|
||||
if (code >= 95 && code <= 99)
|
||||
return "weather-cloud-lightning";
|
||||
|
||||
return "weather-cloud";
|
||||
}
|
||||
|
||||
function weatherColorFromCode(code) {
|
||||
// Clear sky - bright yellow
|
||||
if (code === 0)
|
||||
return Colors.yellow;
|
||||
|
||||
// Mainly clear/Partly cloudy - soft peach/rosewater tones
|
||||
if (code === 1 || code === 2)
|
||||
return Colors.peach;
|
||||
|
||||
// Overcast - neutral sky blue
|
||||
if (code === 3)
|
||||
return Colors.sky;
|
||||
|
||||
// Fog - soft lavender/muted tone
|
||||
if (code >= 45 && code <= 48)
|
||||
return Colors.lavender;
|
||||
|
||||
// Drizzle - light blue/sapphire
|
||||
if (code >= 51 && code <= 67)
|
||||
return Colors.sapphire;
|
||||
|
||||
// Snow - cool teal
|
||||
if (code >= 71 && code <= 77)
|
||||
return Colors.teal;
|
||||
|
||||
// Rain showers - deeper blue
|
||||
if (code >= 80 && code <= 82)
|
||||
return Colors.blue;
|
||||
|
||||
// Snow showers - teal
|
||||
if (code >= 85 && code <= 86)
|
||||
return Colors.teal;
|
||||
|
||||
// Thunderstorm - dramatic mauve/pink
|
||||
if (code >= 95 && code <= 99)
|
||||
return Colors.mauve;
|
||||
|
||||
// Default - sky blue
|
||||
return Colors.sky;
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function weatherDescriptionFromCode(code) {
|
||||
if (code === 0)
|
||||
return "Clear sky";
|
||||
|
||||
if (code === 1)
|
||||
return "Mainly clear";
|
||||
|
||||
if (code === 2)
|
||||
return "Partly cloudy";
|
||||
|
||||
if (code === 3)
|
||||
return "Overcast";
|
||||
|
||||
if (code === 45 || code === 48)
|
||||
return "Fog";
|
||||
|
||||
if (code >= 51 && code <= 67)
|
||||
return "Drizzle";
|
||||
|
||||
if (code >= 71 && code <= 77)
|
||||
return "Snow";
|
||||
|
||||
if (code >= 80 && code <= 82)
|
||||
return "Rain showers";
|
||||
|
||||
if (code >= 95 && code <= 99)
|
||||
return "Thunderstorm";
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function celsiusToFahrenheit(celsius) {
|
||||
return 32 + celsius * 1.8;
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: locationFileView
|
||||
|
||||
path: locationFile
|
||||
printErrors: false
|
||||
onAdapterUpdated: saveTimer.start()
|
||||
onLoaded: {
|
||||
Logger.log("Location", "Loaded cached data");
|
||||
// Initialize stable properties on load
|
||||
if (adapter.latitude !== "" && adapter.longitude !== "" && adapter.weatherLastFetch > 0) {
|
||||
root.stableLatitude = adapter.latitude;
|
||||
root.stableLongitude = adapter.longitude;
|
||||
root.stableName = adapter.name;
|
||||
root.coordinatesReady = true;
|
||||
Logger.log("Location", "Coordinates ready");
|
||||
}
|
||||
updateWeather();
|
||||
}
|
||||
onLoadFailed: function(error) {
|
||||
updateWeather();
|
||||
}
|
||||
|
||||
JsonAdapter {
|
||||
id: adapter
|
||||
|
||||
// Core data properties
|
||||
property string latitude: ""
|
||||
property string longitude: ""
|
||||
property string name: ""
|
||||
property int weatherLastFetch: 0
|
||||
property var weather: null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Every 20s check if we need to fetch new weather
|
||||
Timer {
|
||||
id: updateTimer
|
||||
|
||||
interval: 20 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
updateWeather();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: saveTimer
|
||||
|
||||
running: false
|
||||
interval: 1000
|
||||
onTriggered: locationFileView.writeAdapter()
|
||||
}
|
||||
|
||||
NetworkFetch {
|
||||
id: curl
|
||||
}
|
||||
|
||||
}
|
||||
141
config/quickshell/Services/LyricsService.qml
Normal file
141
config/quickshell/Services/LyricsService.qml
Normal file
@@ -0,0 +1,141 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property int linesCount: 3
|
||||
property int linesAhead: linesCount / 2
|
||||
property int currentIndex: linesCount - linesAhead - 1
|
||||
property string offsetFile: CacheService.lyricsOffsetCacheFile
|
||||
property int offset: 0 // in ms
|
||||
property int offsetStep: 500 // in ms
|
||||
property int referenceCount: 0
|
||||
// with linesCount=3 and linesAhead=1, lyrics will be like:
|
||||
// line 1
|
||||
// line 2 <- current line
|
||||
// line 3
|
||||
property var lyrics: Array(linesCount).fill(" ")
|
||||
|
||||
function startSyncing() {
|
||||
referenceCount++;
|
||||
Logger.log("LyricsService", "Reference count:", referenceCount);
|
||||
if (referenceCount === 1) {
|
||||
Logger.log("LyricsService", "Starting lyrics syncing");
|
||||
// fill lyrics with empty lines
|
||||
lyrics = Array(linesCount).fill(" ");
|
||||
listenProcess.exec(["sh", "-c", `sl-wrap listen -l ${linesCount} -a ${linesAhead} -f ${offsetFile}`]);
|
||||
}
|
||||
}
|
||||
|
||||
function stopSyncing() {
|
||||
referenceCount--;
|
||||
Logger.log("LyricsService", "Reference count:", referenceCount);
|
||||
if (referenceCount <= 0) {
|
||||
Logger.log("LyricsService", "Stopping lyrics syncing");
|
||||
// Execute again to stop
|
||||
// kinda ugly but works, but meanwhile:
|
||||
// listenProcess.signal(9)
|
||||
// listenProcess.signal(15)
|
||||
// listenProcess.running = false
|
||||
// counts on exec() to terminate previous exec()
|
||||
// all don't work
|
||||
listenProcess.exec(["sh", "-c", `sl-wrap trackid`]);
|
||||
}
|
||||
}
|
||||
|
||||
function writeOffset() {
|
||||
offsetFileView.setText(String(offset));
|
||||
}
|
||||
|
||||
function increaseOffset() {
|
||||
offset += offsetStep;
|
||||
}
|
||||
|
||||
function decreaseOffset() {
|
||||
offset -= offsetStep;
|
||||
}
|
||||
|
||||
function resetOffset() {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
action.command = ["sh", "-c", "spotify-lyrics clear $(spotify-lyrics trackid)"];
|
||||
action.startDetached();
|
||||
}
|
||||
|
||||
function showLyricsText() {
|
||||
action.command = ["sh", "-c", "ghostty -e sh -c 'spotify-lyrics fetch | less'"];
|
||||
action.startDetached();
|
||||
}
|
||||
|
||||
onOffsetChanged: {
|
||||
if (SettingsService.showLyricsBar)
|
||||
SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`);
|
||||
|
||||
writeOffset();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: listenProcess
|
||||
|
||||
running: false
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: ""
|
||||
onRead: (data) => {
|
||||
lyrics = data.split("\n").slice(0, linesCount);
|
||||
if (lyrics.length < linesCount) {
|
||||
// fill with empty lines if not enough
|
||||
for (let i = lyrics.length; i < linesCount; i++) {
|
||||
lyrics[i] = " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Process {
|
||||
id: action
|
||||
|
||||
running: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: offsetFileView
|
||||
|
||||
path: offsetFile
|
||||
watchChanges: false
|
||||
onLoaded: {
|
||||
try {
|
||||
const fileContents = text();
|
||||
if (fileContents.length > 0) {
|
||||
const val = parseInt(fileContents);
|
||||
if (!isNaN(val)) {
|
||||
offset = val;
|
||||
Logger.log("LyricsService", "Loaded offset:", offset);
|
||||
} else {
|
||||
offset = 0;
|
||||
writeOffset();
|
||||
}
|
||||
} else {
|
||||
offset = 0;
|
||||
writeOffset();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("LyricsService", "Error reading offset file:", e);
|
||||
}
|
||||
}
|
||||
onLoadFailed: {
|
||||
Logger.error("LyricsService", "Error loading offset file:", errorString);
|
||||
}
|
||||
onSaveFailed: {
|
||||
Logger.error("LyricsService", "Error saving offset file:", errorString);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
180
config/quickshell/Services/MusicManager.qml
Normal file
180
config/quickshell/Services/MusicManager.qml
Normal file
@@ -0,0 +1,180 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.Modules.Misc
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: manager
|
||||
|
||||
// Properties
|
||||
property var currentPlayer: null
|
||||
property real currentPosition: 0
|
||||
property bool isPlaying: currentPlayer ? currentPlayer.isPlaying : false
|
||||
property int selectedPlayerIndex: -1
|
||||
property string trackTitle: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : ""
|
||||
property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : ""
|
||||
property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : ""
|
||||
property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : ""
|
||||
property real trackLength: currentPlayer ? currentPlayer.length : 0
|
||||
property bool canPlay: currentPlayer ? currentPlayer.canPlay : false
|
||||
property bool canPause: currentPlayer ? currentPlayer.canPause : false
|
||||
property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false
|
||||
property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false
|
||||
property bool canSeek: currentPlayer ? currentPlayer.canSeek : false
|
||||
property bool hasPlayer: getAvailablePlayers().length > 0
|
||||
// Expose cava values
|
||||
property alias cavaValues: cava.values
|
||||
|
||||
// Returns available MPRIS players
|
||||
function getAvailablePlayers() {
|
||||
if (!Mpris.players || !Mpris.players.values)
|
||||
return [];
|
||||
|
||||
let allPlayers = Mpris.players.values;
|
||||
let controllablePlayers = [];
|
||||
for (let i = 0; i < allPlayers.length; i++) {
|
||||
let player = allPlayers[i];
|
||||
if (player && player.canControl)
|
||||
controllablePlayers.push(player);
|
||||
|
||||
}
|
||||
return controllablePlayers;
|
||||
}
|
||||
|
||||
// Returns active player or first available
|
||||
function findActivePlayer() {
|
||||
let availablePlayers = getAvailablePlayers();
|
||||
if (availablePlayers.length === 0)
|
||||
return null;
|
||||
|
||||
// Get the first playing player
|
||||
for (let i = availablePlayers.length - 1; i >= 0; i--) {
|
||||
if (availablePlayers[i].isPlaying)
|
||||
return availablePlayers[i];
|
||||
|
||||
}
|
||||
// Fallback to last player
|
||||
return availablePlayers[availablePlayers.length - 1];
|
||||
}
|
||||
|
||||
// Updates currentPlayer and currentPosition
|
||||
function updateCurrentPlayer() {
|
||||
// Use selected player if index is valid
|
||||
if (selectedPlayerIndex >= 0) {
|
||||
let availablePlayers = getAvailablePlayers();
|
||||
if (selectedPlayerIndex < availablePlayers.length) {
|
||||
currentPlayer = availablePlayers[selectedPlayerIndex];
|
||||
currentPosition = currentPlayer.position;
|
||||
Logger.log("MusicManager", "Current player set by index:", currentPlayer ? currentPlayer.identity : "None");
|
||||
return ;
|
||||
} else {
|
||||
selectedPlayerIndex = -1; // Reset if index is out of range
|
||||
}
|
||||
}
|
||||
// Otherwise, find active player
|
||||
let newPlayer = findActivePlayer();
|
||||
if (newPlayer !== currentPlayer) {
|
||||
currentPlayer = newPlayer;
|
||||
currentPosition = currentPlayer ? currentPlayer.position : 0;
|
||||
}
|
||||
Logger.log("MusicManager", "Current player updated:", currentPlayer ? currentPlayer.identity : "None");
|
||||
}
|
||||
|
||||
// Player control functions
|
||||
function playPause() {
|
||||
if (currentPlayer) {
|
||||
if (currentPlayer.isPlaying)
|
||||
currentPlayer.pause();
|
||||
else
|
||||
currentPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
function isAllPaused() {
|
||||
let availablePlayers = getAvailablePlayers();
|
||||
for (let i = 0; i < availablePlayers.length; i++) {
|
||||
if (availablePlayers[i].isPlaying)
|
||||
return false;
|
||||
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (currentPlayer && currentPlayer.canPlay)
|
||||
currentPlayer.play();
|
||||
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (currentPlayer && currentPlayer.canPause)
|
||||
currentPlayer.pause();
|
||||
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (currentPlayer && currentPlayer.canGoNext)
|
||||
currentPlayer.next();
|
||||
|
||||
}
|
||||
|
||||
function previous() {
|
||||
if (currentPlayer && currentPlayer.canGoPrevious)
|
||||
currentPlayer.previous();
|
||||
|
||||
}
|
||||
|
||||
function seek(position) {
|
||||
if (currentPlayer && currentPlayer.canSeek) {
|
||||
currentPlayer.position = position;
|
||||
currentPosition = position;
|
||||
}
|
||||
}
|
||||
|
||||
function seekByRatio(ratio) {
|
||||
if (currentPlayer && currentPlayer.canSeek && currentPlayer.length > 0) {
|
||||
let seekPosition = ratio * currentPlayer.length;
|
||||
currentPlayer.position = seekPosition;
|
||||
currentPosition = seekPosition;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
Item {
|
||||
Component.onCompleted: {
|
||||
updateCurrentPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
// Updates progress bar every second
|
||||
Timer {
|
||||
id: positionTimer
|
||||
|
||||
interval: 1000
|
||||
running: currentPlayer && currentPlayer.isPlaying && currentPlayer.length > 0
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (currentPlayer && currentPlayer.isPlaying)
|
||||
currentPosition = currentPlayer.position;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Reacts to player list changes
|
||||
Connections {
|
||||
function onValuesChanged() {
|
||||
updateCurrentPlayer();
|
||||
}
|
||||
|
||||
target: Mpris.players
|
||||
}
|
||||
|
||||
Cava {
|
||||
id: cava
|
||||
|
||||
count: 44
|
||||
}
|
||||
|
||||
}
|
||||
65
config/quickshell/Services/NetworkFetch.qml
Normal file
65
config/quickshell/Services/NetworkFetch.qml
Normal file
@@ -0,0 +1,65 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real fetchTimeout: 10 // in seconds
|
||||
property string fetchedData: ""
|
||||
property var fetchingCallback: null
|
||||
|
||||
function fetch(url, callback) {
|
||||
if (curlProcess.running) {
|
||||
Logger.warn("NetworkFetch", "A fetch operation is already in progress.");
|
||||
return ;
|
||||
}
|
||||
fetchedData = "";
|
||||
fetchingCallback = callback;
|
||||
curlProcess.command = ["curl", "-s", "-L", "-m", fetchTimeout.toString(), url];
|
||||
curlProcess.running = true;
|
||||
}
|
||||
|
||||
function fakeFetch(resp, callback) {
|
||||
if (curlProcess.running) {
|
||||
Logger.warn("NetworkFetch", "A fetch operation is already in progress.");
|
||||
return ;
|
||||
}
|
||||
fetchedData = "";
|
||||
fetchingCallback = callback;
|
||||
curlProcess.command = ["echo", resp];
|
||||
curlProcess.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: curlProcess
|
||||
|
||||
running: false
|
||||
onStarted: {
|
||||
Logger.log("NetworkFetch", "Process started with command: " + curlProcess.command.join(" "));
|
||||
}
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if (!fetchingCallback) {
|
||||
Logger.error("NetworkFetch", "No callback defined for fetch operation.");
|
||||
return ;
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
Logger.log("NetworkFetch", "Fetched data: " + fetchedData);
|
||||
fetchingCallback(true, fetchedData);
|
||||
} else {
|
||||
Logger.error("NetworkFetch", "Fetch failed with exit code: " + exitCode);
|
||||
fetchingCallback(false, "");
|
||||
}
|
||||
}
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: ""
|
||||
onRead: (data) => {
|
||||
fetchedData += data;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
190
config/quickshell/Services/Niri.qml
Normal file
190
config/quickshell/Services/Niri.qml
Normal file
@@ -0,0 +1,190 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var workspaces: []
|
||||
property var windows: {}
|
||||
property int focusedWindowId: -1
|
||||
property bool noFocus: focusedWindowId === -1
|
||||
property bool inOverview: false
|
||||
property string focusedWindowTitle: ""
|
||||
property string focusedWindowAppId: ""
|
||||
|
||||
function updateFocusedWindowTitle() {
|
||||
if (windows && windows[focusedWindowId]) {
|
||||
focusedWindowTitle = windows[focusedWindowId].title || "";
|
||||
focusedWindowAppId = windows[focusedWindowId].appId || "";
|
||||
} else {
|
||||
focusedWindowTitle = "";
|
||||
focusedWindowAppId = "";
|
||||
}
|
||||
}
|
||||
|
||||
function getFocusedWindow() {
|
||||
return (windows && windows[focusedWindowId]) || null;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
eventStream.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: workspaceProcess
|
||||
|
||||
running: false
|
||||
command: ["niri", "msg", "--json", "workspaces"]
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: function(line) {
|
||||
try {
|
||||
const workspacesData = JSON.parse(line);
|
||||
const workspacesList = [];
|
||||
for (const ws of workspacesData) {
|
||||
workspacesList.push({
|
||||
"id": ws.id,
|
||||
"idx": ws.idx,
|
||||
"name": ws.name || "",
|
||||
"output": ws.output || "",
|
||||
"isFocused": ws.is_focused === true,
|
||||
"isActive": ws.is_active === true,
|
||||
"isUrgent": ws.is_urgent === true,
|
||||
"activeWindowId": ws.active_window_id
|
||||
});
|
||||
}
|
||||
workspacesList.sort((a, b) => {
|
||||
if (a.output !== b.output)
|
||||
return a.output.localeCompare(b.output);
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
root.workspaces = workspacesList;
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Failed to parse workspaces:", e, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Process {
|
||||
id: eventStream
|
||||
|
||||
running: false
|
||||
command: ["niri", "msg", "--json", "event-stream"]
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: (data) => {
|
||||
try {
|
||||
const event = JSON.parse(data.trim());
|
||||
if (event.WorkspacesChanged) {
|
||||
workspaceProcess.running = true;
|
||||
} else if (event.WindowsChanged) {
|
||||
try {
|
||||
const windowsData = event.WindowsChanged.windows;
|
||||
const windowsMap = {};
|
||||
for (const win of windowsData) {
|
||||
if (win.is_focused === true) {
|
||||
root.focusedWindowId = win.id;
|
||||
}
|
||||
windowsMap[win.id] = {
|
||||
"title": win.title || "",
|
||||
"appId": win.app_id || "",
|
||||
"workspaceId": win.workspace_id || null,
|
||||
"isFocused": win.is_focused === true
|
||||
};
|
||||
}
|
||||
root.windows = windowsMap;
|
||||
root.updateFocusedWindowTitle();
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing windows event:", e);
|
||||
}
|
||||
} else if (event.WorkspaceActivated) {
|
||||
workspaceProcess.running = true;
|
||||
} else if (event.WindowFocusChanged) {
|
||||
try {
|
||||
const focusedId = event.WindowFocusChanged.id;
|
||||
if (focusedId) {
|
||||
if (root.windows[focusedId]) {
|
||||
root.focusedWindowId = focusedId;
|
||||
} else {
|
||||
root.focusedWindowId = -1;
|
||||
}
|
||||
|
||||
} else {
|
||||
root.focusedWindowId = -1;
|
||||
}
|
||||
root.updateFocusedWindowTitle();
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing window focus event:", e);
|
||||
}
|
||||
} else if (event.OverviewOpenedOrClosed) {
|
||||
try {
|
||||
root.inOverview = event.OverviewOpenedOrClosed.is_open === true;
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing overview state:", e);
|
||||
}
|
||||
} else if (event.WindowOpenedOrChanged) {
|
||||
try {
|
||||
const targetWin = event.WindowOpenedOrChanged.window;
|
||||
const id = targetWin.id;
|
||||
const isFocused = targetWin.is_focused === true;
|
||||
let needUpdateTitle = false;
|
||||
if (id) {
|
||||
if (root.windows && root.windows[id]) {
|
||||
const win = root.windows[id];
|
||||
// Update existing window
|
||||
needUpdateTitle = win.title !== targetWin.title;
|
||||
win.title = targetWin.title || win.title;
|
||||
win.appId = targetWin.app_id || win.appId;
|
||||
win.workspaceId = targetWin.workspace_id || win.workspaceId;
|
||||
win.isFocused = isFocused;
|
||||
} else {
|
||||
// New window
|
||||
const newWin = {
|
||||
"title": targetWin.title || "",
|
||||
"appId": targetWin.app_id || "",
|
||||
"workspaceId": targetWin.workspace_id || null,
|
||||
"isFocused": isFocused
|
||||
};
|
||||
root.windows[id] = targetWin;
|
||||
}
|
||||
if (isFocused) {
|
||||
if (root.focusedWindowId !== id || needUpdateTitle){
|
||||
root.focusedWindowId = id;
|
||||
root.updateFocusedWindowTitle();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing window opened/changed event:", e);
|
||||
}
|
||||
} else if (event.windowClosed) {
|
||||
try {
|
||||
const closedId = event.windowClosed.id;
|
||||
if (closedId && (root.windows && root.windows[closedId])) {
|
||||
delete root.windows[closedId];
|
||||
if (root.focusedWindowId === closedId) {
|
||||
root.focusedWindowId = -1;
|
||||
root.updateFocusedWindowTitle();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing window closed event:", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing event stream:", e, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
474
config/quickshell/Services/NotificationService.qml
Normal file
474
config/quickshell/Services/NotificationService.qml
Normal file
@@ -0,0 +1,474 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.Utils
|
||||
import qs.Services
|
||||
import qs.Constants
|
||||
import "../Utils/sha256.js" as Checksum
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Configuration
|
||||
property int maxVisible: 5
|
||||
property int maxHistory: 100
|
||||
property string historyFile: CacheService.notificationsCacheFile
|
||||
property string cacheDirImagesNotifications: Quickshell.env("HOME") + "/.cache/quickshell/notifications/"
|
||||
property real lowUrgencyDuration: 3
|
||||
property real normalUrgencyDuration: 8
|
||||
property real criticalUrgencyDuration: 15
|
||||
|
||||
// Models
|
||||
property ListModel activeList: ListModel {}
|
||||
property ListModel historyList: ListModel {}
|
||||
|
||||
// Internal state
|
||||
property var activeMap: ({})
|
||||
property var imageQueue: []
|
||||
|
||||
// Performance optimization: Track notification metadata separately
|
||||
property var notificationMetadata: ({}) // Stores timestamp and duration for each notification
|
||||
|
||||
PanelWindow {
|
||||
implicitHeight: 1
|
||||
implicitWidth: 1
|
||||
color: Color.transparent
|
||||
mask: Region {}
|
||||
|
||||
Image {
|
||||
id: cacher
|
||||
width: 64
|
||||
height: 64
|
||||
visible: true
|
||||
cache: false
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
antialiasing: true
|
||||
|
||||
onStatusChanged: {
|
||||
if (imageQueue.length === 0)
|
||||
return
|
||||
const req = imageQueue[0]
|
||||
|
||||
if (status === Image.Ready) {
|
||||
Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications])
|
||||
grabToImage(result => {
|
||||
if (result.saveToFile(req.dest))
|
||||
updateImagePath(req.imageId, req.dest)
|
||||
processNextImage()
|
||||
})
|
||||
} else if (status === Image.Error) {
|
||||
processNextImage()
|
||||
}
|
||||
}
|
||||
|
||||
function processNextImage() {
|
||||
imageQueue.shift()
|
||||
if (imageQueue.length > 0) {
|
||||
source = imageQueue[0].src
|
||||
} else {
|
||||
source = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification server
|
||||
NotificationServer {
|
||||
keepOnReload: false
|
||||
imageSupported: true
|
||||
actionsSupported: true
|
||||
onNotification: notification => handleNotification(notification)
|
||||
}
|
||||
|
||||
// Main handler
|
||||
function handleNotification(notification) {
|
||||
const data = createData(notification)
|
||||
addToHistory(data)
|
||||
|
||||
if (SettingsService.notifications.doNotDisturb)
|
||||
return
|
||||
|
||||
activeMap[data.id] = notification
|
||||
notification.tracked = true
|
||||
notification.closed.connect(() => removeActive(data.id))
|
||||
|
||||
// Store metadata for efficient progress calculation
|
||||
const durations = [lowUrgencyDuration * 1000, normalUrgencyDuration * 1000, criticalUrgencyDuration * 1000]
|
||||
|
||||
let expire = 0
|
||||
if (data.expireTimeout === 0) {
|
||||
expire = -1 // Never expire
|
||||
} else if (data.expireTimeout > 0) {
|
||||
expire = data.expireTimeout
|
||||
} else {
|
||||
expire = durations[data.urgency]
|
||||
}
|
||||
|
||||
notificationMetadata[data.id] = {
|
||||
"timestamp": data.timestamp.getTime(),
|
||||
"duration": expire,
|
||||
"urgency": data.urgency
|
||||
}
|
||||
|
||||
activeList.insert(0, data)
|
||||
while (activeList.count > maxVisible) {
|
||||
const last = activeList.get(activeList.count - 1)
|
||||
activeMap[last.id]?.dismiss()
|
||||
activeList.remove(activeList.count - 1)
|
||||
delete notificationMetadata[last.id]
|
||||
}
|
||||
}
|
||||
|
||||
function createData(n) {
|
||||
const time = new Date()
|
||||
const id = Checksum.sha256(JSON.stringify({
|
||||
"summary": n.summary,
|
||||
"body": n.body,
|
||||
"app": n.appName,
|
||||
"time": time.getTime()
|
||||
}))
|
||||
|
||||
const image = n.image || getIcon(n.appIcon)
|
||||
const imageId = generateImageId(n, image)
|
||||
queueImage(image, imageId)
|
||||
|
||||
return {
|
||||
"id": id,
|
||||
"summary": (n.summary || ""),
|
||||
"body": stripTags(n.body || ""),
|
||||
"appName": getAppName(n.appName),
|
||||
"urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency,
|
||||
"expireTimeout": n.expireTimeout,
|
||||
"timestamp": time,
|
||||
"progress": 1.0,
|
||||
"originalImage": image,
|
||||
"cachedImage": imageId ? (cacheDirImagesNotifications + imageId + ".png") : image,
|
||||
"actionsJson": JSON.stringify((n.actions || []).map(a => ({
|
||||
"text": a.text || "Action",
|
||||
"identifier": a.identifier || ""
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
function queueImage(path, imageId) {
|
||||
if (!path || !path.startsWith("image://") || !imageId)
|
||||
return
|
||||
|
||||
const dest = cacheDirImagesNotifications + imageId + ".png"
|
||||
|
||||
for (const req of imageQueue) {
|
||||
if (req.imageId === imageId)
|
||||
return
|
||||
}
|
||||
|
||||
imageQueue.push({
|
||||
"src": path,
|
||||
"dest": dest,
|
||||
"imageId": imageId
|
||||
})
|
||||
|
||||
if (imageQueue.length === 1)
|
||||
cacher.source = path
|
||||
}
|
||||
|
||||
function updateImagePath(id, path) {
|
||||
updateModel(activeList, id, "cachedImage", path)
|
||||
updateModel(historyList, id, "cachedImage", path)
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
function updateModel(model, id, prop, value) {
|
||||
for (var i = 0; i < model.count; i++) {
|
||||
if (model.get(i).id === id) {
|
||||
model.setProperty(i, prop, value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeActive(id) {
|
||||
for (var i = 0; i < activeList.count; i++) {
|
||||
if (activeList.get(i).id === id) {
|
||||
activeList.remove(i)
|
||||
delete activeMap[id]
|
||||
delete notificationMetadata[id]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized batch progress update
|
||||
Timer {
|
||||
interval: 50 // Reduced from 10ms to 50ms (20 updates/sec instead of 100)
|
||||
repeat: true
|
||||
running: activeList.count > 0
|
||||
onTriggered: updateAllProgress()
|
||||
}
|
||||
|
||||
function updateAllProgress() {
|
||||
const now = Date.now()
|
||||
const toRemove = []
|
||||
const updates = [] // Batch updates
|
||||
|
||||
// Collect all updates first
|
||||
for (var i = 0; i < activeList.count; i++) {
|
||||
const notif = activeList.get(i)
|
||||
const meta = notificationMetadata[notif.id]
|
||||
|
||||
if (!meta || meta.duration === -1)
|
||||
continue
|
||||
|
||||
// Skip infinite notifications
|
||||
const elapsed = now - meta.timestamp
|
||||
const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0)
|
||||
|
||||
if (progress <= 0) {
|
||||
toRemove.push(notif.id)
|
||||
} else if (Math.abs(notif.progress - progress) > 0.005) {
|
||||
// Only update if change is significant
|
||||
updates.push({
|
||||
"index": i,
|
||||
"progress": progress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Apply batch updates
|
||||
for (const update of updates) {
|
||||
activeList.setProperty(update.index, "progress", update.progress)
|
||||
}
|
||||
|
||||
// Remove expired notifications (one at a time to allow animation)
|
||||
if (toRemove.length > 0) {
|
||||
animateAndRemove(toRemove[0])
|
||||
}
|
||||
}
|
||||
|
||||
// History management
|
||||
function addToHistory(data) {
|
||||
historyList.insert(0, data)
|
||||
|
||||
while (historyList.count > maxHistory) {
|
||||
const old = historyList.get(historyList.count - 1)
|
||||
if (old.cachedImage && !old.cachedImage.startsWith("image://")) {
|
||||
Quickshell.execDetached(["rm", "-f", old.cachedImage])
|
||||
}
|
||||
historyList.remove(historyList.count - 1)
|
||||
}
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// Persistence
|
||||
FileView {
|
||||
id: historyFileView
|
||||
path: historyFile
|
||||
printErrors: false
|
||||
onLoaded: loadHistory()
|
||||
onLoadFailed: error => {
|
||||
if (error === 2)
|
||||
writeAdapter()
|
||||
}
|
||||
|
||||
JsonAdapter {
|
||||
id: adapter
|
||||
property var notifications: []
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: saveTimer
|
||||
interval: 200
|
||||
onTriggered: performSaveHistory()
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
saveTimer.restart()
|
||||
}
|
||||
|
||||
function performSaveHistory() {
|
||||
try {
|
||||
const items = []
|
||||
for (var i = 0; i < historyList.count; i++) {
|
||||
const n = historyList.get(i)
|
||||
const copy = Object.assign({}, n)
|
||||
copy.timestamp = n.timestamp.getTime()
|
||||
items.push(copy)
|
||||
}
|
||||
adapter.notifications = items
|
||||
historyFileView.writeAdapter()
|
||||
} catch (e) {
|
||||
Logger.error("Notifications", "Save history failed:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
historyList.clear()
|
||||
for (const item of adapter.notifications || []) {
|
||||
const time = new Date(item.timestamp)
|
||||
|
||||
let cachedImage = item.cachedImage || ""
|
||||
if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) {
|
||||
const imageId = generateImageId(item, item.originalImage)
|
||||
if (imageId) {
|
||||
cachedImage = cacheDirImagesNotifications + imageId + ".png"
|
||||
}
|
||||
}
|
||||
|
||||
historyList.append({
|
||||
"id": item.id || "",
|
||||
"summary": item.summary || "",
|
||||
"body": item.body || "",
|
||||
"appName": item.appName || "",
|
||||
"urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency,
|
||||
"timestamp": time,
|
||||
"originalImage": item.originalImage || "",
|
||||
"cachedImage": cachedImage
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("Notifications", "Load failed:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function getAppName(name) {
|
||||
if (!name || name.trim() === "")
|
||||
return "Unknown"
|
||||
|
||||
name = name.trim()
|
||||
|
||||
if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) {
|
||||
const parts = name.split(".")
|
||||
let appPart = parts[parts.length - 1]
|
||||
|
||||
if (!appPart || appPart === "app" || appPart === "desktop") {
|
||||
appPart = parts[parts.length - 2] || parts[0]
|
||||
}
|
||||
|
||||
if (appPart) {
|
||||
name = appPart
|
||||
}
|
||||
}
|
||||
|
||||
if (name.includes(".")) {
|
||||
const parts = name.split(".")
|
||||
let displayName = parts[parts.length - 1]
|
||||
|
||||
if (!displayName || /^\d+$/.test(displayName)) {
|
||||
displayName = parts[parts.length - 2] || parts[0]
|
||||
}
|
||||
|
||||
if (displayName) {
|
||||
displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1)
|
||||
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
displayName = displayName.replace(/app$/i, '').trim()
|
||||
displayName = displayName.replace(/desktop$/i, '').trim()
|
||||
displayName = displayName.replace(/flatpak$/i, '').trim()
|
||||
|
||||
if (!displayName) {
|
||||
displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
return displayName || name
|
||||
}
|
||||
|
||||
let displayName = name.charAt(0).toUpperCase() + name.slice(1)
|
||||
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
displayName = displayName.replace(/app$/i, '').trim()
|
||||
displayName = displayName.replace(/desktop$/i, '').trim()
|
||||
|
||||
return displayName || name
|
||||
}
|
||||
|
||||
function getIcon(icon) {
|
||||
if (!icon)
|
||||
return ""
|
||||
if (icon.startsWith("/") || icon.startsWith("file://"))
|
||||
return icon
|
||||
return ThemeIcons.iconFromName(icon)
|
||||
}
|
||||
|
||||
function stripTags(text) {
|
||||
return text.replace(/<[^>]*>?/gm, '')
|
||||
}
|
||||
|
||||
function generateImageId(notification, image) {
|
||||
if (image && image.startsWith("image://")) {
|
||||
if (image.startsWith("image://qsimage/")) {
|
||||
const key = (notification.appName || "") + "|" + (notification.summary || "")
|
||||
return Checksum.sha256(key)
|
||||
}
|
||||
return Checksum.sha256(image)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Public API
|
||||
function dismissActiveNotification(id) {
|
||||
activeMap[id]?.dismiss()
|
||||
removeActive(id)
|
||||
}
|
||||
|
||||
function dismissAllActive() {
|
||||
Object.values(activeMap).forEach(n => n.dismiss())
|
||||
activeList.clear()
|
||||
activeMap = {}
|
||||
notificationMetadata = {}
|
||||
}
|
||||
|
||||
function invokeAction(id, actionId) {
|
||||
const n = activeMap[id]
|
||||
if (!n?.actions)
|
||||
return false
|
||||
|
||||
for (const action of n.actions) {
|
||||
if (action.identifier === actionId && action.invoke) {
|
||||
action.invoke()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function removeFromHistory(notificationId) {
|
||||
for (var i = 0; i < historyList.count; i++) {
|
||||
const notif = historyList.get(i)
|
||||
if (notif.id === notificationId) {
|
||||
if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) {
|
||||
Quickshell.execDetached(["rm", "-f", notif.cachedImage])
|
||||
}
|
||||
historyList.remove(i)
|
||||
saveHistory()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
try {
|
||||
Quickshell.execDetached(["sh", "-c", `rm -rf "${cacheDirImagesNotifications}"*`])
|
||||
} catch (e) {
|
||||
Logger.error("Notifications", "Failed to clear cache directory:", e)
|
||||
}
|
||||
|
||||
historyList.clear()
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// Signals & connections
|
||||
signal animateAndRemove(string notificationId)
|
||||
|
||||
Connections {
|
||||
target: SettingsService.notifications
|
||||
function onDoNotDisturbChanged() {
|
||||
const enabled = SettingsService.notifications.doNotDisturb
|
||||
}
|
||||
}
|
||||
}
|
||||
23
config/quickshell/Services/NukeKded6.qml
Normal file
23
config/quickshell/Services/NukeKded6.qml
Normal file
@@ -0,0 +1,23 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property bool done: false
|
||||
|
||||
Process {
|
||||
id: process
|
||||
|
||||
running: true
|
||||
command: ["kquitapp6", "kded6"]
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
Logger.warn("NukeKded6", `Failed to kill kded6: ${code}`);
|
||||
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
67
config/quickshell/Services/PanelService.qml
Normal file
67
config/quickshell/Services/PanelService.qml
Normal file
@@ -0,0 +1,67 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// A ref. to the lockScreen, so it's accessible from anywhere
|
||||
// This is not a panel...
|
||||
property var lockScreen: null
|
||||
|
||||
// Panels
|
||||
property var registeredPanels: ({})
|
||||
property var openedPanel: null
|
||||
signal willOpen
|
||||
|
||||
// Currently opened popups, can have more than one.
|
||||
// ex: when opening an NIconPicker from a widget setting.
|
||||
property var openedPopups: []
|
||||
property bool hasOpenedPopup: false
|
||||
signal popupChanged
|
||||
|
||||
// Register this panel
|
||||
function registerPanel(panel) {
|
||||
registeredPanels[panel.objectName] = panel
|
||||
}
|
||||
|
||||
// Returns a panel
|
||||
function getPanel(name) {
|
||||
return registeredPanels[name] || null
|
||||
}
|
||||
|
||||
// Check if a panel exists
|
||||
function hasPanel(name) {
|
||||
return name in registeredPanels
|
||||
}
|
||||
|
||||
// Helper to keep only one panel open at any time
|
||||
function willOpenPanel(panel) {
|
||||
if (openedPanel && openedPanel !== panel) {
|
||||
openedPanel.close()
|
||||
}
|
||||
openedPanel = panel
|
||||
|
||||
// emit signal
|
||||
willOpen()
|
||||
}
|
||||
|
||||
function closedPanel(panel) {
|
||||
if (openedPanel && openedPanel === panel) {
|
||||
openedPanel = null
|
||||
}
|
||||
}
|
||||
|
||||
// Popups
|
||||
function willOpenPopup(popup) {
|
||||
openedPopups.push(popup)
|
||||
hasOpenedPopup = (openedPopups.length !== 0)
|
||||
popupChanged()
|
||||
}
|
||||
|
||||
function willClosePopup(popup) {
|
||||
openedPopups = openedPopups.filter(p => p !== popup)
|
||||
hasOpenedPopup = (openedPopups.length !== 0)
|
||||
popupChanged()
|
||||
}
|
||||
}
|
||||
88
config/quickshell/Services/PowerProfileService.qml
Normal file
88
config/quickshell/Services/PowerProfileService.qml
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
163
config/quickshell/Services/RecordService.qml
Normal file
163
config/quickshell/Services/RecordService.qml
Normal file
@@ -0,0 +1,163 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property string recordingDir: CacheService.recordingDir
|
||||
property bool isRecording: false
|
||||
property bool isStopping: false
|
||||
property string codec: "av1_nvenc"
|
||||
property string container: "mkv"
|
||||
property string pixelFormat: "p010le"
|
||||
property string recordingDisplay: ""
|
||||
property int framerate: 60
|
||||
property var codecParams: ["preset=p5", "rc=vbr", "cq=18", "b:v=80M", "maxrate=120M", "bufsize=160M", "color_range=tv"]
|
||||
property var filterArgs: []
|
||||
|
||||
function getFilename() {
|
||||
var d = new Date();
|
||||
var year = d.getFullYear();
|
||||
var month = ("0" + (d.getMonth() + 1)).slice(-2);
|
||||
var day = ("0" + d.getDate()).slice(-2);
|
||||
var hours = ("0" + d.getHours()).slice(-2);
|
||||
var minutes = ("0" + d.getMinutes()).slice(-2);
|
||||
var seconds = ("0" + d.getSeconds()).slice(-2);
|
||||
return "recording_" + year + "-" + month + "-" + day + "_" + hours + "." + minutes + "." + seconds + "." + container;
|
||||
}
|
||||
|
||||
function getAudioSink() {
|
||||
return AudioService.sink ? AudioService.sink.name + '.monitor' : null; // this works on my machine :)
|
||||
}
|
||||
|
||||
function getVideoSource(callback) {
|
||||
if (niriFocusedOutputProcess.running) {
|
||||
Logger.warn("RecordService", "Already fetching focused output, returning null.");
|
||||
callback(null);
|
||||
}
|
||||
niriFocusedOutputProcess.onGetName = callback;
|
||||
niriFocusedOutputProcess.running = true;
|
||||
}
|
||||
|
||||
function startOrStop() {
|
||||
if (isRecording)
|
||||
stop();
|
||||
else
|
||||
start();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!isRecording) {
|
||||
Logger.warn("RecordService", "Not currently recording, cannot stop.");
|
||||
return ;
|
||||
}
|
||||
if (isStopping) {
|
||||
Logger.warn("RecordService", "Already stopping, please wait.");
|
||||
return ;
|
||||
}
|
||||
isStopping = true;
|
||||
recordProcess.signal(15);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (isRecording || isStopping) {
|
||||
Logger.warn("RecordService", "Already recording, cannot start.");
|
||||
return ;
|
||||
}
|
||||
isRecording = true;
|
||||
getVideoSource((source) => {
|
||||
if (!source) {
|
||||
SendNotification.show("Recording failed", "Could not determine which display to record from.");
|
||||
return ;
|
||||
}
|
||||
recordingDisplay = source;
|
||||
const audioSink = getAudioSink();
|
||||
if (!audioSink) {
|
||||
SendNotification.show("Recording failed", "No audio sink available to record from.");
|
||||
return ;
|
||||
}
|
||||
recordProcess.filePath = recordingDir + getFilename();
|
||||
recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath];
|
||||
for (const param of codecParams) {
|
||||
recordProcess.command.push("-p");
|
||||
recordProcess.command.push(param);
|
||||
}
|
||||
for (const filter of filterArgs) {
|
||||
recordProcess.command.push("-F");
|
||||
recordProcess.command.push(filter);
|
||||
}
|
||||
Logger.log("RecordService", "Starting recording with command: " + recordProcess.command.join(" "));
|
||||
recordProcess.onErrorExit = function() {
|
||||
SendNotification.show("Recording failed", "An error occurred while trying to record the screen.");
|
||||
};
|
||||
recordProcess.onNormalExit = function() {
|
||||
SendNotification.show("Recording stopped", recordProcess.filePath);
|
||||
};
|
||||
recordProcess.running = true;
|
||||
SendNotification.show("Recording started", "Recording to " + recordProcess.filePath);
|
||||
});
|
||||
}
|
||||
|
||||
Process {
|
||||
id: recordProcess
|
||||
|
||||
property string filePath: ""
|
||||
property var onNormalExit: null
|
||||
property var onErrorExit: null
|
||||
|
||||
running: false
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if (exitCode === 0) {
|
||||
Logger.log("RecordService", "Recording stopped successfully.");
|
||||
if (onNormalExit) {
|
||||
onNormalExit();
|
||||
onNormalExit = null;
|
||||
}
|
||||
} else {
|
||||
Logger.error("RecordService", "Recording process exited with error code: " + exitCode);
|
||||
if (onErrorExit) {
|
||||
onErrorExit();
|
||||
onErrorExit = null;
|
||||
}
|
||||
}
|
||||
isRecording = false;
|
||||
isStopping = false;
|
||||
recordingDisplay = "";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: niriFocusedOutputProcess
|
||||
|
||||
property var onGetName: null
|
||||
|
||||
running: false
|
||||
command: ["niri", "msg", "focused-output"]
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if (exitCode !== 0) {
|
||||
Logger.error("RecordService", "Failed to get focused output via niri.");
|
||||
if (niriFocusedOutputProcess.onGetName) {
|
||||
niriFocusedOutputProcess.onGetName(null);
|
||||
niriFocusedOutputProcess.onGetName = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: (data) => {
|
||||
if (niriFocusedOutputProcess.onGetName) {
|
||||
const parts = data.split(' ');
|
||||
const name = parts.length > 0 ? parts[parts.length - 1].slice(1)?.slice(0, -1) : null;
|
||||
name ? Logger.log("RecordService", "Focused output is: " + name) : Logger.warn("RecordService", "No focused output found.");
|
||||
niriFocusedOutputProcess.onGetName(name);
|
||||
niriFocusedOutputProcess.onGetName = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
16
config/quickshell/Services/SendNotification.qml
Normal file
16
config/quickshell/Services/SendNotification.qml
Normal file
@@ -0,0 +1,16 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function show(title, message, icon = "", urgency = "normal") {
|
||||
if (icon)
|
||||
Quickshell.execDetached(["notify-send", "-u", urgency, "-i", icon, title, message, "-a", "quickshell"]);
|
||||
else
|
||||
Quickshell.execDetached(["notify-send", "-u", urgency, title, message, "-a", "quickshell"]);
|
||||
}
|
||||
|
||||
}
|
||||
39
config/quickshell/Services/SettingsService.qml
Normal file
39
config/quickshell/Services/SettingsService.qml
Normal file
@@ -0,0 +1,39 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property alias primaryColor: adapter.primaryColor
|
||||
property alias showLyricsBar: adapter.showLyricsBar
|
||||
property alias notifications: adapter.notifications
|
||||
property alias location: adapter.location
|
||||
property string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json")
|
||||
|
||||
FileView {
|
||||
id: settingsFile
|
||||
|
||||
path: settingsFilePath
|
||||
watchChanges: true
|
||||
onFileChanged: reload()
|
||||
onAdapterUpdated: writeAdapter()
|
||||
|
||||
JsonAdapter {
|
||||
id: adapter
|
||||
|
||||
property string primaryColor: "#89b4fa"
|
||||
property bool showLyricsBar: false
|
||||
property JsonObject notifications
|
||||
property string location: "New York"
|
||||
|
||||
notifications: JsonObject {
|
||||
property bool doNotDisturb: false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
392
config/quickshell/Services/SystemStatService.qml
Normal file
392
config/quickshell/Services/SystemStatService.qml
Normal file
@@ -0,0 +1,392 @@
|
||||
import Qt.labs.folderlistmodel
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
// For Intel coretemp, start averaging all available sensors/cores
|
||||
|
||||
id: root
|
||||
|
||||
// Public values
|
||||
property real cpuUsage: 0
|
||||
property real cpuTemp: 0
|
||||
property real memGb: 0
|
||||
property real memPercent: 0
|
||||
property real diskPercent: 0
|
||||
property real rxSpeed: 0
|
||||
property real txSpeed: 0
|
||||
// Configuration
|
||||
property int sleepDuration: 3000
|
||||
property int fasterSleepDuration: 1000
|
||||
// Internal state for CPU calculation
|
||||
property var prevCpuStats: null
|
||||
// Internal state for network speed calculation
|
||||
// Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered
|
||||
// since the computer started, so their value will easily overlfow a 32bit int.
|
||||
property real prevRxBytes: 0
|
||||
property real prevTxBytes: 0
|
||||
property real prevTime: 0
|
||||
// Cpu temperature is the most complex
|
||||
readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"]
|
||||
property string cpuTempSensorName: ""
|
||||
property string cpuTempHwmonPath: ""
|
||||
// For Intel coretemp averaging of all cores/sensors
|
||||
property var intelTempValues: []
|
||||
property int intelTempFilesChecked: 0
|
||||
property int intelTempMaxFiles: 20 // Will test up to temp20_input
|
||||
|
||||
// -------------------------------------------------------
|
||||
// -------------------------------------------------------
|
||||
// Parse memory info from /proc/meminfo
|
||||
function parseMemoryInfo(text) {
|
||||
if (!text)
|
||||
return ;
|
||||
|
||||
const lines = text.split('\n');
|
||||
let memTotal = 0;
|
||||
let memAvailable = 0;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('MemTotal:'))
|
||||
memTotal = parseInt(line.split(/\s+/)[1]) || 0;
|
||||
else if (line.startsWith('MemAvailable:'))
|
||||
memAvailable = parseInt(line.split(/\s+/)[1]) || 0;
|
||||
}
|
||||
if (memTotal > 0) {
|
||||
const usageKb = memTotal - memAvailable;
|
||||
root.memGb = (usageKb / 1e+06).toFixed(1);
|
||||
root.memPercent = Math.round((usageKb / memTotal) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Calculate CPU usage from /proc/stat
|
||||
function calculateCpuUsage(text) {
|
||||
if (!text)
|
||||
return ;
|
||||
|
||||
const lines = text.split('\n');
|
||||
const cpuLine = lines[0];
|
||||
// First line is total CPU
|
||||
if (!cpuLine.startsWith('cpu '))
|
||||
return ;
|
||||
|
||||
const parts = cpuLine.split(/\s+/);
|
||||
const stats = {
|
||||
"user": parseInt(parts[1]) || 0,
|
||||
"nice": parseInt(parts[2]) || 0,
|
||||
"system": parseInt(parts[3]) || 0,
|
||||
"idle": parseInt(parts[4]) || 0,
|
||||
"iowait": parseInt(parts[5]) || 0,
|
||||
"irq": parseInt(parts[6]) || 0,
|
||||
"softirq": parseInt(parts[7]) || 0,
|
||||
"steal": parseInt(parts[8]) || 0,
|
||||
"guest": parseInt(parts[9]) || 0,
|
||||
"guestNice": parseInt(parts[10]) || 0
|
||||
};
|
||||
const totalIdle = stats.idle + stats.iowait;
|
||||
const total = Object.values(stats).reduce((sum, val) => {
|
||||
return sum + val;
|
||||
}, 0);
|
||||
if (root.prevCpuStats) {
|
||||
const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait;
|
||||
const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => {
|
||||
return sum + val;
|
||||
}, 0);
|
||||
const diffTotal = total - prevTotal;
|
||||
const diffIdle = totalIdle - prevTotalIdle;
|
||||
if (diffTotal > 0)
|
||||
root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1);
|
||||
|
||||
}
|
||||
root.prevCpuStats = stats;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Calculate RX and TX speed from /proc/net/dev
|
||||
// Average speed of all interfaces excepted 'lo'
|
||||
function calculateNetworkSpeed(text) {
|
||||
if (!text)
|
||||
return ;
|
||||
|
||||
const currentTime = Date.now() / 1000;
|
||||
const lines = text.split('\n');
|
||||
let totalRx = 0;
|
||||
let totalTx = 0;
|
||||
for (var i = 2; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line)
|
||||
continue;
|
||||
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1)
|
||||
continue;
|
||||
|
||||
const iface = line.substring(0, colonIndex).trim();
|
||||
if (iface === 'lo')
|
||||
continue;
|
||||
|
||||
const statsLine = line.substring(colonIndex + 1).trim();
|
||||
const stats = statsLine.split(/\s+/);
|
||||
const rxBytes = parseInt(stats[0], 10) || 0;
|
||||
const txBytes = parseInt(stats[8], 10) || 0;
|
||||
totalRx += rxBytes;
|
||||
totalTx += txBytes;
|
||||
}
|
||||
// Compute only if we have a previous run to compare to.
|
||||
if (root.prevTime > 0) {
|
||||
const timeDiff = currentTime - root.prevTime;
|
||||
// Avoid division by zero if time hasn't passed.
|
||||
if (timeDiff > 0) {
|
||||
let rxDiff = totalRx - root.prevRxBytes;
|
||||
let txDiff = totalTx - root.prevTxBytes;
|
||||
// Handle counter resets (e.g., WiFi reconnect), which would cause a negative value.
|
||||
if (rxDiff < 0)
|
||||
rxDiff = 0;
|
||||
|
||||
if (txDiff < 0)
|
||||
txDiff = 0;
|
||||
|
||||
root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s
|
||||
root.txSpeed = Math.round(txDiff / timeDiff);
|
||||
}
|
||||
}
|
||||
root.prevRxBytes = totalRx;
|
||||
root.prevTxBytes = totalTx;
|
||||
root.prevTime = currentTime;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Helper function to format network speeds
|
||||
function formatSpeed(bytesPerSecond) {
|
||||
if (bytesPerSecond < 1024 * 1024) {
|
||||
const kb = bytesPerSecond / 1024;
|
||||
if (kb < 10)
|
||||
return kb.toFixed(1) + "KB";
|
||||
else
|
||||
return Math.round(kb) + "KB";
|
||||
} else if (bytesPerSecond < 1024 * 1024 * 1024) {
|
||||
return (bytesPerSecond / (1024 * 1024)).toFixed(1) + "MB";
|
||||
} else {
|
||||
return (bytesPerSecond / (1024 * 1024 * 1024)).toFixed(1) + "GB";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Compact speed formatter for vertical bar display
|
||||
function formatCompactSpeed(bytesPerSecond) {
|
||||
if (!bytesPerSecond || bytesPerSecond <= 0)
|
||||
return "0";
|
||||
|
||||
const units = ["", "K", "M", "G"];
|
||||
let value = bytesPerSecond;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value = value / 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
// Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded)
|
||||
if (unitIndex < units.length - 1 && value >= 100) {
|
||||
value = value / 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
const display = Math.round(value).toString();
|
||||
return display + units[unitIndex];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Function to start fetching and computing the cpu temperature
|
||||
function updateCpuTemperature() {
|
||||
// For AMD sensors (k10temp and zenpower), only use Tctl sensor
|
||||
// temp1_input corresponds to Tctl (Temperature Control) on these sensors
|
||||
if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") {
|
||||
cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`;
|
||||
cpuTempReader.reload();
|
||||
} else if (root.cpuTempSensorName === "coretemp") {
|
||||
root.intelTempValues = [];
|
||||
root.intelTempFilesChecked = 0;
|
||||
checkNextIntelTemp();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Function to check next Intel temperature sensor
|
||||
function checkNextIntelTemp() {
|
||||
if (root.intelTempFilesChecked >= root.intelTempMaxFiles) {
|
||||
// Calculate average of all found temperatures
|
||||
if (root.intelTempValues.length > 0) {
|
||||
let sum = 0;
|
||||
for (var i = 0; i < root.intelTempValues.length; i++) {
|
||||
sum += root.intelTempValues[i];
|
||||
}
|
||||
root.cpuTemp = Math.round(sum / root.intelTempValues.length);
|
||||
} else {
|
||||
Logger.warn("SystemStatService", "No temperature sensors found for coretemp");
|
||||
root.cpuTemp = 0;
|
||||
}
|
||||
return ;
|
||||
}
|
||||
// Check next temperature file
|
||||
root.intelTempFilesChecked++;
|
||||
cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`;
|
||||
cpuTempReader.reload();
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
Component.onCompleted: {
|
||||
// Kickoff the cpu name detection for temperature
|
||||
cpuTempNameReader.checkNext();
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// Timer for periodic updates
|
||||
Timer {
|
||||
id: updateTimer
|
||||
|
||||
interval: root.sleepDuration
|
||||
repeat: true
|
||||
running: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
// Trigger all direct system files reads
|
||||
memInfoFile.reload();
|
||||
cpuStatFile.reload();
|
||||
// Run df (disk free) one time
|
||||
dfProcess.running = true;
|
||||
updateCpuTemperature();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: fasterUpdateTimer
|
||||
|
||||
interval: root.fasterSleepDuration
|
||||
repeat: true
|
||||
running: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
netDevFile.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// FileView components for reading system files
|
||||
FileView {
|
||||
id: memInfoFile
|
||||
|
||||
path: "/proc/meminfo"
|
||||
onLoaded: parseMemoryInfo(text())
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: cpuStatFile
|
||||
|
||||
path: "/proc/stat"
|
||||
onLoaded: calculateCpuUsage(text())
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: netDevFile
|
||||
|
||||
path: "/proc/net/dev"
|
||||
onLoaded: calculateNetworkSpeed(text())
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// Process to fetch disk usage in percent
|
||||
// Uses 'df' aka 'disk free'
|
||||
Process {
|
||||
id: dfProcess
|
||||
|
||||
command: ["df", "--output=pcent", "/"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const percent = lines[1].replace(/[^0-9]/g, '');
|
||||
root.diskPercent = parseInt(percent) || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// --------------------------------------------
|
||||
// CPU Temperature
|
||||
// It's more complex.
|
||||
// ----
|
||||
// #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower"
|
||||
FileView {
|
||||
id: cpuTempNameReader
|
||||
|
||||
property int currentIndex: 0
|
||||
|
||||
function checkNext() {
|
||||
if (currentIndex >= 16) {
|
||||
// Check up to hwmon10
|
||||
Logger.warn("SystemStatService", "No supported temperature sensor found");
|
||||
return ;
|
||||
}
|
||||
cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`;
|
||||
cpuTempNameReader.reload();
|
||||
}
|
||||
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
const name = text().trim();
|
||||
if (root.supportedTempCpuSensorNames.includes(name)) {
|
||||
root.cpuTempSensorName = name;
|
||||
root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`;
|
||||
Logger.log("SystemStatService", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`);
|
||||
} else {
|
||||
currentIndex++;
|
||||
Qt.callLater(() => {
|
||||
// Qt.callLater is mandatory
|
||||
checkNext();
|
||||
});
|
||||
}
|
||||
}
|
||||
onLoadFailed: function(error) {
|
||||
currentIndex++;
|
||||
Qt.callLater(() => {
|
||||
// Qt.callLater is mandatory
|
||||
checkNext();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// #2 - Read sensor value
|
||||
FileView {
|
||||
id: cpuTempReader
|
||||
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
const data = text().trim();
|
||||
if (root.cpuTempSensorName === "coretemp") {
|
||||
// For Intel, collect all temperature values
|
||||
const temp = parseInt(data) / 1000;
|
||||
root.intelTempValues.push(temp);
|
||||
Qt.callLater(() => {
|
||||
// Qt.callLater is mandatory
|
||||
checkNextIntelTemp();
|
||||
});
|
||||
} else {
|
||||
// For AMD sensors (k10temp and zenpower), directly set the temperature
|
||||
root.cpuTemp = Math.round(parseInt(data) / 1000);
|
||||
}
|
||||
}
|
||||
onLoadFailed: function(error) {
|
||||
Qt.callLater(() => {
|
||||
// Qt.callLater is mandatory
|
||||
checkNextIntelTemp();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
46
config/quickshell/Services/ThemeIcons.qml
Normal file
46
config/quickshell/Services/ThemeIcons.qml
Normal file
@@ -0,0 +1,46 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
// ignore and fall back
|
||||
|
||||
id: root
|
||||
|
||||
function iconFromName(iconName, fallbackName) {
|
||||
const fallback = fallbackName || "application-x-executable";
|
||||
try {
|
||||
if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) {
|
||||
const p = Quickshell.iconPath(iconName, fallback);
|
||||
if (p && p !== "")
|
||||
return p;
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
try {
|
||||
return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : "";
|
||||
} catch (e2) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve icon path for a DesktopEntries appId - safe on missing entries
|
||||
function iconForAppId(appId, fallbackName) {
|
||||
const fallback = fallbackName || "application-x-executable";
|
||||
if (!appId)
|
||||
return iconFromName(fallback, fallback);
|
||||
|
||||
try {
|
||||
if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId)
|
||||
return iconFromName(fallback, fallback);
|
||||
|
||||
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId);
|
||||
const name = entry && entry.icon ? entry.icon : "";
|
||||
return iconFromName(name || fallback, fallback);
|
||||
} catch (e) {
|
||||
return iconFromName(fallback, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
44
config/quickshell/Services/TimeService.qml
Normal file
44
config/quickshell/Services/TimeService.qml
Normal file
@@ -0,0 +1,44 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var date: new Date()
|
||||
property string time: Qt.formatDateTime(date, "HH:mm")
|
||||
property string dateString: {
|
||||
let now = date;
|
||||
let dayName = now.toLocaleDateString(Qt.locale(), "ddd");
|
||||
dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1);
|
||||
let day = now.getDate();
|
||||
let suffix;
|
||||
if (day > 3 && day < 21)
|
||||
suffix = 'th';
|
||||
else
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
suffix = "st";
|
||||
break;
|
||||
case 2:
|
||||
suffix = "nd";
|
||||
break;
|
||||
case 3:
|
||||
suffix = "rd";
|
||||
break;
|
||||
default:
|
||||
suffix = "th";
|
||||
};
|
||||
let month = now.toLocaleDateString(Qt.locale(), "MMMM");
|
||||
let year = now.toLocaleDateString(Qt.locale(), "yyyy");
|
||||
return `${dayName}, ` + `${day}${suffix} ${month} ${year}`;
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: root.date = new Date()
|
||||
}
|
||||
|
||||
}
|
||||
56
config/quickshell/Services/WorkspaceManager.qml
Normal file
56
config/quickshell/Services/WorkspaceManager.qml
Normal file
@@ -0,0 +1,56 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property ListModel workspaces
|
||||
|
||||
workspaces: ListModel {
|
||||
}
|
||||
|
||||
function initNiri() {
|
||||
updateNiriWorkspaces();
|
||||
}
|
||||
|
||||
function updateNiriWorkspaces() {
|
||||
const niriWorkspaces = Niri.workspaces || [];
|
||||
workspaces.clear();
|
||||
for (let i = 0; i < niriWorkspaces.length; i++) {
|
||||
const ws = niriWorkspaces[i];
|
||||
workspaces.append({
|
||||
"id": ws.id,
|
||||
"idx": ws.idx || 1,
|
||||
"name": ws.name || "",
|
||||
"output": ws.output || "",
|
||||
"isFocused": ws.isFocused === true,
|
||||
"isActive": ws.isActive === true,
|
||||
"isUrgent": ws.isUrgent === true
|
||||
});
|
||||
}
|
||||
workspacesChanged();
|
||||
}
|
||||
|
||||
function switchToWorkspace(workspaceId) {
|
||||
try {
|
||||
Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]);
|
||||
} catch (e) {
|
||||
Logger.error("WorkspaceManager", "Error switching Niri workspace:", e);
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onWorkspacesChanged() {
|
||||
updateNiriWorkspaces();
|
||||
}
|
||||
|
||||
target: Niri
|
||||
}
|
||||
|
||||
}
|
||||
20
config/quickshell/Services/WriteClipboard.qml
Normal file
20
config/quickshell/Services/WriteClipboard.qml
Normal file
@@ -0,0 +1,20 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function write(text) {
|
||||
action.command = ["sh", "-c", `echo ${text} | wl-copy -n`];
|
||||
action.startDetached();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: action
|
||||
|
||||
running: false
|
||||
}
|
||||
|
||||
}
|
||||
BIN
config/quickshell/Shaders/qsb/circled_image.frag.qsb
Normal file
BIN
config/quickshell/Shaders/qsb/circled_image.frag.qsb
Normal file
Binary file not shown.
BIN
config/quickshell/Shaders/qsb/rounded_image.frag.qsb
Normal file
BIN
config/quickshell/Shaders/qsb/rounded_image.frag.qsb
Normal file
Binary file not shown.
71
config/quickshell/Utils/Cava.qml
Normal file
71
config/quickshell/Utils/Cava.qml
Normal file
@@ -0,0 +1,71 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
property int count: 32
|
||||
property int noiseReduction: 60
|
||||
property string channels: "mono"
|
||||
property string monoOption: "average"
|
||||
property var config: ({
|
||||
"general": {
|
||||
"bars": count,
|
||||
"framerate": 30,
|
||||
"autosens": 1
|
||||
},
|
||||
"smoothing": {
|
||||
"monstercat": 1,
|
||||
"gravity": 1e+06,
|
||||
"noise_reduction": noiseReduction
|
||||
},
|
||||
"output": {
|
||||
"method": "raw",
|
||||
"data_format": "ascii",
|
||||
"ascii_max_range": 100,
|
||||
"bit_format": "8bit",
|
||||
"channels": channels,
|
||||
"mono_option": monoOption
|
||||
}
|
||||
})
|
||||
property var values: Array(count).fill(0)
|
||||
|
||||
Process {
|
||||
id: process
|
||||
|
||||
stdinEnabled: true
|
||||
running: !MusicManager.isAllPaused()
|
||||
command: ["cava", "-p", "/dev/stdin"]
|
||||
onExited: {
|
||||
stdinEnabled = true;
|
||||
values = Array(count).fill(0);
|
||||
}
|
||||
onStarted: {
|
||||
for (const k in config) {
|
||||
if (typeof config[k] !== "object") {
|
||||
write(k + "=" + config[k] + "\n");
|
||||
continue;
|
||||
}
|
||||
write("[" + k + "]\n");
|
||||
const obj = config[k];
|
||||
for (const k2 in obj) {
|
||||
write(k2 + "=" + obj[k2] + "\n");
|
||||
}
|
||||
}
|
||||
stdinEnabled = false;
|
||||
values = Array(count).fill(0);
|
||||
}
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: (data) => {
|
||||
root.values = data.slice(0, -1).split(";").map((v) => {
|
||||
return parseInt(v, 10) / 100;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
10
config/quickshell/Utils/CavaColorList.qml
Normal file
10
config/quickshell/Utils/CavaColorList.qml
Normal file
@@ -0,0 +1,10 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Constants
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var colorList: [Colors.lavender, Colors.blue, Colors.sapphire, Colors.sky, Colors.teal, Colors.green, Colors.yellow, Colors.peach]
|
||||
}
|
||||
58
config/quickshell/Utils/Logger.qml
Normal file
58
config/quickshell/Utils/Logger.qml
Normal 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
config/quickshell/Utils/Time.qml
Normal file
84
config/quickshell/Utils/Time.qml
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
192
config/quickshell/Utils/sha256.js
Normal file
192
config/quickshell/Utils/sha256.js
Normal file
@@ -0,0 +1,192 @@
|
||||
function sha256(message) {
|
||||
// SHA-256 constants
|
||||
const K = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
||||
];
|
||||
|
||||
// Initial hash values
|
||||
let h0 = 0x6a09e667;
|
||||
let h1 = 0xbb67ae85;
|
||||
let h2 = 0x3c6ef372;
|
||||
let h3 = 0xa54ff53a;
|
||||
let h4 = 0x510e527f;
|
||||
let h5 = 0x9b05688c;
|
||||
let h6 = 0x1f83d9ab;
|
||||
let h7 = 0x5be0cd19;
|
||||
|
||||
// Convert string to UTF-8 bytes manually
|
||||
const msgBytes = stringToUtf8Bytes(message);
|
||||
const msgLength = msgBytes.length;
|
||||
const bitLength = msgLength * 8;
|
||||
|
||||
// Calculate padding
|
||||
// Message + 1 bit (0x80) + padding zeros + 8 bytes for length = multiple of 64 bytes
|
||||
const totalBitsNeeded = bitLength + 1 + 64; // message bits + padding bit + 64-bit length
|
||||
const totalBytesNeeded = Math.ceil(totalBitsNeeded / 8);
|
||||
const paddedLength = Math.ceil(totalBytesNeeded / 64) * 64; // Round up to multiple of 64
|
||||
|
||||
const paddedMsg = new Array(paddedLength).fill(0);
|
||||
|
||||
// Copy original message
|
||||
for (let i = 0; i < msgLength; i++) {
|
||||
paddedMsg[i] = msgBytes[i];
|
||||
}
|
||||
|
||||
// Add padding bit (0x80 = 10000000 in binary)
|
||||
paddedMsg[msgLength] = 0x80;
|
||||
|
||||
// Add length as 64-bit big-endian integer at the end
|
||||
// JavaScript numbers are not precise enough for 64-bit integers, so we handle high/low separately
|
||||
const highBits = Math.floor(bitLength / 0x100000000);
|
||||
const lowBits = bitLength % 0x100000000;
|
||||
|
||||
// Write 64-bit length in big-endian format
|
||||
paddedMsg[paddedLength - 8] = (highBits >>> 24) & 0xFF;
|
||||
paddedMsg[paddedLength - 7] = (highBits >>> 16) & 0xFF;
|
||||
paddedMsg[paddedLength - 6] = (highBits >>> 8) & 0xFF;
|
||||
paddedMsg[paddedLength - 5] = highBits & 0xFF;
|
||||
paddedMsg[paddedLength - 4] = (lowBits >>> 24) & 0xFF;
|
||||
paddedMsg[paddedLength - 3] = (lowBits >>> 16) & 0xFF;
|
||||
paddedMsg[paddedLength - 2] = (lowBits >>> 8) & 0xFF;
|
||||
paddedMsg[paddedLength - 1] = lowBits & 0xFF;
|
||||
|
||||
// Process message in 512-bit (64-byte) chunks
|
||||
for (let chunk = 0; chunk < paddedLength; chunk += 64) {
|
||||
const w = new Array(64);
|
||||
|
||||
// Break chunk into sixteen 32-bit big-endian words
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const offset = chunk + i * 4;
|
||||
w[i] = (paddedMsg[offset] << 24) |
|
||||
(paddedMsg[offset + 1] << 16) |
|
||||
(paddedMsg[offset + 2] << 8) |
|
||||
paddedMsg[offset + 3];
|
||||
// Ensure unsigned 32-bit
|
||||
w[i] = w[i] >>> 0;
|
||||
}
|
||||
|
||||
// Extend the sixteen 32-bit words into sixty-four 32-bit words
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rightRotate(w[i - 15], 7) ^ rightRotate(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rightRotate(w[i - 2], 17) ^ rightRotate(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
|
||||
// Initialize working variables for this chunk
|
||||
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7;
|
||||
|
||||
// Main loop
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const temp1 = (h + S1 + ch + K[i] + w[i]) >>> 0;
|
||||
const S0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temp2 = (S0 + maj) >>> 0;
|
||||
|
||||
h = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + temp1) >>> 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (temp1 + temp2) >>> 0;
|
||||
}
|
||||
|
||||
// Add this chunk's hash to result so far
|
||||
h0 = (h0 + a) >>> 0;
|
||||
h1 = (h1 + b) >>> 0;
|
||||
h2 = (h2 + c) >>> 0;
|
||||
h3 = (h3 + d) >>> 0;
|
||||
h4 = (h4 + e) >>> 0;
|
||||
h5 = (h5 + f) >>> 0;
|
||||
h6 = (h6 + g) >>> 0;
|
||||
h7 = (h7 + h) >>> 0;
|
||||
}
|
||||
|
||||
// Produce the final hash value as a hex string
|
||||
return [h0, h1, h2, h3, h4, h5, h6, h7]
|
||||
.map(h => h.toString(16).padStart(8, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function stringToUtf8Bytes(str) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let code = str.charCodeAt(i);
|
||||
|
||||
if (code < 0x80) {
|
||||
// 1-byte character (ASCII)
|
||||
bytes.push(code);
|
||||
} else if (code < 0x800) {
|
||||
// 2-byte character
|
||||
bytes.push(0xC0 | (code >> 6));
|
||||
bytes.push(0x80 | (code & 0x3F));
|
||||
} else if (code < 0xD800 || code > 0xDFFF) {
|
||||
// 3-byte character (not surrogate)
|
||||
bytes.push(0xE0 | (code >> 12));
|
||||
bytes.push(0x80 | ((code >> 6) & 0x3F));
|
||||
bytes.push(0x80 | (code & 0x3F));
|
||||
} else {
|
||||
// 4-byte character (surrogate pair)
|
||||
i++; // Move to next character
|
||||
const code2 = str.charCodeAt(i);
|
||||
const codePoint = 0x10000 + (((code & 0x3FF) << 10) | (code2 & 0x3FF));
|
||||
bytes.push(0xF0 | (codePoint >> 18));
|
||||
bytes.push(0x80 | ((codePoint >> 12) & 0x3F));
|
||||
bytes.push(0x80 | ((codePoint >> 6) & 0x3F));
|
||||
bytes.push(0x80 | (codePoint & 0x3F));
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function rightRotate(value, amount) {
|
||||
return ((value >>> amount) | (value << (32 - amount))) >>> 0;
|
||||
}
|
||||
|
||||
// Test function to verify implementation
|
||||
// function testSHA256() {
|
||||
// const tests = [
|
||||
// {
|
||||
// input: "",
|
||||
// expected:
|
||||
// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
// },
|
||||
// {
|
||||
// input: "Hello World",
|
||||
// expected:
|
||||
// "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e",
|
||||
// },
|
||||
// {
|
||||
// input: "abc",
|
||||
// expected:
|
||||
// "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
// },
|
||||
// {
|
||||
// input: "The quick brown fox jumps over the lazy dog",
|
||||
// expected:
|
||||
// "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592",
|
||||
// },
|
||||
// ];
|
||||
|
||||
// console.log("Running SHA-256 tests:");
|
||||
// tests.forEach((test, i) => {
|
||||
// const result = Crypto.sha256(test.input);
|
||||
// const passed = result === test.expected;
|
||||
// console.log(`Test ${i + 1}: ${passed ? "PASS" : "FAIL"}`);
|
||||
// if (!passed) {
|
||||
// console.log(` Input: "${test.input}"`);
|
||||
// console.log(` Expected: ${test.expected}`);
|
||||
// console.log(` Got: ${result}`);
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
16
config/quickshell/apply-color
Executable file
16
config/quickshell/apply-color
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
path=$(dirname "$(readlink -f "$0")")
|
||||
. "$path"/../../utils/apply-color-helper
|
||||
|
||||
if pgrep -x "quickshell" -u "$USER" >/dev/null; then
|
||||
qs ipc call colors setPrimary ${colorHex} || {
|
||||
log_error "Failed to send IPC command to quickshell"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_success "quickshell"
|
||||
else
|
||||
log_error "quickshell is not running. Cannot apply color."
|
||||
exit 1
|
||||
fi
|
||||
57
config/quickshell/shell.qml
Normal file
57
config/quickshell/shell.qml
Normal file
@@ -0,0 +1,57 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.Constants
|
||||
import qs.Modules.Bar
|
||||
import qs.Modules.Misc
|
||||
import qs.Modules.Panel
|
||||
import qs.Services
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
Loader {
|
||||
id: loader
|
||||
|
||||
active: CacheService.loaded && NukeKded6.done
|
||||
|
||||
sourceComponent: Item {
|
||||
Notification {
|
||||
id: notification
|
||||
}
|
||||
|
||||
IPCService {
|
||||
id: ipcService
|
||||
}
|
||||
|
||||
Bar {
|
||||
id: bar
|
||||
}
|
||||
|
||||
Corners {
|
||||
id: corners
|
||||
}
|
||||
|
||||
CalendarPanel {
|
||||
id: calendarPanel
|
||||
|
||||
objectName: "calendarPanel"
|
||||
}
|
||||
|
||||
ControlCenterPanel {
|
||||
id: controlCenterPanel
|
||||
|
||||
objectName: "controlCenterPanel"
|
||||
}
|
||||
|
||||
NotificationHistoryPanel {
|
||||
id: notificationHistoryPanel
|
||||
|
||||
objectName: "notificationHistoryPanel"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user