add noctalia and fuzzel
This commit is contained in:
@@ -0,0 +1,801 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
|
||||
Variants {
|
||||
id: backgroundVariants
|
||||
model: Quickshell.screens
|
||||
|
||||
delegate: Loader {
|
||||
|
||||
required property ShellScreen modelData
|
||||
|
||||
active: modelData && Settings.data.wallpaper.enabled && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableWallpaper)
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
id: root
|
||||
|
||||
// Internal state management
|
||||
property string transitionType: "fade"
|
||||
property real transitionProgress: 0
|
||||
property bool isStartupTransition: true
|
||||
property bool wallpaperReady: false
|
||||
|
||||
visible: wallpaperReady
|
||||
|
||||
readonly property real edgeSmoothness: Settings.data.wallpaper.transitionEdgeSmoothness
|
||||
readonly property var allTransitions: WallpaperService.allTransitions
|
||||
readonly property bool transitioning: transitionAnimation.running
|
||||
|
||||
// Wipe direction: 0=left, 1=right, 2=up, 3=down
|
||||
property real wipeDirection: 0
|
||||
|
||||
// Disc
|
||||
property real discCenterX: 0.5
|
||||
property real discCenterY: 0.5
|
||||
|
||||
// Stripe
|
||||
property real stripesCount: 16
|
||||
property real stripesAngle: 0
|
||||
|
||||
// Pixelate
|
||||
property real pixelateMaxBlockSize: 64.0
|
||||
|
||||
// Honeycomb
|
||||
property real honeycombCellSize: 0.04
|
||||
property real honeycombCenterX: 0.5
|
||||
property real honeycombCenterY: 0.5
|
||||
|
||||
// Used to debounce wallpaper changes
|
||||
property string futureWallpaper: ""
|
||||
// Track the original wallpaper path being transitioned to (before caching)
|
||||
property string transitioningToOriginalPath: ""
|
||||
|
||||
// Fillmode default is "crop"
|
||||
property real fillMode: WallpaperService.getFillModeUniform()
|
||||
property vector4d fillColor: Qt.vector4d(Settings.data.wallpaper.fillColor.r, Settings.data.wallpaper.fillColor.g, Settings.data.wallpaper.fillColor.b, 1.0)
|
||||
|
||||
// Solid color mode - track whether current/next are solid colors
|
||||
property bool isSolid1: false
|
||||
property bool isSolid2: false
|
||||
property color _solidColor1: Settings.data.wallpaper.solidColor
|
||||
property color _solidColor2: Settings.data.wallpaper.solidColor
|
||||
property vector4d solidColor1: Qt.vector4d(_solidColor1.r, _solidColor1.g, _solidColor1.b, 1.0)
|
||||
property vector4d solidColor2: Qt.vector4d(_solidColor2.r, _solidColor2.g, _solidColor2.b, 1.0)
|
||||
|
||||
Component.onCompleted: setWallpaperInitial()
|
||||
|
||||
Component.onDestruction: {
|
||||
transitionAnimation.stop();
|
||||
startupTransitionTimer.stop();
|
||||
debounceTimer.stop();
|
||||
shaderLoader.active = false;
|
||||
currentWallpaper.source = "";
|
||||
nextWallpaper.source = "";
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Settings.data.wallpaper
|
||||
function onFillModeChanged() {
|
||||
fillMode = WallpaperService.getFillModeUniform();
|
||||
}
|
||||
}
|
||||
|
||||
// External state management
|
||||
Connections {
|
||||
target: WallpaperService
|
||||
function onWallpaperChanged(screenName, path) {
|
||||
if (screenName === modelData.name) {
|
||||
requestPreprocessedWallpaper(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onDisplayScalesChanged() {
|
||||
if (!WallpaperService.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = WallpaperService.getWallpaper(modelData.name);
|
||||
if (!currentPath || WallpaperService.isSolidColorPath(currentPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStartupTransition) {
|
||||
// During startup, just ensure the correct cache exists without visual changes
|
||||
const compositorScale = CompositorService.getDisplayScale(modelData.name);
|
||||
const targetWidth = Math.round(modelData.width * compositorScale);
|
||||
const targetHeight = Math.round(modelData.height * compositorScale);
|
||||
ImageCacheService.getLarge(currentPath, targetWidth, targetHeight, function (cachedPath, success) {
|
||||
WallpaperService.wallpaperProcessingComplete(modelData.name, currentPath, success ? cachedPath : "");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
requestPreprocessedWallpaper(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
screen: modelData
|
||||
WlrLayershell.layer: WlrLayer.Background
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.namespace: "noctalia-wallpaper-" + (screen?.name || "unknown")
|
||||
|
||||
anchors {
|
||||
bottom: true
|
||||
top: true
|
||||
right: true
|
||||
left: true
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: debounceTimer
|
||||
interval: 333
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: changeWallpaper()
|
||||
}
|
||||
|
||||
// Delay startup transition to ensure the compositor has mapped the window
|
||||
Timer {
|
||||
id: startupTransitionTimer
|
||||
interval: 100
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: _executeStartupTransition()
|
||||
}
|
||||
|
||||
Image {
|
||||
id: currentWallpaper
|
||||
|
||||
source: ""
|
||||
smooth: true
|
||||
mipmap: false
|
||||
visible: false
|
||||
cache: true // Cached so Overview can share the same texture
|
||||
asynchronous: true
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error) {
|
||||
Logger.w("Current wallpaper failed to load:", source);
|
||||
} else if (status === Image.Ready && !wallpaperReady) {
|
||||
wallpaperReady = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
id: nextWallpaper
|
||||
|
||||
property bool pendingTransition: false
|
||||
|
||||
source: ""
|
||||
smooth: true
|
||||
mipmap: false
|
||||
visible: false
|
||||
cache: false // Not cached - temporary during transitions
|
||||
asynchronous: true
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error) {
|
||||
Logger.w("Next wallpaper failed to load:", source);
|
||||
pendingTransition = false;
|
||||
} else if (status === Image.Ready) {
|
||||
if (!wallpaperReady) {
|
||||
wallpaperReady = true;
|
||||
}
|
||||
if (pendingTransition) {
|
||||
pendingTransition = false;
|
||||
currentWallpaper.asynchronous = false;
|
||||
transitionAnimation.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic shader loader - only loads the active transition shader
|
||||
Loader {
|
||||
id: shaderLoader
|
||||
anchors.fill: parent
|
||||
active: true
|
||||
|
||||
sourceComponent: {
|
||||
switch (transitionType) {
|
||||
case "wipe":
|
||||
return wipeShaderComponent;
|
||||
case "disc":
|
||||
return discShaderComponent;
|
||||
case "stripes":
|
||||
return stripesShaderComponent;
|
||||
case "pixelate":
|
||||
return pixelateShaderComponent;
|
||||
case "honeycomb":
|
||||
return honeycombShaderComponent;
|
||||
case "fade":
|
||||
case "none":
|
||||
default:
|
||||
return fadeShaderComponent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fade or None transition shader component
|
||||
Component {
|
||||
id: fadeShaderComponent
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
|
||||
property variant source1: currentWallpaper
|
||||
property variant source2: nextWallpaper.status === Image.Ready ? nextWallpaper : currentWallpaper.status === Image.Ready ? nextWallpaper : currentWallpaper
|
||||
property real progress: root.transitionProgress
|
||||
|
||||
// Fill mode properties
|
||||
property real fillMode: root.fillMode
|
||||
property vector4d fillColor: root.fillColor
|
||||
property real imageWidth1: source1.sourceSize.width
|
||||
property real imageHeight1: source1.sourceSize.height
|
||||
property real imageWidth2: source2.sourceSize.width
|
||||
property real imageHeight2: source2.sourceSize.height
|
||||
property real screenWidth: width
|
||||
property real screenHeight: height
|
||||
|
||||
// Solid color mode
|
||||
property real isSolid1: root.isSolid1 ? 1.0 : 0.0
|
||||
property real isSolid2: root.isSolid2 ? 1.0 : 0.0
|
||||
property vector4d solidColor1: root.solidColor1
|
||||
property vector4d solidColor2: root.solidColor2
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/wp_fade.frag.qsb")
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe transition shader component
|
||||
Component {
|
||||
id: wipeShaderComponent
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
|
||||
property variant source1: currentWallpaper
|
||||
property variant source2: nextWallpaper.status === Image.Ready ? nextWallpaper : currentWallpaper
|
||||
property real progress: root.transitionProgress
|
||||
property real smoothness: root.edgeSmoothness
|
||||
property real direction: root.wipeDirection
|
||||
|
||||
// Fill mode properties
|
||||
property real fillMode: root.fillMode
|
||||
property vector4d fillColor: root.fillColor
|
||||
property real imageWidth1: source1.sourceSize.width
|
||||
property real imageHeight1: source1.sourceSize.height
|
||||
property real imageWidth2: source2.sourceSize.width
|
||||
property real imageHeight2: source2.sourceSize.height
|
||||
property real screenWidth: width
|
||||
property real screenHeight: height
|
||||
|
||||
// Solid color mode
|
||||
property real isSolid1: root.isSolid1 ? 1.0 : 0.0
|
||||
property real isSolid2: root.isSolid2 ? 1.0 : 0.0
|
||||
property vector4d solidColor1: root.solidColor1
|
||||
property vector4d solidColor2: root.solidColor2
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/wp_wipe.frag.qsb")
|
||||
}
|
||||
}
|
||||
|
||||
// Disc reveal transition shader component
|
||||
Component {
|
||||
id: discShaderComponent
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
|
||||
property variant source1: currentWallpaper
|
||||
property variant source2: nextWallpaper.status === Image.Ready ? nextWallpaper : currentWallpaper
|
||||
property real progress: root.transitionProgress
|
||||
property real smoothness: root.edgeSmoothness
|
||||
property real aspectRatio: root.width / root.height
|
||||
property real centerX: root.discCenterX
|
||||
property real centerY: root.discCenterY
|
||||
|
||||
// Fill mode properties
|
||||
property real fillMode: root.fillMode
|
||||
property vector4d fillColor: root.fillColor
|
||||
property real imageWidth1: source1.sourceSize.width
|
||||
property real imageHeight1: source1.sourceSize.height
|
||||
property real imageWidth2: source2.sourceSize.width
|
||||
property real imageHeight2: source2.sourceSize.height
|
||||
property real screenWidth: width
|
||||
property real screenHeight: height
|
||||
|
||||
// Solid color mode
|
||||
property real isSolid1: root.isSolid1 ? 1.0 : 0.0
|
||||
property real isSolid2: root.isSolid2 ? 1.0 : 0.0
|
||||
property vector4d solidColor1: root.solidColor1
|
||||
property vector4d solidColor2: root.solidColor2
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/wp_disc.frag.qsb")
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal stripes transition shader component
|
||||
Component {
|
||||
id: stripesShaderComponent
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
|
||||
property variant source1: currentWallpaper
|
||||
property variant source2: nextWallpaper.status === Image.Ready ? nextWallpaper : currentWallpaper
|
||||
property real progress: root.transitionProgress
|
||||
property real smoothness: root.edgeSmoothness
|
||||
property real aspectRatio: root.width / root.height
|
||||
property real stripeCount: root.stripesCount
|
||||
property real angle: root.stripesAngle
|
||||
|
||||
// Fill mode properties
|
||||
property real fillMode: root.fillMode
|
||||
property vector4d fillColor: root.fillColor
|
||||
property real imageWidth1: source1.sourceSize.width
|
||||
property real imageHeight1: source1.sourceSize.height
|
||||
property real imageWidth2: source2.sourceSize.width
|
||||
property real imageHeight2: source2.sourceSize.height
|
||||
property real screenWidth: width
|
||||
property real screenHeight: height
|
||||
|
||||
// Solid color mode
|
||||
property real isSolid1: root.isSolid1 ? 1.0 : 0.0
|
||||
property real isSolid2: root.isSolid2 ? 1.0 : 0.0
|
||||
property vector4d solidColor1: root.solidColor1
|
||||
property vector4d solidColor2: root.solidColor2
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/wp_stripes.frag.qsb")
|
||||
}
|
||||
}
|
||||
|
||||
// Pixelate transition shader component
|
||||
Component {
|
||||
id: pixelateShaderComponent
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
|
||||
property variant source1: currentWallpaper
|
||||
property variant source2: nextWallpaper.status === Image.Ready ? nextWallpaper : currentWallpaper
|
||||
property real progress: root.transitionProgress
|
||||
property real maxBlockSize: root.pixelateMaxBlockSize
|
||||
|
||||
// Fill mode properties
|
||||
property real fillMode: root.fillMode
|
||||
property vector4d fillColor: root.fillColor
|
||||
property real imageWidth1: source1.sourceSize.width
|
||||
property real imageHeight1: source1.sourceSize.height
|
||||
property real imageWidth2: source2.sourceSize.width
|
||||
property real imageHeight2: source2.sourceSize.height
|
||||
property real screenWidth: width
|
||||
property real screenHeight: height
|
||||
|
||||
// Solid color mode
|
||||
property real isSolid1: root.isSolid1 ? 1.0 : 0.0
|
||||
property real isSolid2: root.isSolid2 ? 1.0 : 0.0
|
||||
property vector4d solidColor1: root.solidColor1
|
||||
property vector4d solidColor2: root.solidColor2
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/wp_pixelate.frag.qsb")
|
||||
}
|
||||
}
|
||||
|
||||
// Honeycomb transition shader component
|
||||
Component {
|
||||
id: honeycombShaderComponent
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
|
||||
property variant source1: currentWallpaper
|
||||
property variant source2: nextWallpaper.status === Image.Ready ? nextWallpaper : currentWallpaper
|
||||
property real progress: root.transitionProgress
|
||||
property real cellSize: root.honeycombCellSize
|
||||
property real centerX: root.honeycombCenterX
|
||||
property real centerY: root.honeycombCenterY
|
||||
property real aspectRatio: root.width / root.height
|
||||
|
||||
// Fill mode properties
|
||||
property real fillMode: root.fillMode
|
||||
property vector4d fillColor: root.fillColor
|
||||
property real imageWidth1: source1.sourceSize.width
|
||||
property real imageHeight1: source1.sourceSize.height
|
||||
property real imageWidth2: source2.sourceSize.width
|
||||
property real imageHeight2: source2.sourceSize.height
|
||||
property real screenWidth: width
|
||||
property real screenHeight: height
|
||||
|
||||
// Solid color mode
|
||||
property real isSolid1: root.isSolid1 ? 1.0 : 0.0
|
||||
property real isSolid2: root.isSolid2 ? 1.0 : 0.0
|
||||
property vector4d solidColor1: root.solidColor1
|
||||
property vector4d solidColor2: root.solidColor2
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/wp_honeycomb.frag.qsb")
|
||||
}
|
||||
}
|
||||
|
||||
// Animation for the transition progress
|
||||
NumberAnimation {
|
||||
id: transitionAnimation
|
||||
target: root
|
||||
property: "transitionProgress"
|
||||
from: 0.0
|
||||
to: 1.0
|
||||
duration: Settings.data.wallpaper.transitionDuration
|
||||
easing.type: Easing.InOutCubic
|
||||
onFinished: {
|
||||
// Mark startup complete now that the animation has finished,
|
||||
// so displayScalesChanged doesn't trigger a duplicate transition.
|
||||
if (isStartupTransition) {
|
||||
isStartupTransition = false;
|
||||
}
|
||||
|
||||
// Clear the tracking of what we're transitioning to
|
||||
transitioningToOriginalPath = "";
|
||||
|
||||
// Transfer solid color state from next to current
|
||||
isSolid1 = isSolid2;
|
||||
_solidColor1 = _solidColor2;
|
||||
|
||||
// Assign new image to current BEFORE clearing to prevent flicker
|
||||
const tempSource = nextWallpaper.source;
|
||||
currentWallpaper.source = tempSource;
|
||||
transitionProgress = 0.0;
|
||||
|
||||
// Now clear nextWallpaper after currentWallpaper has the new source
|
||||
// Force complete cleanup to free texture memory
|
||||
Qt.callLater(() => {
|
||||
nextWallpaper.source = "";
|
||||
isSolid2 = false;
|
||||
Qt.callLater(() => {
|
||||
currentWallpaper.asynchronous = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a path (string or QUrl) to a plain string for comparison.
|
||||
// QML Image.source is a url type; comparing url === string can return
|
||||
// false even for identical paths. This converts both sides to strings.
|
||||
function _pathStr(p) {
|
||||
var s = p.toString();
|
||||
// QUrl.toString() may add a file:// prefix for local paths
|
||||
if (s.startsWith("file://")) {
|
||||
return s.substring(7);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
function setWallpaperInitial() {
|
||||
// On startup, defer assigning wallpaper until the services are ready
|
||||
if (!WallpaperService || !WallpaperService.isInitialized) {
|
||||
Qt.callLater(setWallpaperInitial);
|
||||
return;
|
||||
}
|
||||
if (!ImageCacheService || !ImageCacheService.initialized) {
|
||||
Qt.callLater(setWallpaperInitial);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in solid color mode
|
||||
if (Settings.data.wallpaper.useSolidColor) {
|
||||
var solidPath = WallpaperService.createSolidColorPath(Settings.data.wallpaper.solidColor.toString());
|
||||
futureWallpaper = solidPath;
|
||||
performStartupTransition();
|
||||
WallpaperService.wallpaperProcessingComplete(modelData.name, solidPath, "");
|
||||
return;
|
||||
}
|
||||
|
||||
const wallpaperPath = WallpaperService.getWallpaper(modelData.name);
|
||||
|
||||
// Check if the path is a solid color
|
||||
if (WallpaperService.isSolidColorPath(wallpaperPath)) {
|
||||
futureWallpaper = wallpaperPath;
|
||||
performStartupTransition();
|
||||
WallpaperService.wallpaperProcessingComplete(modelData.name, wallpaperPath, "");
|
||||
return;
|
||||
}
|
||||
|
||||
const compositorScale = CompositorService.getDisplayScale(modelData.name);
|
||||
const targetWidth = Math.round(modelData.width * compositorScale);
|
||||
const targetHeight = Math.round(modelData.height * compositorScale);
|
||||
|
||||
ImageCacheService.getLarge(wallpaperPath, targetWidth, targetHeight, function (cachedPath, success) {
|
||||
if (success) {
|
||||
futureWallpaper = cachedPath;
|
||||
} else {
|
||||
// Fallback to original
|
||||
futureWallpaper = wallpaperPath;
|
||||
}
|
||||
performStartupTransition();
|
||||
// Pass cached path for blur optimization (already resized)
|
||||
WallpaperService.wallpaperProcessingComplete(modelData.name, wallpaperPath, success ? cachedPath : "");
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
function requestPreprocessedWallpaper(originalPath) {
|
||||
// If we're already transitioning to this exact wallpaper, skip the request
|
||||
if (transitioning && originalPath === transitioningToOriginalPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the original path we're working towards
|
||||
transitioningToOriginalPath = originalPath;
|
||||
|
||||
// Handle solid color paths - no preprocessing needed
|
||||
if (WallpaperService.isSolidColorPath(originalPath)) {
|
||||
futureWallpaper = originalPath;
|
||||
debounceTimer.restart();
|
||||
WallpaperService.wallpaperProcessingComplete(modelData.name, originalPath, "");
|
||||
return;
|
||||
}
|
||||
|
||||
const compositorScale = CompositorService.getDisplayScale(modelData.name);
|
||||
const targetWidth = Math.round(modelData.width * compositorScale);
|
||||
const targetHeight = Math.round(modelData.height * compositorScale);
|
||||
|
||||
ImageCacheService.getLarge(originalPath, targetWidth, targetHeight, function (cachedPath, success) {
|
||||
// Ignore stale callback if we've moved on to a different wallpaper
|
||||
if (originalPath !== transitioningToOriginalPath) {
|
||||
return;
|
||||
}
|
||||
if (success) {
|
||||
futureWallpaper = cachedPath;
|
||||
} else {
|
||||
futureWallpaper = originalPath;
|
||||
}
|
||||
|
||||
// Skip transition if the resolved path matches what's already displayed
|
||||
if (_pathStr(futureWallpaper) === _pathStr(currentWallpaper.source)) {
|
||||
transitioningToOriginalPath = "";
|
||||
WallpaperService.wallpaperProcessingComplete(modelData.name, originalPath, success ? cachedPath : "");
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer.restart();
|
||||
// Pass cached path for blur optimization (already resized)
|
||||
WallpaperService.wallpaperProcessingComplete(modelData.name, originalPath, success ? cachedPath : "");
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
function setWallpaperImmediate(source) {
|
||||
transitionAnimation.stop();
|
||||
transitionProgress = 0.0;
|
||||
|
||||
// Check if this is a solid color
|
||||
var isSolidSource = WallpaperService.isSolidColorPath(source);
|
||||
isSolid1 = isSolidSource;
|
||||
isSolid2 = false;
|
||||
|
||||
if (isSolidSource) {
|
||||
var colorStr = WallpaperService.getSolidColor(source);
|
||||
_solidColor1 = colorStr;
|
||||
// Clear image sources for memory efficiency
|
||||
currentWallpaper.source = "";
|
||||
nextWallpaper.source = "";
|
||||
if (!wallpaperReady) {
|
||||
wallpaperReady = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear nextWallpaper completely to free texture memory
|
||||
nextWallpaper.source = "";
|
||||
nextWallpaper.sourceSize = undefined;
|
||||
|
||||
currentWallpaper.source = "";
|
||||
|
||||
Qt.callLater(() => {
|
||||
currentWallpaper.source = source;
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
function setWallpaperWithTransition(source) {
|
||||
// Check if this is a solid color transition
|
||||
var isSolidSource = WallpaperService.isSolidColorPath(source);
|
||||
|
||||
// For solid colors, check if we're already showing the same color
|
||||
if (isSolidSource && isSolid1) {
|
||||
var newColor = WallpaperService.getSolidColor(source);
|
||||
if (newColor === _solidColor1.toString()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For images, check if source matches (use _pathStr to normalize url vs string)
|
||||
if (!isSolidSource && _pathStr(source) === _pathStr(currentWallpaper.source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're already transitioning to this same wallpaper, skip
|
||||
if (transitioning && source === nextWallpaper.source) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (transitioning) {
|
||||
// We are interrupting a transition - handle cleanup properly
|
||||
transitionAnimation.stop();
|
||||
transitionProgress = 0;
|
||||
|
||||
// Transfer next state to current
|
||||
isSolid1 = isSolid2;
|
||||
_solidColor1 = _solidColor2;
|
||||
const newCurrentSource = nextWallpaper.source;
|
||||
currentWallpaper.source = newCurrentSource;
|
||||
|
||||
// Now clear nextWallpaper after current has the new source
|
||||
Qt.callLater(() => {
|
||||
nextWallpaper.source = "";
|
||||
isSolid2 = false;
|
||||
|
||||
// Now set the next wallpaper after a brief delay
|
||||
Qt.callLater(() => {
|
||||
_startTransitionTo(source, isSolidSource);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_startTransitionTo(source, isSolidSource);
|
||||
}
|
||||
|
||||
// Helper to start transition to a new source
|
||||
function _startTransitionTo(source, isSolidSource) {
|
||||
isSolid2 = isSolidSource;
|
||||
|
||||
if (isSolidSource) {
|
||||
var colorStr = WallpaperService.getSolidColor(source);
|
||||
_solidColor2 = colorStr;
|
||||
// No image to load, start transition immediately
|
||||
nextWallpaper.source = "";
|
||||
if (!wallpaperReady) {
|
||||
wallpaperReady = true;
|
||||
}
|
||||
currentWallpaper.asynchronous = false;
|
||||
transitionAnimation.start();
|
||||
} else {
|
||||
nextWallpaper.source = source;
|
||||
if (nextWallpaper.status === Image.Ready) {
|
||||
if (!wallpaperReady) {
|
||||
wallpaperReady = true;
|
||||
}
|
||||
currentWallpaper.asynchronous = false;
|
||||
transitionAnimation.start();
|
||||
} else {
|
||||
nextWallpaper.pendingTransition = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Main method that actually trigger the wallpaper change
|
||||
function changeWallpaper() {
|
||||
// Pick a transition from the user's selected list
|
||||
var selected = Settings.data.wallpaper.transitionType;
|
||||
if (!selected || selected.length === 0) {
|
||||
transitionType = "none";
|
||||
} else if (selected.length === 1) {
|
||||
transitionType = selected[0];
|
||||
} else {
|
||||
var index = Math.floor(Math.random() * selected.length);
|
||||
transitionType = selected[index];
|
||||
}
|
||||
|
||||
// Ensure the transition type really exists
|
||||
if (transitionType !== "none" && !allTransitions.includes(transitionType)) {
|
||||
transitionType = "fade";
|
||||
}
|
||||
|
||||
//Logger.i("Background", "New wallpaper: ", futureWallpaper, "On:", modelData.name, "Transition:", transitionType)
|
||||
switch (transitionType) {
|
||||
case "none":
|
||||
setWallpaperImmediate(futureWallpaper);
|
||||
break;
|
||||
case "wipe":
|
||||
wipeDirection = Math.random() * 4;
|
||||
setWallpaperWithTransition(futureWallpaper);
|
||||
break;
|
||||
case "disc":
|
||||
discCenterX = Math.random();
|
||||
discCenterY = Math.random();
|
||||
setWallpaperWithTransition(futureWallpaper);
|
||||
break;
|
||||
case "stripes":
|
||||
stripesCount = Math.round(Math.random() * 20 + 4);
|
||||
stripesAngle = Math.random() * 360;
|
||||
setWallpaperWithTransition(futureWallpaper);
|
||||
break;
|
||||
case "pixelate":
|
||||
pixelateMaxBlockSize = Math.round(Math.random() * 80 + 32);
|
||||
setWallpaperWithTransition(futureWallpaper);
|
||||
break;
|
||||
case "honeycomb":
|
||||
honeycombCellSize = Math.random() * 0.04 + 0.02;
|
||||
honeycombCenterX = Math.random();
|
||||
honeycombCenterY = Math.random();
|
||||
setWallpaperWithTransition(futureWallpaper);
|
||||
break;
|
||||
default:
|
||||
setWallpaperWithTransition(futureWallpaper);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Dedicated function for startup animation
|
||||
// Sets up transition params, then defers the actual animation
|
||||
// to allow the compositor time to map the window.
|
||||
function performStartupTransition() {
|
||||
if (Settings.data.wallpaper.skipStartupTransition) {
|
||||
setWallpaperImmediate(futureWallpaper);
|
||||
isStartupTransition = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick a transition from the user's selected list
|
||||
var selected = Settings.data.wallpaper.transitionType;
|
||||
if (!selected || selected.length === 0) {
|
||||
transitionType = "none";
|
||||
} else if (selected.length === 1) {
|
||||
transitionType = selected[0];
|
||||
} else {
|
||||
var index = Math.floor(Math.random() * selected.length);
|
||||
transitionType = selected[index];
|
||||
}
|
||||
|
||||
// Ensure the transition type really exists
|
||||
if (transitionType !== "none" && !allTransitions.includes(transitionType)) {
|
||||
transitionType = "fade";
|
||||
}
|
||||
|
||||
// Pre-compute per-type params so the shader is ready
|
||||
switch (transitionType) {
|
||||
case "wipe":
|
||||
wipeDirection = Math.random() * 4;
|
||||
break;
|
||||
case "disc":
|
||||
// Force center origin for elegant startup animation
|
||||
discCenterX = 0.5;
|
||||
discCenterY = 0.5;
|
||||
break;
|
||||
case "stripes":
|
||||
stripesCount = Math.round(Math.random() * 20 + 4);
|
||||
stripesAngle = Math.random() * 360;
|
||||
break;
|
||||
case "pixelate":
|
||||
pixelateMaxBlockSize = 64.0;
|
||||
break;
|
||||
case "honeycomb":
|
||||
honeycombCellSize = 0.04;
|
||||
honeycombCenterX = 0.5;
|
||||
honeycombCenterY = 0.5;
|
||||
break;
|
||||
}
|
||||
|
||||
// Defer the actual transition start so the compositor can map the window
|
||||
startupTransitionTimer.start();
|
||||
}
|
||||
|
||||
// Actually kick off the startup transition after the delay
|
||||
function _executeStartupTransition() {
|
||||
if (transitionType === "none") {
|
||||
setWallpaperImmediate(futureWallpaper);
|
||||
isStartupTransition = false;
|
||||
} else {
|
||||
// isStartupTransition stays true until transitionAnimation.onFinished
|
||||
// to prevent displayScalesChanged from triggering a duplicate transition.
|
||||
setWallpaperWithTransition(futureWallpaper);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.Power
|
||||
|
||||
/**
|
||||
* IdleFadeOverlay — full-screen fade-to-black shown before each idle action.
|
||||
*
|
||||
* A single Loader wraps a Variants so per-screen windows only exist while
|
||||
* a fade is in progress, keeping VRAM usage at zero at rest.
|
||||
*
|
||||
* Any mouse movement cancels the fade and unloads the windows immediately.
|
||||
*/
|
||||
Item {
|
||||
id: root
|
||||
|
||||
Loader {
|
||||
active: IdleService.fadePending !== ""
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: Variants {
|
||||
model: Quickshell.screens
|
||||
delegate: PanelWindow {
|
||||
id: overlay
|
||||
required property ShellScreen modelData
|
||||
screen: modelData
|
||||
|
||||
color: Qt.rgba(0, 0, 0, 0)
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.namespace: "noctalia-fade-overlay"
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
ColorAnimation on color {
|
||||
running: true
|
||||
from: Qt.rgba(0, 0, 0, 0)
|
||||
to: Qt.rgba(0, 0, 0, 1)
|
||||
duration: IdleService.fadeDuration * 1000
|
||||
easing.type: Easing.InQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
|
||||
Loader {
|
||||
active: CompositorService.isNiri && Settings.data.wallpaper.enabled && Settings.data.wallpaper.overviewEnabled && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableWallpaper)
|
||||
|
||||
sourceComponent: Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
delegate: PanelWindow {
|
||||
id: panelWindow
|
||||
|
||||
required property ShellScreen modelData
|
||||
property string wallpaper: ""
|
||||
property string preprocessedWallpaper: "" // Pre-resized wallpaper from Background.qml
|
||||
property bool isSolidColor: Settings.data.wallpaper.useSolidColor
|
||||
property color solidColor: Settings.data.wallpaper.solidColor
|
||||
property color tintColor: Settings.data.colorSchemes.darkMode ? Color.mSurface : Color.mOnSurface
|
||||
|
||||
visible: wallpaper !== "" || isSolidColor
|
||||
|
||||
Component.onCompleted: {
|
||||
if (modelData) {
|
||||
Logger.d("Overview", "Loading overview for Niri on", modelData.name);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
bgImage.source = "";
|
||||
}
|
||||
|
||||
// External state management - wait for wallpaper processing to complete
|
||||
// Reuses the same cached image as Background.qml for memory efficiency + GPU blur
|
||||
Connections {
|
||||
target: WallpaperService
|
||||
function onWallpaperProcessingComplete(screenName, path, cachedPath) {
|
||||
if (screenName === modelData.name) {
|
||||
preprocessedWallpaper = cachedPath || "";
|
||||
wallpaper = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle wallpaper changes (solid color detection)
|
||||
onWallpaperChanged: {
|
||||
if (!wallpaper)
|
||||
return;
|
||||
|
||||
// Check if this is a solid color path
|
||||
if (WallpaperService.isSolidColorPath(wallpaper)) {
|
||||
isSolidColor = true;
|
||||
var colorStr = WallpaperService.getSolidColor(wallpaper);
|
||||
solidColor = colorStr;
|
||||
return;
|
||||
}
|
||||
|
||||
isSolidColor = false;
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
screen: modelData
|
||||
WlrLayershell.layer: WlrLayer.Background
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.namespace: "noctalia-overview-" + (screen?.name || "unknown")
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
right: true
|
||||
left: true
|
||||
}
|
||||
|
||||
// Solid color background
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: isSolidColor
|
||||
color: solidColor
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: tintColor
|
||||
opacity: Settings.data.wallpaper.overviewTint
|
||||
}
|
||||
}
|
||||
|
||||
// Image background with GPU-based blur
|
||||
Image {
|
||||
id: bgImage
|
||||
anchors.fill: parent
|
||||
visible: !isSolidColor
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
source: preprocessedWallpaper || wallpaper
|
||||
smooth: true
|
||||
mipmap: false
|
||||
cache: true // Shares texture with Background's currentWallpaper
|
||||
asynchronous: true
|
||||
|
||||
layer.enabled: Settings.data.wallpaper.overviewBlur > 0 && !PowerProfileService.noctaliaPerformanceMode
|
||||
layer.smooth: false
|
||||
layer.effect: MultiEffect {
|
||||
blurEnabled: true
|
||||
blur: Settings.data.wallpaper.overviewBlur
|
||||
blurMax: 48
|
||||
}
|
||||
|
||||
// Tint overlay
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: tintColor
|
||||
opacity: Settings.data.wallpaper.overviewTint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Notification
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
// Bar Component
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// This property will be set by MainScreen
|
||||
property ShellScreen screen: null
|
||||
|
||||
// Filter widgets to only include those that exist in the registry
|
||||
// This prevents errors when plugins are missing or widgets are being cleaned up
|
||||
function filterValidWidgets(widgets: list<var>): list<var> {
|
||||
if (!widgets)
|
||||
return [];
|
||||
return widgets.filter(function (w) {
|
||||
return w && w.id && BarWidgetRegistry.hasWidget(w.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Hot corner: trigger click on first widget in a section
|
||||
function triggerFirstWidgetInSection(sectionName: string) {
|
||||
var widgets = BarService.getWidgetsBySection(sectionName, screen?.name);
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
var widget = widgets[i];
|
||||
if (widget && widget.visible && widget.widgetId !== "Spacer") {
|
||||
if (typeof widget.clicked === "function") {
|
||||
widget.clicked();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hot corner: trigger click on last widget in a section
|
||||
function triggerLastWidgetInSection(sectionName: string) {
|
||||
var widgets = BarService.getWidgetsBySection(sectionName, screen?.name);
|
||||
for (var i = widgets.length - 1; i >= 0; i--) {
|
||||
var widget = widgets[i];
|
||||
if (widget && widget.visible && widget.widgetId !== "Spacer") {
|
||||
if (typeof widget.clicked === "function") {
|
||||
widget.clicked();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose bar region for click-through mask
|
||||
readonly property var barRegion: barContentLoader.item?.children[0] || null
|
||||
|
||||
// Expose the actual bar Item for unified background system
|
||||
readonly property var barItem: barRegion
|
||||
|
||||
// Bar positioning properties (per-screen)
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
|
||||
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property bool barFloating: Settings.data.bar.barType === "floating"
|
||||
|
||||
// Bar density (per-screen)
|
||||
readonly property string barDensity: Settings.getBarDensityForScreen(screen?.name)
|
||||
|
||||
// Bar sizing based on per-screen density
|
||||
readonly property real barHeight: Style.getBarHeightForDensity(barDensity, barIsVertical)
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForDensity(barDensity, barHeight)
|
||||
readonly property real barFontSize: Style.getBarFontSizeForDensity(barHeight, capsuleHeight, barIsVertical)
|
||||
|
||||
// Bar widgets (per-screen) - initial configuration
|
||||
// Note: Updates are handled via Connections to BarService.widgetsRevisionChanged
|
||||
readonly property var barWidgets: Settings.getBarWidgetsForScreen(screen?.name)
|
||||
|
||||
// Stable ListModels for each section - prevents Repeater recreation on settings changes
|
||||
property ListModel leftWidgetsModel: ListModel {}
|
||||
property ListModel centerWidgetsModel: ListModel {}
|
||||
property ListModel rightWidgetsModel: ListModel {}
|
||||
|
||||
// Guard: set when Bar is destroyed; prevents Qt.callLater callbacks from running
|
||||
// during/after teardown (avoids SIGSEGV in QV4::Object::insertMember when rapid
|
||||
// workspace switch causes load/unload overlap with async widget incubation)
|
||||
property bool _destroyed: false
|
||||
Component.onDestruction: root._destroyed = true
|
||||
|
||||
// Sync a ListModel with widget data, preserving delegates when only settings change
|
||||
function syncWidgetModel(model, newWidgets) {
|
||||
var validWidgets = filterValidWidgets(newWidgets);
|
||||
|
||||
// Build list of current IDs in model
|
||||
var currentIds = [];
|
||||
for (var i = 0; i < model.count; i++) {
|
||||
currentIds.push(model.get(i).id);
|
||||
}
|
||||
|
||||
// Build list of new IDs
|
||||
var newIds = validWidgets.map(w => w.id);
|
||||
|
||||
// Check if structure changed (different IDs or order)
|
||||
var structureChanged = currentIds.length !== newIds.length;
|
||||
if (!structureChanged) {
|
||||
for (var i = 0; i < currentIds.length; i++) {
|
||||
if (currentIds[i] !== newIds[i]) {
|
||||
structureChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d("Bar", "syncWidgetModel:", currentIds.join("|"), "→", newIds.join("|"), "changed:", structureChanged);
|
||||
|
||||
if (structureChanged) {
|
||||
// Rebuild model - IDs changed
|
||||
model.clear();
|
||||
for (var i = 0; i < validWidgets.length; i++) {
|
||||
model.append(validWidgets[i]);
|
||||
}
|
||||
}
|
||||
// If structure didn't change, delegates are preserved and will read fresh settings
|
||||
}
|
||||
|
||||
// Sync models when widget revision changes
|
||||
// Note: We use Connections instead of onBarWidgetsChanged because getBarWidgetsForScreen
|
||||
// returns the same object reference (Settings.data.bar.widgets) even when content changes,
|
||||
// so QML won't detect the change via property binding.
|
||||
Connections {
|
||||
target: BarService
|
||||
function onWidgetsRevisionChanged() {
|
||||
Logger.d("Bar", "onWidgetsRevisionChanged, revision:", BarService.widgetsRevision, "screen:", root.screen?.name);
|
||||
Qt.callLater(root._syncFromRevision);
|
||||
}
|
||||
}
|
||||
|
||||
function _syncFromRevision() {
|
||||
if (root._destroyed)
|
||||
return;
|
||||
var widgets = Settings.getBarWidgetsForScreen(screen?.name);
|
||||
if (widgets) {
|
||||
syncWidgetModel(leftWidgetsModel, widgets.left);
|
||||
syncWidgetModel(centerWidgetsModel, widgets.center);
|
||||
syncWidgetModel(rightWidgetsModel, widgets.right);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize models — deferred to next event-loop tick via Qt.callLater to avoid
|
||||
// re-entrant incubation: Component.onCompleted fires during QQmlObjectCreator::finalize,
|
||||
// and ListModel.append synchronously creates Repeater delegates whose own finalization
|
||||
// can corrupt the V4 heap (SIGSEGV in QV4::Object::insertMember).
|
||||
Component.onCompleted: {
|
||||
Logger.d("Bar", "Bar Component.onCompleted for screen:", screen?.name);
|
||||
Qt.callLater(root._initModels);
|
||||
}
|
||||
|
||||
function _initModels() {
|
||||
if (root._destroyed)
|
||||
return;
|
||||
var widgets = Settings.getBarWidgetsForScreen(screen?.name);
|
||||
if (widgets) {
|
||||
syncWidgetModel(leftWidgetsModel, widgets.left);
|
||||
syncWidgetModel(centerWidgetsModel, widgets.center);
|
||||
syncWidgetModel(rightWidgetsModel, widgets.right);
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the parent (the Loader)
|
||||
anchors.fill: parent
|
||||
|
||||
// Register bar when screen becomes available
|
||||
onScreenChanged: {
|
||||
if (screen && screen.name) {
|
||||
Logger.d("Bar", "Bar screen set to:", screen.name);
|
||||
Logger.d("Bar", " Position:", barPosition, "Floating:", barFloating);
|
||||
BarService.registerBar(screen.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for screen to be set before loading bar content
|
||||
Loader {
|
||||
id: barContentLoader
|
||||
anchors.fill: parent
|
||||
active: {
|
||||
if (root.screen === null || root.screen === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
var result = monitors.length === 0 || monitors.includes(root.screen.name);
|
||||
return result;
|
||||
}
|
||||
|
||||
sourceComponent: Item {
|
||||
anchors.fill: parent
|
||||
|
||||
// Bar container - Content
|
||||
Item {
|
||||
id: bar
|
||||
|
||||
// Wheel scroll handling (empty bar area)
|
||||
property int barWheelAccumulatedDelta: 0
|
||||
property bool barWheelCooldown: false
|
||||
readonly property string barWheelAction: {
|
||||
return Settings.data.bar.mouseWheelAction || "none";
|
||||
}
|
||||
readonly property string barRightClickAction: Settings.data.bar.rightClickAction || "controlCenter"
|
||||
|
||||
// Position and size the bar content based on orientation
|
||||
x: (root.barPosition === "right") ? (parent.width - root.barHeight) : 0
|
||||
y: (root.barPosition === "bottom") ? (parent.height - root.barHeight) : 0
|
||||
width: root.barIsVertical ? root.barHeight : parent.width
|
||||
height: root.barIsVertical ? parent.height : root.barHeight
|
||||
|
||||
// Corner states for new unified background system
|
||||
// State -1: No radius (flat/square corner)
|
||||
// State 0: Normal (inner curve)
|
||||
// State 1: Horizontal inversion (outer curve on X-axis)
|
||||
// State 2: Vertical inversion (outer curve on Y-axis)
|
||||
readonly property int topLeftCornerState: {
|
||||
// Floating bar: always simple rounded corners
|
||||
if (barFloating)
|
||||
return 0;
|
||||
// Top bar: top corners against screen edge = no radius
|
||||
if (barPosition === "top")
|
||||
return -1;
|
||||
// Left bar: top-left against screen edge = no radius
|
||||
if (barPosition === "left")
|
||||
return -1;
|
||||
// Bottom/Right bar with outerCorners: inverted corner
|
||||
if (Settings.data.bar.outerCorners && (barPosition === "bottom" || barPosition === "right")) {
|
||||
return barIsVertical ? 1 : 2; // horizontal invert for vertical bars, vertical invert for horizontal
|
||||
}
|
||||
// No outerCorners = square
|
||||
return -1;
|
||||
}
|
||||
|
||||
readonly property int topRightCornerState: {
|
||||
// Floating bar: always simple rounded corners
|
||||
if (barFloating)
|
||||
return 0;
|
||||
// Top bar: top corners against screen edge = no radius
|
||||
if (barPosition === "top")
|
||||
return -1;
|
||||
// Right bar: top-right against screen edge = no radius
|
||||
if (barPosition === "right")
|
||||
return -1;
|
||||
// Bottom/Left bar with outerCorners: inverted corner
|
||||
if (Settings.data.bar.outerCorners && (barPosition === "bottom" || barPosition === "left")) {
|
||||
return barIsVertical ? 1 : 2;
|
||||
}
|
||||
// No outerCorners = square
|
||||
return -1;
|
||||
}
|
||||
|
||||
readonly property int bottomLeftCornerState: {
|
||||
// Floating bar: always simple rounded corners
|
||||
if (barFloating)
|
||||
return 0;
|
||||
// Bottom bar: bottom corners against screen edge = no radius
|
||||
if (barPosition === "bottom")
|
||||
return -1;
|
||||
// Left bar: bottom-left against screen edge = no radius
|
||||
if (barPosition === "left")
|
||||
return -1;
|
||||
// Top/Right bar with outerCorners: inverted corner
|
||||
if (Settings.data.bar.outerCorners && (barPosition === "top" || barPosition === "right")) {
|
||||
return barIsVertical ? 1 : 2;
|
||||
}
|
||||
// No outerCorners = square
|
||||
return -1;
|
||||
}
|
||||
|
||||
readonly property int bottomRightCornerState: {
|
||||
// Floating bar: always simple rounded corners
|
||||
if (barFloating)
|
||||
return 0;
|
||||
// Bottom bar: bottom corners against screen edge = no radius
|
||||
if (barPosition === "bottom")
|
||||
return -1;
|
||||
// Right bar: bottom-right against screen edge = no radius
|
||||
if (barPosition === "right")
|
||||
return -1;
|
||||
// Top/Left bar with outerCorners: inverted corner
|
||||
if (Settings.data.bar.outerCorners && (barPosition === "top" || barPosition === "left")) {
|
||||
return barIsVertical ? 1 : 2;
|
||||
}
|
||||
// No outerCorners = square
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isPointOverWidget(xPos, yPos) {
|
||||
var widgets = BarService.getAllWidgetInstances(null, screen.name);
|
||||
for (var i = 0; i < widgets.length; i++) {
|
||||
var widget = widgets[i];
|
||||
if (!widget || !widget.visible || widget.widgetId === "Spacer") {
|
||||
continue;
|
||||
}
|
||||
var localPos = mapToItem(widget, xPos, yPos);
|
||||
|
||||
if (root.barIsVertical) {
|
||||
if (localPos.y >= -Style.marginS && localPos.y <= widget.height + Style.marginS) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (localPos.x >= -Style.marginS && localPos.x <= widget.width + Style.marginS) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function switchWorkspaceByOffset(offset) {
|
||||
if (!root.screen || CompositorService.workspaces.count === 0)
|
||||
return;
|
||||
|
||||
var screenName = root.screen.name.toLowerCase();
|
||||
var candidates = [];
|
||||
for (var i = 0; i < CompositorService.workspaces.count; i++) {
|
||||
var ws = CompositorService.workspaces.get(i);
|
||||
var matchesScreen = CompositorService.globalWorkspaces || (ws.output && ws.output.toLowerCase() === screenName);
|
||||
if (matchesScreen)
|
||||
candidates.push(ws);
|
||||
}
|
||||
|
||||
if (candidates.length <= 1)
|
||||
return;
|
||||
|
||||
var current = -1;
|
||||
for (var j = 0; j < candidates.length; j++) {
|
||||
if (candidates[j].isFocused) {
|
||||
current = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (current < 0)
|
||||
current = 0;
|
||||
|
||||
var next = current + offset;
|
||||
if (Settings.data.bar.mouseWheelWrap) {
|
||||
next = next % candidates.length;
|
||||
if (next < 0)
|
||||
next = candidates.length - 1;
|
||||
} else {
|
||||
if (next < 0 || next >= candidates.length)
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === current)
|
||||
return;
|
||||
CompositorService.switchToWorkspace(candidates[next]);
|
||||
}
|
||||
|
||||
function handleEmptyBarClick(action, followMouse, command, mouse) {
|
||||
if (action === "none")
|
||||
return;
|
||||
if (action === "controlCenter") {
|
||||
var controlCenterPanel = PanelService.getPanel("controlCenterPanel", screen);
|
||||
controlCenterPanel?.toggle(null, followMouse ? mapToItem(null, mouse.x, mouse.y) : "ControlCenter");
|
||||
mouse.accepted = true;
|
||||
} else if (action === "settings") {
|
||||
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
|
||||
settingsPanel?.toggle(null, followMouse ? mapToItem(null, mouse.x, mouse.y) : null);
|
||||
mouse.accepted = true;
|
||||
} else if (action === "launcherPanel") {
|
||||
var launcherPanel = PanelService.getPanel("launcherPanel", screen);
|
||||
launcherPanel?.toggle(null, followMouse ? mapToItem(null, mouse.x, mouse.y) : null);
|
||||
mouse.accepted = true;
|
||||
} else if (action === "command") {
|
||||
runCustomCommand(command);
|
||||
mouse.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
function runCustomCommand(command) {
|
||||
if (!command || command.trim() === "")
|
||||
return;
|
||||
|
||||
const processString = "import QtQuick; import Quickshell.Io; Process { command: [\"sh\", \"-lc\", \"\"] }";
|
||||
|
||||
try {
|
||||
const processObj = Qt.createQmlObject(processString, root, "BarCommandProcess_" + Date.now());
|
||||
processObj.command = ["sh", "-lc", command];
|
||||
|
||||
processObj.exited.connect(function (exitCode) {
|
||||
if (exitCode !== 0) {
|
||||
ToastService.showError(I18n.tr("toast.custom-command-failed.title"), I18n.tr("toast.custom-command-failed.description", {
|
||||
command: command,
|
||||
code: exitCode
|
||||
}));
|
||||
}
|
||||
processObj.destroy();
|
||||
});
|
||||
|
||||
processObj.running = true;
|
||||
} catch (e) {
|
||||
Logger.e("Bar", "Failed to start custom command:", e);
|
||||
ToastService.showError(I18n.tr("toast.custom-command-failed.title"), I18n.tr("toast.custom-command-failed.description", {
|
||||
command: command,
|
||||
code: "start_error"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton | Qt.MiddleButton
|
||||
enabled: bar.barRightClickAction !== "none" || Settings.data.bar.middleClickAction !== "none"
|
||||
hoverEnabled: false
|
||||
preventStealing: true
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
if (bar.isPointOverWidget(mouse.x, mouse.y))
|
||||
return;
|
||||
bar.handleEmptyBarClick(bar.barRightClickAction, Settings.data.bar.rightClickFollowMouse, Settings.data.bar.rightClickCommand, mouse);
|
||||
return;
|
||||
}
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
if (bar.isPointOverWidget(mouse.x, mouse.y))
|
||||
return;
|
||||
bar.handleEmptyBarClick(Settings.data.bar.middleClickAction || "none", Settings.data.bar.middleClickFollowMouse, Settings.data.bar.middleClickCommand, mouse);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce timer for wheel interactions
|
||||
Timer {
|
||||
id: barWheelDebounce
|
||||
interval: 150
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
bar.barWheelCooldown = false;
|
||||
bar.barWheelAccumulatedDelta = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll on empty bar area action
|
||||
WheelHandler {
|
||||
id: barWheelHandler
|
||||
target: bar
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
enabled: bar.barWheelAction !== "none"
|
||||
|
||||
onWheel: function (event) {
|
||||
if (bar.isPointOverWidget(event.x, event.y))
|
||||
return;
|
||||
|
||||
var dy = event.angleDelta.y;
|
||||
var dx = event.angleDelta.x;
|
||||
var useDy = Math.abs(dy) >= Math.abs(dx);
|
||||
var delta = useDy ? dy : dx;
|
||||
var step = 120;
|
||||
|
||||
if (bar.barWheelAction === "volume") {
|
||||
if (Settings.data.bar.reverseScroll)
|
||||
delta *= -1;
|
||||
|
||||
bar.barWheelAccumulatedDelta += delta;
|
||||
if (bar.barWheelAccumulatedDelta >= step) {
|
||||
AudioService.increaseVolume();
|
||||
bar.barWheelAccumulatedDelta = 0;
|
||||
event.accepted = true;
|
||||
} else if (bar.barWheelAccumulatedDelta <= -step) {
|
||||
AudioService.decreaseVolume();
|
||||
bar.barWheelAccumulatedDelta = 0;
|
||||
event.accepted = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bar.barWheelCooldown)
|
||||
return;
|
||||
|
||||
bar.barWheelAccumulatedDelta += delta;
|
||||
if (Math.abs(bar.barWheelAccumulatedDelta) >= step) {
|
||||
var direction = bar.barWheelAccumulatedDelta > 0 ? -1 : 1;
|
||||
if (Settings.data.bar.reverseScroll)
|
||||
direction *= -1;
|
||||
if (bar.barWheelAction === "workspace") {
|
||||
bar.switchWorkspaceByOffset(direction);
|
||||
} else if (bar.barWheelAction === "content") {
|
||||
CompositorService.scrollWorkspaceContent(direction);
|
||||
}
|
||||
bar.barWheelCooldown = true;
|
||||
barWheelDebounce.restart();
|
||||
bar.barWheelAccumulatedDelta = 0;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
sourceComponent: root.barIsVertical ? verticalBarComponent : horizontalBarComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For vertical bars
|
||||
Component {
|
||||
id: verticalBarComponent
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
|
||||
// Top edge hot corner - triggers first widget in left (top) section
|
||||
MouseArea {
|
||||
width: parent.width
|
||||
height: Style.marginS
|
||||
x: 0
|
||||
y: 0
|
||||
onClicked: root.triggerFirstWidgetInSection("left")
|
||||
}
|
||||
|
||||
// Bottom edge hot corner - triggers last widget in right (bottom) section
|
||||
MouseArea {
|
||||
width: parent.width
|
||||
height: Style.marginS
|
||||
x: 0
|
||||
anchors.bottom: parent.bottom
|
||||
onClicked: root.triggerLastWidgetInSection("right")
|
||||
}
|
||||
|
||||
// Calculate margin to center widgets vertically within the bar height
|
||||
readonly property real verticalBarMargin: Math.round((root.barHeight - root.capsuleHeight) / 2)
|
||||
|
||||
// Top section (left widgets)
|
||||
ColumnLayout {
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: verticalBarMargin + Settings.data.bar.contentPadding
|
||||
spacing: Settings.data.bar.widgetSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.leftWidgetsModel
|
||||
delegate: BarWidgetLoader {
|
||||
required property var model
|
||||
required property int index
|
||||
|
||||
widgetId: model.id || ""
|
||||
widgetScreen: root.screen
|
||||
widgetProps: ({
|
||||
"widgetId": model.id,
|
||||
"section": "left",
|
||||
"sectionWidgetIndex": index,
|
||||
"sectionWidgetsCount": root.leftWidgetsModel.count
|
||||
})
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center section (center widgets)
|
||||
ColumnLayout {
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Settings.data.bar.widgetSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.centerWidgetsModel
|
||||
delegate: BarWidgetLoader {
|
||||
required property var model
|
||||
required property int index
|
||||
|
||||
widgetId: model.id || ""
|
||||
widgetScreen: root.screen
|
||||
widgetProps: ({
|
||||
"widgetId": model.id,
|
||||
"section": "center",
|
||||
"sectionWidgetIndex": index,
|
||||
"sectionWidgetsCount": root.centerWidgetsModel.count
|
||||
})
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom section (right widgets)
|
||||
ColumnLayout {
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: verticalBarMargin + Settings.data.bar.contentPadding
|
||||
spacing: Settings.data.bar.widgetSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.rightWidgetsModel
|
||||
delegate: BarWidgetLoader {
|
||||
required property var model
|
||||
required property int index
|
||||
|
||||
widgetId: model.id || ""
|
||||
widgetScreen: root.screen
|
||||
widgetProps: ({
|
||||
"widgetId": model.id,
|
||||
"section": "right",
|
||||
"sectionWidgetIndex": index,
|
||||
"sectionWidgetsCount": root.rightWidgetsModel.count
|
||||
})
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For horizontal bars
|
||||
Component {
|
||||
id: horizontalBarComponent
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
|
||||
// Left edge hot corner - triggers first widget in left section
|
||||
MouseArea {
|
||||
width: Style.marginS
|
||||
height: parent.height
|
||||
x: 0
|
||||
y: 0
|
||||
onClicked: root.triggerFirstWidgetInSection("left")
|
||||
}
|
||||
|
||||
// Right edge hot corner - triggers last widget in right section
|
||||
MouseArea {
|
||||
width: Style.marginS
|
||||
height: parent.height
|
||||
anchors.right: parent.right
|
||||
y: 0
|
||||
onClicked: root.triggerLastWidgetInSection("right")
|
||||
}
|
||||
|
||||
// Calculate margin to center widgets horizontally within the bar height
|
||||
readonly property real horizontalBarMargin: Math.round((root.barHeight - root.capsuleHeight) / 2)
|
||||
|
||||
// Left Section
|
||||
RowLayout {
|
||||
id: leftSection
|
||||
objectName: "leftSection"
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: horizontalBarMargin + Settings.data.bar.contentPadding
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
spacing: Settings.data.bar.widgetSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.leftWidgetsModel
|
||||
delegate: BarWidgetLoader {
|
||||
required property var model
|
||||
required property int index
|
||||
|
||||
widgetId: model.id || ""
|
||||
widgetScreen: root.screen
|
||||
widgetProps: ({
|
||||
"widgetId": model.id,
|
||||
"section": "left",
|
||||
"sectionWidgetIndex": index,
|
||||
"sectionWidgetsCount": root.leftWidgetsModel.count
|
||||
})
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center Section
|
||||
RowLayout {
|
||||
id: centerSection
|
||||
objectName: "centerSection"
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
spacing: Settings.data.bar.widgetSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.centerWidgetsModel
|
||||
delegate: BarWidgetLoader {
|
||||
required property var model
|
||||
required property int index
|
||||
|
||||
widgetId: model.id || ""
|
||||
widgetScreen: root.screen
|
||||
widgetProps: ({
|
||||
"widgetId": model.id,
|
||||
"section": "center",
|
||||
"sectionWidgetIndex": index,
|
||||
"sectionWidgetsCount": root.centerWidgetsModel.count
|
||||
})
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right Section
|
||||
RowLayout {
|
||||
id: rightSection
|
||||
objectName: "rightSection"
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: horizontalBarMargin + Settings.data.bar.contentPadding
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
spacing: Settings.data.bar.widgetSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.rightWidgetsModel
|
||||
delegate: BarWidgetLoader {
|
||||
required property var model
|
||||
required property int index
|
||||
|
||||
widgetId: model.id || ""
|
||||
widgetScreen: root.screen
|
||||
widgetProps: ({
|
||||
"widgetId": model.id,
|
||||
"section": "right",
|
||||
"sectionWidgetIndex": index,
|
||||
"sectionWidgetsCount": root.rightWidgetsModel.count
|
||||
})
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
|
||||
/**
|
||||
* BarExclusionZone - Invisible PanelWindow that reserves exclusive space for the bar
|
||||
*
|
||||
* This is a minimal window that works with the compositor to reserve space,
|
||||
* while the actual bar UI is rendered in NFullScreenWindow.
|
||||
*/
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
|
||||
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property bool barFloating: Settings.data.bar.barType === "floating"
|
||||
readonly property real barMarginH: barFloating ? Settings.data.bar.marginHorizontal : 0
|
||||
readonly property real barMarginV: barFloating ? Settings.data.bar.marginVertical : 0
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(screen?.name)
|
||||
|
||||
// Invisible - just reserves space
|
||||
color: "transparent"
|
||||
|
||||
mask: Region {}
|
||||
|
||||
// Wayland layer shell configuration
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
WlrLayershell.namespace: "noctalia-bar-exclusion-" + (screen?.name || "unknown")
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Auto
|
||||
|
||||
// Anchor based on bar position
|
||||
anchors {
|
||||
top: barPosition === "top"
|
||||
bottom: barPosition === "bottom"
|
||||
left: barPosition === "left" || barPosition === "top" || barPosition === "bottom"
|
||||
right: barPosition === "right" || barPosition === "top" || barPosition === "bottom"
|
||||
}
|
||||
|
||||
// Size based on bar orientation
|
||||
// When floating, only reserve space for the bar + margin on the anchored edge
|
||||
implicitWidth: {
|
||||
if (barIsVertical) {
|
||||
// Vertical bar: reserve bar height + margin on the anchored edge only
|
||||
if (barFloating) {
|
||||
// For left bar, reserve left margin; for right bar, reserve right margin
|
||||
return barHeight + barMarginH;
|
||||
}
|
||||
return barHeight;
|
||||
}
|
||||
return 0; // Auto-width when left/right anchors are true
|
||||
}
|
||||
|
||||
implicitHeight: {
|
||||
if (!barIsVertical) {
|
||||
// Horizontal bar: reserve bar height + margin on the anchored edge only
|
||||
if (barFloating) {
|
||||
// For top bar, reserve top margin; for bottom bar, reserve bottom margin
|
||||
return barHeight + barMarginV;
|
||||
}
|
||||
return barHeight;
|
||||
}
|
||||
return 0; // Auto-height when top/bottom anchors are true
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.d("BarExclusionZone", "Created for screen:", screen?.name);
|
||||
Logger.d("BarExclusionZone", " Position:", barPosition, "Floating:", barFloating);
|
||||
Logger.d("BarExclusionZone", " Anchors - top:", anchors.top, "bottom:", anchors.bottom, "left:", anchors.left, "right:", anchors.right);
|
||||
Logger.d("BarExclusionZone", " Size:", width, "x", height, "implicitWidth:", implicitWidth, "implicitHeight:", implicitHeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ShellScreen screen
|
||||
|
||||
property string icon: ""
|
||||
property string text: ""
|
||||
property string suffix: ""
|
||||
property var tooltipText
|
||||
property bool autoHide: false
|
||||
property bool forceOpen: false
|
||||
property bool forceClose: false
|
||||
property bool oppositeDirection: false
|
||||
property bool hovered: false
|
||||
property bool rotateText: false
|
||||
property color customBackgroundColor: "transparent"
|
||||
property color customTextIconColor: "transparent"
|
||||
property color customIconColor: "transparent"
|
||||
property color customTextColor: "transparent"
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
|
||||
readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
|
||||
|
||||
signal shown
|
||||
signal hidden
|
||||
signal entered
|
||||
signal exited
|
||||
signal clicked
|
||||
signal rightClicked
|
||||
signal middleClicked
|
||||
signal wheel(int delta)
|
||||
|
||||
// Size based on content for the content dimension, fill parent for the extended dimension
|
||||
// Horizontal bars: width = content, height = fill parent (for extended click area)
|
||||
// Vertical bars: width = fill parent, height = content
|
||||
width: isVerticalBar ? parent.width : (pillLoader.item ? pillLoader.item.implicitWidth : 0)
|
||||
height: isVerticalBar ? (pillLoader.item ? pillLoader.item.implicitHeight : 0) : parent.height
|
||||
implicitWidth: pillLoader.item ? pillLoader.item.implicitWidth : 0
|
||||
implicitHeight: pillLoader.item ? pillLoader.item.implicitHeight : 0
|
||||
|
||||
// Loader fills BarPill so child components can extend to full bar dimension
|
||||
Loader {
|
||||
id: pillLoader
|
||||
anchors.fill: parent
|
||||
sourceComponent: isVerticalBar ? verticalPillComponent : horizontalPillComponent
|
||||
|
||||
Component {
|
||||
id: verticalPillComponent
|
||||
BarPillVertical {
|
||||
screen: root.screen
|
||||
icon: root.icon
|
||||
text: root.text
|
||||
suffix: root.suffix
|
||||
tooltipText: root.tooltipText
|
||||
autoHide: root.autoHide
|
||||
forceOpen: root.forceOpen
|
||||
forceClose: root.forceClose
|
||||
oppositeDirection: root.oppositeDirection
|
||||
hovered: root.hovered
|
||||
rotateText: root.rotateText
|
||||
customBackgroundColor: root.customBackgroundColor
|
||||
customTextIconColor: root.customTextIconColor
|
||||
customIconColor: root.customIconColor
|
||||
customTextColor: root.customTextColor
|
||||
onShown: root.shown()
|
||||
onHidden: root.hidden()
|
||||
onEntered: root.entered()
|
||||
onExited: root.exited()
|
||||
onClicked: root.clicked()
|
||||
onRightClicked: root.rightClicked()
|
||||
onMiddleClicked: root.middleClicked()
|
||||
onWheel: delta => root.wheel(delta)
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: horizontalPillComponent
|
||||
BarPillHorizontal {
|
||||
screen: root.screen
|
||||
icon: root.icon
|
||||
text: root.text
|
||||
suffix: root.suffix
|
||||
tooltipText: root.tooltipText
|
||||
autoHide: root.autoHide
|
||||
forceOpen: root.forceOpen
|
||||
forceClose: root.forceClose
|
||||
oppositeDirection: root.oppositeDirection
|
||||
hovered: root.hovered
|
||||
customBackgroundColor: root.customBackgroundColor
|
||||
customTextIconColor: root.customTextIconColor
|
||||
customIconColor: root.customIconColor
|
||||
customTextColor: root.customTextColor
|
||||
onShown: root.shown()
|
||||
onHidden: root.hidden()
|
||||
onEntered: root.entered()
|
||||
onExited: root.exited()
|
||||
onClicked: root.clicked()
|
||||
onRightClicked: root.rightClicked()
|
||||
onMiddleClicked: root.middleClicked()
|
||||
onWheel: delta => root.wheel(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (pillLoader.item && pillLoader.item.show) {
|
||||
pillLoader.item.show();
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (pillLoader.item && pillLoader.item.hide) {
|
||||
pillLoader.item.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function showDelayed() {
|
||||
if (pillLoader.item && pillLoader.item.showDelayed) {
|
||||
pillLoader.item.showDelayed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ShellScreen screen
|
||||
|
||||
property string icon: ""
|
||||
property string text: ""
|
||||
property string suffix: ""
|
||||
property var tooltipText
|
||||
property bool autoHide: false
|
||||
property bool forceOpen: false
|
||||
property bool forceClose: false
|
||||
property bool oppositeDirection: false
|
||||
property bool hovered: false
|
||||
property color customBackgroundColor: "transparent"
|
||||
property color customTextIconColor: "transparent"
|
||||
property color customIconColor: "transparent"
|
||||
property color customTextColor: "transparent"
|
||||
|
||||
readonly property bool collapseToIcon: forceClose && !forceOpen
|
||||
|
||||
// Effective shown state (true if hovered/animated open or forced)
|
||||
readonly property bool revealed: !forceClose && (forceOpen || showPill)
|
||||
readonly property bool hasIcon: root.icon !== ""
|
||||
|
||||
signal shown
|
||||
signal hidden
|
||||
signal entered
|
||||
signal exited
|
||||
signal clicked
|
||||
signal rightClicked
|
||||
signal middleClicked
|
||||
signal wheel(int delta)
|
||||
|
||||
// Internal state
|
||||
property bool showPill: false
|
||||
property bool shouldAnimateHide: false
|
||||
|
||||
readonly property int pillHeight: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
readonly property real barFontSize: Style.getBarFontSizeForScreen(screen?.name)
|
||||
readonly property int pillPaddingHorizontal: Math.round(pillHeight * 0.2)
|
||||
readonly property int pillOverlap: Math.round(pillHeight * 0.5)
|
||||
readonly property int pillMaxWidth: Math.max(1, Math.round(textItem.implicitWidth + pillPaddingHorizontal * 2 + pillOverlap))
|
||||
|
||||
// Always prioritize hover color, then the custom one and finally the fallback color
|
||||
readonly property color bgColor: hovered ? Color.mHover : (customBackgroundColor.a > 0) ? customBackgroundColor : Style.capsuleColor
|
||||
readonly property color fgColor: hovered ? Color.mOnHover : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface
|
||||
readonly property color iconFgColor: hovered ? Color.mOnHover : (customIconColor.a > 0) ? customIconColor : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface
|
||||
readonly property color textFgColor: hovered ? Color.mOnHover : (customTextColor.a > 0) ? customTextColor : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface
|
||||
|
||||
readonly property real iconSize: Style.toOdd(pillHeight * 0.48)
|
||||
|
||||
// Content width calculation (for implicit sizing)
|
||||
readonly property real contentWidth: {
|
||||
if (collapseToIcon) {
|
||||
return hasIcon ? pillHeight : 0;
|
||||
}
|
||||
var overlap = hasIcon ? pillOverlap : 0;
|
||||
var baseWidth = hasIcon ? pillHeight : 0;
|
||||
return baseWidth + Math.max(0, pill.width - overlap);
|
||||
}
|
||||
|
||||
// Fill parent to extend click area to full bar height
|
||||
// Visual content is centered vertically within
|
||||
anchors.fill: parent
|
||||
implicitWidth: contentWidth
|
||||
implicitHeight: pillHeight
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onTooltipTextChanged() {
|
||||
if (hovered) {
|
||||
TooltipService.updateText(root.tooltipText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unified background for the entire pill area to avoid overlapping opacity
|
||||
Rectangle {
|
||||
id: pillBackground
|
||||
width: collapseToIcon ? pillHeight : root.width
|
||||
height: pillHeight
|
||||
radius: Style.radiusM
|
||||
color: root.bgColor
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
|
||||
width: revealed ? pillMaxWidth : 1
|
||||
height: pillHeight
|
||||
|
||||
x: {
|
||||
if (!hasIcon)
|
||||
return 0;
|
||||
return oppositeDirection ? (iconCircle.x + iconCircle.width / 2) : (iconCircle.x + iconCircle.width / 2) - width;
|
||||
}
|
||||
|
||||
opacity: revealed ? Style.opacityFull : Style.opacityNone
|
||||
color: "transparent" // Make pill background transparent to avoid double opacity
|
||||
|
||||
topLeftRadius: oppositeDirection ? 0 : Style.radiusM
|
||||
bottomLeftRadius: oppositeDirection ? 0 : Style.radiusM
|
||||
topRightRadius: oppositeDirection ? Style.radiusM : 0
|
||||
bottomRightRadius: oppositeDirection ? Style.radiusM : 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
NText {
|
||||
id: textItem
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: {
|
||||
if (!hasIcon)
|
||||
return (parent.width - width) / 2;
|
||||
|
||||
// Better text horizontal centering
|
||||
var centerX = (parent.width - width) / 2;
|
||||
var offset = oppositeDirection ? Style.marginXS : -Style.marginXS;
|
||||
if (forceOpen) {
|
||||
// If its force open, the icon disc background is the same color as the bg pill move text slightly
|
||||
offset += oppositeDirection ? -Style.marginXXS : Style.marginXXS;
|
||||
}
|
||||
return centerX + offset;
|
||||
}
|
||||
text: root.text + root.suffix
|
||||
family: Settings.data.ui.fontFixed
|
||||
pointSize: root.barFontSize
|
||||
applyUiScale: false
|
||||
color: root.textFgColor
|
||||
visible: revealed
|
||||
}
|
||||
|
||||
Behavior on width {
|
||||
enabled: showAnim.running || hideAnim.running
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
enabled: showAnim.running || hideAnim.running
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: iconCircle
|
||||
width: hasIcon ? pillHeight : 0
|
||||
height: pillHeight
|
||||
radius: Math.min(Style.radiusL, width / 2)
|
||||
color: "transparent" // Make icon background transparent to avoid double opacity
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
x: oppositeDirection ? 0 : (parent.width - width)
|
||||
|
||||
NIcon {
|
||||
icon: root.icon
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
color: root.iconFgColor
|
||||
// Center horizontally
|
||||
x: (iconCircle.width - width) / 2
|
||||
// Center vertically accounting for font metrics
|
||||
y: (iconCircle.height - height) / 2 + (height - contentHeight) / 2
|
||||
}
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: showAnim
|
||||
running: false
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "width"
|
||||
from: 1
|
||||
to: pillMaxWidth
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
onStarted: {
|
||||
showPill = true;
|
||||
}
|
||||
onStopped: {
|
||||
delayedHideAnim.start();
|
||||
root.shown();
|
||||
}
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: delayedHideAnim
|
||||
running: false
|
||||
PauseAnimation {
|
||||
duration: 2500
|
||||
}
|
||||
ScriptAction {
|
||||
script: if (shouldAnimateHide) {
|
||||
hideAnim.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: hideAnim
|
||||
running: false
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "width"
|
||||
from: pillMaxWidth
|
||||
to: 1
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "opacity"
|
||||
from: 1
|
||||
to: 0
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InCubic
|
||||
}
|
||||
onStopped: {
|
||||
showPill = false;
|
||||
shouldAnimateHide = false;
|
||||
root.hidden();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: showTimer
|
||||
interval: Style.pillDelay
|
||||
onTriggered: {
|
||||
if (!showPill) {
|
||||
showAnim.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
cursorShape: root.clicked ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onEntered: {
|
||||
hovered = true;
|
||||
root.entered();
|
||||
TooltipService.show(root, root.tooltipText, BarService.getTooltipDirection(root.screen?.name), (forceOpen || forceClose) ? Style.tooltipDelay : Style.tooltipDelayLong);
|
||||
if (forceClose) {
|
||||
return;
|
||||
}
|
||||
if (!forceOpen) {
|
||||
showDelayed();
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
hovered = false;
|
||||
root.exited();
|
||||
if (!forceOpen && !forceClose) {
|
||||
hide();
|
||||
}
|
||||
TooltipService.hide();
|
||||
}
|
||||
onClicked: mouse => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
onWheel: wheel => root.wheel(wheel.angleDelta.y)
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (collapseToIcon || root.text.trim().length === 0)
|
||||
return;
|
||||
if (!showPill) {
|
||||
shouldAnimateHide = autoHide;
|
||||
showAnim.start();
|
||||
} else {
|
||||
hideAnim.stop();
|
||||
delayedHideAnim.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (collapseToIcon)
|
||||
return;
|
||||
if (forceOpen) {
|
||||
return;
|
||||
}
|
||||
if (showPill) {
|
||||
hideAnim.start();
|
||||
}
|
||||
showTimer.stop();
|
||||
}
|
||||
|
||||
function showDelayed() {
|
||||
if (collapseToIcon || root.text.trim().length === 0)
|
||||
return;
|
||||
if (!showPill) {
|
||||
shouldAnimateHide = autoHide;
|
||||
showTimer.start();
|
||||
} else {
|
||||
hideAnim.stop();
|
||||
delayedHideAnim.restart();
|
||||
}
|
||||
}
|
||||
|
||||
onForceOpenChanged: {
|
||||
if (forceOpen) {
|
||||
// Immediately lock open without animations
|
||||
showAnim.stop();
|
||||
hideAnim.stop();
|
||||
delayedHideAnim.stop();
|
||||
showPill = true;
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ShellScreen screen
|
||||
|
||||
property string icon: ""
|
||||
property string text: ""
|
||||
property string suffix: ""
|
||||
property var tooltipText
|
||||
property bool autoHide: false
|
||||
property bool forceOpen: false
|
||||
property bool forceClose: false
|
||||
property bool oppositeDirection: false
|
||||
property bool hovered: false
|
||||
property bool rotateText: false
|
||||
property color customBackgroundColor: "transparent"
|
||||
property color customTextIconColor: "transparent"
|
||||
property color customIconColor: "transparent"
|
||||
property color customTextColor: "transparent"
|
||||
|
||||
readonly property bool collapseToIcon: forceClose && !forceOpen
|
||||
|
||||
signal shown
|
||||
signal hidden
|
||||
signal entered
|
||||
signal exited
|
||||
signal clicked
|
||||
signal rightClicked
|
||||
signal middleClicked
|
||||
signal wheel(int delta)
|
||||
|
||||
// Internal state
|
||||
property bool showPill: false
|
||||
property bool shouldAnimateHide: false
|
||||
|
||||
// Sizing logic for vertical bars
|
||||
readonly property int buttonSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
readonly property real barFontSize: Style.getBarFontSizeForScreen(screen?.name)
|
||||
readonly property int pillHeight: buttonSize
|
||||
readonly property int pillOverlap: Math.round(buttonSize * 0.5)
|
||||
readonly property int maxPillWidth: rotateText ? Math.max(buttonSize, Math.round(textItem.implicitHeight + Style.margin2M)) : buttonSize
|
||||
readonly property int maxPillHeight: rotateText ? Math.max(1, Math.round(textItem.implicitWidth + Style.margin2M + Math.round(iconCircle.height / 4))) : Math.max(1, Math.round(textItem.implicitHeight + Style.margin2M))
|
||||
|
||||
// Determine pill direction based on section position
|
||||
readonly property bool openDownward: oppositeDirection
|
||||
readonly property bool openUpward: !oppositeDirection
|
||||
|
||||
// Effective shown state (true if animated open or forced, but not if force closed)
|
||||
readonly property bool revealed: !forceClose && (forceOpen || showPill)
|
||||
readonly property bool hasIcon: root.icon !== ""
|
||||
|
||||
// Always prioritize hover color, then the custom one and finally the fallback color
|
||||
readonly property color bgColor: hovered ? Color.mHover : (customBackgroundColor.a > 0) ? customBackgroundColor : Style.capsuleColor
|
||||
readonly property color fgColor: hovered ? Color.mOnHover : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface
|
||||
readonly property color iconFgColor: hovered ? Color.mOnHover : (customIconColor.a > 0) ? customIconColor : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface
|
||||
readonly property color textFgColor: hovered ? Color.mOnHover : (customTextColor.a > 0) ? customTextColor : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface
|
||||
|
||||
readonly property real iconSize: Style.toOdd(pillHeight * 0.48)
|
||||
|
||||
// Content height calculation (for implicit sizing)
|
||||
readonly property real contentHeight: {
|
||||
if (collapseToIcon) {
|
||||
return hasIcon ? buttonSize : 0;
|
||||
}
|
||||
var overlap = hasIcon ? pillOverlap : 0;
|
||||
var baseHeight = hasIcon ? buttonSize : 0;
|
||||
return baseHeight + Math.max(0, pill.height - overlap);
|
||||
}
|
||||
|
||||
// Fill parent width to extend horizontal click area
|
||||
// Keep content-based height for visual layout
|
||||
anchors.left: parent ? parent.left : undefined
|
||||
anchors.right: parent ? parent.right : undefined
|
||||
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
|
||||
height: contentHeight
|
||||
implicitWidth: buttonSize
|
||||
implicitHeight: contentHeight
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onTooltipTextChanged() {
|
||||
if (hovered) {
|
||||
TooltipService.updateText(root.tooltipText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unified background for the entire pill area to avoid overlapping opacity
|
||||
Rectangle {
|
||||
id: pillBackground
|
||||
width: buttonSize
|
||||
height: root.contentHeight
|
||||
radius: Style.radiusM
|
||||
color: root.bgColor
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
|
||||
width: revealed ? maxPillWidth : 1
|
||||
height: revealed ? maxPillHeight : 1
|
||||
|
||||
// Position based on direction - center the pill relative to the icon
|
||||
x: 0
|
||||
y: {
|
||||
if (!hasIcon)
|
||||
return 0;
|
||||
return openUpward ? (iconCircle.y + iconCircle.height / 2 - height) : (iconCircle.y + iconCircle.height / 2);
|
||||
}
|
||||
|
||||
opacity: revealed ? Style.opacityFull : Style.opacityNone
|
||||
color: "transparent" // Make pill background transparent to avoid double opacity
|
||||
|
||||
// Radius logic for vertical expansion - rounded on the side that connects to icon
|
||||
topLeftRadius: openUpward ? Style.radiusM : 0
|
||||
bottomLeftRadius: openDownward ? Style.radiusM : 0
|
||||
topRightRadius: openUpward ? Style.radiusM : 0
|
||||
bottomRightRadius: openDownward ? Style.radiusM : 0
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
NText {
|
||||
id: textItem
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: hasIcon ? (openDownward ? Style.marginXXS : -Style.marginXXS) : 0
|
||||
rotation: rotateText ? -90 : 0
|
||||
text: root.text + root.suffix
|
||||
family: Settings.data.ui.fontFixed
|
||||
pointSize: root.barFontSize
|
||||
applyUiScale: false
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: root.textFgColor
|
||||
visible: revealed
|
||||
|
||||
function getVerticalCenterOffset() {
|
||||
// A small, symmetrical offset to push the text slightly away from the icon's edge.
|
||||
return openDownward ? Style.marginXS : -Style.marginXS;
|
||||
}
|
||||
}
|
||||
Behavior on width {
|
||||
enabled: showAnim.running || hideAnim.running
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
Behavior on height {
|
||||
enabled: showAnim.running || hideAnim.running
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
enabled: showAnim.running || hideAnim.running
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: iconCircle
|
||||
width: buttonSize
|
||||
height: buttonSize
|
||||
radius: Math.min(Style.radiusL, width / 2)
|
||||
color: "transparent" // Make icon background transparent to avoid double opacity
|
||||
|
||||
// Icon positioning based on direction
|
||||
x: 0
|
||||
y: openUpward ? (root.contentHeight - height) : 0
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
NIcon {
|
||||
icon: root.icon
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
color: root.iconFgColor
|
||||
// Center horizontally
|
||||
x: (iconCircle.width - width) / 2
|
||||
// Center vertically accounting for font metrics
|
||||
y: (iconCircle.height - height) / 2 + (height - contentHeight) / 2
|
||||
}
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: showAnim
|
||||
running: false
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "width"
|
||||
from: 1
|
||||
to: maxPillWidth
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "height"
|
||||
from: 1
|
||||
to: maxPillHeight
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
onStarted: {
|
||||
showPill = true;
|
||||
}
|
||||
onStopped: {
|
||||
delayedHideAnim.start();
|
||||
root.shown();
|
||||
}
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: delayedHideAnim
|
||||
running: false
|
||||
PauseAnimation {
|
||||
duration: 2500
|
||||
}
|
||||
ScriptAction {
|
||||
script: if (shouldAnimateHide) {
|
||||
hideAnim.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: hideAnim
|
||||
running: false
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "width"
|
||||
from: maxPillWidth
|
||||
to: 1
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "height"
|
||||
from: maxPillHeight
|
||||
to: 1
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: pill
|
||||
property: "opacity"
|
||||
from: 1
|
||||
to: 0
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InCubic
|
||||
}
|
||||
onStopped: {
|
||||
showPill = false;
|
||||
shouldAnimateHide = false;
|
||||
root.hidden();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: showTimer
|
||||
interval: Style.pillDelay
|
||||
onTriggered: {
|
||||
if (!showPill) {
|
||||
showAnim.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
cursorShape: root.clicked ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onEntered: {
|
||||
hovered = true;
|
||||
root.entered();
|
||||
TooltipService.show(root, root.tooltipText, BarService.getTooltipDirection(root.screen?.name), (forceOpen || forceClose) ? Style.tooltipDelay : Style.tooltipDelayLong);
|
||||
if (forceClose) {
|
||||
return;
|
||||
}
|
||||
if (!forceOpen) {
|
||||
showDelayed();
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
hovered = false;
|
||||
root.exited();
|
||||
if (!forceOpen && !forceClose) {
|
||||
hide();
|
||||
}
|
||||
TooltipService.hide();
|
||||
}
|
||||
onClicked: mouse => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
onWheel: wheel => root.wheel(wheel.angleDelta.y)
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (collapseToIcon || root.text.trim().length === 0)
|
||||
return;
|
||||
if (!showPill) {
|
||||
shouldAnimateHide = autoHide;
|
||||
showAnim.start();
|
||||
} else {
|
||||
hideAnim.stop();
|
||||
delayedHideAnim.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (collapseToIcon)
|
||||
return;
|
||||
if (forceOpen) {
|
||||
return;
|
||||
}
|
||||
if (showPill) {
|
||||
hideAnim.start();
|
||||
}
|
||||
showTimer.stop();
|
||||
}
|
||||
|
||||
function showDelayed() {
|
||||
if (collapseToIcon || root.text.trim().length === 0)
|
||||
return;
|
||||
if (!showPill) {
|
||||
shouldAnimateHide = autoHide;
|
||||
showTimer.start();
|
||||
} else {
|
||||
hideAnim.stop();
|
||||
delayedHideAnim.restart();
|
||||
}
|
||||
}
|
||||
|
||||
onForceOpenChanged: {
|
||||
if (forceOpen) {
|
||||
// Immediately lock open without animations
|
||||
showAnim.stop();
|
||||
hideAnim.stop();
|
||||
delayedHideAnim.stop();
|
||||
showPill = true;
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Noctalia
|
||||
import qs.Services.UI
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string widgetId
|
||||
required property var widgetScreen
|
||||
required property var widgetProps
|
||||
|
||||
// Extract section info from widgetProps
|
||||
readonly property string section: widgetProps ? (widgetProps.section || "") : ""
|
||||
readonly property int sectionIndex: widgetProps ? (widgetProps.sectionWidgetIndex || 0) : 0
|
||||
|
||||
// Store registration key at registration time so unregistration always uses the correct key,
|
||||
// even if binding properties (section, sectionIndex) have changed by destruction time
|
||||
property string _regScreen: ""
|
||||
property string _regSection: ""
|
||||
property string _regWidgetId: ""
|
||||
property int _regIndex: -1
|
||||
|
||||
function _unregister() {
|
||||
if (_regScreen !== "") {
|
||||
BarService.unregisterWidget(_regScreen, _regSection, _regWidgetId, _regIndex);
|
||||
_regScreen = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Bar orientation and height for extended click areas
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(widgetScreen?.name)
|
||||
readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(widgetScreen?.name)
|
||||
|
||||
// Request full bar dimension from layout to extend click areas above/below widgets
|
||||
// For horizontal bars: full bar height, widget's content width
|
||||
// For vertical bars: full bar width, widget's content height
|
||||
implicitWidth: isVerticalBar ? barHeight : getImplicitSize(loader.item, "implicitWidth")
|
||||
implicitHeight: isVerticalBar ? getImplicitSize(loader.item, "implicitHeight") : barHeight
|
||||
|
||||
// Remove layout space left by hidden widgets
|
||||
visible: loader.item ? ((loader.item.opacity > 0.0) || (loader.item.hasOwnProperty("hideMode") && loader.item.hideMode === "transparent")) : false
|
||||
|
||||
function getImplicitSize(item, prop) {
|
||||
return (item && item.visible) ? Math.round(item[prop]) : 0;
|
||||
}
|
||||
|
||||
// Only load if widget exists in registry
|
||||
function checkWidgetExists(): bool {
|
||||
return root.widgetId !== "" && BarWidgetRegistry.hasWidget(root.widgetId);
|
||||
}
|
||||
|
||||
// Force reload counter - incremented when plugin widget registry changes
|
||||
property int reloadCounter: 0
|
||||
|
||||
// Listen for plugin widget registry changes to force reload
|
||||
Connections {
|
||||
target: BarWidgetRegistry
|
||||
enabled: BarWidgetRegistry.isPluginWidget(root.widgetId)
|
||||
|
||||
function onPluginWidgetRegistryUpdated() {
|
||||
if (BarWidgetRegistry.hasWidget(root.widgetId)) {
|
||||
root.reloadCounter++;
|
||||
// Plugin widgets use setSource, so also trigger reload directly
|
||||
if (root._isPlugin && loader.active)
|
||||
root._loadWidget();
|
||||
Logger.d("BarWidgetLoader", "Plugin widget registry updated, reloading:", root.widgetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property bool _isPlugin: BarWidgetRegistry.isPluginWidget(widgetId)
|
||||
|
||||
// Build initial properties that must be available during Component.onCompleted.
|
||||
// This prevents registration-key mismatches in widgets that build IDs from
|
||||
// screen.name, section, or sectionWidgetIndex.
|
||||
// All standard bar widget props are passed for both core and plugin widgets.
|
||||
// Plugins that don't define some of these properties will get harmless warnings
|
||||
// but still load correctly — and plugins that DO define them (e.g. for unique
|
||||
// SpectrumService keys with multiple instances) get correct values from the start.
|
||||
function _initialProps() {
|
||||
return {
|
||||
"screen": widgetScreen,
|
||||
"widgetId": widgetProps.widgetId || "",
|
||||
"section": widgetProps.section || "",
|
||||
"sectionWidgetIndex": widgetProps.sectionWidgetIndex || 0,
|
||||
"sectionWidgetsCount": widgetProps.sectionWidgetsCount || 0
|
||||
};
|
||||
}
|
||||
|
||||
// Core widget URLs: file names match widget IDs exactly
|
||||
readonly property string _barWidgetsDir: Quickshell.shellDir + "/Modules/Bar/Widgets/"
|
||||
|
||||
function _loadWidget() {
|
||||
if (!BarWidgetRegistry.hasWidget(root.widgetId))
|
||||
return;
|
||||
|
||||
var props = _initialProps();
|
||||
|
||||
if (_isPlugin) {
|
||||
var comp = BarWidgetRegistry.getWidget(root.widgetId);
|
||||
if (!comp)
|
||||
return;
|
||||
var pluginId = root.widgetId.replace("plugin:", "");
|
||||
var api = PluginService.getPluginAPI(pluginId);
|
||||
if (api)
|
||||
props.pluginApi = api;
|
||||
loader.setSource(comp.url, props);
|
||||
} else {
|
||||
loader.setSource(_barWidgetsDir + root.widgetId + ".qml", props);
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: loader
|
||||
anchors.fill: parent
|
||||
asynchronous: true
|
||||
active: root.checkWidgetExists() && (root.reloadCounter >= 0)
|
||||
|
||||
// All widgets use setSource() so that screen and widget properties
|
||||
// are set as initial properties, available during Component.onCompleted.
|
||||
Component.onCompleted: root._loadWidget()
|
||||
|
||||
onActiveChanged: {
|
||||
if (active)
|
||||
root._loadWidget();
|
||||
}
|
||||
|
||||
// Unregister when the loaded item is destroyed (Loader deactivated or sourceComponent changed)
|
||||
onItemChanged: {
|
||||
if (!item) {
|
||||
root._unregister();
|
||||
}
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
Logger.d("BarWidgetLoader", "Loading widget", widgetId, "on screen:", widgetScreen.name);
|
||||
|
||||
// Extend widget to fill full bar dimension for extended click areas
|
||||
// For horizontal bars: widget fills bar height (content width preserved)
|
||||
// For vertical bars: widget fills bar width (content height preserved)
|
||||
if (root.isVerticalBar) {
|
||||
item.width = Qt.binding(function () {
|
||||
return root.barHeight;
|
||||
});
|
||||
} else {
|
||||
item.height = Qt.binding(function () {
|
||||
return root.barHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply remaining widget properties (screen is already set as initial prop)
|
||||
for (var prop in widgetProps) {
|
||||
if (item.hasOwnProperty(prop)) {
|
||||
item[prop] = widgetProps[prop];
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister any previous registration before registering the new instance
|
||||
root._unregister();
|
||||
|
||||
// Register and store the key for reliable unregistration
|
||||
BarService.registerWidget(widgetScreen.name, section, widgetId, sectionIndex, item);
|
||||
root._regScreen = widgetScreen.name;
|
||||
root._regSection = section;
|
||||
root._regWidgetId = widgetId;
|
||||
root._regIndex = sectionIndex;
|
||||
|
||||
// Call custom onLoaded if it exists
|
||||
if (item.hasOwnProperty("onLoaded")) {
|
||||
item.onLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
root._unregister();
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling
|
||||
Component.onCompleted: {
|
||||
if (!BarWidgetRegistry.hasWidget(widgetId)) {
|
||||
Logger.w("BarWidgetLoader", "Widget not found in registry:", widgetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
PopupWindow {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
property var trayItem: null
|
||||
property var anchorItem: null
|
||||
property real anchorX
|
||||
property real anchorY
|
||||
property bool isSubMenu: false
|
||||
property string widgetSection: ""
|
||||
property int widgetIndex: -1
|
||||
|
||||
// Derive menu from trayItem (only used for non-submenus)
|
||||
readonly property QsMenuHandle menu: isSubMenu ? null : (trayItem ? trayItem.menu : null)
|
||||
|
||||
// Compute if current tray item is pinned
|
||||
readonly property bool isPinned: {
|
||||
if (!trayItem || widgetSection === "" || widgetIndex < 0)
|
||||
return false;
|
||||
var widgets = Settings.getBarWidgetsForScreen(root.screen?.name)[widgetSection];
|
||||
if (!widgets || widgetIndex >= widgets.length)
|
||||
return false;
|
||||
var widgetSettings = widgets[widgetIndex];
|
||||
if (!widgetSettings || widgetSettings.id !== "Tray")
|
||||
return false;
|
||||
var pinnedList = widgetSettings.pinned || [];
|
||||
const itemName = trayItem.tooltipTitle || trayItem.name || trayItem.id || "";
|
||||
for (var i = 0; i < pinnedList.length; i++) {
|
||||
if (pinnedList[i] === itemName)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly property int menuWidth: 220
|
||||
|
||||
implicitWidth: menuWidth
|
||||
|
||||
// Use the content height of the Flickable for implicit height
|
||||
implicitHeight: Math.min(screen?.height * 0.9, flickable.contentHeight + Style.margin2S)
|
||||
|
||||
// When implicitHeight changes (menu content loads), force anchor recalculation
|
||||
onImplicitHeightChanged: {
|
||||
if (visible && anchorItem) {
|
||||
Qt.callLater(() => {
|
||||
anchor.updateAnchor();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
visible: false
|
||||
color: "transparent"
|
||||
anchor.item: anchorItem
|
||||
anchor.rect.x: {
|
||||
if (anchorItem && screen) {
|
||||
let baseX = anchorX;
|
||||
|
||||
// Calculate position relative to current screen
|
||||
let menuScreenX;
|
||||
if (isSubMenu && anchorItem.Window && anchorItem.Window.window) {
|
||||
const posInPopup = anchorItem.mapToItem(null, 0, 0);
|
||||
const parentWindow = anchorItem.Window.window;
|
||||
const windowXOnScreen = parentWindow.x - screen.x;
|
||||
menuScreenX = windowXOnScreen + posInPopup.x + baseX;
|
||||
} else {
|
||||
const anchorGlobalPos = anchorItem.mapToItem(null, 0, 0);
|
||||
const anchorScreenX = anchorGlobalPos.x;
|
||||
menuScreenX = anchorScreenX + baseX;
|
||||
}
|
||||
|
||||
const menuRight = menuScreenX + implicitWidth;
|
||||
const screenRight = screen.width;
|
||||
const menuLeft = menuScreenX;
|
||||
|
||||
// Only adjust if menu would clip off screen boundaries
|
||||
// Don't adjust if the positioning is intentional (e.g., negative offset for right bar)
|
||||
if (menuRight > screenRight && menuLeft < screenRight) {
|
||||
// Clipping on right edge - shift left
|
||||
const overflow = menuRight - screenRight;
|
||||
return baseX - overflow - Style.marginS;
|
||||
} else if (menuLeft < 0 && menuRight > 0) {
|
||||
// Clipping on left edge - shift right
|
||||
return baseX - menuLeft + Style.marginS;
|
||||
}
|
||||
|
||||
return baseX;
|
||||
}
|
||||
return anchorX;
|
||||
}
|
||||
anchor.rect.y: {
|
||||
if (anchorItem && screen) {
|
||||
const barPosition = Settings.getBarPositionForScreen(root.screen?.name);
|
||||
|
||||
let baseY = anchorY;
|
||||
|
||||
// Only apply bottom bar special positioning if:
|
||||
// 1. Not a submenu
|
||||
// 2. Bar is at bottom
|
||||
// 3. anchorY is not already negative (if negative, it's pre-calculated from drawer)
|
||||
const shouldApplyBottomBarLogic = !isSubMenu && barPosition === "bottom" && anchorY >= 0;
|
||||
|
||||
if (shouldApplyBottomBarLogic) {
|
||||
// For bottom bar from the bar itself, position menu above the anchor with margin
|
||||
baseY = -(implicitHeight + Style.marginS);
|
||||
} else if (barPosition === "top" && !isSubMenu && anchorY >= 0) {
|
||||
// For top bar: position menu below bar with margin
|
||||
const barHeight = Style.getBarHeightForScreen(root.screen?.name);
|
||||
baseY = barHeight + Style.marginS;
|
||||
}
|
||||
|
||||
// Use a robust way to get screen coordinates
|
||||
const posInWindow = anchorItem.mapToItem(null, 0, 0);
|
||||
const parentWindow = anchorItem.Window.window;
|
||||
|
||||
// Calculate screen-relative Y of the window
|
||||
let windowYOnScreen = (parentWindow && screen) ? (parentWindow.y - screen.y) : 0;
|
||||
|
||||
// If window reported 0 but bar is at bottom, assume it's at screen bottom
|
||||
if (windowYOnScreen === 0 && barPosition === "bottom" && screen) {
|
||||
windowYOnScreen = screen.height - (parentWindow ? parentWindow.height : Style.getBarHeightForScreen(screen.name));
|
||||
}
|
||||
|
||||
// Calculate the screen Y of the menu top
|
||||
// Use a small guess for height if implicitHeight is 0 to avoid covering the bar on the first frame
|
||||
const effectiveHeight = implicitHeight > 0 ? implicitHeight : 200;
|
||||
const effectiveBaseY = shouldApplyBottomBarLogic ? -(effectiveHeight + Style.marginS) : baseY;
|
||||
|
||||
const menuScreenY = windowYOnScreen + posInWindow.y + effectiveBaseY;
|
||||
const menuBottom = menuScreenY + (implicitHeight > 0 ? implicitHeight : effectiveHeight);
|
||||
const screenHeight = screen ? screen.height : 1080;
|
||||
|
||||
// Adjust the final baseY (the actual value returned to anchor.rect.y)
|
||||
let finalBaseY = shouldApplyBottomBarLogic ? -(implicitHeight + Style.marginS) : baseY;
|
||||
|
||||
// Adjust if menu would clip off the bottom
|
||||
if (menuBottom > screenHeight) {
|
||||
const overflow = menuBottom - screenHeight;
|
||||
finalBaseY -= (overflow + Style.marginS);
|
||||
}
|
||||
|
||||
// Adjust if menu would clip off the top
|
||||
// menuScreenY < 0 means it's above the screen edge
|
||||
if (menuScreenY < 0) {
|
||||
finalBaseY -= (menuScreenY - Style.marginS);
|
||||
}
|
||||
|
||||
return finalBaseY;
|
||||
}
|
||||
|
||||
// Fallback if no anchor/screen
|
||||
if (isSubMenu) {
|
||||
return anchorY;
|
||||
}
|
||||
return anchorY + (Settings.getBarPositionForScreen(root.screen?.name) === "bottom" ? -implicitHeight : Style.getBarHeightForScreen(root.screen?.name));
|
||||
}
|
||||
|
||||
function showAt(item, x, y) {
|
||||
if (!item) {
|
||||
Logger.w("TrayMenu", "anchorItem is undefined, won't show menu.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opener.children || opener.children.values.length === 0) {
|
||||
//Logger.w("TrayMenu", "Menu not ready, delaying show")
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
Keys.onEscapePressed: root.hideMenu()
|
||||
}
|
||||
|
||||
QsMenuOpener {
|
||||
id: opener
|
||||
menu: root.menu
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Color.mSurface
|
||||
border.color: Color.mOutline
|
||||
border.width: Math.max(1, Style.borderS)
|
||||
radius: Style.radiusM
|
||||
|
||||
// Fade-in animation
|
||||
opacity: root.visible ? 1.0 : 0.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: flickable
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
contentHeight: columnLayout.implicitHeight
|
||||
interactive: true
|
||||
|
||||
// Fade-in animation
|
||||
opacity: root.visible ? 1.0 : 0.0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
// 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.margin2S);
|
||||
}
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
property var subMenu: null
|
||||
|
||||
NDivider {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Style.margin2M
|
||||
visible: modelData?.isSeparator ?? false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: innerRect
|
||||
anchors.fill: parent
|
||||
color: mouseArea.containsMouse ? Color.mHover : "transparent"
|
||||
radius: Style.radiusS
|
||||
visible: !(modelData?.isSeparator ?? false)
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
// Indicator Container
|
||||
Item {
|
||||
visible: (modelData?.buttonType ?? QsMenuButtonType.None) !== QsMenuButtonType.None
|
||||
|
||||
implicitWidth: Math.round(Style.baseWidgetSize * 0.5)
|
||||
implicitHeight: Math.round(Style.baseWidgetSize * 0.5)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
// Helper properties
|
||||
readonly property int type: modelData?.buttonType ?? QsMenuButtonType.None
|
||||
readonly property bool isRadio: type === QsMenuButtonType.RadioButton
|
||||
readonly property bool isChecked: modelData?.checkState === Qt.Checked || (modelData?.checked ?? false)
|
||||
|
||||
// Color Logic
|
||||
readonly property color activeColor: mouseArea.containsMouse ? Color.mOnHover : Color.mPrimary
|
||||
readonly property color checkMarkColor: mouseArea.containsMouse ? Color.mHover : Color.mOnPrimary
|
||||
readonly property color borderColor: isChecked ? activeColor : (mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface)
|
||||
|
||||
// Checkbox Visuals
|
||||
Rectangle {
|
||||
visible: !parent.isRadio
|
||||
anchors.centerIn: parent
|
||||
width: Math.round(Style.baseWidgetSize * 0.5)
|
||||
height: Math.round(Style.baseWidgetSize * 0.5)
|
||||
radius: Style.iRadiusXS
|
||||
color: "transparent" // Transparent to match RadioButton style
|
||||
border.color: parent.borderColor
|
||||
border.width: Style.borderM
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
visible: parent.parent.isChecked
|
||||
anchors.centerIn: parent
|
||||
anchors.horizontalCenterOffset: -1
|
||||
icon: "check"
|
||||
color: parent.parent.activeColor
|
||||
pointSize: Math.max(Style.fontSizeXXS, parent.width * 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
// RadioButton Visuals
|
||||
Rectangle {
|
||||
visible: parent.isRadio
|
||||
anchors.centerIn: parent
|
||||
width: Style.toOdd(Style.baseWidgetSize * 0.5)
|
||||
height: Style.toOdd(Style.baseWidgetSize * 0.5)
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.color: parent.borderColor
|
||||
border.width: Style.borderM // Slightly thicker for radio look
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: parent.parent.isChecked
|
||||
anchors.centerIn: parent
|
||||
width: Style.toOdd(parent.width * 0.5)
|
||||
height: Style.toOdd(parent.height * 0.5)
|
||||
radius: width / 2
|
||||
color: parent.parent.activeColor
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
id: text
|
||||
Layout.fillWidth: true
|
||||
color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface) : Color.mOnSurfaceVariant
|
||||
text: modelData?.text !== "" ? modelData?.text.replace(/[\n\r]+/g, ' ') : "..."
|
||||
pointSize: Style.fontSizeS
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
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
|
||||
applyUiScale: false
|
||||
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
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
onClicked: mouse => {
|
||||
if (modelData && !modelData.isSeparator) {
|
||||
if (modelData.hasChildren) {
|
||||
// Click on items with children toggles submenu
|
||||
if (entry.subMenu) {
|
||||
// Close existing submenu
|
||||
entry.subMenu.hideMenu();
|
||||
entry.subMenu.destroy();
|
||||
entry.subMenu = null;
|
||||
} else {
|
||||
// Close any other open submenus first
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine submenu opening direction
|
||||
let openLeft = false;
|
||||
const barPosition = Settings.getBarPositionForScreen(root.screen?.name);
|
||||
const globalPos = entry.mapToItem(null, 0, 0);
|
||||
|
||||
if (barPosition === "right") {
|
||||
openLeft = true;
|
||||
} else if (barPosition === "left") {
|
||||
openLeft = false;
|
||||
} else {
|
||||
openLeft = (root.widgetSection === "right");
|
||||
}
|
||||
|
||||
// Open new submenu
|
||||
entry.subMenu = Qt.createComponent("TrayMenu.qml").createObject(root, {
|
||||
"menu": modelData,
|
||||
"isSubMenu": true,
|
||||
"screen": root.screen
|
||||
});
|
||||
|
||||
if (entry.subMenu) {
|
||||
const overlap = 60;
|
||||
entry.subMenu.anchorItem = entry;
|
||||
entry.subMenu.anchorX = openLeft ? -overlap : overlap;
|
||||
entry.subMenu.anchorY = 0;
|
||||
entry.subMenu.visible = true;
|
||||
// Force anchor update with new position
|
||||
Qt.callLater(() => {
|
||||
entry.subMenu.anchor.updateAnchor();
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Click on regular items triggers them
|
||||
modelData.triggered();
|
||||
root.hideMenu();
|
||||
|
||||
// Close the drawer if it's open
|
||||
if (root.screen) {
|
||||
const panel = PanelService.getPanel("trayDrawerPanel", root.screen);
|
||||
if (panel && panel.visible) {
|
||||
panel.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (subMenu) {
|
||||
subMenu.destroy();
|
||||
subMenu = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PIN / UNPIN
|
||||
Rectangle {
|
||||
visible: {
|
||||
if (widgetSection === "" || widgetIndex < 0)
|
||||
return false;
|
||||
var widgets = Settings.getBarWidgetsForScreen(root.screen?.name)[widgetSection];
|
||||
if (!widgets || widgetIndex >= widgets.length)
|
||||
return false;
|
||||
var widgetSettings = widgets[widgetIndex];
|
||||
if (!widgetSettings)
|
||||
return false;
|
||||
return widgetSettings.drawerEnabled ?? false;
|
||||
}
|
||||
Layout.preferredWidth: parent.width
|
||||
Layout.preferredHeight: 28
|
||||
color: pinUnpinMouseArea.containsMouse ? Qt.alpha(Color.mPrimary, 0.2) : Qt.alpha(Color.mPrimary, 0.08)
|
||||
radius: Style.radiusS
|
||||
border.color: Qt.alpha(Color.mPrimary, pinUnpinMouseArea.containsMouse ? 0.4 : 0.2)
|
||||
border.width: Style.borderS
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NIcon {
|
||||
icon: root.isPinned ? "unpin" : "pin"
|
||||
pointSize: Style.fontSizeS
|
||||
applyUiScale: false
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
color: Color.mPrimary
|
||||
text: root.isPinned ? I18n.tr("panels.bar.tray-unpin-application") : I18n.tr("panels.bar.tray-pin-application")
|
||||
pointSize: Style.fontSizeS
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: pinUnpinMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
onClicked: {
|
||||
if (root.isPinned) {
|
||||
root.removeFromPinned();
|
||||
} else {
|
||||
root.addToPinned();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addToPinned() {
|
||||
if (!trayItem || widgetSection === "" || widgetIndex < 0) {
|
||||
Logger.w("TrayMenu", "Cannot pin: missing tray item or widget info");
|
||||
return;
|
||||
}
|
||||
const itemName = trayItem.tooltipTitle || trayItem.name || trayItem.id || "";
|
||||
if (!itemName) {
|
||||
Logger.w("TrayMenu", "Cannot pin: tray item has no name");
|
||||
return;
|
||||
}
|
||||
var screenName = root.screen?.name || "";
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[widgetSection];
|
||||
if (!widgets || widgetIndex >= widgets.length) {
|
||||
Logger.w("TrayMenu", "Cannot pin: invalid widget index");
|
||||
return;
|
||||
}
|
||||
var widgetSettings = widgets[widgetIndex];
|
||||
if (!widgetSettings || widgetSettings.id !== "Tray") {
|
||||
Logger.w("TrayMenu", "Cannot pin: widget is not a Tray widget");
|
||||
return;
|
||||
}
|
||||
var pinnedList = widgetSettings.pinned || [];
|
||||
var newPinned = pinnedList.slice();
|
||||
newPinned.push(itemName);
|
||||
var newSettings = Object.assign({}, widgetSettings);
|
||||
newSettings.pinned = newPinned;
|
||||
widgets[widgetIndex] = newSettings;
|
||||
|
||||
// Write to the correct location: screen override or global
|
||||
if (Settings.hasScreenOverride(screenName, "widgets")) {
|
||||
var overrideWidgets = Settings.getBarWidgetsForScreen(screenName);
|
||||
overrideWidgets[widgetSection] = widgets;
|
||||
Settings.setScreenOverride(screenName, "widgets", overrideWidgets);
|
||||
} else {
|
||||
Settings.data.bar.widgets[widgetSection] = widgets;
|
||||
}
|
||||
Settings.saveImmediate();
|
||||
|
||||
// Close drawer when pinning (drawer needs to resize)
|
||||
if (screen) {
|
||||
const panel = PanelService.getPanel("trayDrawerPanel", screen);
|
||||
if (panel)
|
||||
panel.close();
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromPinned() {
|
||||
if (!trayItem || widgetSection === "" || widgetIndex < 0) {
|
||||
Logger.w("TrayMenu", "Cannot unpin: missing tray item or widget info");
|
||||
return;
|
||||
}
|
||||
const itemName = trayItem.tooltipTitle || trayItem.name || trayItem.id || "";
|
||||
if (!itemName) {
|
||||
Logger.w("TrayMenu", "Cannot unpin: tray item has no name");
|
||||
return;
|
||||
}
|
||||
var screenName = root.screen?.name || "";
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[widgetSection];
|
||||
if (!widgets || widgetIndex >= widgets.length) {
|
||||
Logger.w("TrayMenu", "Cannot unpin: invalid widget index");
|
||||
return;
|
||||
}
|
||||
var widgetSettings = widgets[widgetIndex];
|
||||
if (!widgetSettings || widgetSettings.id !== "Tray") {
|
||||
Logger.w("TrayMenu", "Cannot unpin: widget is not a Tray widget");
|
||||
return;
|
||||
}
|
||||
var pinnedList = widgetSettings.pinned || [];
|
||||
var newPinned = [];
|
||||
for (var i = 0; i < pinnedList.length; i++) {
|
||||
if (pinnedList[i] !== itemName) {
|
||||
newPinned.push(pinnedList[i]);
|
||||
}
|
||||
}
|
||||
var newSettings = Object.assign({}, widgetSettings);
|
||||
newSettings.pinned = newPinned;
|
||||
widgets[widgetIndex] = newSettings;
|
||||
|
||||
// Write to the correct location: screen override or global
|
||||
if (Settings.hasScreenOverride(screenName, "widgets")) {
|
||||
var overrideWidgets = Settings.getBarWidgetsForScreen(screenName);
|
||||
overrideWidgets[widgetSection] = widgets;
|
||||
Settings.setScreenOverride(screenName, "widgets", overrideWidgets);
|
||||
} else {
|
||||
Settings.data.bar.widgets[widgetSection] = widgets;
|
||||
}
|
||||
Settings.saveImmediate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: pillContainer
|
||||
|
||||
required property var workspace
|
||||
required property bool isVertical
|
||||
|
||||
// These must be provided by the parent Workspace widget
|
||||
required property real baseDimensionRatio
|
||||
required property real capsuleHeight
|
||||
required property real barHeight
|
||||
required property string labelMode
|
||||
required property int fontWeight
|
||||
required property int characterCount
|
||||
required property real textRatio
|
||||
required property bool showLabelsOnlyWhenOccupied
|
||||
required property string focusedColor
|
||||
required property string occupiedColor
|
||||
required property string emptyColor
|
||||
required property real masterProgress
|
||||
required property bool effectsActive
|
||||
required property color effectColor
|
||||
required property var getWorkspaceWidth
|
||||
required property var getWorkspaceHeight
|
||||
|
||||
// Fixed dimension (cross-axis) for visual pill
|
||||
readonly property real fixedDimension: Style.toOdd(capsuleHeight * baseDimensionRatio)
|
||||
|
||||
// Animated pill dimensions (for visual pill, not container)
|
||||
property real pillWidth: isVertical ? fixedDimension : getWorkspaceWidth(workspace, false)
|
||||
property real pillHeight: isVertical ? getWorkspaceHeight(workspace, false) : fixedDimension
|
||||
|
||||
// Container uses full barHeight on cross-axis for larger click area
|
||||
width: isVertical ? barHeight : getWorkspaceWidth(workspace, false)
|
||||
height: isVertical ? getWorkspaceHeight(workspace, false) : barHeight
|
||||
|
||||
states: [
|
||||
State {
|
||||
name: "active"
|
||||
when: workspace.isActive
|
||||
PropertyChanges {
|
||||
target: pillContainer
|
||||
width: isVertical ? barHeight : getWorkspaceWidth(workspace, true)
|
||||
height: isVertical ? getWorkspaceHeight(workspace, true) : barHeight
|
||||
pillWidth: isVertical ? fixedDimension : getWorkspaceWidth(workspace, true)
|
||||
pillHeight: isVertical ? getWorkspaceHeight(workspace, true) : fixedDimension
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
transitions: [
|
||||
Transition {
|
||||
from: "inactive"
|
||||
to: "active"
|
||||
NumberAnimation {
|
||||
properties: isVertical ? "height,pillHeight" : "width,pillWidth"
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
},
|
||||
Transition {
|
||||
from: "active"
|
||||
to: "inactive"
|
||||
NumberAnimation {
|
||||
properties: isVertical ? "height,pillHeight" : "width,pillWidth"
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
width: pillContainer.pillWidth
|
||||
height: pillContainer.pillHeight
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
radius: Style.radiusM
|
||||
z: 0
|
||||
|
||||
color: {
|
||||
if (pillMouseArea.containsMouse)
|
||||
return Color.mHover;
|
||||
if (workspace.isFocused)
|
||||
return Color.resolveColorKey(focusedColor);
|
||||
if (workspace.isUrgent)
|
||||
return Color.mError;
|
||||
if (workspace.isOccupied)
|
||||
return Color.resolveColorKey(occupiedColor);
|
||||
return Qt.alpha(Color.resolveColorKey(emptyColor), 0.3);
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: (labelMode !== "none") && (!showLabelsOnlyWhenOccupied || workspace.isOccupied || workspace.isFocused)
|
||||
anchors.fill: parent
|
||||
sourceComponent: Component {
|
||||
NText {
|
||||
text: {
|
||||
if (workspace.name && workspace.name.length > 0) {
|
||||
if (labelMode === "name") {
|
||||
return workspace.name.substring(0, characterCount);
|
||||
}
|
||||
if (labelMode === "index+name") {
|
||||
// Vertical mode: compact format (no space, first char only)
|
||||
// Horizontal mode: full format (space, more chars)
|
||||
if (isVertical) {
|
||||
return workspace.idx.toString() + workspace.name.substring(0, 1);
|
||||
}
|
||||
return workspace.idx.toString() + " " + workspace.name.substring(0, characterCount);
|
||||
}
|
||||
}
|
||||
return workspace.idx.toString();
|
||||
}
|
||||
family: Settings.data.ui.fontFixed
|
||||
// Size based on the fixed dimension (cross-axis) of the visual pill
|
||||
pointSize: (isVertical ? pillContainer.pillWidth : pillContainer.pillHeight) * textRatio
|
||||
applyUiScale: false
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.weight: fontWeight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
wrapMode: Text.Wrap
|
||||
color: {
|
||||
if (pillMouseArea.containsMouse)
|
||||
return Color.mOnHover;
|
||||
if (workspace.isFocused)
|
||||
return Color.resolveOnColorKey(focusedColor);
|
||||
if (workspace.isUrgent)
|
||||
return Color.mOnError;
|
||||
if (workspace.isOccupied)
|
||||
return Color.resolveOnColorKey(occupiedColor);
|
||||
return Color.resolveOnColorKey(emptyColor);
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Material 3-inspired smooth animations
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
}
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
Behavior on radius {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
}
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
}
|
||||
Behavior on pillWidth {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
}
|
||||
Behavior on pillHeight {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
}
|
||||
}
|
||||
|
||||
// Full-height click area
|
||||
MouseArea {
|
||||
id: pillMouseArea
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
CompositorService.switchToWorkspace(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
// Burst effect overlay for focused pill
|
||||
Rectangle {
|
||||
id: pillBurst
|
||||
anchors.centerIn: pill
|
||||
width: pillContainer.pillWidth + 18 * masterProgress * scale
|
||||
height: pillContainer.pillHeight + 18 * masterProgress * scale
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
border.color: effectColor
|
||||
border.width: Math.max(1, Math.round((2 + 6 * (1.0 - masterProgress))))
|
||||
opacity: effectsActive && workspace.isFocused ? (1.0 - masterProgress) * 0.7 : 0
|
||||
visible: effectsActive && workspace.isFocused
|
||||
z: 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
Layout.preferredHeight: isVerticalBar ? -1 : Style.getBarHeightForScreen(screenName)
|
||||
Layout.preferredWidth: isVerticalBar ? Style.getBarHeightForScreen(screenName) : -1
|
||||
Layout.fillHeight: false
|
||||
Layout.fillWidth: false
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] || {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length && widgets[sectionWidgetIndex]) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Widget settings - matching MediaMini pattern
|
||||
readonly property bool showIcon: (widgetSettings.showIcon !== undefined) ? widgetSettings.showIcon : (widgetMetadata.showIcon || false)
|
||||
readonly property bool showText: (widgetSettings.showText !== undefined) ? widgetSettings.showText : (widgetMetadata.showText || false)
|
||||
readonly property string hideMode: (widgetSettings.hideMode !== undefined) ? widgetSettings.hideMode : (widgetMetadata.hideMode || "hidden")
|
||||
readonly property string scrollingMode: (widgetSettings.scrollingMode !== undefined) ? widgetSettings.scrollingMode : (widgetMetadata.scrollingMode || "hover")
|
||||
|
||||
// Maximum widget width with user settings support
|
||||
readonly property real maxWidth: (widgetSettings.maxWidth !== undefined) ? widgetSettings.maxWidth : Math.max(widgetMetadata.maxWidth || 0, screen ? screen.width * 0.06 : 0)
|
||||
readonly property bool useFixedWidth: (widgetSettings.useFixedWidth !== undefined) ? widgetSettings.useFixedWidth : (widgetMetadata.useFixedWidth || false)
|
||||
readonly property string textColorKey: (widgetSettings.textColor !== undefined) ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
readonly property color textColor: Color.resolveColorKey(textColorKey)
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(screenName)
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
|
||||
readonly property bool hasFocusedWindow: CompositorService.getFocusedWindow() !== null
|
||||
readonly property string windowTitle: CompositorService.getFocusedWindowTitle() || "No active window"
|
||||
readonly property string fallbackIcon: "user-desktop"
|
||||
|
||||
readonly property int iconSize: Style.toOdd(capsuleHeight * 0.75)
|
||||
readonly property int verticalSize: Style.toOdd(capsuleHeight * 0.85)
|
||||
|
||||
// For horizontal bars, height is always barHeight (no animation needed)
|
||||
// For vertical bars, collapse to 0 when hidden
|
||||
implicitHeight: isVerticalBar ? (((!hasFocusedWindow) && hideMode === "hidden") ? 0 : verticalSize) : barHeight
|
||||
implicitWidth: isVerticalBar ? (((!hasFocusedWindow) && hideMode === "hidden") ? 0 : verticalSize) : (((!hasFocusedWindow) && hideMode === "hidden") ? 0 : dynamicWidth)
|
||||
|
||||
// "visible": Always Visible, "hidden": Hide When Empty, "transparent": Transparent When Empty
|
||||
visible: (hideMode !== "hidden" || hasFocusedWindow) || opacity > 0
|
||||
opacity: ((hideMode !== "hidden" || hasFocusedWindow) && (hideMode !== "transparent" || hasFocusedWindow)) ? 1.0 : 0.0
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
|
||||
function calculateContentWidth() {
|
||||
// Calculate the actual content width based on visible elements
|
||||
var contentWidth = 0;
|
||||
var margins = Style.margin2S; // Left and right margins
|
||||
|
||||
// Icon width (if visible)
|
||||
if (showIcon) {
|
||||
contentWidth += iconSize;
|
||||
if (showText) {
|
||||
contentWidth += Style.marginS; // Spacing after icon
|
||||
}
|
||||
}
|
||||
|
||||
// Text width (use the measured width)
|
||||
if (showText) {
|
||||
contentWidth += titleContainer.measuredWidth;
|
||||
// Additional small margin for text
|
||||
contentWidth += Style.margin2XXS;
|
||||
}
|
||||
|
||||
// Add container margins
|
||||
contentWidth += margins;
|
||||
|
||||
return Math.ceil(contentWidth);
|
||||
}
|
||||
|
||||
// Dynamic width: adapt to content but respect maximum width setting
|
||||
readonly property real dynamicWidth: {
|
||||
// If using fixed width mode, always use maxWidth
|
||||
if (useFixedWidth) {
|
||||
return maxWidth;
|
||||
}
|
||||
// Otherwise, adapt to content
|
||||
if (!hasFocusedWindow) {
|
||||
return Math.min(calculateContentWidth(), maxWidth);
|
||||
}
|
||||
// Use content width but don't exceed user-set maximum width
|
||||
return Math.min(calculateContentWidth(), maxWidth);
|
||||
}
|
||||
|
||||
function getAppIcon() {
|
||||
try {
|
||||
// Try CompositorService first
|
||||
const focusedWindow = CompositorService.getFocusedWindow();
|
||||
if (focusedWindow && focusedWindow.appId) {
|
||||
try {
|
||||
const idValue = focusedWindow.appId;
|
||||
const normalizedId = (typeof idValue === 'string') ? idValue : String(idValue);
|
||||
const iconResult = ThemeIcons.iconForAppId(normalizedId.toLowerCase());
|
||||
if (iconResult && iconResult !== "") {
|
||||
return iconResult;
|
||||
}
|
||||
} catch (iconError) {
|
||||
Logger.w("ActiveWindow", "Error getting icon from CompositorService:", iconError);
|
||||
}
|
||||
}
|
||||
|
||||
if (CompositorService.isHyprland) {
|
||||
// Fallback to ToplevelManager
|
||||
if (ToplevelManager && ToplevelManager.activeToplevel) {
|
||||
try {
|
||||
const activeToplevel = ToplevelManager.activeToplevel;
|
||||
if (activeToplevel.appId) {
|
||||
const idValue2 = activeToplevel.appId;
|
||||
const normalizedId2 = (typeof idValue2 === 'string') ? idValue2 : String(idValue2);
|
||||
const iconResult2 = ThemeIcons.iconForAppId(normalizedId2.toLowerCase());
|
||||
if (iconResult2 && iconResult2 !== "") {
|
||||
return iconResult2;
|
||||
}
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
Logger.w("ActiveWindow", "Error getting icon from ToplevelManager:", fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ThemeIcons.iconFromName(fallbackIcon);
|
||||
} catch (e) {
|
||||
Logger.w("ActiveWindow", "Error in getAppIcon:", e);
|
||||
return ThemeIcons.iconFromName(fallbackIcon);
|
||||
}
|
||||
}
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: windowActiveRect
|
||||
visible: root.visible
|
||||
x: isVerticalBar ? Style.pixelAlignCenter(parent.width, width) : 0
|
||||
y: isVerticalBar ? 0 : Style.pixelAlignCenter(parent.height, height)
|
||||
width: isVerticalBar ? ((!hasFocusedWindow) && hideMode === "hidden" ? 0 : verticalSize) : ((!hasFocusedWindow) && (hideMode === "hidden") ? 0 : dynamicWidth)
|
||||
height: isVerticalBar ? ((!hasFocusedWindow) && hideMode === "hidden" ? 0 : verticalSize) : capsuleHeight
|
||||
radius: Style.radiusM
|
||||
color: Style.capsuleColor
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
// Smooth width transition
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: mainContainer
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: isVerticalBar ? 0 : Style.marginS
|
||||
anchors.rightMargin: isVerticalBar ? 0 : Style.marginS
|
||||
|
||||
// Horizontal layout for top/bottom bars
|
||||
RowLayout {
|
||||
id: rowLayout
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.marginS
|
||||
visible: !isVerticalBar
|
||||
z: 1
|
||||
|
||||
// Window icon
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: iconSize
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: showIcon
|
||||
|
||||
IconImage {
|
||||
id: windowIcon
|
||||
anchors.fill: parent
|
||||
source: getAppIcon()
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
visible: source !== ""
|
||||
|
||||
// Apply dock shader to active window icon (always themed)
|
||||
layer.enabled: widgetSettings.colorizeIcons !== false
|
||||
layer.effect: ShaderEffect {
|
||||
property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant
|
||||
property real colorizeMode: 0.0 // Dock mode (grayscale)
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NScrollText {
|
||||
id: titleContainer
|
||||
text: windowTitle
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.preferredHeight: root.capsuleHeight
|
||||
fadeRoundLeftCorners: !showIcon
|
||||
visible: showText
|
||||
|
||||
maxWidth: {
|
||||
// Calculate available width based on other elements
|
||||
var iconWidth = (showIcon && windowIcon.visible ? (iconSize + Style.marginS) : 0);
|
||||
var totalMargins = Style.margin2XXS;
|
||||
var availableWidth = mainContainer.width - iconWidth - totalMargins;
|
||||
return Math.max(20, availableWidth);
|
||||
}
|
||||
scrollMode: {
|
||||
if (scrollingMode === "always")
|
||||
return NScrollText.ScrollMode.Always;
|
||||
if (scrollingMode === "hover")
|
||||
return NScrollText.ScrollMode.Hover;
|
||||
return NScrollText.ScrollMode.Never;
|
||||
}
|
||||
forcedHover: mainMouseArea.containsMouse
|
||||
fadeExtent: 0.1
|
||||
fadeCornerRadius: Style.radiusM
|
||||
|
||||
NText {
|
||||
text: windowTitle
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: root.textColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical layout for left/right bars - icon only
|
||||
Item {
|
||||
id: verticalLayout
|
||||
width: parent.width - Style.margin2M
|
||||
height: parent.height - Style.margin2M
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
visible: isVerticalBar
|
||||
z: 1
|
||||
|
||||
// Window icon
|
||||
Item {
|
||||
id: verticalIconContainer
|
||||
width: root.iconSize
|
||||
height: width
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
visible: windowTitle !== ""
|
||||
|
||||
IconImage {
|
||||
id: windowIconVertical
|
||||
anchors.fill: parent
|
||||
source: getAppIcon()
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
visible: source !== ""
|
||||
|
||||
// Apply dock shader to active window icon (always themed)
|
||||
layer.enabled: widgetSettings.colorizeIcons !== false
|
||||
layer.effect: ShaderEffect {
|
||||
property color targetColor: Color.mOnSurface
|
||||
property real colorizeMode: 0.0 // Dock mode (grayscale)
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse area moved to root
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse area for hover detection
|
||||
MouseArea {
|
||||
id: mainMouseArea
|
||||
anchors.fill: parent
|
||||
|
||||
// Extend click area to screen edge if widget is at the start/end
|
||||
anchors.leftMargin: (!isVerticalBar && section === "left" && sectionWidgetIndex === 0) ? -Style.marginS : 0
|
||||
anchors.rightMargin: (!isVerticalBar && section === "right" && sectionWidgetIndex === sectionWidgetsCount - 1) ? -Style.marginS : 0
|
||||
anchors.topMargin: (isVerticalBar && section === "left" && sectionWidgetIndex === 0) ? -Style.marginM : 0
|
||||
anchors.bottomMargin: (isVerticalBar && section === "right" && sectionWidgetIndex === sectionWidgetsCount - 1) ? -Style.marginM : 0
|
||||
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onEntered: {
|
||||
if ((windowTitle !== "") && isVerticalBar || (scrollingMode === "never")) {
|
||||
TooltipService.show(root, windowTitle, BarService.getTooltipDirection(root.screen?.name));
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
TooltipService.hide();
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onActiveWindowChanged() {
|
||||
try {
|
||||
windowIcon.source = Qt.binding(getAppIcon);
|
||||
windowIconVertical.source = Qt.binding(getAppIcon);
|
||||
} catch (e) {
|
||||
Logger.w("ActiveWindow", "Error in onActiveWindowChanged:", e);
|
||||
}
|
||||
}
|
||||
function onWindowListChanged() {
|
||||
try {
|
||||
windowIcon.source = Qt.binding(getAppIcon);
|
||||
windowIconVertical.source = Qt.binding(getAppIcon);
|
||||
} catch (e) {
|
||||
Logger.w("ActiveWindow", "Error in onWindowListChanged:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
import qs.Widgets.AudioSpectrum
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Resolve settings: try user settings or defaults from BarWidgetRegistry
|
||||
readonly property int visualizerWidth: widgetSettings.width !== undefined ? widgetSettings.width : widgetMetadata.width
|
||||
readonly property bool hideWhenIdle: widgetSettings.hideWhenIdle !== undefined ? widgetSettings.hideWhenIdle : widgetMetadata.hideWhenIdle
|
||||
readonly property string colorName: widgetSettings.colorName !== undefined ? widgetSettings.colorName : widgetMetadata.colorName
|
||||
|
||||
readonly property color fillColor: Color.resolveColorKey(colorName)
|
||||
|
||||
readonly property bool shouldShow: (currentVisualizerType !== "" && currentVisualizerType !== "none") && (!hideWhenIdle || MediaService.isPlaying)
|
||||
|
||||
// Register/unregister with SpectrumService based on visibility (use screenName — screen can be null after DPMS/output changes)
|
||||
readonly property string spectrumComponentId: "bar:audiovisualizer:" + screenName + ":" + root.section + ":" + root.sectionWidgetIndex
|
||||
|
||||
onShouldShowChanged: {
|
||||
if (root.shouldShow) {
|
||||
SpectrumService.registerComponent(root.spectrumComponentId);
|
||||
} else {
|
||||
SpectrumService.unregisterComponent(root.spectrumComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (root.shouldShow) {
|
||||
SpectrumService.unregisterComponent(root.spectrumComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Content dimensions for implicit sizing
|
||||
readonly property real contentWidth: !shouldShow ? 0 : isVerticalBar ? capsuleHeight : visualizerWidth
|
||||
readonly property real contentHeight: !shouldShow ? 0 : isVerticalBar ? visualizerWidth : capsuleHeight
|
||||
|
||||
implicitWidth: contentWidth
|
||||
implicitHeight: contentHeight
|
||||
visible: shouldShow
|
||||
opacity: shouldShow ? 1.0 : 0.0
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Store visualizer type to force re-evaluation
|
||||
readonly property string currentVisualizerType: Settings.data.audio.visualizerType
|
||||
|
||||
// Visual capsule centered in parent
|
||||
Rectangle {
|
||||
id: background
|
||||
width: root.contentWidth
|
||||
height: root.contentHeight
|
||||
anchors.centerIn: parent
|
||||
radius: Style.radiusS
|
||||
color: Style.capsuleColor
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
// When visualizer type or playback changes, shouldShow updates automatically
|
||||
// The Loader dynamically loads the appropriate visualizer based on settings
|
||||
Loader {
|
||||
id: visualizerLoader
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
active: shouldShow
|
||||
asynchronous: true
|
||||
|
||||
sourceComponent: {
|
||||
switch (currentVisualizerType) {
|
||||
case "linear":
|
||||
return linearComponent;
|
||||
case "mirrored":
|
||||
return mirroredComponent;
|
||||
case "wave":
|
||||
return waveComponent;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.cycle-visualizer"),
|
||||
"action": "cycle-visualizer",
|
||||
"icon": "chart-column"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
if (screen) {
|
||||
PanelService.closeContextMenu(screen);
|
||||
}
|
||||
|
||||
if (action === "cycle-visualizer") {
|
||||
const types = ["linear", "mirrored", "wave"];
|
||||
const currentIndex = types.indexOf(currentVisualizerType);
|
||||
const nextIndex = (currentIndex + 1) % types.length;
|
||||
Settings.data.audio.visualizerType = types[nextIndex];
|
||||
} else if (action === "widget-settings" && screen) {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click to cycle through visualizer types
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
if (screen) {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
} else {
|
||||
const types = ["linear", "mirrored", "wave"];
|
||||
const currentIndex = types.indexOf(currentVisualizerType);
|
||||
const nextIndex = (currentIndex + 1) % types.length;
|
||||
Settings.data.audio.visualizerType = types[nextIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: linearComponent
|
||||
NLinearSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: root.fillColor
|
||||
showMinimumSignal: true
|
||||
vertical: root.isVerticalBar
|
||||
barPosition: root.barPosition
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: mirroredComponent
|
||||
NMirroredSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: root.fillColor
|
||||
showMinimumSignal: true
|
||||
vertical: root.isVerticalBar
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: waveComponent
|
||||
NWaveSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: root.fillColor
|
||||
showMinimumSignal: true
|
||||
vertical: root.isVerticalBar
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.Hardware
|
||||
import qs.Services.Networking
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
|
||||
readonly property string displayMode: widgetSettings.displayMode !== undefined ? widgetSettings.displayMode : widgetMetadata.displayMode
|
||||
readonly property bool useGraphicMode: displayMode === "graphic" || displayMode === "graphic-clean"
|
||||
|
||||
readonly property bool hideIfNotDetected: widgetSettings.hideIfNotDetected !== undefined ? widgetSettings.hideIfNotDetected : widgetMetadata.hideIfNotDetected
|
||||
readonly property bool hideIfIdle: widgetSettings.hideIfIdle !== undefined ? widgetSettings.hideIfIdle : widgetMetadata.hideIfIdle
|
||||
|
||||
// Check if selected device is actually present/connected
|
||||
readonly property bool isReady: BatteryService.isDeviceReady(selectedDevice)
|
||||
readonly property bool isPresent: BatteryService.isDevicePresent(selectedDevice)
|
||||
readonly property real percent: isReady ? BatteryService.getPercentage(selectedDevice) : -1
|
||||
readonly property bool isCharging: isReady ? BatteryService.isCharging(selectedDevice) : false
|
||||
readonly property bool isPluggedIn: isReady ? BatteryService.isPluggedIn(selectedDevice) : false
|
||||
readonly property bool isLowBattery: isReady ? BatteryService.isLowBattery(selectedDevice) : false
|
||||
readonly property bool isCriticalBattery: isReady ? BatteryService.isCriticalBattery(selectedDevice) : false
|
||||
|
||||
// Visibility: show if hideIfNotDetected is false, or if battery is ready
|
||||
readonly property bool shouldShow: !hideIfNotDetected || (isReady && (hideIfIdle ? !isPluggedIn : true))
|
||||
readonly property string deviceNativePath: widgetSettings.deviceNativePath !== undefined ? widgetSettings.deviceNativePath : widgetMetadata.deviceNativePath
|
||||
readonly property var selectedDevice: BatteryService.isDevicePresent(BatteryService.findDevice(deviceNativePath)) ? BatteryService.findDevice(deviceNativePath) : null
|
||||
|
||||
readonly property var tooltipContent: {
|
||||
if (!isReady || !isPresent) {
|
||||
return I18n.tr("battery.no-battery-detected");
|
||||
}
|
||||
|
||||
let rows = [];
|
||||
const isInternal = selectedDevice.isLaptopBattery;
|
||||
if (isInternal) {
|
||||
// Show charge percentage
|
||||
rows.push([I18n.tr("battery.battery-level"), `${percent}%`]);
|
||||
|
||||
let timeText = BatteryService.getTimeRemainingText(selectedDevice);
|
||||
if (timeText) {
|
||||
const colonIdx = timeText.indexOf(":");
|
||||
if (colonIdx >= 0) {
|
||||
rows.push([timeText.substring(0, colonIdx).trim(), timeText.substring(colonIdx + 1).trim()]);
|
||||
} else {
|
||||
rows.push([timeText, ""]);
|
||||
}
|
||||
}
|
||||
|
||||
let rateText = BatteryService.getRateText(selectedDevice);
|
||||
if (!isPluggedIn && rateText) {
|
||||
const colonIdx = rateText.indexOf(":");
|
||||
if (colonIdx >= 0) {
|
||||
rows.push([rateText.substring(0, colonIdx).trim(), rateText.substring(colonIdx + 1).trim()]);
|
||||
} else {
|
||||
rows.push([rateText, ""]);
|
||||
}
|
||||
}
|
||||
|
||||
// Show battery health if supported (check actual battery, not DisplayDevice)
|
||||
let healthDevice = selectedDevice.healthSupported ? selectedDevice : (BatteryService.laptopBatteries.length > 0 ? BatteryService.laptopBatteries[0] : null);
|
||||
if (healthDevice && healthDevice.healthSupported) {
|
||||
rows.push([I18n.tr("battery.battery-health"), `${Math.round(healthDevice.healthPercentage)}%`]);
|
||||
}
|
||||
} else if (selectedDevice) {
|
||||
// External / Peripheral Device (Phone, Keyboard, Mouse, Gamepad, Headphone etc.)
|
||||
let name = BatteryService.getDeviceName(selectedDevice);
|
||||
rows.push([name, `${percent}%`]);
|
||||
}
|
||||
|
||||
// If we are showing the main laptop battery, append external devices
|
||||
if (isInternal) {
|
||||
var external = BatteryService.bluetoothBatteries;
|
||||
if (external.length > 0) {
|
||||
if (rows.length > 0) {
|
||||
rows.push(["---", "---"]); // Separator
|
||||
}
|
||||
for (var j = 0; j < external.length; j++) {
|
||||
var dev = external[j];
|
||||
var dName = BatteryService.getDeviceName(dev);
|
||||
var dPct = BatteryService.getPercentage(dev);
|
||||
rows.push([dName, `${dPct}%`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
visible: shouldShow
|
||||
opacity: shouldShow ? 1.0 : 0.0
|
||||
|
||||
implicitWidth: useGraphicMode ? capsule.width : pill.width
|
||||
implicitHeight: useGraphicMode ? capsule.height : pill.height
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GRAPHIC MODE ====================
|
||||
|
||||
// Capsule background (graphic mode only)
|
||||
Rectangle {
|
||||
id: capsule
|
||||
visible: root.useGraphicMode
|
||||
anchors.centerIn: nBattery
|
||||
width: root.isBarVertical ? root.capsuleHeight : nBattery.width + Style.margin2S
|
||||
height: root.isBarVertical ? nBattery.height + Style.margin2S : root.capsuleHeight
|
||||
radius: Math.min(Style.radiusL, width / 2)
|
||||
color: graphicMouseArea.containsMouse ? Color.mHover : Style.capsuleColor
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
Behavior on color {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NBattery {
|
||||
id: nBattery
|
||||
visible: root.useGraphicMode
|
||||
anchors.centerIn: parent
|
||||
baseSize: (Style.getBarHeightForScreen(root.screenName) / root.capsuleHeight) * Style.fontSizeXXS
|
||||
showPercentageText: root.displayMode !== "graphic-clean"
|
||||
vertical: root.isBarVertical
|
||||
percentage: root.percent
|
||||
ready: root.isReady
|
||||
charging: root.isCharging
|
||||
pluggedIn: root.isPluggedIn
|
||||
low: root.isLowBattery
|
||||
critical: root.isCriticalBattery
|
||||
baseColor: graphicMouseArea.containsMouse ? Color.mOnHover : Color.mOnSurface
|
||||
textColor: graphicMouseArea.containsMouse ? Color.mHover : Color.mSurface
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: graphicMouseArea
|
||||
visible: root.useGraphicMode
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onEntered: {
|
||||
if (!getBatteryPanel()?.isPanelOpen && root.tooltipContent) {
|
||||
TooltipService.show(root, root.tooltipContent, BarService.getTooltipDirection(root.screen?.name));
|
||||
tooltipRefreshTimer.start();
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
tooltipRefreshTimer.stop();
|
||||
TooltipService.hide();
|
||||
}
|
||||
onClicked: mouse => {
|
||||
TooltipService.hide();
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
PanelService.showContextMenu(contextMenu, nBattery, screen);
|
||||
} else {
|
||||
toggleBatteryPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: tooltipRefreshTimer
|
||||
interval: 1000
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (graphicMouseArea.containsMouse) {
|
||||
TooltipService.updateText(root.tooltipContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ICON MODE ====================
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
visible: !root.useGraphicMode
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
icon: BatteryService.getIcon(root.percent, root.isCharging, root.isPluggedIn, root.isReady)
|
||||
text: root.isReady ? root.percent : "-"
|
||||
suffix: "%"
|
||||
autoHide: false
|
||||
forceOpen: root.isReady && root.displayMode === "icon-always"
|
||||
forceClose: root.displayMode === "icon-only" || !root.isReady
|
||||
customBackgroundColor: root.isCharging ? Color.mPrimary : ((root.isLowBattery || root.isCriticalBattery) ? Color.mError : "transparent")
|
||||
customTextIconColor: root.isCharging ? Color.mOnPrimary : ((root.isLowBattery || root.isCriticalBattery) ? Color.mOnError : "transparent")
|
||||
tooltipText: !getBatteryPanel()?.isPanelOpen ? root.tooltipContent : ""
|
||||
onClicked: toggleBatteryPanel()
|
||||
onRightClicked: PanelService.showContextMenu(contextMenu, pill, screen)
|
||||
}
|
||||
|
||||
// ==================== SHARED ====================
|
||||
|
||||
function getBatteryPanel() {
|
||||
var panel = PanelService.getPanel("batteryPanel", screen);
|
||||
if (panel) {
|
||||
panel.panelID = {
|
||||
showPowerProfiles: widgetSettings.showPowerProfiles !== undefined ? widgetSettings.showPowerProfiles : widgetMetadata.showPowerProfiles,
|
||||
showNoctaliaPerformance: widgetSettings.showNoctaliaPerformance !== undefined ? widgetSettings.showNoctaliaPerformance : widgetMetadata.showNoctaliaPerformance
|
||||
};
|
||||
}
|
||||
return panel;
|
||||
}
|
||||
|
||||
function toggleBatteryPanel() {
|
||||
getBatteryPanel()?.toggle(root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings // For SettingsPanel
|
||||
import qs.Services.Networking
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property string displayMode: widgetSettings.displayMode !== undefined ? widgetSettings.displayMode : widgetMetadata.displayMode
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": BluetoothService.enabled ? I18n.tr("actions.disable-bluetooth") : I18n.tr("actions.enable-bluetooth"),
|
||||
"action": "toggle-bluetooth",
|
||||
"icon": BluetoothService.enabled ? "bluetooth-off" : "bluetooth",
|
||||
"enabled": !NetworkService.airplaneModeEnabled && BluetoothService.bluetoothAvailable
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("common.bluetooth") + " " + I18n.tr("tooltips.open-settings"),
|
||||
"action": "bluetooth-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "toggle-bluetooth") {
|
||||
BluetoothService.setBluetoothEnabled(!BluetoothService.enabled);
|
||||
} else if (action === "bluetooth-settings") {
|
||||
SettingsPanelService.openToTab(SettingsPanel.Tab.Connections, 1, screen);
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
|
||||
customTextColor: Color.resolveColorKeyOptional(root.textColorKey)
|
||||
icon: !BluetoothService.enabled ? "bluetooth-off" : ((BluetoothService.connectedDevices && BluetoothService.connectedDevices.length > 0) ? "bluetooth-connected" : "bluetooth")
|
||||
text: {
|
||||
if (BluetoothService.connectedDevices && BluetoothService.connectedDevices.length > 0) {
|
||||
const firstDevice = BluetoothService.connectedDevices[0];
|
||||
return firstDevice.name || firstDevice.deviceName;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
suffix: {
|
||||
if (BluetoothService.connectedDevices && BluetoothService.connectedDevices.length > 1) {
|
||||
return ` + ${BluetoothService.connectedDevices.length - 1}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
autoHide: false
|
||||
forceOpen: !isBarVertical && root.displayMode === "alwaysShow"
|
||||
forceClose: isBarVertical || root.displayMode === "alwaysHide" || text === ""
|
||||
onClicked: {
|
||||
var p = PanelService.getPanel("bluetoothPanel", screen);
|
||||
if (p)
|
||||
p.toggle(this);
|
||||
}
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, pill, screen);
|
||||
}
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("bluetoothPanel", screen)?.isPanelOpen) {
|
||||
return "";
|
||||
}
|
||||
if (pill.text !== "") {
|
||||
return pill.text;
|
||||
}
|
||||
return I18n.tr("tooltips.bluetooth-devices");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Hardware
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property string displayMode: (widgetSettings.displayMode !== undefined) ? widgetSettings.displayMode : widgetMetadata.displayMode
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
readonly property bool applyToAllMonitors: widgetSettings.applyToAllMonitors !== undefined ? widgetSettings.applyToAllMonitors : (Settings.data.brightness.syncAllMonitors !== undefined ? Settings.data.brightness.syncAllMonitors : widgetMetadata.applyToAllMonitors)
|
||||
readonly property bool reverseScroll: Settings.data.general.reverseScroll
|
||||
|
||||
// Used to avoid opening the pill on Quickshell startup
|
||||
property bool firstBrightnessReceived: false
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
// Track the brightness monitor reactively via declarative binding so it
|
||||
// updates atomically when monitors change, avoiding a transient undefined
|
||||
// state that occurs when Monitor QtObjects are destroyed before the
|
||||
// imperative updateMonitor() call would run.
|
||||
property var brightnessMonitor: {
|
||||
var _ = BrightnessService.monitors; // reactive dependency
|
||||
var __ = BrightnessService.ddcMonitors; // reactive dependency
|
||||
if (!screen)
|
||||
return null;
|
||||
return BrightnessService.getMonitorForScreen(screen) ?? null;
|
||||
}
|
||||
|
||||
function getControllableMonitorCount() {
|
||||
var monitors = BrightnessService.monitors || [];
|
||||
var count = 0;
|
||||
for (var i = 0; i < monitors.length; i++) {
|
||||
if (monitors[i] && monitors[i].brightnessControlAvailable)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
visible: brightnessMonitor !== null
|
||||
opacity: brightnessMonitor !== null ? 1.0 : 0.0
|
||||
|
||||
function getIcon() {
|
||||
var monitor = brightnessMonitor;
|
||||
if (!monitor || !monitor.brightnessControlAvailable || isNaN(monitor.brightness))
|
||||
return "sun-off";
|
||||
var brightness = monitor.brightness;
|
||||
if (brightness <= 0.001)
|
||||
return "sun-off";
|
||||
return brightness <= 0.5 ? "brightness-low" : "brightness-high";
|
||||
}
|
||||
|
||||
// Connection used to open the pill when brightness changes
|
||||
Connections {
|
||||
target: brightnessMonitor
|
||||
ignoreUnknownSignals: true
|
||||
function onBrightnessUpdated() {
|
||||
// Ignore if this is the first time we receive an update.
|
||||
// Most likely service just kicked off.
|
||||
if (!firstBrightnessReceived) {
|
||||
firstBrightnessReceived = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pill.show();
|
||||
hideTimerAfterChange.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimerAfterChange
|
||||
interval: 2500
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: pill.hide()
|
||||
}
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.open-display-settings"),
|
||||
"action": "open-display-settings",
|
||||
"icon": "sun"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "open-display-settings") {
|
||||
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
|
||||
settingsPanel.requestedTab = SettingsPanel.Tab.Display;
|
||||
settingsPanel.open();
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
|
||||
customTextColor: Color.resolveColorKeyOptional(root.textColorKey)
|
||||
icon: getIcon()
|
||||
autoHide: false // Important to be false so we can hover as long as we want
|
||||
text: {
|
||||
var monitor = brightnessMonitor;
|
||||
if (!monitor || !monitor.brightnessControlAvailable || isNaN(monitor.brightness))
|
||||
return "";
|
||||
return Math.round(monitor.brightness * 100);
|
||||
}
|
||||
suffix: text.length > 0 ? "%" : "-"
|
||||
forceOpen: displayMode === "alwaysShow"
|
||||
forceClose: displayMode === "alwaysHide"
|
||||
tooltipText: {
|
||||
var monitor = brightnessMonitor;
|
||||
var panel = PanelService.getPanel("brightnessPanel", screen);
|
||||
if (panel?.isPanelOpen || !monitor || !monitor.brightnessControlAvailable || isNaN(monitor.brightness))
|
||||
return "";
|
||||
return I18n.tr("tooltips.brightness-at", {
|
||||
"brightness": Math.round(monitor.brightness * 100)
|
||||
});
|
||||
}
|
||||
|
||||
onWheel: function (angle) {
|
||||
var monitor = brightnessMonitor;
|
||||
if (!monitor || !monitor.brightnessControlAvailable)
|
||||
return;
|
||||
|
||||
if (root.reverseScroll)
|
||||
angle *= -1;
|
||||
|
||||
if (angle === 0)
|
||||
return;
|
||||
|
||||
var shouldApplyToAll = root.applyToAllMonitors && root.getControllableMonitorCount() > 1;
|
||||
if (shouldApplyToAll) {
|
||||
var direction = angle > 0 ? 1 : -1;
|
||||
var baseValue = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness;
|
||||
var step = monitor.stepSize;
|
||||
var minValue = monitor.minBrightnessValue;
|
||||
|
||||
if (direction > 0 && Settings.data.brightness.enforceMinimum && baseValue < minValue) {
|
||||
baseValue = Math.max(step, minValue);
|
||||
} else {
|
||||
baseValue = baseValue + direction * step;
|
||||
}
|
||||
|
||||
var targetValue = Math.max(minValue, Math.min(1, baseValue));
|
||||
|
||||
BrightnessService.monitors.forEach(function (m) {
|
||||
if (m && m.brightnessControlAvailable) {
|
||||
m.setBrightnessDebounced(targetValue);
|
||||
}
|
||||
});
|
||||
} else if (angle > 0) {
|
||||
monitor.increaseBrightness();
|
||||
} else if (angle < 0) {
|
||||
monitor.decreaseBrightness();
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: PanelService.getPanel("brightnessPanel", screen)?.toggle(this)
|
||||
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, pill, screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
|
||||
readonly property var now: Time.now
|
||||
|
||||
// Resolve settings: try user settings or defaults from BarWidgetRegistry
|
||||
readonly property string clockColor: widgetSettings.clockColor !== undefined ? widgetSettings.clockColor : widgetMetadata.clockColor
|
||||
readonly property bool useCustomFont: widgetSettings.useCustomFont !== undefined ? widgetSettings.useCustomFont : widgetMetadata.useCustomFont
|
||||
readonly property string customFont: widgetSettings.customFont !== undefined ? widgetSettings.customFont : widgetMetadata.customFont
|
||||
readonly property string formatHorizontal: widgetSettings.formatHorizontal !== undefined ? widgetSettings.formatHorizontal : widgetMetadata.formatHorizontal
|
||||
readonly property string formatVertical: widgetSettings.formatVertical !== undefined ? widgetSettings.formatVertical : widgetMetadata.formatVertical
|
||||
readonly property string tooltipFormat: widgetSettings.tooltipFormat !== undefined ? widgetSettings.tooltipFormat : widgetMetadata.tooltipFormat
|
||||
|
||||
readonly property color textColor: Color.resolveColorKey(clockColor)
|
||||
|
||||
// Content dimensions for implicit sizing
|
||||
readonly property real contentWidth: isBarVertical ? capsuleHeight : Math.round((isBarVertical ? verticalLoader.implicitWidth : horizontalLoader.implicitWidth) + Style.margin2M)
|
||||
readonly property real contentHeight: isBarVertical ? Math.round(verticalLoader.implicitHeight + Style.margin2S) : capsuleHeight
|
||||
|
||||
// Size: use implicit width/height
|
||||
// BarWidgetLoader sets explicit width/height to extend click area
|
||||
implicitWidth: contentWidth
|
||||
implicitHeight: contentHeight
|
||||
|
||||
// Visual clock capsule - stays at content size, centered in parent
|
||||
Rectangle {
|
||||
id: visualClock
|
||||
width: root.contentWidth
|
||||
height: root.contentHeight
|
||||
anchors.centerIn: parent
|
||||
|
||||
radius: Style.radiusL
|
||||
color: Style.capsuleColor
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
Item {
|
||||
id: clockContainer
|
||||
anchors.centerIn: parent
|
||||
|
||||
// Horizontal
|
||||
Loader {
|
||||
id: horizontalLoader
|
||||
active: !isBarVertical
|
||||
anchors.centerIn: parent
|
||||
sourceComponent: ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: Settings.data.bar.showCapsule ? -5 : -3
|
||||
Repeater {
|
||||
id: repeater
|
||||
model: I18n.locale.toString(now, formatHorizontal.trim()).split("\\n")
|
||||
NText {
|
||||
visible: text !== ""
|
||||
text: modelData
|
||||
family: useCustomFont && customFont ? customFont : Settings.data.ui.fontDefault
|
||||
Binding on pointSize {
|
||||
value: {
|
||||
if (repeater.model.length == 1) {
|
||||
// Single line: Full size
|
||||
return barFontSize;
|
||||
} else if (repeater.model.length == 2) {
|
||||
// Two lines: First line is bigger than the second
|
||||
return (index == 0) ? Math.round(barFontSize * 0.9) : Math.round(barFontSize * 0.75);
|
||||
} else {
|
||||
// More than two lines: Make it small!
|
||||
return Math.round(barFontSize * 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyUiScale: false
|
||||
color: textColor
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
|
||||
features: ({
|
||||
"tnum": 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical
|
||||
Loader {
|
||||
id: verticalLoader
|
||||
active: isBarVertical
|
||||
anchors.centerIn: parent // Now this works without layout conflicts
|
||||
sourceComponent: ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: -2
|
||||
Repeater {
|
||||
model: I18n.locale.toString(now, formatVertical.trim()).split(" ")
|
||||
delegate: NText {
|
||||
visible: text !== ""
|
||||
text: modelData
|
||||
family: useCustomFont && customFont ? customFont : Settings.data.ui.fontDefault
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
color: textColor
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
|
||||
features: ({
|
||||
"tnum": 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.open-calendar"),
|
||||
"action": "open-calendar",
|
||||
"icon": "calendar"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
// Close the context menu
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "open-calendar") {
|
||||
PanelService.getPanel("clockPanel", screen)?.toggle(root);
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build tooltip text with formatted time/date
|
||||
function buildTooltipText() {
|
||||
if (tooltipFormat && tooltipFormat.trim() !== "") {
|
||||
return I18n.locale.toString(now, tooltipFormat.trim());
|
||||
}
|
||||
// Fallback to default if no format is set
|
||||
return I18n.tr("common.calendar"); // Defaults to "Calendar"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: clockMouseArea
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onEntered: {
|
||||
if (!PanelService.getPanel("clockPanel", screen)?.isPanelOpen) {
|
||||
TooltipService.show(root, buildTooltipText(), BarService.getTooltipDirection(root.screen?.name));
|
||||
tooltipRefreshTimer.start();
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
tooltipRefreshTimer.stop();
|
||||
TooltipService.hide();
|
||||
}
|
||||
onClicked: mouse => {
|
||||
TooltipService.hide();
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
} else {
|
||||
PanelService.getPanel("clockPanel", screen)?.toggle(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: tooltipRefreshTimer
|
||||
interval: 1000
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (clockMouseArea.containsMouse && !PanelService.getPanel("clockPanel", screen)?.isPanelOpen) {
|
||||
TooltipService.updateText(buildTooltipText());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string customIcon: widgetSettings.icon !== undefined ? widgetSettings.icon : widgetMetadata.icon
|
||||
readonly property bool useDistroLogo: widgetSettings.useDistroLogo !== undefined ? widgetSettings.useDistroLogo : widgetMetadata.useDistroLogo
|
||||
readonly property string customIconPath: widgetSettings.customIconPath !== undefined ? widgetSettings.customIconPath : widgetMetadata.customIconPath
|
||||
readonly property bool enableColorization: widgetSettings.enableColorization !== undefined ? widgetSettings.enableColorization : widgetMetadata.enableColorization
|
||||
readonly property string colorizeSystemIcon: widgetSettings.colorizeSystemIcon !== undefined ? widgetSettings.colorizeSystemIcon : widgetMetadata.colorizeSystemIcon
|
||||
|
||||
readonly property color iconColor: {
|
||||
if (!enableColorization)
|
||||
return Color.mOnSurface;
|
||||
return Color.resolveColorKey(colorizeSystemIcon);
|
||||
}
|
||||
|
||||
// If we have a custom path and not using distro logo, use the theme icon.
|
||||
// If using distro logo, don't use theme icon.
|
||||
icon: (customIconPath === "" && !useDistroLogo) ? customIcon : ""
|
||||
tooltipText: {
|
||||
if (!screen || PanelService.getPanel("controlCenterPanel", screen)?.isPanelOpen) {
|
||||
return "";
|
||||
} else {
|
||||
return I18n.tr("tooltips.open-control-center");
|
||||
}
|
||||
}
|
||||
tooltipDirection: BarService.getTooltipDirection(screen?.name)
|
||||
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
colorBg: Style.capsuleColor
|
||||
colorFg: iconColor
|
||||
colorBgHover: Color.mHover
|
||||
colorFgHover: Color.mOnHover
|
||||
colorBorder: Style.capsuleBorderColor
|
||||
colorBorderHover: Style.capsuleBorderColor
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.open-launcher"),
|
||||
"action": "open-launcher",
|
||||
"icon": "search"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.open-settings"),
|
||||
"action": "open-settings",
|
||||
"icon": "adjustments"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "open-launcher") {
|
||||
PanelService.toggleLauncher(screen);
|
||||
} else if (action === "open-settings") {
|
||||
var panel = PanelService.getPanel("settingsPanel", screen);
|
||||
panel.requestedTab = SettingsPanel.Tab.General;
|
||||
panel.toggle();
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
var controlCenterPanel = PanelService.getPanel("controlCenterPanel", screen);
|
||||
if (Settings.data.controlCenter.position === "close_to_bar_button") {
|
||||
// Will open the panel next to the bar button.
|
||||
controlCenterPanel?.toggle(this);
|
||||
} else {
|
||||
controlCenterPanel?.toggle();
|
||||
}
|
||||
}
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
onMiddleClicked: PanelService.toggleLauncher(screen)
|
||||
|
||||
IconImage {
|
||||
id: customOrDistroLogo
|
||||
anchors.centerIn: parent
|
||||
width: root.buttonSize * 0.8
|
||||
height: width
|
||||
source: {
|
||||
if (useDistroLogo)
|
||||
return HostService.osLogo;
|
||||
if (customIconPath !== "")
|
||||
return customIconPath.startsWith("file://") ? customIconPath : "file://" + customIconPath;
|
||||
return "";
|
||||
}
|
||||
visible: source !== ""
|
||||
smooth: true
|
||||
asynchronous: true
|
||||
layer.enabled: (enableColorization) && (useDistroLogo || customIconPath !== "")
|
||||
layer.effect: ShaderEffect {
|
||||
property color targetColor: !hovering ? iconColor : Color.mOnHover
|
||||
property real colorizeMode: 2.0
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Control
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
|
||||
|
||||
readonly property string customIcon: widgetSettings.icon || widgetMetadata.icon
|
||||
readonly property string leftClickExec: widgetSettings.leftClickExec || widgetMetadata.leftClickExec
|
||||
readonly property bool leftClickUpdateText: widgetSettings.leftClickUpdateText ?? widgetMetadata.leftClickUpdateText
|
||||
readonly property string rightClickExec: widgetSettings.rightClickExec || widgetMetadata.rightClickExec
|
||||
readonly property bool rightClickUpdateText: widgetSettings.rightClickUpdateText ?? widgetMetadata.rightClickUpdateText
|
||||
readonly property string middleClickExec: widgetSettings.middleClickExec || widgetMetadata.middleClickExec
|
||||
readonly property bool middleClickUpdateText: widgetSettings.middleClickUpdateText ?? widgetMetadata.middleClickUpdateText
|
||||
readonly property string ipcIdentifier: widgetSettings.ipcIdentifier !== undefined ? widgetSettings.ipcIdentifier : (widgetMetadata.ipcIdentifier || "")
|
||||
readonly property string wheelExec: widgetSettings.wheelExec || widgetMetadata.wheelExec
|
||||
readonly property string wheelUpExec: widgetSettings.wheelUpExec || widgetMetadata.wheelUpExec
|
||||
readonly property string wheelDownExec: widgetSettings.wheelDownExec || widgetMetadata.wheelDownExec
|
||||
readonly property string wheelMode: widgetSettings.wheelMode || widgetMetadata.wheelMode
|
||||
readonly property bool wheelUpdateText: widgetSettings.wheelUpdateText ?? widgetMetadata.wheelUpdateText
|
||||
readonly property bool wheelUpUpdateText: widgetSettings.wheelUpUpdateText ?? widgetMetadata.wheelUpUpdateText
|
||||
readonly property bool wheelDownUpdateText: widgetSettings.wheelDownUpdateText ?? widgetMetadata.wheelDownUpdateText
|
||||
readonly property string textCommand: widgetSettings.textCommand !== undefined ? widgetSettings.textCommand : (widgetMetadata.textCommand || "")
|
||||
readonly property bool textStream: widgetSettings.textStream !== undefined ? widgetSettings.textStream : (widgetMetadata.textStream || false)
|
||||
readonly property int textIntervalMs: widgetSettings.textIntervalMs !== undefined ? widgetSettings.textIntervalMs : (widgetMetadata.textIntervalMs || 3000)
|
||||
readonly property string textCollapse: widgetSettings.textCollapse !== undefined ? widgetSettings.textCollapse : (widgetMetadata.textCollapse || "")
|
||||
readonly property bool parseJson: widgetSettings.parseJson !== undefined ? widgetSettings.parseJson : (widgetMetadata.parseJson || false)
|
||||
readonly property bool hasExec: (leftClickExec || rightClickExec || middleClickExec || (wheelMode === "unified" && wheelExec) || (wheelMode === "separate" && (wheelUpExec || wheelDownExec)))
|
||||
readonly property bool showIcon: (widgetSettings.showIcon !== undefined) ? widgetSettings.showIcon : true
|
||||
readonly property bool showExecTooltip: widgetSettings.showExecTooltip !== undefined ? widgetSettings.showExecTooltip : (widgetMetadata.showExecTooltip !== undefined ? widgetMetadata.showExecTooltip : true)
|
||||
readonly property bool showTextTooltip: widgetSettings.showTextTooltip !== undefined ? widgetSettings.showTextTooltip : (widgetMetadata.showTextTooltip !== undefined ? widgetMetadata.showTextTooltip : true)
|
||||
readonly property string generalTooltipText: widgetSettings.generalTooltipText !== undefined ? widgetSettings.generalTooltipText : (widgetMetadata.generalTooltipText || "")
|
||||
readonly property bool _hasCustomTooltip: generalTooltipText.trim() !== ""
|
||||
readonly property string hideMode: widgetSettings.hideMode || "alwaysExpanded"
|
||||
readonly property bool hasOutput: _dynamicText !== ""
|
||||
readonly property bool shouldForceOpen: textStream && (hideMode === "alwaysExpanded" || hideMode === "maxTransparent")
|
||||
|
||||
readonly property bool _useNewHideLogic: textCommand && textCommand.length > 0 && textStream
|
||||
readonly property bool _useTextCommandLogic: textCommand && textCommand.length > 0 && textStream
|
||||
|
||||
readonly property bool _pillVisible: {
|
||||
if (!_useTextCommandLogic) {
|
||||
return true;
|
||||
}
|
||||
if (hideMode === "alwaysExpanded" || hideMode === "maxTransparent") {
|
||||
return true;
|
||||
}
|
||||
var hasActualIcon = (_dynamicIcon !== "" || customIcon !== "");
|
||||
return hasOutput || (showIcon && hasActualIcon);
|
||||
}
|
||||
|
||||
readonly property real _pillOpacity: {
|
||||
if (!_useTextCommandLogic) {
|
||||
return 1.0;
|
||||
}
|
||||
if (hideMode === "maxTransparent" && !hasOutput) {
|
||||
return 0.0;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
readonly property bool _pillForceOpen: {
|
||||
if (!_useTextCommandLogic) {
|
||||
return _dynamicText !== "" || (textStream && currentMaxTextLength > 0);
|
||||
}
|
||||
if (currentMaxTextLength <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hideMode === "alwaysExpanded" || hideMode === "maxTransparent") {
|
||||
return true;
|
||||
}
|
||||
return hasOutput;
|
||||
}
|
||||
|
||||
readonly property string _pillIcon: {
|
||||
if (!_useTextCommandLogic) {
|
||||
if (textCommand && textCommand.length > 0 && showIcon) {
|
||||
return _dynamicIcon !== "" ? _dynamicIcon : customIcon;
|
||||
} else if (!(textCommand && textCommand.length > 0)) {
|
||||
return _dynamicIcon !== "" ? _dynamicIcon : customIcon;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
if (!showIcon)
|
||||
return "";
|
||||
var actualIcon = _dynamicIcon !== "" ? _dynamicIcon : customIcon;
|
||||
if (hideMode === "expandWithOutput" && actualIcon === "") {
|
||||
return "question-mark";
|
||||
}
|
||||
return actualIcon;
|
||||
}
|
||||
|
||||
readonly property string _pillText: {
|
||||
if (!_useTextCommandLogic) {
|
||||
return (!isVerticalBar || currentMaxTextLength > 0) ? _dynamicText : "";
|
||||
}
|
||||
if (currentMaxTextLength <= 0) {
|
||||
return "";
|
||||
}
|
||||
if (hasOutput) {
|
||||
return _dynamicText;
|
||||
}
|
||||
if (hideMode === "expandWithOutput") {
|
||||
return "";
|
||||
}
|
||||
return " ".repeat(currentMaxTextLength);
|
||||
}
|
||||
|
||||
readonly property bool enableColorization: widgetSettings.enableColorization || false
|
||||
readonly property string colorizeSystemIcon: {
|
||||
if (widgetSettings.colorizeSystemIcon !== undefined)
|
||||
return widgetSettings.colorizeSystemIcon;
|
||||
return widgetMetadata.colorizeSystemIcon !== undefined ? widgetMetadata.colorizeSystemIcon : "none";
|
||||
}
|
||||
|
||||
readonly property bool isColorizing: enableColorization && colorizeSystemIcon !== "none"
|
||||
|
||||
// Get color value from color name (returns null for invalid names)
|
||||
function _getColorValue(colorName, forHover) {
|
||||
const baseColor = (function () {
|
||||
switch (colorName) {
|
||||
case "primary":
|
||||
return Color.mPrimary;
|
||||
case "secondary":
|
||||
return Color.mSecondary;
|
||||
case "tertiary":
|
||||
return Color.mTertiary;
|
||||
case "error":
|
||||
return Color.mError;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return baseColor !== null ? (forHover ? Qt.darker(baseColor, 1.2) : baseColor) : null;
|
||||
}
|
||||
|
||||
// Resolve icon color with priority: dynamic > static > default
|
||||
function _resolveIconColor(dynamicColorName, staticColorName, isHover) {
|
||||
if (dynamicColorName && dynamicColorName !== "") {
|
||||
if (dynamicColorName === "none") {
|
||||
return isHover ? Color.mOnHover : Color.mOnSurface;
|
||||
}
|
||||
const color = _getColorValue(dynamicColorName, isHover);
|
||||
if (color !== null)
|
||||
return color;
|
||||
}
|
||||
|
||||
if (staticColorName && staticColorName !== "" && isColorizing) {
|
||||
const color = _getColorValue(staticColorName, isHover);
|
||||
if (color !== null)
|
||||
return color;
|
||||
}
|
||||
|
||||
return isHover ? Color.mOnHover : Color.mOnSurface;
|
||||
}
|
||||
|
||||
readonly property color iconColor: _resolveIconColor(_dynamicColor, colorizeSystemIcon, false)
|
||||
readonly property color iconHoverColor: _resolveIconColor(_dynamicColor, colorizeSystemIcon, true)
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
|
||||
visible: _pillVisible
|
||||
opacity: _pillOpacity
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
icon: _pillIcon
|
||||
text: _pillText
|
||||
rotateText: isVerticalBar && currentMaxTextLength > 0
|
||||
autoHide: false
|
||||
forceOpen: _pillForceOpen
|
||||
forceClose: !_pillForceOpen
|
||||
customTextIconColor: iconColor
|
||||
|
||||
// Helper function to build tooltip content
|
||||
function _buildTooltipContent() {
|
||||
var lines = [];
|
||||
|
||||
// Add custom tooltip if set
|
||||
if (_hasCustomTooltip) {
|
||||
lines.push(generalTooltipText);
|
||||
}
|
||||
|
||||
// Add command details if enabled and available
|
||||
if (showExecTooltip && hasExec) {
|
||||
if (leftClickExec !== "") {
|
||||
lines.push(I18n.tr("bar.custom-button.left-click-label") + `: ${leftClickExec}`);
|
||||
} else if (!leftClickUpdateText) {
|
||||
lines.push(I18n.tr("bar.custom-button.left-click-label") + ": " + I18n.tr("actions.widget-settings"));
|
||||
}
|
||||
|
||||
if (rightClickExec !== "") {
|
||||
lines.push(I18n.tr("bar.custom-button.right-click-label") + `: ${rightClickExec}`);
|
||||
} else if (!rightClickUpdateText) {
|
||||
lines.push(I18n.tr("bar.custom-button.right-click-label") + ": " + I18n.tr("actions.widget-settings"));
|
||||
}
|
||||
|
||||
if (middleClickExec !== "") {
|
||||
lines.push(I18n.tr("bar.custom-button.middle-click-label") + `: ${middleClickExec}`);
|
||||
} else if (!middleClickUpdateText) {
|
||||
lines.push(I18n.tr("bar.custom-button.middle-click-label") + ": " + I18n.tr("actions.widget-settings"));
|
||||
}
|
||||
|
||||
if (wheelMode === "unified" && wheelExec !== "") {
|
||||
lines.push(I18n.tr("bar.custom-button.wheel-label") + `: ${wheelExec}`);
|
||||
} else if (wheelMode === "separate") {
|
||||
if (wheelUpExec !== "") {
|
||||
lines.push(I18n.tr("bar.custom-button.wheel-up") + `: ${wheelUpExec}`);
|
||||
}
|
||||
if (wheelDownExec !== "") {
|
||||
lines.push(I18n.tr("bar.custom-button.wheel-down") + `Wheel down: ${wheelDownExec}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add dynamic text tooltip if enabled and available
|
||||
if (showTextTooltip && _dynamicTooltip !== "") {
|
||||
lines.push(_dynamicTooltip);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
tooltipText: {
|
||||
var lines = _buildTooltipContent();
|
||||
|
||||
// If no custom tooltip and both switches are off, show default tooltip
|
||||
if (!_hasCustomTooltip && !showExecTooltip && !showTextTooltip) {
|
||||
return I18n.tr("bar.custom-button.default-tooltip");
|
||||
}
|
||||
|
||||
// If there's content, join with newlines
|
||||
if (lines.length > 0) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Fallback (shouldn't reach here normally)
|
||||
return I18n.tr("bar.custom-button.default-tooltip");
|
||||
}
|
||||
|
||||
onClicked: root.clicked()
|
||||
onRightClicked: root.rightClicked()
|
||||
onMiddleClicked: root.middleClicked()
|
||||
onWheel: delta => root.wheeled(delta)
|
||||
}
|
||||
|
||||
// Internal state for dynamic text
|
||||
property string _dynamicText: ""
|
||||
property string _dynamicIcon: ""
|
||||
property string _dynamicTooltip: ""
|
||||
property string _dynamicColor: ""
|
||||
|
||||
// Maximum length for text display before scrolling (different values for horizontal and vertical)
|
||||
readonly property var maxTextLength: {
|
||||
"horizontal": ((widgetSettings && widgetSettings.maxTextLength && widgetSettings.maxTextLength.horizontal !== undefined) ? widgetSettings.maxTextLength.horizontal : ((widgetMetadata && widgetMetadata.maxTextLength && widgetMetadata.maxTextLength.horizontal !== undefined) ? widgetMetadata.maxTextLength.horizontal : 10)),
|
||||
"vertical": ((widgetSettings && widgetSettings.maxTextLength && widgetSettings.maxTextLength.vertical !== undefined) ? widgetSettings.maxTextLength.vertical : ((widgetMetadata && widgetMetadata.maxTextLength && widgetMetadata.maxTextLength.vertical !== undefined) ? widgetMetadata.maxTextLength.vertical : 10))
|
||||
}
|
||||
readonly property int _staticDuration: 6 // How many cycles to stay static at start/end
|
||||
|
||||
// Encapsulated state for scrolling text implementation
|
||||
property var _scrollState: {
|
||||
"originalText": "",
|
||||
"needsScrolling": false,
|
||||
"offset": 0,
|
||||
"phase": 0 // 0=static start, 1=scrolling, 2=static end
|
||||
,
|
||||
"phaseCounter": 0
|
||||
}
|
||||
|
||||
// Current max text length based on bar orientation
|
||||
readonly property int currentMaxTextLength: isVerticalBar ? maxTextLength.vertical : maxTextLength.horizontal
|
||||
|
||||
// Periodically run the text command (if set)
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: Math.max(250, textIntervalMs)
|
||||
repeat: true
|
||||
running: (!isVerticalBar || currentMaxTextLength > 0) && !textStream && textCommand && textCommand.length > 0
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.runTextCommand()
|
||||
}
|
||||
|
||||
// Restart exited text stream commands after a delay
|
||||
Timer {
|
||||
id: restartTimer
|
||||
interval: 1000
|
||||
running: (!isVerticalBar || currentMaxTextLength > 0) && textStream && !textProc.running
|
||||
onTriggered: root.runTextCommand()
|
||||
}
|
||||
|
||||
// Timer for scrolling text display
|
||||
Timer {
|
||||
id: scrollTimer
|
||||
interval: 300
|
||||
repeat: true
|
||||
running: false
|
||||
onTriggered: {
|
||||
if (_scrollState.needsScrolling && _scrollState.originalText.length > currentMaxTextLength) {
|
||||
// Traditional marquee with pause at beginning and end
|
||||
if (_scrollState.phase === 0) {
|
||||
// Static at beginning
|
||||
_dynamicText = _scrollState.originalText.substring(0, Math.min(currentMaxTextLength, _scrollState.originalText.length));
|
||||
_scrollState.phaseCounter++;
|
||||
if (_scrollState.phaseCounter >= _staticDuration) {
|
||||
_scrollState.phaseCounter = 0;
|
||||
_scrollState.phase = 1; // Move to scrolling
|
||||
}
|
||||
} else if (_scrollState.phase === 1) {
|
||||
// Scrolling
|
||||
_scrollState.offset++;
|
||||
var start = _scrollState.offset;
|
||||
var end = start + currentMaxTextLength;
|
||||
|
||||
if (start >= _scrollState.originalText.length - currentMaxTextLength) {
|
||||
// Reached or passed the end, ensure we show the last part
|
||||
var textEnd = _scrollState.originalText.length;
|
||||
var textStart = Math.max(0, textEnd - currentMaxTextLength);
|
||||
_dynamicText = _scrollState.originalText.substring(textStart, textEnd);
|
||||
_scrollState.phase = 2; // Move to static end phase
|
||||
_scrollState.phaseCounter = 0;
|
||||
} else {
|
||||
_dynamicText = _scrollState.originalText.substring(start, end);
|
||||
}
|
||||
} else if (_scrollState.phase === 2) {
|
||||
// Static at end
|
||||
// Ensure end text is displayed correctly
|
||||
var textEnd = _scrollState.originalText.length;
|
||||
var textStart = Math.max(0, textEnd - currentMaxTextLength);
|
||||
_dynamicText = _scrollState.originalText.substring(textStart, textEnd);
|
||||
_scrollState.phaseCounter++;
|
||||
if (_scrollState.phaseCounter >= _staticDuration) {
|
||||
// Do NOT loop back to start, just stop scrolling
|
||||
scrollTimer.stop();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scrollTimer.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SplitParser {
|
||||
id: textStdoutSplit
|
||||
onRead: line => root.parseDynamicContent(line)
|
||||
}
|
||||
|
||||
StdioCollector {
|
||||
id: textStdoutCollect
|
||||
onStreamFinished: () => root.parseDynamicContent(this.text)
|
||||
}
|
||||
|
||||
Process {
|
||||
id: textProc
|
||||
stdout: textStream ? textStdoutSplit : textStdoutCollect
|
||||
stderr: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (textStream) {
|
||||
Logger.w("CustomButton", `Streaming text command exited (code: ${exitCode}), restarting...`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseDynamicContent(content) {
|
||||
var contentStr = String(content || "").trim();
|
||||
|
||||
if (parseJson && contentStr) {
|
||||
// Handle multi-line JSON by filtering empty lines and joining
|
||||
var jsonStr = contentStr;
|
||||
if (contentStr.includes('\n')) {
|
||||
const lines = contentStr.split('\n').filter(line => line.trim() !== '');
|
||||
jsonStr = lines.join('');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const text = parsed.text || "";
|
||||
const icon = parsed.icon || "";
|
||||
let tooltip = parsed.tooltip || "";
|
||||
const color = parsed.color || "";
|
||||
|
||||
// Validate color value
|
||||
const validColors = ["primary", "secondary", "tertiary", "error", "none"];
|
||||
const validColor = (color && validColors.includes(color)) ? color : "";
|
||||
|
||||
if (checkCollapse(text)) {
|
||||
_scrollState.originalText = "";
|
||||
_dynamicText = "";
|
||||
_dynamicIcon = "";
|
||||
_dynamicTooltip = "";
|
||||
_dynamicColor = "";
|
||||
_scrollState.needsScrolling = false;
|
||||
_scrollState.phase = 0;
|
||||
_scrollState.phaseCounter = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollState.originalText = text;
|
||||
_scrollState.needsScrolling = text.length > currentMaxTextLength && currentMaxTextLength > 0;
|
||||
if (_scrollState.needsScrolling) {
|
||||
_dynamicText = text.substring(0, currentMaxTextLength);
|
||||
_scrollState.phase = 0;
|
||||
_scrollState.phaseCounter = 0;
|
||||
_scrollState.offset = 0;
|
||||
scrollTimer.start();
|
||||
} else {
|
||||
_dynamicText = text;
|
||||
scrollTimer.stop();
|
||||
}
|
||||
_dynamicIcon = icon;
|
||||
_dynamicColor = validColor;
|
||||
|
||||
_dynamicTooltip = toHtml(tooltip);
|
||||
_scrollState.offset = 0;
|
||||
return;
|
||||
} catch (e) {
|
||||
Logger.w("CustomButton", `Failed to parse JSON. Content: "${contentStr}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (checkCollapse(contentStr)) {
|
||||
_scrollState.originalText = "";
|
||||
_dynamicText = "";
|
||||
_dynamicIcon = "";
|
||||
_dynamicTooltip = "";
|
||||
_dynamicColor = "";
|
||||
_scrollState.needsScrolling = false;
|
||||
_scrollState.phase = 0;
|
||||
_scrollState.phaseCounter = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollState.originalText = contentStr;
|
||||
_scrollState.needsScrolling = contentStr.length > currentMaxTextLength && currentMaxTextLength > 0;
|
||||
if (_scrollState.needsScrolling) {
|
||||
_dynamicText = contentStr.substring(0, currentMaxTextLength);
|
||||
_scrollState.phase = 0;
|
||||
_scrollState.phaseCounter = 0;
|
||||
_scrollState.offset = 0;
|
||||
scrollTimer.start();
|
||||
} else {
|
||||
_dynamicText = contentStr;
|
||||
scrollTimer.stop();
|
||||
}
|
||||
_dynamicIcon = "";
|
||||
_dynamicColor = "";
|
||||
_dynamicTooltip = toHtml(contentStr);
|
||||
_scrollState.offset = 0;
|
||||
}
|
||||
|
||||
function checkCollapse(text) {
|
||||
if (!textCollapse || textCollapse.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (textCollapse.startsWith("/") && textCollapse.endsWith("/") && textCollapse.length > 1) {
|
||||
// Treat as regex
|
||||
var pattern = textCollapse.substring(1, textCollapse.length - 1);
|
||||
try {
|
||||
var regex = new RegExp(pattern);
|
||||
return regex.test(text);
|
||||
} catch (e) {
|
||||
Logger.w("CustomButton", `Invalid regex for textCollapse: ${textCollapse} - ${e.message}`);
|
||||
return (textCollapse === text); // Fallback to exact match on invalid regex
|
||||
}
|
||||
} else {
|
||||
// Treat as plain string
|
||||
return (textCollapse === text);
|
||||
}
|
||||
}
|
||||
|
||||
function clicked() {
|
||||
if (leftClickExec) {
|
||||
Quickshell.execDetached(["sh", "-lc", leftClickExec]);
|
||||
Logger.i("CustomButton", `Executing command: ${leftClickExec}`);
|
||||
} else if (!leftClickUpdateText) {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
if (!textStream && leftClickUpdateText) {
|
||||
runTextCommand();
|
||||
}
|
||||
}
|
||||
|
||||
function rightClicked() {
|
||||
if (rightClickExec) {
|
||||
Quickshell.execDetached(["sh", "-lc", rightClickExec]);
|
||||
Logger.i("CustomButton", `Executing command: ${rightClickExec}`);
|
||||
} else if (!rightClickUpdateText) {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
if (!textStream && rightClickUpdateText) {
|
||||
runTextCommand();
|
||||
}
|
||||
}
|
||||
|
||||
function middleClicked() {
|
||||
if (middleClickExec) {
|
||||
Quickshell.execDetached(["sh", "-lc", middleClickExec]);
|
||||
Logger.i("CustomButton", `Executing command: ${middleClickExec}`);
|
||||
} else if (!middleClickUpdateText) {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
if (!textStream && middleClickUpdateText) {
|
||||
runTextCommand();
|
||||
}
|
||||
}
|
||||
|
||||
function toHtml(str) {
|
||||
const htmlTagRegex = /<\/?[a-zA-Z][^>]*>/g;
|
||||
const placeholders = [];
|
||||
let i = 0;
|
||||
const protectedStr = str.replace(htmlTagRegex, tag => {
|
||||
placeholders.push(tag);
|
||||
return `___HTML_TAG_${i++}___`;
|
||||
});
|
||||
|
||||
let escaped = protectedStr.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'").replace(/\r\n|\r|\n/g, "<br/>");
|
||||
|
||||
escaped = escaped.replace(/___HTML_TAG_(\d+)___/g, (_, index) => placeholders[Number(index)]);
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
function runTextCommand() {
|
||||
if (!textCommand || textCommand.length === 0)
|
||||
return;
|
||||
if (textProc.running)
|
||||
return;
|
||||
textProc.command = ["sh", "-lc", textCommand];
|
||||
textProc.running = true;
|
||||
}
|
||||
|
||||
function wheeled(delta) {
|
||||
if (wheelMode === "unified" && wheelExec) {
|
||||
let normalizedDelta = delta > 0 ? 1 : -1;
|
||||
|
||||
let command = wheelExec.replace(/\$delta([+\-*/]\d+)?/g, function (match, operation) {
|
||||
if (operation) {
|
||||
try {
|
||||
let operator = operation.charAt(0);
|
||||
let operand = parseInt(operation.substring(1));
|
||||
|
||||
let result;
|
||||
switch (operator) {
|
||||
case '+':
|
||||
result = normalizedDelta + operand;
|
||||
break;
|
||||
case '-':
|
||||
result = normalizedDelta - operand;
|
||||
break;
|
||||
case '*':
|
||||
result = normalizedDelta * operand;
|
||||
break;
|
||||
case '/':
|
||||
result = Math.floor(normalizedDelta / operand);
|
||||
break;
|
||||
default:
|
||||
result = normalizedDelta;
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
} catch (e) {
|
||||
Logger.w("CustomButton", `Error evaluating expression: ${match}, using normalized value ${normalizedDelta}`);
|
||||
return normalizedDelta.toString();
|
||||
}
|
||||
} else {
|
||||
return normalizedDelta.toString();
|
||||
}
|
||||
});
|
||||
|
||||
Quickshell.execDetached(["sh", "-lc", command]);
|
||||
Logger.i("CustomButton", `Executing command: ${command}`);
|
||||
} else if (wheelMode === "separate") {
|
||||
if ((delta > 0 && wheelUpExec) || (delta < 0 && wheelDownExec)) {
|
||||
let commandExec = delta > 0 ? wheelUpExec : wheelDownExec;
|
||||
let normalizedDelta = delta > 0 ? 1 : -1;
|
||||
|
||||
let command = commandExec.replace(/\$delta([+\-*/]\d+)?/g, function (match, operation) {
|
||||
if (operation) {
|
||||
try {
|
||||
let operator = operation.charAt(0);
|
||||
let operand = parseInt(operation.substring(1));
|
||||
|
||||
let result;
|
||||
switch (operator) {
|
||||
case '+':
|
||||
result = normalizedDelta + operand;
|
||||
break;
|
||||
case '-':
|
||||
result = normalizedDelta - operand;
|
||||
break;
|
||||
case '*':
|
||||
result = normalizedDelta * operand;
|
||||
break;
|
||||
case '/':
|
||||
result = Math.floor(normalizedDelta / operand);
|
||||
break;
|
||||
default:
|
||||
result = normalizedDelta;
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
} catch (e) {
|
||||
Logger.w("CustomButton", `Error evaluating expression: ${match}, using normalized value ${normalizedDelta}`);
|
||||
return normalizedDelta.toString();
|
||||
}
|
||||
} else {
|
||||
return normalizedDelta.toString();
|
||||
}
|
||||
});
|
||||
|
||||
Quickshell.execDetached(["sh", "-lc", command]);
|
||||
Logger.i("CustomButton", `Executing command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!textStream) {
|
||||
if (wheelMode === "unified" && wheelUpdateText) {
|
||||
runTextCommand();
|
||||
} else if (wheelMode === "separate") {
|
||||
if ((delta > 0 && wheelUpUpdateText) || (delta < 0 && wheelDownUpdateText)) {
|
||||
runTextCommand();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register IPC button in onLoaded (called by BarWidgetLoader after properties are set).
|
||||
// Component.onCompleted is too early — widgetSettings depends on section/index/screen
|
||||
// which are set by BarWidgetLoader.onLoaded after the component is created.
|
||||
function onLoaded() {
|
||||
if (ipcIdentifier && ipcIdentifier.trim() !== "")
|
||||
CustomButtonIPCService.registerButton(root);
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (ipcIdentifier && ipcIdentifier.trim() !== "")
|
||||
CustomButtonIPCService.unregisterButton(root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
|
||||
icon: "dark-mode"
|
||||
tooltipText: Settings.data.colorSchemes.darkMode ? I18n.tr("tooltips.switch-to-light-mode") : I18n.tr("tooltips.switch-to-dark-mode")
|
||||
tooltipDirection: BarService.getTooltipDirection(screen?.name)
|
||||
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
colorBg: Style.capsuleColor
|
||||
colorFg: Color.resolveColorKey(iconColorKey)
|
||||
onClicked: Settings.data.colorSchemes.darkMode = !Settings.data.colorSchemes.darkMode
|
||||
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
|
||||
screen: root.screen
|
||||
text: IdleInhibitorService.timeout == null ? "" : Time.formatVagueHumanReadableDuration(IdleInhibitorService.timeout)
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
|
||||
customTextColor: Color.resolveColorKeyOptional(root.textColorKey)
|
||||
icon: IdleInhibitorService.isInhibited ? "keep-awake-on" : "keep-awake-off"
|
||||
tooltipText: IdleInhibitorService.isInhibited ? I18n.tr("tooltips.keep-awake") : I18n.tr("tooltips.keep-awake")
|
||||
onClicked: IdleInhibitorService.manualToggle()
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, pill, screen);
|
||||
}
|
||||
forceOpen: IdleInhibitorService.timeout !== null
|
||||
forceClose: IdleInhibitorService.timeout == null
|
||||
onWheel: function (delta) {
|
||||
var sign = delta > 0 ? 1 : -1;
|
||||
// the offset makes scrolling down feel symmetrical to scrolling up
|
||||
var timeout = IdleInhibitorService.timeout - (delta < 0 ? 60 : 0);
|
||||
if (timeout == null || timeout < 600) {
|
||||
delta = 60; // <= 10m, increment at 1m interval
|
||||
} else if (timeout >= 600 && timeout < 1800) {
|
||||
delta = 300; // >= 10m, increment at 5m interval
|
||||
} else if (timeout >= 1800 && timeout < 3600) {
|
||||
delta = 600; // >= 30m, increment at 10m interval
|
||||
} else if (timeout >= 3600) {
|
||||
delta = 1800; // > 1h, increment at 30m interval
|
||||
}
|
||||
|
||||
IdleInhibitorService.changeTimeout(delta * sign);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Keyboard
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
|
||||
readonly property string displayMode: (widgetSettings.displayMode !== undefined) ? widgetSettings.displayMode : widgetMetadata.displayMode
|
||||
readonly property bool showIcon: (widgetSettings.showIcon !== undefined) ? widgetSettings.showIcon : widgetMetadata.showIcon
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
|
||||
// Use the shared service for keyboard layout
|
||||
property string currentLayout: KeyboardLayoutService.currentLayout
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
|
||||
customTextColor: Color.resolveColorKeyOptional(root.textColorKey)
|
||||
icon: root.showIcon ? "keyboard" : ""
|
||||
autoHide: false // Important to be false so we can hover as long as we want
|
||||
text: isBarVertical ? currentLayout.substring(0, 3).toUpperCase() : currentLayout
|
||||
tooltipText: KeyboardLayoutService.fullLayoutName
|
||||
// When icon is disabled, always show the layout text
|
||||
forceOpen: !root.showIcon || root.displayMode === "forceOpen"
|
||||
forceClose: root.showIcon && root.displayMode === "alwaysHide"
|
||||
onClicked: CompositorService.cycleKeyboardLayout()
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, pill, screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string customIcon: widgetSettings.icon || (widgetMetadata ? widgetMetadata.icon : "rocket")
|
||||
readonly property bool useDistroLogo: widgetSettings.useDistroLogo !== undefined ? widgetSettings.useDistroLogo : widgetMetadata.useDistroLogo
|
||||
readonly property string customIconPath: widgetSettings.customIconPath !== undefined ? widgetSettings.customIconPath : widgetMetadata.customIconPath
|
||||
readonly property bool enableColorization: widgetSettings.enableColorization !== undefined ? widgetSettings.enableColorization : widgetMetadata.enableColorization
|
||||
readonly property string colorizeSystemIcon: widgetSettings.colorizeSystemIcon !== undefined ? widgetSettings.colorizeSystemIcon : widgetMetadata.colorizeSystemIcon
|
||||
|
||||
readonly property color iconColor: {
|
||||
if (!enableColorization)
|
||||
return Color.mOnSurface;
|
||||
return Color.resolveColorKey(colorizeSystemIcon);
|
||||
}
|
||||
|
||||
// If we have a custom path or are using distro logo, don't show the theme icon.
|
||||
icon: (customIconPath === "" && !useDistroLogo) ? customIcon : ""
|
||||
tooltipText: I18n.tr("actions.open-launcher")
|
||||
tooltipDirection: BarService.getTooltipDirection(screenName)
|
||||
baseSize: Style.getCapsuleHeightForScreen(screenName)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
colorBg: Style.capsuleColor
|
||||
colorFg: iconColor
|
||||
colorBgHover: Color.mHover
|
||||
colorFgHover: Color.mOnHover
|
||||
colorBorder: Style.capsuleBorderColor
|
||||
colorBorderHover: Style.capsuleBorderColor
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.launcher-settings"),
|
||||
"action": "launcher-settings",
|
||||
"icon": "adjustments"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
}
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "launcher-settings") {
|
||||
var panel = PanelService.getPanel("settingsPanel", screen);
|
||||
panel.requestedTab = SettingsPanel.Tab.Launcher;
|
||||
panel.toggle();
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: PanelService.toggleLauncher(screen)
|
||||
onMiddleClicked: PanelService.toggleLauncher(screen)
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: customOrDistroLogo
|
||||
anchors.centerIn: parent
|
||||
width: root.buttonSize * 0.8
|
||||
height: width
|
||||
source: {
|
||||
if (useDistroLogo)
|
||||
return HostService.osLogo;
|
||||
if (customIconPath !== "")
|
||||
return customIconPath.startsWith("file://") ? customIconPath : "file://" + customIconPath;
|
||||
return "";
|
||||
}
|
||||
visible: source !== ""
|
||||
smooth: true
|
||||
asynchronous: true
|
||||
layer.enabled: (enableColorization) && (useDistroLogo || customIconPath !== "")
|
||||
layer.effect: ShaderEffect {
|
||||
property color targetColor: !hovering ? iconColor : Color.mOnHover
|
||||
property real colorizeMode: 2.0
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Keyboard
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
//test
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
// Settings
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
|
||||
// Content dimensions for implicit sizing
|
||||
readonly property real contentWidth: isVertical ? capsuleHeight : Math.round(layout.implicitWidth + Style.margin2M)
|
||||
readonly property real contentHeight: isVertical ? Math.round(layout.implicitHeight + Style.margin2M) : capsuleHeight
|
||||
|
||||
readonly property bool hideWhenOff: (widgetSettings.hideWhenOff !== undefined) ? widgetSettings.hideWhenOff : (widgetMetadata.hideWhenOff !== undefined ? widgetMetadata.hideWhenOff : false)
|
||||
|
||||
readonly property string capsIcon: widgetSettings.capsLockIcon !== undefined ? widgetSettings.capsLockIcon : widgetMetadata.capsLockIcon
|
||||
readonly property string numIcon: widgetSettings.numLockIcon !== undefined ? widgetSettings.numLockIcon : widgetMetadata.numLockIcon
|
||||
readonly property string scrollIcon: widgetSettings.scrollLockIcon !== undefined ? widgetSettings.scrollLockIcon : widgetMetadata.scrollLockIcon
|
||||
readonly property bool showCaps: (widgetSettings.showCapsLock !== undefined) ? widgetSettings.showCapsLock : widgetMetadata.showCapsLock
|
||||
readonly property bool showNum: (widgetSettings.showNumLock !== undefined) ? widgetSettings.showNumLock : widgetMetadata.showNumLock
|
||||
readonly property bool showScroll: (widgetSettings.showScrollLock !== undefined) ? widgetSettings.showScrollLock : widgetMetadata.showScrollLock
|
||||
|
||||
visible: !root.hideWhenOff || (root.showCaps && LockKeysService.capsLockOn) || (root.showNum && LockKeysService.numLockOn) || (root.showScroll && LockKeysService.scrollLockOn)
|
||||
|
||||
Component.onCompleted: LockKeysService.registerComponent("bar-lockkeys:" + (screen?.name || "unknown"))
|
||||
Component.onDestruction: LockKeysService.unregisterComponent("bar-lockkeys:" + (screen?.name || "unknown"))
|
||||
|
||||
implicitHeight: contentHeight
|
||||
implicitWidth: contentWidth
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Visual capsule centered in parent
|
||||
Rectangle {
|
||||
id: visualCapsule
|
||||
anchors.centerIn: parent
|
||||
color: Style.capsuleColor
|
||||
width: root.contentWidth
|
||||
height: root.contentHeight
|
||||
radius: Style.radiusM
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
Item {
|
||||
id: layout
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitHeight: rowLayout.visible ? rowLayout.implicitHeight : colLayout.implicitHeight
|
||||
implicitWidth: rowLayout.visible ? rowLayout.implicitWidth : colLayout.implicitWidth
|
||||
|
||||
RowLayout {
|
||||
id: rowLayout
|
||||
|
||||
spacing: 0
|
||||
visible: !root.isVertical
|
||||
|
||||
NIcon {
|
||||
color: LockKeysService.capsLockOn ? Color.mTertiary : Qt.alpha(Color.mOnSurfaceVariant, 0.3)
|
||||
icon: root.capsIcon
|
||||
visible: root.showCaps && (!root.hideWhenOff || LockKeysService.capsLockOn)
|
||||
}
|
||||
|
||||
NIcon {
|
||||
color: LockKeysService.numLockOn ? Color.mTertiary : Qt.alpha(Color.mOnSurfaceVariant, 0.3)
|
||||
icon: root.numIcon
|
||||
visible: root.showNum && (!root.hideWhenOff || LockKeysService.numLockOn)
|
||||
}
|
||||
|
||||
NIcon {
|
||||
color: LockKeysService.scrollLockOn ? Color.mTertiary : Qt.alpha(Color.mOnSurfaceVariant, 0.3)
|
||||
icon: root.scrollIcon
|
||||
visible: root.showScroll && (!root.hideWhenOff || LockKeysService.scrollLockOn)
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: colLayout
|
||||
|
||||
spacing: 0
|
||||
visible: root.isVertical
|
||||
|
||||
NIcon {
|
||||
color: LockKeysService.capsLockOn ? Color.mTertiary : Qt.alpha(Color.mOnSurfaceVariant, 0.3)
|
||||
icon: root.capsIcon
|
||||
visible: root.showCaps && (!root.hideWhenOff || LockKeysService.capsLockOn)
|
||||
}
|
||||
|
||||
NIcon {
|
||||
color: LockKeysService.numLockOn ? Color.mTertiary : Qt.alpha(Color.mOnSurfaceVariant, 0.3)
|
||||
icon: root.numIcon
|
||||
visible: root.showNum && (!root.hideWhenOff || LockKeysService.numLockOn)
|
||||
}
|
||||
|
||||
NIcon {
|
||||
color: LockKeysService.scrollLockOn ? Color.mTertiary : Qt.alpha(Color.mOnSurfaceVariant, 0.3)
|
||||
icon: root.scrollIcon
|
||||
visible: root.showScroll && (!root.hideWhenOff || LockKeysService.scrollLockOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MouseArea at root level for extended click area
|
||||
MouseArea {
|
||||
acceptedButtons: Qt.RightButton
|
||||
anchors.fill: parent
|
||||
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
import qs.Widgets.AudioSpectrum
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
// Settings
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Bar orientation (per-screen)
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
|
||||
|
||||
// Widget settings
|
||||
readonly property string hideMode: widgetSettings.hideMode !== undefined ? widgetSettings.hideMode : widgetMetadata.hideMode
|
||||
readonly property bool hideWhenIdle: widgetSettings.hideWhenIdle !== undefined ? widgetSettings.hideWhenIdle : widgetMetadata.hideWhenIdle
|
||||
readonly property bool showAlbumArt: widgetSettings.showAlbumArt !== undefined ? widgetSettings.showAlbumArt : widgetMetadata.showAlbumArt
|
||||
readonly property bool showArtistFirst: widgetSettings.showArtistFirst !== undefined ? widgetSettings.showArtistFirst : widgetMetadata.showArtistFirst
|
||||
readonly property bool showVisualizer: widgetSettings.showVisualizer !== undefined ? widgetSettings.showVisualizer : widgetMetadata.showVisualizer
|
||||
readonly property string visualizerType: widgetSettings.visualizerType !== undefined ? widgetSettings.visualizerType : widgetMetadata.visualizerType
|
||||
readonly property string scrollingMode: widgetSettings.scrollingMode !== undefined ? widgetSettings.scrollingMode : widgetMetadata.scrollingMode
|
||||
readonly property bool showProgressRing: widgetSettings.showProgressRing !== undefined ? widgetSettings.showProgressRing : widgetMetadata.showProgressRing
|
||||
readonly property bool useFixedWidth: widgetSettings.useFixedWidth !== undefined ? widgetSettings.useFixedWidth : widgetMetadata.useFixedWidth
|
||||
readonly property real maxWidth: widgetSettings.maxWidth !== undefined ? widgetSettings.maxWidth : Math.max(widgetMetadata.maxWidth, screen ? screen.width * 0.06 : 0)
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
readonly property color textColor: Color.resolveColorKey(textColorKey)
|
||||
|
||||
// Dimensions
|
||||
readonly property int artSize: Style.toOdd(capsuleHeight * 0.75)
|
||||
readonly property int iconSize: Style.toOdd(capsuleHeight * 0.75)
|
||||
readonly property int verticalSize: Style.toOdd(capsuleHeight * 0.85)
|
||||
readonly property int progressWidth: 2
|
||||
|
||||
// State
|
||||
readonly property bool hasPlayer: MediaService.currentPlayer !== null
|
||||
readonly property bool shouldHideIdle: (hideMode === "idle" || hideWhenIdle) && !MediaService.isPlaying
|
||||
readonly property bool shouldHideEmpty: !hasPlayer && hideMode === "hidden"
|
||||
readonly property bool isHidden: shouldHideIdle || shouldHideEmpty
|
||||
|
||||
// Title
|
||||
readonly property string title: {
|
||||
if (!hasPlayer)
|
||||
return I18n.tr("bar.media-mini.no-active-player");
|
||||
var artist = MediaService.trackArtist;
|
||||
var track = MediaService.trackTitle;
|
||||
return showArtistFirst ? (artist ? `${artist} - ${track}` : track) : (artist ? `${track} - ${artist}` : track);
|
||||
}
|
||||
|
||||
// SpectrumService registration for visualizer
|
||||
readonly property string spectrumComponentId: "bar:mediamini:" + root.screen?.name + ":" + root.section + ":" + root.sectionWidgetIndex
|
||||
readonly property bool needsSpectrum: root.showVisualizer && root.visualizerType !== "" && root.visualizerType !== "none" && !root.isHidden
|
||||
|
||||
Layout.preferredHeight: isVertical ? -1 : Style.getBarHeightForScreen(screenName)
|
||||
Layout.preferredWidth: isVertical ? Style.getBarHeightForScreen(screenName) : -1
|
||||
Layout.fillHeight: false
|
||||
Layout.fillWidth: false
|
||||
|
||||
onNeedsSpectrumChanged: {
|
||||
if (root.needsSpectrum) {
|
||||
SpectrumService.registerComponent(root.spectrumComponentId);
|
||||
} else {
|
||||
SpectrumService.unregisterComponent(root.spectrumComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.needsSpectrum) {
|
||||
SpectrumService.registerComponent(root.spectrumComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
SpectrumService.unregisterComponent(root.spectrumComponentId);
|
||||
}
|
||||
|
||||
// Layout
|
||||
// For horizontal bars, height is always capsuleHeight (no animation needed to prevent jitter)
|
||||
// For vertical bars, collapse to 0 when hidden
|
||||
implicitWidth: isVertical ? (isHidden ? 0 : verticalSize) : (isHidden ? 0 : contentWidth)
|
||||
implicitHeight: isVertical ? (isHidden ? 0 : verticalSize) : capsuleHeight
|
||||
visible: !shouldHideIdle && (hideMode !== "hidden" || opacity > 0)
|
||||
opacity: isHidden ? 0.0 : ((hideMode === "transparent" && !hasPlayer) ? 0.0 : 1.0)
|
||||
|
||||
property real mainContentWidth: 0
|
||||
readonly property real contentWidth: {
|
||||
if (useFixedWidth)
|
||||
return maxWidth;
|
||||
|
||||
// Calculate icon/art width (must match RowLayout visibility)
|
||||
var iconWidth = 0;
|
||||
if (!hasPlayer) {
|
||||
iconWidth = iconSize;
|
||||
} else if (showAlbumArt || showProgressRing) {
|
||||
iconWidth = artSize;
|
||||
}
|
||||
|
||||
var margins = isVertical ? 0 : Style.margin2S;
|
||||
|
||||
// Add spacing and text width
|
||||
var textWidth = 0;
|
||||
if (titleContainer.measuredWidth > 0) {
|
||||
if (iconWidth > 0)
|
||||
margins += Style.marginS;
|
||||
textWidth = titleContainer.measuredWidth + Style.margin2XXS;
|
||||
}
|
||||
|
||||
var total = iconWidth + textWidth + margins;
|
||||
|
||||
// calculate the width of all elements except the scrolling text
|
||||
mainContentWidth = total - textWidth;
|
||||
|
||||
return hasPlayer ? Math.min(total, maxWidth) : total;
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
model: {
|
||||
var items = [];
|
||||
if (hasPlayer && MediaService.canPlay) {
|
||||
items.push({
|
||||
"label": MediaService.isPlaying ? I18n.tr("common.pause") : I18n.tr("common.play"),
|
||||
"action": "play-pause",
|
||||
"icon": MediaService.isPlaying ? "media-pause" : "media-play"
|
||||
});
|
||||
}
|
||||
if (hasPlayer && MediaService.canGoPrevious) {
|
||||
items.push({
|
||||
"label": I18n.tr("common.previous"),
|
||||
"action": "previous",
|
||||
"icon": "media-prev"
|
||||
});
|
||||
}
|
||||
if (hasPlayer && MediaService.canGoNext) {
|
||||
items.push({
|
||||
"label": I18n.tr("common.next"),
|
||||
"action": "next",
|
||||
"icon": "media-next"
|
||||
});
|
||||
}
|
||||
|
||||
// Append available players (like in Control Center) so user can switch from the bar
|
||||
var players = MediaService.getAvailablePlayers ? MediaService.getAvailablePlayers() : [];
|
||||
if (players && players.length > 1) {
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var isCurrent = (i === MediaService.selectedPlayerIndex);
|
||||
items.push({
|
||||
"label": players[i].identity,
|
||||
"action": "player-" + i,
|
||||
"icon": isCurrent ? "check" : "disc",
|
||||
"enabled": true,
|
||||
"visible": true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "play-pause")
|
||||
MediaService.playPause();
|
||||
else if (action === "previous")
|
||||
MediaService.previous();
|
||||
else if (action === "next")
|
||||
MediaService.next();
|
||||
else if (action && action.indexOf("player-") === 0) {
|
||||
var idx = parseInt(action.split("-")[1]);
|
||||
if (!isNaN(idx)) {
|
||||
MediaService.switchToPlayer(idx);
|
||||
}
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main container - stays at content size, pixel-perfect centered in parent
|
||||
Rectangle {
|
||||
id: container
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
width: Style.toOdd(isVertical ? (isHidden ? 0 : verticalSize) : (isHidden ? 0 : contentWidth))
|
||||
height: Style.toOdd(isVertical ? (isHidden ? 0 : verticalSize) : capsuleHeight)
|
||||
radius: Style.radiusM
|
||||
color: Style.capsuleColor
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: isVertical ? 0 : Style.marginS
|
||||
anchors.rightMargin: isVertical ? 0 : Style.marginS
|
||||
|
||||
// Visualizer
|
||||
Loader {
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
width: Style.toOdd(parent.width)
|
||||
height: Style.toOdd(parent.height)
|
||||
active: showVisualizer
|
||||
z: 0
|
||||
sourceComponent: {
|
||||
if (!showVisualizer)
|
||||
return null;
|
||||
if (visualizerType === "linear")
|
||||
return linearSpectrum;
|
||||
if (visualizerType === "mirrored")
|
||||
return mirroredSpectrum;
|
||||
if (visualizerType === "wave")
|
||||
return waveSpectrum;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal layout
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.marginS
|
||||
visible: !isVertical
|
||||
z: 1
|
||||
|
||||
// Album art / Progress ring
|
||||
Item {
|
||||
visible: hasPlayer && (showAlbumArt || showProgressRing)
|
||||
Layout.preferredWidth: visible ? artSize : 0
|
||||
Layout.preferredHeight: visible ? artSize : 0
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
ProgressRing {
|
||||
id: progressRing
|
||||
anchors.fill: parent
|
||||
visible: showProgressRing
|
||||
progress: MediaService.trackLength > 0 ? MediaService.currentPosition / MediaService.trackLength : 0
|
||||
lineWidth: root.progressWidth
|
||||
}
|
||||
|
||||
NImageRounded {
|
||||
visible: showAlbumArt && hasPlayer
|
||||
anchors.fill: parent
|
||||
anchors.margins: showProgressRing ? root.progressWidth * 2 : 0
|
||||
radius: width / 2
|
||||
imagePath: MediaService.trackArtUrl
|
||||
borderWidth: 0
|
||||
imageFillMode: Image.PreserveAspectCrop
|
||||
}
|
||||
}
|
||||
|
||||
// Scrolling title
|
||||
NScrollText {
|
||||
id: titleContainer
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.preferredHeight: capsuleHeight
|
||||
fadeRoundLeftCorners: !(showAlbumArt || showProgressRing)
|
||||
|
||||
text: title
|
||||
|
||||
scrollMode: {
|
||||
if (scrollingMode === "always")
|
||||
return NScrollText.ScrollMode.Always;
|
||||
if (scrollingMode === "hover")
|
||||
return NScrollText.ScrollMode.Hover;
|
||||
return NScrollText.ScrollMode.Never;
|
||||
}
|
||||
cursorShape: hasPlayer ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
maxWidth: root.maxWidth - root.mainContentWidth
|
||||
forcedHover: mainMouseArea.containsMouse
|
||||
fadeExtent: 0.1
|
||||
fadeCornerRadius: Style.radiusM
|
||||
|
||||
NText {
|
||||
color: hasPlayer ? root.textColor : Color.mOnSurfaceVariant
|
||||
pointSize: barFontSize
|
||||
elide: Text.ElideNone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical layout
|
||||
Item {
|
||||
id: verticalLayout
|
||||
visible: isVertical
|
||||
width: Style.toOdd(verticalSize)
|
||||
height: Style.toOdd(width)
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
z: 1
|
||||
|
||||
ProgressRing {
|
||||
anchors.fill: parent
|
||||
visible: showProgressRing
|
||||
progress: MediaService.trackLength > 0 ? MediaService.currentPosition / MediaService.trackLength : 0
|
||||
lineWidth: root.progressWidth
|
||||
}
|
||||
|
||||
NImageRounded {
|
||||
visible: showAlbumArt && hasPlayer
|
||||
anchors.fill: parent
|
||||
anchors.margins: showProgressRing ? root.progressWidth * 2 : 0
|
||||
radius: width / 2
|
||||
imagePath: MediaService.trackArtUrl
|
||||
borderWidth: 0
|
||||
imageFillMode: Image.PreserveAspectCrop
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse interaction moved to root
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse interaction
|
||||
MouseArea {
|
||||
id: mainMouseArea
|
||||
anchors.fill: parent
|
||||
|
||||
// Extend click area to screen edge if widget is at the start/end
|
||||
anchors.leftMargin: (!isVertical && section === "left" && sectionWidgetIndex === 0) ? -Style.marginS : 0
|
||||
anchors.rightMargin: (!isVertical && section === "right" && sectionWidgetIndex === sectionWidgetsCount - 1) ? -Style.marginS : 0
|
||||
anchors.topMargin: (isVertical && section === "left" && sectionWidgetIndex === 0) ? -Style.marginM : 0
|
||||
anchors.bottomMargin: (isVertical && section === "right" && sectionWidgetIndex === sectionWidgetsCount - 1) ? -Style.marginM : 0
|
||||
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton | Qt.ForwardButton | Qt.BackButton
|
||||
|
||||
onClicked: mouse => {
|
||||
TooltipService.hide();
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
PanelService.getPanel("mediaPlayerPanel", screen)?.toggle(container);
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
PanelService.showContextMenu(contextMenu, container, screen);
|
||||
} else if (mouse.button === Qt.MiddleButton && hasPlayer) {
|
||||
MediaService.playPause();
|
||||
} else if (mouse.button === Qt.ForwardButton && hasPlayer) {
|
||||
MediaService.next();
|
||||
} else if (mouse.button === Qt.BackButton && hasPlayer) {
|
||||
MediaService.previous();
|
||||
}
|
||||
}
|
||||
|
||||
onEntered: {
|
||||
if (!root || !screen) {
|
||||
return;
|
||||
}
|
||||
var scrollMode = scrollingMode;
|
||||
if ((isVertical || scrollMode === "never")) {
|
||||
var panel = PanelService.getPanel("mediaPlayerPanel", screen);
|
||||
if (panel && !panel.isPanelOpen) {
|
||||
TooltipService.show(root, title, BarService.getTooltipDirection(root.screen?.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: TooltipService.hide()
|
||||
}
|
||||
|
||||
// Components
|
||||
Component {
|
||||
id: linearSpectrum
|
||||
NLinearSpectrum {
|
||||
width: parent.width - Style.marginS
|
||||
height: 20
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.4
|
||||
barPosition: root.barPosition
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: mirroredSpectrum
|
||||
NMirroredSpectrum {
|
||||
width: parent.width - Style.marginS
|
||||
height: parent.height - Style.marginS
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.4
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: waveSpectrum
|
||||
NWaveSpectrum {
|
||||
width: parent.width - Style.marginS
|
||||
height: parent.height - Style.marginS
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.4
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
// Progress Ring Component
|
||||
component ProgressRing: Canvas {
|
||||
property real progress: 0
|
||||
property real lineWidth: 2
|
||||
|
||||
function repaint() {
|
||||
if (this.visible && this.opacity > 0)
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
onProgressChanged: repaint()
|
||||
Component.onCompleted: repaint()
|
||||
|
||||
Connections {
|
||||
target: Color
|
||||
function onMPrimaryChanged() {
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
if (width <= 0 || height <= 0)
|
||||
return;
|
||||
|
||||
var ctx = getContext("2d");
|
||||
var centerX = width / 2;
|
||||
var centerY = height / 2;
|
||||
var radius = Math.min(width, height) / 2 - lineWidth;
|
||||
|
||||
ctx.reset();
|
||||
|
||||
// Background
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.4);
|
||||
ctx.stroke();
|
||||
|
||||
// Progress
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI);
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.strokeStyle = Color.mPrimary;
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property string displayMode: (widgetSettings.displayMode !== undefined) ? widgetSettings.displayMode : widgetMetadata.displayMode
|
||||
readonly property string middleClickCommand: (widgetSettings.middleClickCommand !== undefined) ? widgetSettings.middleClickCommand : widgetMetadata.middleClickCommand
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
|
||||
// Used to avoid opening the pill on Quickshell startup
|
||||
property bool firstInputVolumeReceived: false
|
||||
property int wheelAccumulator: 0
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
// Connection used to open the pill when input volume changes
|
||||
Connections {
|
||||
target: AudioService.source?.audio ? AudioService.source?.audio : null
|
||||
function onVolumeChanged() {
|
||||
// Logger.i("Bar:Microphone", "onInputVolumeChanged")
|
||||
if (!firstInputVolumeReceived) {
|
||||
// Ignore the first volume change
|
||||
firstInputVolumeReceived = true;
|
||||
} else {
|
||||
// If a tooltip is visible while we show the pill
|
||||
// hide it so it doesn't overlap the volume slider.
|
||||
TooltipService.hide();
|
||||
pill.show();
|
||||
externalHideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection used to open the pill when input mute state changes
|
||||
Connections {
|
||||
target: AudioService.source?.audio ? AudioService.source?.audio : null
|
||||
function onMutedChanged() {
|
||||
// Logger.i("Bar:Microphone", "onInputMutedChanged")
|
||||
if (!firstInputVolumeReceived) {
|
||||
// Ignore the first mute change
|
||||
firstInputVolumeReceived = true;
|
||||
} else {
|
||||
TooltipService.hide();
|
||||
pill.show();
|
||||
externalHideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: externalHideTimer
|
||||
running: false
|
||||
interval: 1500
|
||||
onTriggered: {
|
||||
pill.hide();
|
||||
}
|
||||
}
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.toggle-mute"),
|
||||
"action": "toggle-mute",
|
||||
"icon": AudioService.inputMuted ? "microphone-off" : "microphone"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.run-custom-command"),
|
||||
"action": "custom-command",
|
||||
"icon": "adjustments"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "toggle-mute") {
|
||||
AudioService.setInputMuted(!AudioService.inputMuted);
|
||||
} else if (action === "custom-command") {
|
||||
Quickshell.execDetached(["sh", "-c", middleClickCommand]);
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
|
||||
customTextColor: Color.resolveColorKeyOptional(root.textColorKey)
|
||||
icon: AudioService.getInputIcon()
|
||||
autoHide: false // Important to be false so we can hover as long as we want
|
||||
text: {
|
||||
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
|
||||
const displayVolume = Math.min(maxVolume, AudioService.inputVolume);
|
||||
return Math.round(displayVolume * 100);
|
||||
}
|
||||
suffix: "%"
|
||||
forceOpen: displayMode === "alwaysShow"
|
||||
forceClose: displayMode === "alwaysHide"
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("audioPanel", screen)?.isPanelOpen) {
|
||||
return "";
|
||||
} else {
|
||||
const nick = AudioService.source?.nickname ?? "";
|
||||
const volumeText = I18n.tr("tooltips.microphone-volume-at", {
|
||||
"volume": (() => {
|
||||
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
|
||||
const displayVolume = Math.min(maxVolume, AudioService.inputVolume);
|
||||
return Math.round(displayVolume * 100);
|
||||
})()
|
||||
});
|
||||
return nick ? volumeText + "\n" + nick : volumeText;
|
||||
}
|
||||
}
|
||||
|
||||
onWheel: function (delta) {
|
||||
// As soon as we start scrolling to adjust volume, hide the tooltip
|
||||
TooltipService.hide();
|
||||
|
||||
wheelAccumulator += delta;
|
||||
if (wheelAccumulator >= 120) {
|
||||
wheelAccumulator = 0;
|
||||
AudioService.setInputVolume(AudioService.inputVolume + AudioService.stepVolume);
|
||||
} else if (wheelAccumulator <= -120) {
|
||||
wheelAccumulator = 0;
|
||||
AudioService.setInputVolume(AudioService.inputVolume - AudioService.stepVolume);
|
||||
}
|
||||
}
|
||||
onClicked: {
|
||||
PanelService.getPanel("audioPanel", screen)?.toggle(this);
|
||||
}
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, pill, screen);
|
||||
}
|
||||
onMiddleClicked: {
|
||||
Quickshell.execDetached(["sh", "-c", middleClickCommand]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings // For SettingsPanel
|
||||
import qs.Services.Networking
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property string displayMode: widgetSettings.displayMode !== undefined ? widgetSettings.displayMode : widgetMetadata.displayMode
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": NetworkService.wifiEnabled ? I18n.tr("actions.disable-wifi") : I18n.tr("actions.enable-wifi"),
|
||||
"action": "toggle-wifi",
|
||||
"icon": NetworkService.wifiEnabled ? "wifi-off" : "wifi",
|
||||
"enabled": !NetworkService.airplaneModeEnabled && NetworkService.wifiAvailable
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("common.wifi") + " " + I18n.tr("tooltips.open-settings"),
|
||||
"action": "wifi-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "toggle-wifi") {
|
||||
NetworkService.setWifiEnabled(!NetworkService.wifiEnabled);
|
||||
} else if (action === "wifi-settings") {
|
||||
SettingsPanelService.openToTab(SettingsPanel.Tab.Connections, 0, screen);
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
|
||||
customTextColor: Color.resolveColorKeyOptional(root.textColorKey)
|
||||
icon: NetworkService.getIcon()
|
||||
text: NetworkService.getStatusText(false)
|
||||
autoHide: false
|
||||
forceOpen: !isBarVertical && root.displayMode === "alwaysShow"
|
||||
forceClose: isBarVertical || root.displayMode === "alwaysHide" || text === ""
|
||||
onClicked: {
|
||||
var panel = PanelService.getPanel("networkPanel", screen);
|
||||
panel?.toggle(this);
|
||||
}
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, pill, screen);
|
||||
}
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("networkPanel", screen)?.isPanelOpen) {
|
||||
return "";
|
||||
}
|
||||
return NetworkService.getStatusText(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
|
||||
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
colorBg: Settings.data.nightLight.enabled ? Color.mPrimary : Style.capsuleColor
|
||||
colorFg: Settings.data.nightLight.enabled ? Color.mOnPrimary : Color.resolveColorKey(iconColorKey)
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
icon: Settings.data.nightLight.enabled ? (Settings.data.nightLight.forced ? "nightlight-forced" : "nightlight-on") : "nightlight-off"
|
||||
tooltipText: Settings.data.nightLight.enabled ? (Settings.data.nightLight.forced ? I18n.tr("common.night-light") : I18n.tr("common.night-light")) : I18n.tr("common.night-light")
|
||||
tooltipDirection: BarService.getTooltipDirection(screen?.name)
|
||||
onClicked: {
|
||||
// Check if wlsunset is available before enabling night light
|
||||
if (!ProgramCheckerService.wlsunsetAvailable) {
|
||||
ToastService.showWarning(I18n.tr("common.night-light"), I18n.tr("toast.night-light.not-installed"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Settings.data.nightLight.enabled) {
|
||||
Settings.data.nightLight.enabled = true;
|
||||
Settings.data.nightLight.forced = false;
|
||||
} else if (Settings.data.nightLight.enabled && !Settings.data.nightLight.forced) {
|
||||
Settings.data.nightLight.forced = true;
|
||||
} else {
|
||||
Settings.data.nightLight.enabled = false;
|
||||
Settings.data.nightLight.forced = false;
|
||||
}
|
||||
}
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
|
||||
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
colorBg: PowerProfileService.noctaliaPerformanceMode ? Color.mPrimary : Style.capsuleColor
|
||||
colorFg: PowerProfileService.noctaliaPerformanceMode ? Color.mOnPrimary : Color.resolveColorKey(iconColorKey)
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
icon: PowerProfileService.noctaliaPerformanceMode ? "rocket" : "rocket-off"
|
||||
tooltipText: PowerProfileService.noctaliaPerformanceMode ? I18n.tr("tooltips.noctalia-performance-enabled") : I18n.tr("tooltips.noctalia-performance-enabled")
|
||||
tooltipDirection: BarService.getTooltipDirection(screen?.name)
|
||||
onClicked: PowerProfileService.toggleNoctaliaPerformance()
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
readonly property bool showUnreadBadge: widgetSettings.showUnreadBadge !== undefined ? widgetSettings.showUnreadBadge : widgetMetadata.showUnreadBadge
|
||||
readonly property bool hideWhenZero: widgetSettings.hideWhenZero !== undefined ? widgetSettings.hideWhenZero : widgetMetadata.hideWhenZero
|
||||
readonly property bool hideWhenZeroUnread: widgetSettings.hideWhenZeroUnread !== undefined ? widgetSettings.hideWhenZeroUnread : widgetMetadata.hideWhenZeroUnread
|
||||
readonly property string unreadBadgeColor: widgetSettings.unreadBadgeColor !== undefined ? widgetSettings.unreadBadgeColor : widgetMetadata.unreadBadgeColor
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
|
||||
readonly property color badgeColor: Color.resolveColorKey(unreadBadgeColor)
|
||||
|
||||
function computeUnreadCount() {
|
||||
var since = NotificationService.lastSeenTs;
|
||||
var count = 0;
|
||||
var model = NotificationService.historyModel;
|
||||
for (var i = 0; i < model.count; i++) {
|
||||
var item = model.get(i);
|
||||
var ts = item.timestamp instanceof Date ? item.timestamp.getTime() : item.timestamp;
|
||||
if (ts > since)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
readonly property int count: computeUnreadCount()
|
||||
|
||||
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
icon: NotificationService.doNotDisturb ? "bell-off" : "bell"
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("notificationHistoryPanel", screen)?.isPanelOpen) {
|
||||
return "";
|
||||
} else {
|
||||
return I18n.tr("tooltips.open-notification-history-enable-dnd");
|
||||
}
|
||||
}
|
||||
tooltipDirection: BarService.getTooltipDirection(screen?.name)
|
||||
colorBg: Style.capsuleColor
|
||||
colorFg: Color.resolveColorKey(iconColorKey)
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
visible: !((hideWhenZero && NotificationService.historyModel.count === 0) || (hideWhenZeroUnread && count === 0))
|
||||
opacity: !((hideWhenZero && NotificationService.historyModel.count === 0) || (hideWhenZeroUnread && count === 0)) ? 1.0 : 0.0
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": NotificationService.doNotDisturb ? I18n.tr("actions.disable-dnd") : I18n.tr("actions.enable-dnd"),
|
||||
"action": "toggle-dnd",
|
||||
"icon": NotificationService.doNotDisturb ? "bell" : "bell-off"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.clear-history"),
|
||||
"action": "clear-history",
|
||||
"icon": "trash"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "toggle-dnd") {
|
||||
NotificationService.doNotDisturb = !NotificationService.doNotDisturb;
|
||||
} else if (action === "clear-history") {
|
||||
NotificationService.clearHistory();
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
var panel = PanelService.getPanel("notificationHistoryPanel", screen);
|
||||
panel?.toggle(this);
|
||||
}
|
||||
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.horizontalCenterOffset: parent.baseSize / 4
|
||||
anchors.verticalCenterOffset: -parent.baseSize / 4
|
||||
z: 2
|
||||
active: showUnreadBadge
|
||||
sourceComponent: Rectangle {
|
||||
id: badge
|
||||
height: 7
|
||||
width: height
|
||||
radius: Style.radiusXS
|
||||
color: root.hovering ? Color.mOnHover : (root.badgeColor || Color.mError)
|
||||
border.color: Color.mSurface
|
||||
border.width: Style.borderS
|
||||
visible: count > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import qs.Commons
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
|
||||
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
visible: PowerProfileService.available
|
||||
icon: PowerProfileService.getIcon()
|
||||
tooltipText: I18n.tr("tooltips.power-profile", {
|
||||
"profile": PowerProfileService.getName()
|
||||
})
|
||||
tooltipDirection: BarService.getTooltipDirection(screen?.name)
|
||||
colorBg: (PowerProfileService.profile === PowerProfile.Balanced) ? Style.capsuleColor : Color.mPrimary
|
||||
colorFg: (PowerProfileService.profile === PowerProfile.Balanced) ? Color.resolveColorKey(iconColorKey) : Color.mOnPrimary
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
onClicked: PowerProfileService.cycleProfile()
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string iconColorKey: (widgetSettings.iconColor !== undefined) ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
|
||||
baseSize: Style.getCapsuleHeightForScreen(screenName)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
icon: "power"
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("sessionMenuPanel", screen)?.isPanelOpen)
|
||||
return "";
|
||||
else
|
||||
return I18n.tr("tooltips.session-menu");
|
||||
}
|
||||
tooltipDirection: BarService.getTooltipDirection(screenName)
|
||||
colorBg: Style.capsuleColor
|
||||
colorFg: Color.resolveColorKey(iconColorKey)
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: PanelService.getPanel("sessionMenuPanel", screen)?.toggle()
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string valueIconColor: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
|
||||
readonly property color iconColor: Color.resolveColorKey(valueIconColor)
|
||||
|
||||
icon: "settings"
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("settingsPanel", screen)?.isPanelOpen) {
|
||||
return "";
|
||||
} else {
|
||||
return I18n.tr("tooltips.open-settings");
|
||||
}
|
||||
}
|
||||
tooltipDirection: BarService.getTooltipDirection(screen?.name)
|
||||
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
colorBg: Style.capsuleColor
|
||||
colorFg: iconColor
|
||||
colorBgHover: Color.mHover
|
||||
colorFgHover: Color.mOnHover
|
||||
colorBorder: Style.capsuleBorderColor
|
||||
colorBorderHover: Style.capsuleBorderColor
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (Settings.data.ui.settingsPanelMode === "attached") {
|
||||
PanelService.getPanel("settingsPanel", screen)?.toggle(this);
|
||||
} else {
|
||||
PanelService.getPanel("settingsPanel", screen)?.toggle();
|
||||
}
|
||||
}
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(screenName)
|
||||
readonly property int spacerSize: widgetSettings.width !== undefined ? widgetSettings.width : widgetMetadata.width
|
||||
|
||||
implicitWidth: isBarVertical ? barHeight : spacerSize
|
||||
implicitHeight: isBarVertical ? spacerSize : barHeight
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
}
|
||||
@@ -0,0 +1,967 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
|
||||
|
||||
readonly property bool compactMode: widgetSettings.compactMode !== undefined ? widgetSettings.compactMode : widgetMetadata.compactMode
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
|
||||
readonly property bool useMonospaceFont: widgetSettings.useMonospaceFont !== undefined ? widgetSettings.useMonospaceFont : widgetMetadata.useMonospaceFont
|
||||
readonly property bool usePadding: !compactMode && !isVertical && useMonospaceFont && ((widgetSettings.usePadding !== undefined) ? widgetSettings.usePadding : widgetMetadata.usePadding)
|
||||
|
||||
readonly property bool showCpuUsage: (widgetSettings.showCpuUsage !== undefined) ? widgetSettings.showCpuUsage : widgetMetadata.showCpuUsage
|
||||
readonly property bool showCpuCores: (widgetSettings.showCpuCores !== undefined) ? widgetSettings.showCpuCores : widgetMetadata.showCpuCores
|
||||
readonly property bool showCpuFreq: (widgetSettings.showCpuFreq !== undefined) ? widgetSettings.showCpuFreq : widgetMetadata.showCpuFreq
|
||||
readonly property bool showCpuTemp: (widgetSettings.showCpuTemp !== undefined) ? widgetSettings.showCpuTemp : widgetMetadata.showCpuTemp
|
||||
readonly property bool showGpuTemp: (widgetSettings.showGpuTemp !== undefined) ? widgetSettings.showGpuTemp : widgetMetadata.showGpuTemp
|
||||
readonly property bool showMemoryUsage: (widgetSettings.showMemoryUsage !== undefined) ? widgetSettings.showMemoryUsage : widgetMetadata.showMemoryUsage
|
||||
readonly property bool showMemoryAsPercent: (widgetSettings.showMemoryAsPercent !== undefined) ? widgetSettings.showMemoryAsPercent : widgetMetadata.showMemoryAsPercent
|
||||
readonly property bool showSwapUsage: (widgetSettings.showSwapUsage !== undefined) ? widgetSettings.showSwapUsage : widgetMetadata.showSwapUsage
|
||||
readonly property bool showNetworkStats: (widgetSettings.showNetworkStats !== undefined) ? widgetSettings.showNetworkStats : widgetMetadata.showNetworkStats
|
||||
readonly property bool showDiskUsage: (widgetSettings.showDiskUsage !== undefined) ? widgetSettings.showDiskUsage : widgetMetadata.showDiskUsage
|
||||
readonly property bool showDiskUsageAsPercent: (widgetSettings.showDiskUsageAsPercent !== undefined) ? widgetSettings.showDiskUsageAsPercent : widgetMetadata.showDiskUsageAsPercent
|
||||
readonly property bool showDiskAvailable: (widgetSettings.showDiskAvailable !== undefined) ? widgetSettings.showDiskAvailable : widgetMetadata.showDiskAvailable
|
||||
readonly property bool showLoadAverage: (widgetSettings.showLoadAverage !== undefined) ? widgetSettings.showLoadAverage : widgetMetadata.showLoadAverage
|
||||
readonly property string diskPath: (widgetSettings.diskPath !== undefined) ? widgetSettings.diskPath : widgetMetadata.diskPath
|
||||
readonly property string fontFamily: useMonospaceFont ? Settings.data.ui.fontFixed : Settings.data.ui.fontDefault
|
||||
|
||||
readonly property int paddingPercent: usePadding ? String("100%").length : 0
|
||||
readonly property int paddingTemp: usePadding ? String("999°").length : 0
|
||||
readonly property int paddingCpuFreq: usePadding ? String("9.9").length : 0
|
||||
readonly property int paddingSpeed: usePadding ? String("9999G").length : 0
|
||||
|
||||
readonly property real iconSize: Style.toOdd(capsuleHeight * 0.48)
|
||||
readonly property real miniGaugeWidth: Math.max(3, Style.toOdd(root.iconSize * 0.25))
|
||||
|
||||
// Content dimensions for implicit sizing
|
||||
readonly property real contentWidth: isVertical ? capsuleHeight : Math.round(mainGrid.implicitWidth + Style.margin2M)
|
||||
readonly property real contentHeight: isVertical ? Math.round(mainGrid.implicitHeight + Style.margin2M) : capsuleHeight
|
||||
|
||||
readonly property color iconColor: Color.resolveColorKey(iconColorKey)
|
||||
readonly property color textColor: Color.resolveColorKey(textColorKey)
|
||||
|
||||
// Size: use implicit width/height
|
||||
// BarWidgetLoader sets explicit width/height to extend click area
|
||||
implicitWidth: contentWidth
|
||||
implicitHeight: contentHeight
|
||||
|
||||
Component.onCompleted: SystemStatService.registerComponent("bar-sysmon:" + (screen?.name || "unknown"))
|
||||
Component.onDestruction: SystemStatService.unregisterComponent("bar-sysmon:" + (screen?.name || "unknown"))
|
||||
|
||||
function openExternalMonitor() {
|
||||
Quickshell.execDetached(["sh", "-c", Settings.data.systemMonitor.externalMonitor]);
|
||||
}
|
||||
|
||||
// Build comprehensive tooltip text with all stats
|
||||
function buildTooltipContent() {
|
||||
let rows = [];
|
||||
|
||||
// CPU
|
||||
rows.push([I18n.tr("system-monitor.cpu-usage"), `${Math.round(SystemStatService.cpuUsage)}% (${SystemStatService.cpuFreq.replace(/[^0-9.]/g, "")} GHz)`]);
|
||||
if (showCpuCores) {
|
||||
SystemStatService.coresUsage.forEach((usage, core) => rows.push([" " + I18n.tr("system-monitor.core-usage", {
|
||||
"id": core
|
||||
}), `${Math.round(usage)}%`]));
|
||||
}
|
||||
|
||||
if (SystemStatService.cpuTemp > 0) {
|
||||
rows.push([I18n.tr("system-monitor.cpu-temp"), `${Math.round(SystemStatService.cpuTemp)}°C`]);
|
||||
}
|
||||
|
||||
// GPU (if available)
|
||||
if (SystemStatService.gpuAvailable) {
|
||||
rows.push([I18n.tr("system-monitor.gpu-temp"), `${Math.round(SystemStatService.gpuTemp)}°C`]);
|
||||
}
|
||||
|
||||
// Load Average
|
||||
if (SystemStatService.loadAvg1 >= 0) {
|
||||
rows.push([I18n.tr("system-monitor.load-average"), `${SystemStatService.loadAvg1.toFixed(2)} · ${SystemStatService.loadAvg5.toFixed(2)} · ${SystemStatService.loadAvg15.toFixed(2)}`]);
|
||||
}
|
||||
|
||||
// Memory
|
||||
rows.push([I18n.tr("common.memory"), `${Math.round(SystemStatService.memPercent)}% (${(SystemStatService.memGb).toFixed(1)} GiB)`]);
|
||||
|
||||
// Swap (if available)
|
||||
if (SystemStatService.swapTotalGb > 0) {
|
||||
rows.push([I18n.tr("bar.system-monitor.swap-usage-label"), `${Math.round(SystemStatService.swapPercent)}% (${(SystemStatService.swapGb).toFixed(1)} GiB)`]);
|
||||
}
|
||||
|
||||
// Network
|
||||
rows.push([I18n.tr("system-monitor.download-speed"), `${SystemStatService.formatSpeed(SystemStatService.rxSpeed).replace(/([0-9.]+)([A-Za-z]+)/, "$1 $2")}` + "/s"]);
|
||||
rows.push([I18n.tr("system-monitor.upload-speed"), `${SystemStatService.formatSpeed(SystemStatService.txSpeed).replace(/([0-9.]+)([A-Za-z]+)/, "$1 $2")}` + "/s"]);
|
||||
|
||||
// Disk
|
||||
const diskPercent = SystemStatService.diskPercents[diskPath];
|
||||
if (diskPercent !== undefined) {
|
||||
const usedGb = SystemStatService.diskUsedGb[diskPath] || 0;
|
||||
const sizeGb = SystemStatService.diskSizeGb[diskPath] || 0;
|
||||
const availGb = SystemStatService.diskAvailableGb[diskPath] || 0;
|
||||
rows.push([I18n.tr("system-monitor.disk"), `${diskPercent}% (${usedGb.toFixed(1)} / ${sizeGb.toFixed(1)} GB)`]);
|
||||
rows.push([I18n.tr("common.available"), `${availGb.toFixed(1)} GB`]);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Visibility-aware warning/critical states (delegates to service)
|
||||
readonly property bool cpuWarning: showCpuUsage && SystemStatService.cpuWarning
|
||||
readonly property bool cpuCritical: showCpuUsage && SystemStatService.cpuCritical
|
||||
readonly property bool tempWarning: showCpuTemp && SystemStatService.tempWarning
|
||||
readonly property bool tempCritical: showCpuTemp && SystemStatService.tempCritical
|
||||
readonly property bool gpuWarning: showGpuTemp && SystemStatService.gpuWarning
|
||||
readonly property bool gpuCritical: showGpuTemp && SystemStatService.gpuCritical
|
||||
readonly property bool memWarning: showMemoryUsage && SystemStatService.memWarning
|
||||
readonly property bool memCritical: showMemoryUsage && SystemStatService.memCritical
|
||||
readonly property bool swapWarning: showSwapUsage && SystemStatService.swapWarning
|
||||
readonly property bool swapCritical: showSwapUsage && SystemStatService.swapCritical
|
||||
readonly property bool diskWarning: showDiskUsage && SystemStatService.isDiskWarning(diskPath)
|
||||
readonly property bool diskCritical: showDiskUsage && SystemStatService.isDiskCritical(diskPath)
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("system-monitor.title"),
|
||||
"action": "sysmon-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "sysmon-settings") {
|
||||
let monitorCmd = Settings.data.systemMonitor.externalMonitor;
|
||||
if (monitorCmd && monitorCmd.trim() !== "") {
|
||||
openExternalMonitor();
|
||||
} else {
|
||||
SettingsPanelService.openToTab(SettingsPanel.Tab.System, 0, screen);
|
||||
}
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Visual capsule centered in parent
|
||||
Rectangle {
|
||||
id: visualCapsule
|
||||
width: root.contentWidth
|
||||
height: root.contentHeight
|
||||
anchors.centerIn: parent
|
||||
radius: Style.radiusM
|
||||
color: Style.capsuleColor
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
// Mini gauge component for compact mode, vertical gauge that fills from bottom
|
||||
Component {
|
||||
id: miniGaugeComponent
|
||||
|
||||
NLinearGauge {
|
||||
ratio: 0
|
||||
orientation: Qt.Vertical
|
||||
fillColor: Color.mPrimary
|
||||
width: miniGaugeWidth
|
||||
height: iconSize
|
||||
}
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
id: mainGrid
|
||||
anchors.centerIn: parent
|
||||
flow: isVertical ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: isVertical ? -1 : 1
|
||||
columns: isVertical ? 1 : -1
|
||||
rowSpacing: isVertical ? (compactMode ? Style.marginL : Style.marginXL) : 0
|
||||
columnSpacing: isVertical ? 0 : Style.marginM
|
||||
|
||||
// CPU Usage Component
|
||||
Item {
|
||||
id: cpuUsageContainer
|
||||
implicitWidth: cpuUsageContent.implicitWidth
|
||||
implicitHeight: cpuUsageContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showCpuUsage
|
||||
|
||||
GridLayout {
|
||||
id: cpuUsageContent
|
||||
anchors.centerIn: parent
|
||||
|
||||
property bool verticalDisplay: isVertical && (!compactMode || showCpuCores)
|
||||
flow: verticalDisplay ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: verticalDisplay ? -1 : 1
|
||||
columns: verticalDisplay ? 1 : -1
|
||||
rowSpacing: compactMode ? 3 : Style.marginXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "cpu-usage"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: (cpuWarning || cpuCritical) ? SystemStatService.cpuColor : root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: `${Math.round(SystemStatService.cpuUsage)}%`.padStart(paddingPercent, " ")
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: (cpuWarning || cpuCritical) ? SystemStatService.cpuColor : root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode general cpu
|
||||
Loader {
|
||||
active: compactMode && !showCpuCores
|
||||
visible: compactMode && !showCpuCores
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => SystemStatService.cpuUsage / 100);
|
||||
item.fillColor = Qt.binding(() => SystemStatService.cpuColor);
|
||||
}
|
||||
}
|
||||
|
||||
// Compact mode for cores
|
||||
Repeater {
|
||||
model: (compactMode && showCpuCores) ? SystemStatService.coresUsage : []
|
||||
|
||||
delegate: NLinearGauge {
|
||||
required property var modelData
|
||||
width: isVertical ? iconSize : miniGaugeWidth
|
||||
height: isVertical ? miniGaugeWidth : iconSize
|
||||
orientation: isVertical ? Qt.Horizontal : Qt.Vertical
|
||||
ratio: modelData / 100
|
||||
fillColor: SystemStatService.getCoreUsageColor(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CPU Frequency Component
|
||||
Item {
|
||||
id: cpuFreqContainer
|
||||
implicitWidth: cpuFreqContent.implicitWidth
|
||||
implicitHeight: cpuFreqContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showCpuFreq && (!isVertical || compactMode)
|
||||
|
||||
GridLayout {
|
||||
id: cpuFreqContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "cpu-usage"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: SystemStatService.cpuFreq.replace("Hz", "").replace(" ", "").padStart(paddingCpuFreq, " ")
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => SystemStatService.cpuFreqRatio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CPU Temperature Component
|
||||
Item {
|
||||
id: cpuTempContainer
|
||||
implicitWidth: cpuTempContent.implicitWidth
|
||||
implicitHeight: cpuTempContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showCpuTemp
|
||||
|
||||
GridLayout {
|
||||
id: cpuTempContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "cpu-temperature"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: (tempWarning || tempCritical) ? SystemStatService.tempColor : root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: `${Math.round(SystemStatService.cpuTemp)}°`.padStart(paddingTemp, " ")
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: (tempWarning || tempCritical) ? SystemStatService.tempColor : root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode, mini gauge (to the right of icon)
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => SystemStatService.cpuTemp / 100);
|
||||
item.fillColor = Qt.binding(() => SystemStatService.tempColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GPU Temperature Component
|
||||
Item {
|
||||
id: gpuTempContainer
|
||||
implicitWidth: gpuTempContent.implicitWidth
|
||||
implicitHeight: gpuTempContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showGpuTemp && SystemStatService.gpuAvailable
|
||||
|
||||
GridLayout {
|
||||
id: gpuTempContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "gpu-temperature"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: (gpuWarning || gpuCritical) ? SystemStatService.gpuColor : root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: `${Math.round(SystemStatService.gpuTemp)}°`.padStart(paddingTemp, " ")
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: (gpuWarning || gpuCritical) ? SystemStatService.gpuColor : root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => SystemStatService.gpuTemp / 100);
|
||||
item.fillColor = Qt.binding(() => SystemStatService.gpuColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load Average Component
|
||||
Item {
|
||||
id: loadAvgContainer
|
||||
implicitWidth: loadAvgContent.implicitWidth
|
||||
implicitHeight: loadAvgContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showLoadAverage && SystemStatService.nproc > 0 && SystemStatService.loadAvg1 > 0
|
||||
|
||||
GridLayout {
|
||||
id: loadAvgContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "weight"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: `${SystemStatService.loadAvg1.toFixed(1)}`.padStart(usePadding ? `${SystemStatService.nproc.toFixed(1)}`.length : 0, " ")
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => Math.min(1, SystemStatService.loadAvg1 / SystemStatService.nproc));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Memory Usage Component
|
||||
Item {
|
||||
id: memoryContainer
|
||||
implicitWidth: memoryContent.implicitWidth
|
||||
implicitHeight: memoryContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showMemoryUsage
|
||||
|
||||
GridLayout {
|
||||
id: memoryContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "memory"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: (memWarning || memCritical) ? SystemStatService.memColor : root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: SystemStatService.formatRamDisplay({
|
||||
percent: showMemoryAsPercent,
|
||||
padding: usePadding
|
||||
})
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: (memWarning || memCritical) ? SystemStatService.memColor : root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => SystemStatService.memPercent / 100);
|
||||
item.fillColor = Qt.binding(() => SystemStatService.memColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Swap Usage Component
|
||||
Item {
|
||||
id: swapContainer
|
||||
implicitWidth: swapContent.implicitWidth
|
||||
implicitHeight: swapContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showSwapUsage && SystemStatService.swapTotalGb > 0
|
||||
|
||||
GridLayout {
|
||||
id: swapContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "exchange"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: (swapWarning || swapCritical) ? SystemStatService.swapColor : root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: SystemStatService.formatRamDisplay({
|
||||
swap: true,
|
||||
percent: true,
|
||||
padding: usePadding
|
||||
})
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: (swapWarning || swapCritical) ? SystemStatService.swapColor : root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => SystemStatService.swapPercent / 100);
|
||||
item.fillColor = Qt.binding(() => SystemStatService.swapColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Network Download Speed Component
|
||||
Item {
|
||||
implicitWidth: downloadContent.implicitWidth
|
||||
implicitHeight: downloadContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showNetworkStats
|
||||
|
||||
GridLayout {
|
||||
id: downloadContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "download-speed"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: isVertical ? SystemStatService.formatCompactSpeed(SystemStatService.rxSpeed) : SystemStatService.formatSpeed(SystemStatService.rxSpeed).padStart(paddingSpeed, " ")
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => SystemStatService.rxRatio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Network Upload Speed Component
|
||||
Item {
|
||||
implicitWidth: uploadContent.implicitWidth
|
||||
implicitHeight: uploadContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showNetworkStats
|
||||
|
||||
GridLayout {
|
||||
id: uploadContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "upload-speed"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: isVertical ? SystemStatService.formatCompactSpeed(SystemStatService.txSpeed) : SystemStatService.formatSpeed(SystemStatService.txSpeed).padStart(paddingSpeed, " ")
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => SystemStatService.txRatio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disk Usage Component (primary drive)
|
||||
Item {
|
||||
id: diskContainer
|
||||
implicitWidth: diskContent.implicitWidth
|
||||
implicitHeight: diskContent.implicitHeight
|
||||
Layout.preferredWidth: isVertical ? root.width : implicitWidth
|
||||
Layout.preferredHeight: compactMode ? implicitHeight : capsuleHeight
|
||||
Layout.alignment: isVertical ? Qt.AlignHCenter : Qt.AlignVCenter
|
||||
visible: showDiskUsage
|
||||
|
||||
GridLayout {
|
||||
id: diskContent
|
||||
anchors.centerIn: parent
|
||||
flow: (isVertical && !compactMode) ? GridLayout.TopToBottom : GridLayout.LeftToRight
|
||||
rows: (isVertical && !compactMode) ? 2 : 1
|
||||
columns: (isVertical && !compactMode) ? 1 : 2
|
||||
rowSpacing: Style.marginXXS
|
||||
columnSpacing: compactMode ? 3 : Style.marginXS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: iconSize
|
||||
Layout.preferredHeight: (compactMode || isVertical) ? iconSize : capsuleHeight
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: (isVertical && !compactMode) ? 1 : 0
|
||||
Layout.column: 0
|
||||
|
||||
NIcon {
|
||||
icon: "storage"
|
||||
pointSize: iconSize
|
||||
applyUiScale: false
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, contentHeight)
|
||||
color: (diskWarning || diskCritical) ? SystemStatService.getDiskColor(diskPath) : root.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
// Text mode
|
||||
NText {
|
||||
visible: !compactMode
|
||||
text: SystemStatService.formatDiskDisplay(diskPath, {
|
||||
percent: showDiskUsageAsPercent,
|
||||
available: showDiskAvailable,
|
||||
padding: usePadding
|
||||
})
|
||||
family: fontFamily
|
||||
pointSize: barFontSize
|
||||
applyUiScale: false
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: (diskWarning || diskCritical) ? SystemStatService.getDiskColor(diskPath) : root.textColor
|
||||
Layout.row: isVertical ? 0 : 0
|
||||
Layout.column: isVertical ? 0 : 1
|
||||
}
|
||||
|
||||
// Compact mode
|
||||
Loader {
|
||||
active: compactMode
|
||||
visible: compactMode
|
||||
sourceComponent: miniGaugeComponent
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
Layout.row: 0
|
||||
Layout.column: 1
|
||||
|
||||
onLoaded: {
|
||||
item.ratio = Qt.binding(() => (showDiskAvailable ? SystemStatService.diskAvailPercents[diskPath] : SystemStatService.diskPercents[diskPath] ?? 0) / 100);
|
||||
item.fillColor = Qt.binding(() => SystemStatService.getDiskColor(diskPath, showDiskAvailable));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MouseArea at root level for extended click area
|
||||
MouseArea {
|
||||
id: tooltipArea
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
hoverEnabled: true
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
PanelService.getPanel("systemStatsPanel", screen)?.toggle(root);
|
||||
TooltipService.hide();
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
TooltipService.hide();
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
TooltipService.hide();
|
||||
openExternalMonitor();
|
||||
}
|
||||
}
|
||||
onEntered: {
|
||||
if (!PanelService.getPanel("systemStatsPanel", screen).isPanelOpen) {
|
||||
TooltipService.show(root, buildTooltipContent(), BarService.getTooltipDirection(root.screen?.name));
|
||||
tooltipRefreshTimer.start();
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
tooltipRefreshTimer.stop();
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: tooltipRefreshTimer
|
||||
interval: 1000
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (tooltipArea.containsMouse) {
|
||||
TooltipService.updateText(buildTooltipContent());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,580 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.SystemTray
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Trigger re-evaluation when window is registered
|
||||
property int popupMenuUpdateTrigger: 0
|
||||
|
||||
// Get shared popup menu window from PanelService (reactive to trigger changes)
|
||||
readonly property var popupMenuWindow: {
|
||||
// Reference trigger to force re-evaluation
|
||||
var popupMenuUpdateTriggerRef = popupMenuUpdateTrigger;
|
||||
return PanelService.getPopupMenuWindow(screen);
|
||||
}
|
||||
|
||||
readonly property var trayMenu: popupMenuWindow ? popupMenuWindow.trayMenuLoader : null
|
||||
|
||||
Connections {
|
||||
target: PanelService
|
||||
function onPopupMenuWindowRegistered(registeredScreen) {
|
||||
if (registeredScreen === screen) {
|
||||
root.popupMenuUpdateTrigger++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(screenName)
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
readonly property bool density: Settings.data.bar.density
|
||||
readonly property int iconSize: Style.toOdd(capsuleHeight * 0.65)
|
||||
|
||||
property var blacklist: widgetSettings.blacklist || widgetMetadata.blacklist || [] // Read from settings
|
||||
property var pinned: widgetSettings.pinned || widgetMetadata.pinned || [] // Pinned items (shown inline)
|
||||
property bool drawerEnabled: widgetSettings.drawerEnabled !== undefined ? widgetSettings.drawerEnabled : (widgetMetadata.drawerEnabled !== undefined ? widgetMetadata.drawerEnabled : true) // Enable drawer panel
|
||||
property bool hidePassive: widgetSettings.hidePassive !== undefined ? widgetSettings.hidePassive : true // Hide passive status items
|
||||
readonly property string chevronColorKey: widgetSettings.chevronColor !== undefined ? widgetSettings.chevronColor : widgetMetadata.chevronColor
|
||||
readonly property color chevronColor: Color.resolveColorKey(chevronColorKey)
|
||||
property var filteredItems: [] // Items to show inline (pinned)
|
||||
property var dropdownItems: [] // Items to show in drawer (unpinned)
|
||||
property int hoveredItemIndex: -1 // Track hovered item for dot indicator
|
||||
|
||||
Timer {
|
||||
id: updateDebounceTimer
|
||||
interval: 100 // milliseconds
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: _performFilteredItemsUpdate()
|
||||
}
|
||||
|
||||
readonly property var statusSignature: {
|
||||
if (!SystemTray.items || !SystemTray.items.values) {
|
||||
return "";
|
||||
}
|
||||
var sig = "";
|
||||
var items = SystemTray.items.values;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
if (item) {
|
||||
// Direct property access creates reactive binding
|
||||
var s = item.status;
|
||||
sig += (item.id || i) + ":" + (s !== undefined ? s : -1);
|
||||
}
|
||||
}
|
||||
// Trigger update when signature changes (status changed)
|
||||
if (root.hidePassive) {
|
||||
Qt.callLater(root.updateFilteredItems);
|
||||
}
|
||||
return sig;
|
||||
}
|
||||
Repeater {
|
||||
id: statusConnectionsRepeater
|
||||
model: SystemTray.items && SystemTray.items.values ? SystemTray.items.values : []
|
||||
|
||||
delegate: Item {
|
||||
Connections {
|
||||
target: modelData
|
||||
enabled: modelData !== null && modelData !== undefined
|
||||
function onStatusChanged() {
|
||||
if (root.hidePassive) {
|
||||
root.updateFilteredItems();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _performFilteredItemsUpdate() {
|
||||
// Force a fresh read of settings to ensure we have the latest blacklist
|
||||
var currentSettings = {};
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var w = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (w && sectionWidgetIndex < w.length) {
|
||||
currentSettings = w[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// Update local properties with fresh data
|
||||
if (currentSettings.blacklist !== undefined)
|
||||
root.blacklist = currentSettings.blacklist;
|
||||
if (currentSettings.pinned !== undefined)
|
||||
root.pinned = currentSettings.pinned;
|
||||
|
||||
let newItems = [];
|
||||
if (SystemTray.items && SystemTray.items.values) {
|
||||
const trayItems = SystemTray.items.values;
|
||||
for (var i = 0; i < trayItems.length; i++) {
|
||||
const item = trayItems[i];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = item.tooltipTitle || item.name || item.id || "";
|
||||
|
||||
// Skip passive items if hidePassive is enabled
|
||||
if (root.hidePassive && item.status !== undefined && (item.status === SystemTray.Passive || item.status === 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if blacklisted
|
||||
let isBlacklisted = false;
|
||||
if (root.blacklist && root.blacklist.length > 0) {
|
||||
for (var j = 0; j < root.blacklist.length; j++) {
|
||||
const rule = root.blacklist[j];
|
||||
if (wildCardMatch(title, rule)) {
|
||||
isBlacklisted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBlacklisted) {
|
||||
newItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If drawer is disabled, show all items inline
|
||||
if (!root.drawerEnabled) {
|
||||
filteredItems = newItems;
|
||||
dropdownItems = [];
|
||||
} else {
|
||||
// Build inline (pinned) and drawer (unpinned) lists
|
||||
// If pinned list is empty, all items go to drawer (none inline)
|
||||
// If pinned list has items, pinned items are inline, rest go to drawer
|
||||
if (pinned && pinned.length > 0) {
|
||||
let pinnedItems = [];
|
||||
for (var k = 0; k < newItems.length; k++) {
|
||||
const item2 = newItems[k];
|
||||
const title2 = item2.tooltipTitle || item2.name || item2.id || "";
|
||||
for (var m = 0; m < pinned.length; m++) {
|
||||
const rule2 = pinned[m];
|
||||
if (wildCardMatch(title2, rule2)) {
|
||||
pinnedItems.push(item2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
filteredItems = pinnedItems;
|
||||
|
||||
// Unpinned items go to drawer
|
||||
let unpinnedItems = [];
|
||||
for (var v = 0; v < newItems.length; v++) {
|
||||
const cand = newItems[v];
|
||||
let isPinned = false;
|
||||
for (var f = 0; f < filteredItems.length; f++) {
|
||||
if (filteredItems[f] === cand) {
|
||||
isPinned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isPinned)
|
||||
unpinnedItems.push(cand);
|
||||
}
|
||||
dropdownItems = unpinnedItems;
|
||||
} else {
|
||||
// No pinned items: all items go to drawer (none inline)
|
||||
filteredItems = [];
|
||||
dropdownItems = newItems;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilteredItems() {
|
||||
updateDebounceTimer.restart();
|
||||
}
|
||||
|
||||
function wildCardMatch(str, rule) {
|
||||
if (!str || !rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// First, convert '*' to a placeholder to preserve it, then escape other special regex characters
|
||||
// Use a unique placeholder that won't appear in normal strings
|
||||
const placeholder = '\uE000'; // Private use character
|
||||
let processedRule = rule.replace(/\*/g, placeholder);
|
||||
// Escape all special regex characters (but placeholder won't match this)
|
||||
let escapedRule = processedRule.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
// Convert placeholder back to '.*' for wildcard matching
|
||||
let pattern = escapedRule.replace(new RegExp(placeholder, 'g'), '.*');
|
||||
// Add ^ and $ to match the entire string
|
||||
pattern = '^' + pattern + '$';
|
||||
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
// 'i' for case-insensitive
|
||||
return regex.test(str);
|
||||
} catch (e) {
|
||||
Logger.w("Tray", "Invalid regex pattern for wildcard match:", rule, e.message);
|
||||
return false; // If regex is invalid, it won't match
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer(button) {
|
||||
TooltipService.hideImmediately();
|
||||
|
||||
// Close the popup menu if it's open
|
||||
if (popupMenuWindow && popupMenuWindow.visible) {
|
||||
popupMenuWindow.close();
|
||||
}
|
||||
|
||||
const panel = PanelService.getPanel("trayDrawerPanel", root.screen);
|
||||
if (panel) {
|
||||
panel.widgetSection = root.section;
|
||||
panel.widgetIndex = root.sectionWidgetIndex;
|
||||
panel.toggle(this);
|
||||
}
|
||||
}
|
||||
|
||||
function onLoaded() {
|
||||
// When the widget is fully initialized with its props set the screen for the trayMenu
|
||||
if (trayMenu && trayMenu.item) {
|
||||
trayMenu.item.screen = screen;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SystemTray.items
|
||||
function onValuesChanged() {
|
||||
root.updateFilteredItems();
|
||||
// Repeater will automatically update when items change
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Settings
|
||||
function onSettingsSaved() {
|
||||
root.updateFilteredItems();
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for hidePassive changes to update filtering immediately
|
||||
onHidePassiveChanged: {
|
||||
root.updateFilteredItems();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.updateFilteredItems(); // Initial update
|
||||
}
|
||||
|
||||
// Content dimensions for implicit sizing
|
||||
readonly property int visibleItemCount: (root.drawerEnabled && dropdownItems.length > 0 ? 1 : 0) + filteredItems.length
|
||||
readonly property real capsulePadding: 0
|
||||
readonly property real capsuleWidth: isVertical ? capsuleHeight : Math.round(trayFlow.implicitWidth + capsulePadding * 2)
|
||||
readonly property real capsuleContentHeight: isVertical ? Math.round(trayFlow.implicitHeight + capsulePadding * 2) : capsuleHeight
|
||||
|
||||
implicitWidth: isVertical ? barHeight : Math.round(trayFlow.implicitWidth + capsulePadding * 2)
|
||||
implicitHeight: isVertical ? Math.round(trayFlow.implicitHeight + capsulePadding * 2) : barHeight
|
||||
visible: filteredItems.length > 0 || dropdownItems.length > 0
|
||||
opacity: (filteredItems.length > 0 || dropdownItems.length > 0) ? 1.0 : 0.0
|
||||
|
||||
// Visual capsule centered in parent
|
||||
Rectangle {
|
||||
id: visualCapsule
|
||||
width: capsuleWidth
|
||||
height: capsuleContentHeight
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
radius: Style.radiusM
|
||||
color: Style.capsuleColor
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
}
|
||||
|
||||
NPopupContextMenu {
|
||||
id: chevronContextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
chevronContextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: trayFlow
|
||||
spacing: 0
|
||||
flow: isVertical ? Flow.TopToBottom : Flow.LeftToRight
|
||||
|
||||
// Position centered in capsule
|
||||
anchors.centerIn: visualCapsule
|
||||
|
||||
// Drawer opener (before items if opposite direction)
|
||||
NIconButton {
|
||||
id: chevronIconBefore
|
||||
visible: root.drawerEnabled && dropdownItems.length > 0 && BarService.getPillDirection(root)
|
||||
width: isVertical ? barHeight : capsuleHeight
|
||||
height: isVertical ? capsuleHeight : barHeight
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("trayDrawerPanel", root.screen)?.isPanelOpen) {
|
||||
return "";
|
||||
} else {
|
||||
return I18n.tr("tooltips.open-tray-dropdown");
|
||||
}
|
||||
}
|
||||
tooltipDirection: BarService.getTooltipDirection(root.screen?.name)
|
||||
baseSize: capsuleHeight
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
colorBg: "transparent"
|
||||
colorFg: root.chevronColor
|
||||
colorBorder: "transparent"
|
||||
colorBorderHover: "transparent"
|
||||
icon: {
|
||||
switch (barPosition) {
|
||||
case "bottom":
|
||||
return "caret-up";
|
||||
case "left":
|
||||
return "caret-right";
|
||||
case "right":
|
||||
return "caret-left";
|
||||
case "top":
|
||||
default:
|
||||
return "caret-down";
|
||||
}
|
||||
}
|
||||
onClicked: toggleDrawer(this)
|
||||
onRightClicked: PanelService.showContextMenu(chevronContextMenu, this, screen)
|
||||
}
|
||||
|
||||
// Pinned items
|
||||
Repeater {
|
||||
id: repeater
|
||||
model: root.filteredItems
|
||||
|
||||
delegate: Item {
|
||||
id: trayDelegate
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: isVertical ? barHeight : capsuleHeight
|
||||
height: isVertical ? capsuleHeight : barHeight
|
||||
visible: modelData
|
||||
readonly property bool isHovered: root.hoveredItemIndex === index
|
||||
|
||||
// Tooltip anchor representing the visual area (for proper tooltip positioning)
|
||||
Item {
|
||||
id: tooltipAnchor
|
||||
width: capsuleHeight
|
||||
height: capsuleHeight
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: trayIcon
|
||||
width: iconSize
|
||||
height: iconSize
|
||||
x: Style.pixelAlignCenter(parent.width, width)
|
||||
y: Style.pixelAlignCenter(parent.height, height)
|
||||
asynchronous: true
|
||||
backer.fillMode: Image.PreserveAspectFit
|
||||
|
||||
source: {
|
||||
let icon = modelData?.icon || "";
|
||||
if (!icon) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Process icon path
|
||||
if (icon.includes("?path=")) {
|
||||
const chunks = icon.split("?path=");
|
||||
const name = chunks[0];
|
||||
const path = chunks[1];
|
||||
const fileName = name.substring(name.lastIndexOf("/") + 1);
|
||||
return `file://${path}/${fileName}`;
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
|
||||
layer.enabled: widgetSettings.colorizeIcons !== false
|
||||
layer.effect: ShaderEffect {
|
||||
property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant
|
||||
property real colorizeMode: 1.0
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: hoverIndicator
|
||||
anchors.bottom: trayIcon.bottom
|
||||
anchors.bottomMargin: -2
|
||||
anchors.horizontalCenter: trayIcon.horizontalCenter
|
||||
width: Style.toOdd(iconSize * 0.25)
|
||||
height: 4
|
||||
color: trayDelegate.isHovered ? Color.mHover : "transparent"
|
||||
radius: Math.min(Style.radiusXXS, width / 2)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: itemMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
onContainsMouseChanged: {
|
||||
if (containsMouse) {
|
||||
if (popupMenuWindow) {
|
||||
popupMenuWindow.close();
|
||||
}
|
||||
root.hoveredItemIndex = trayDelegate.index;
|
||||
TooltipService.show(tooltipAnchor, modelData.tooltipTitle || modelData.name || modelData.id || "Tray Item", BarService.getTooltipDirection(root.screen?.name));
|
||||
} else if (root.hoveredItemIndex === trayDelegate.index) {
|
||||
root.hoveredItemIndex = -1;
|
||||
TooltipService.hide(tooltipAnchor);
|
||||
}
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (!modelData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
// Close any open menu first
|
||||
if (popupMenuWindow) {
|
||||
popupMenuWindow.close();
|
||||
}
|
||||
|
||||
if (!modelData.onlyMenu) {
|
||||
modelData.activate();
|
||||
}
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
// Close the menu if it was visible
|
||||
if (popupMenuWindow && popupMenuWindow.visible) {
|
||||
popupMenuWindow.close();
|
||||
return;
|
||||
}
|
||||
modelData.secondaryActivate && modelData.secondaryActivate();
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
TooltipService.hideImmediately();
|
||||
|
||||
// Close the menu if it was visible
|
||||
if (popupMenuWindow && popupMenuWindow.visible) {
|
||||
popupMenuWindow.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any opened panel
|
||||
if ((PanelService.openedPanel !== null) && !PanelService.openedPanel.isClosing) {
|
||||
PanelService.openedPanel.close();
|
||||
}
|
||||
|
||||
if (modelData.hasMenu && modelData.menu && trayMenu && trayMenu.item) {
|
||||
// Calculate menu position after ensuring menu is loaded
|
||||
const calculateAndShow = () => {
|
||||
// Position menu based on bar position, using tooltipAnchor for proper positioning
|
||||
// Increased spacing for better alignment with other context menus
|
||||
let menuX, menuY;
|
||||
if (barPosition === "left") {
|
||||
// For left bar: position menu to the right of the visual area
|
||||
menuX = tooltipAnchor.width + Style.marginL;
|
||||
menuY = 0;
|
||||
} else if (barPosition === "right") {
|
||||
// For right bar: position menu to the left of the visual area
|
||||
menuX = -trayMenu.item.implicitWidth - Style.marginL;
|
||||
menuY = 0;
|
||||
} else {
|
||||
// For horizontal bars: center horizontally and position below visual area
|
||||
menuX = (tooltipAnchor.width / 2) - (trayMenu.item.implicitWidth / 2);
|
||||
menuY = tooltipAnchor.height + Style.marginS;
|
||||
}
|
||||
|
||||
PanelService.showTrayMenu(root.screen, modelData, trayMenu.item, tooltipAnchor, menuX, menuY, root.section, root.sectionWidgetIndex);
|
||||
};
|
||||
|
||||
// Use Qt.callLater to ensure menu dimensions are calculated
|
||||
Qt.callLater(calculateAndShow);
|
||||
} else {
|
||||
Logger.d("Tray", "No menu available for", modelData.id, "or trayMenu not set");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawer opener (after items if normal direction)
|
||||
NIconButton {
|
||||
id: chevronIconAfter
|
||||
visible: root.drawerEnabled && dropdownItems.length > 0 && !BarService.getPillDirection(root)
|
||||
width: isVertical ? barHeight : capsuleHeight
|
||||
height: isVertical ? capsuleHeight : barHeight
|
||||
tooltipText: I18n.tr("tooltips.open-tray-dropdown")
|
||||
tooltipDirection: BarService.getTooltipDirection(root.screen?.name)
|
||||
baseSize: capsuleHeight
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
colorBg: "transparent"
|
||||
colorFg: root.chevronColor
|
||||
colorBorder: "transparent"
|
||||
colorBorderHover: "transparent"
|
||||
icon: {
|
||||
switch (barPosition) {
|
||||
case "bottom":
|
||||
return "caret-up";
|
||||
case "left":
|
||||
return "caret-right";
|
||||
case "right":
|
||||
return "caret-left";
|
||||
case "top":
|
||||
default:
|
||||
return "caret-down";
|
||||
}
|
||||
}
|
||||
onClicked: toggleDrawer(this)
|
||||
onRightClicked: PanelService.showContextMenu(chevronContextMenu, this, screen)
|
||||
}
|
||||
} // closes Flow
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.Networking
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property string displayMode: widgetSettings.displayMode !== undefined ? widgetSettings.displayMode : widgetMetadata.displayMode
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: {
|
||||
const items = [];
|
||||
const active = VPNService.activeConnections;
|
||||
for (let i = 0; i < active.length; ++i) {
|
||||
const conn = active[i];
|
||||
items.push({
|
||||
"label": I18n.tr("actions.disconnect-vpn", {
|
||||
"name": conn.name
|
||||
}),
|
||||
"action": "disconnect:" + conn.uuid,
|
||||
"icon": "shield-off"
|
||||
});
|
||||
}
|
||||
const inactive = VPNService.inactiveConnections;
|
||||
for (let i = 0; i < inactive.length; ++i) {
|
||||
const conn = inactive[i];
|
||||
items.push({
|
||||
"label": I18n.tr("actions.connect-vpn", {
|
||||
"name": conn.name
|
||||
}),
|
||||
"action": "connect:" + conn.uuid,
|
||||
"icon": "shield-lock"
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith("connect:")) {
|
||||
const uuid = action.substring("connect:".length);
|
||||
VPNService.connect(uuid);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith("disconnect:")) {
|
||||
const uuid = action.substring("disconnect:".length);
|
||||
VPNService.disconnect(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
|
||||
customTextColor: Color.resolveColorKeyOptional(root.textColorKey)
|
||||
icon: VPNService.hasActiveConnection ? "shield-lock" : "shield"
|
||||
text: {
|
||||
if (VPNService.activeConnections.length > 0) {
|
||||
return VPNService.activeConnections[0].name;
|
||||
}
|
||||
if (VPNService.connectingUuid) {
|
||||
const pending = VPNService.connections[VPNService.connectingUuid];
|
||||
if (pending) {
|
||||
return pending.name;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
suffix: {
|
||||
if (VPNService.activeConnections.length > 1) {
|
||||
return ` + ${VPNService.activeConnections.length - 1}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
autoHide: false
|
||||
forceOpen: !isBarVertical && root.displayMode === "alwaysShow"
|
||||
forceClose: isBarVertical || root.displayMode === "alwaysHide" || !pill.text
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, pill, screen);
|
||||
}
|
||||
tooltipText: {
|
||||
if (pill.text !== "") {
|
||||
return pill.text;
|
||||
}
|
||||
return I18n.tr("tooltips.manage-vpn");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
// Explicit screenName property ensures reactive binding when screen changes
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
|
||||
readonly property bool isBarVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property string displayMode: (widgetSettings.displayMode !== undefined) ? widgetSettings.displayMode : widgetMetadata.displayMode
|
||||
readonly property string middleClickCommand: (widgetSettings.middleClickCommand !== undefined) ? widgetSettings.middleClickCommand : widgetMetadata.middleClickCommand
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
readonly property string textColorKey: widgetSettings.textColor !== undefined ? widgetSettings.textColor : widgetMetadata.textColor
|
||||
readonly property bool reverseScroll: Settings.data.general.reverseScroll
|
||||
|
||||
// Used to avoid opening the pill on Quickshell startup
|
||||
property bool firstVolumeReceived: false
|
||||
property int wheelAccumulator: 0
|
||||
|
||||
implicitWidth: pill.width
|
||||
implicitHeight: pill.height
|
||||
|
||||
// Connection used to open the pill when volume changes
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onVolumeChanged() {
|
||||
// Logger.i("Bar:Volume", "onVolumeChanged")
|
||||
if (!firstVolumeReceived) {
|
||||
// Ignore the first volume change
|
||||
firstVolumeReceived = true;
|
||||
} else {
|
||||
// Hide any tooltip while the pill is visible / being updated
|
||||
TooltipService.hide();
|
||||
pill.show();
|
||||
externalHideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function onMutedChanged() {
|
||||
if (!firstVolumeReceived) {
|
||||
firstVolumeReceived = true;
|
||||
} else {
|
||||
TooltipService.hide();
|
||||
pill.show();
|
||||
externalHideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function onVolumeAtMaximum() {
|
||||
if (!firstVolumeReceived) {
|
||||
firstVolumeReceived = true;
|
||||
} else {
|
||||
// Hide any tooltip while the pill is visible / being updated
|
||||
TooltipService.hide();
|
||||
pill.show();
|
||||
externalHideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function onVolumeAtMinimum() {
|
||||
if (!firstVolumeReceived) {
|
||||
firstVolumeReceived = true;
|
||||
} else {
|
||||
// Hide any tooltip while the pill is visible / being updated
|
||||
TooltipService.hide();
|
||||
pill.show();
|
||||
externalHideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: externalHideTimer
|
||||
running: false
|
||||
interval: 1500
|
||||
onTriggered: {
|
||||
pill.hide();
|
||||
}
|
||||
}
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.toggle-mute"),
|
||||
"action": "toggle-mute",
|
||||
"icon": AudioService.muted ? "volume-off" : "volume"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.run-custom-command"),
|
||||
"action": "custom-command",
|
||||
"icon": "adjustments"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "toggle-mute") {
|
||||
AudioService.setOutputMuted(!AudioService.muted);
|
||||
} else if (action === "custom-command") {
|
||||
Quickshell.execDetached(["sh", "-c", middleClickCommand]);
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BarPill {
|
||||
id: pill
|
||||
|
||||
screen: root.screen
|
||||
oppositeDirection: BarService.getPillDirection(root)
|
||||
customIconColor: Color.resolveColorKeyOptional(root.iconColorKey)
|
||||
customTextColor: Color.resolveColorKeyOptional(root.textColorKey)
|
||||
icon: AudioService.getOutputIcon()
|
||||
autoHide: false // Important to be false so we can hover as long as we want
|
||||
text: {
|
||||
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
|
||||
const displayVolume = Math.min(maxVolume, AudioService.volume);
|
||||
return Math.round(displayVolume * 100);
|
||||
}
|
||||
suffix: "%"
|
||||
forceOpen: displayMode === "alwaysShow"
|
||||
forceClose: displayMode === "alwaysHide"
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("audioPanel", screen)?.isPanelOpen) {
|
||||
return "";
|
||||
} else {
|
||||
const nick = AudioService.sink?.nickname ?? "";
|
||||
const volumeText = I18n.tr("tooltips.volume-at", {
|
||||
"volume": (() => {
|
||||
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
|
||||
const displayVolume = Math.min(maxVolume, AudioService.volume);
|
||||
return Math.round(displayVolume * 100);
|
||||
})()
|
||||
});
|
||||
return nick ? volumeText + "\n" + nick : volumeText;
|
||||
}
|
||||
}
|
||||
|
||||
onWheel: function (delta) {
|
||||
// Hide tooltip as soon as the user starts scrolling to adjust volume
|
||||
TooltipService.hide();
|
||||
|
||||
if (root.reverseScroll)
|
||||
delta *= -1;
|
||||
|
||||
wheelAccumulator += delta;
|
||||
if (wheelAccumulator >= 120) {
|
||||
wheelAccumulator = 0;
|
||||
AudioService.increaseVolume();
|
||||
} else if (wheelAccumulator <= -120) {
|
||||
wheelAccumulator = 0;
|
||||
AudioService.decreaseVolume();
|
||||
}
|
||||
}
|
||||
onClicked: {
|
||||
PanelService.getPanel("audioPanel", screen)?.toggle(this);
|
||||
}
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, pill, screen);
|
||||
}
|
||||
onMiddleClicked: {
|
||||
Quickshell.execDetached(["sh", "-c", middleClickCommand]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButton {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
|
||||
// Widget properties passed from Bar.qml for per-instance settings
|
||||
property string widgetId: ""
|
||||
property string section: ""
|
||||
property int sectionWidgetIndex: -1
|
||||
property int sectionWidgetsCount: 0
|
||||
|
||||
property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {}
|
||||
readonly property string screenName: screen ? screen.name : ""
|
||||
property var widgetSettings: {
|
||||
if (section && sectionWidgetIndex >= 0 && screenName) {
|
||||
var widgets = Settings.getBarWidgetsForScreen(screenName)[section];
|
||||
if (widgets && sectionWidgetIndex < widgets.length) {
|
||||
return widgets[sectionWidgetIndex];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
readonly property string iconColorKey: widgetSettings.iconColor !== undefined ? widgetSettings.iconColor : widgetMetadata.iconColor
|
||||
|
||||
enabled: Settings.data.wallpaper.enabled
|
||||
baseSize: Style.getCapsuleHeightForScreen(screen?.name)
|
||||
applyUiScale: false
|
||||
customRadius: Style.radiusL
|
||||
icon: "wallpaper-selector"
|
||||
tooltipText: {
|
||||
if (PanelService.getPanel("wallpaperPanel", screen)?.isPanelOpen) {
|
||||
return "";
|
||||
} else {
|
||||
return I18n.tr("tooltips.wallpaper-selector");
|
||||
}
|
||||
}
|
||||
tooltipDirection: BarService.getTooltipDirection(screen?.name)
|
||||
colorBg: Style.capsuleColor
|
||||
colorFg: Color.resolveColorKey(iconColorKey)
|
||||
border.color: Style.capsuleBorderColor
|
||||
border.width: Style.capsuleBorderWidth
|
||||
|
||||
NPopupContextMenu {
|
||||
id: contextMenu
|
||||
|
||||
model: [
|
||||
{
|
||||
"label": I18n.tr("actions.random-wallpaper"),
|
||||
"action": "random-wallpaper",
|
||||
"icon": "dice"
|
||||
},
|
||||
{
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
},
|
||||
]
|
||||
|
||||
onTriggered: action => {
|
||||
contextMenu.close();
|
||||
PanelService.closeContextMenu(screen);
|
||||
|
||||
if (action === "random-wallpaper") {
|
||||
WallpaperService.setRandomWallpaper();
|
||||
} else if (action === "widget-settings") {
|
||||
BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
var wallpaperPanel = PanelService.getPanel("wallpaperPanel", screen);
|
||||
if (Settings.data.wallpaper.panelPosition === "follow_bar") {
|
||||
wallpaperPanel?.toggle(this);
|
||||
} else {
|
||||
wallpaperPanel?.toggle();
|
||||
}
|
||||
}
|
||||
onRightClicked: {
|
||||
PanelService.showContextMenu(contextMenu, root, screen);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Media
|
||||
import qs.Widgets
|
||||
|
||||
// Audio controls card: output and input volume controls
|
||||
NBox {
|
||||
id: root
|
||||
|
||||
property real localOutputVolume: 0
|
||||
property bool localOutputVolumeChanging: false
|
||||
property int lastSinkId: -1
|
||||
|
||||
property real localInputVolume: 0
|
||||
property bool localInputVolumeChanging: false
|
||||
property int lastSourceId: -1
|
||||
|
||||
readonly property bool outputVolumeGuard: outputVolumeSlider.sliderActive || localOutputVolumeChanging
|
||||
readonly property bool inputVolumeGuard: inputVolumeSlider.sliderActive || localInputVolumeChanging
|
||||
|
||||
Component.onCompleted: {
|
||||
var vol = AudioService.volume;
|
||||
localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
var inputVol = AudioService.inputVolume;
|
||||
localInputVolume = (inputVol !== undefined && !isNaN(inputVol)) ? inputVol : 0;
|
||||
if (AudioService.sink) {
|
||||
lastSinkId = AudioService.sink.id;
|
||||
}
|
||||
if (AudioService.source) {
|
||||
lastSourceId = AudioService.source.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset local volume when device changes - use current device's volume
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onSinkChanged() {
|
||||
if (AudioService.sink) {
|
||||
const newSinkId = AudioService.sink.id;
|
||||
if (newSinkId !== lastSinkId) {
|
||||
lastSinkId = newSinkId;
|
||||
// Immediately set local volume to current device's volume
|
||||
var vol = AudioService.volume;
|
||||
localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
} else {
|
||||
lastSinkId = -1;
|
||||
localOutputVolume = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onSourceChanged() {
|
||||
if (AudioService.source) {
|
||||
const newSourceId = AudioService.source.id;
|
||||
if (newSourceId !== lastSourceId) {
|
||||
lastSourceId = newSourceId;
|
||||
// Immediately set local volume to current device's volume
|
||||
var vol = AudioService.inputVolume;
|
||||
localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
} else {
|
||||
lastSourceId = -1;
|
||||
localInputVolume = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to debounce volume changes
|
||||
// Only sync if the device hasn't changed (check by comparing IDs)
|
||||
Timer {
|
||||
interval: 100
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
// Only sync if sink hasn't changed
|
||||
if (AudioService.sink && AudioService.sink.id === lastSinkId) {
|
||||
if (Math.abs(localOutputVolume - AudioService.volume) >= 0.01) {
|
||||
AudioService.setVolume(localOutputVolume);
|
||||
}
|
||||
}
|
||||
// Only sync if source hasn't changed
|
||||
if (AudioService.source && AudioService.source.id === lastSourceId) {
|
||||
if (Math.abs(localInputVolume - AudioService.inputVolume) >= 0.01) {
|
||||
AudioService.setInputVolume(localInputVolume);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onVolumeChanged() {
|
||||
if (!outputVolumeGuard && !AudioService.isSettingOutputVolume && AudioService.sink && AudioService.sink.id === lastSinkId) {
|
||||
var vol = AudioService.volume;
|
||||
localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onInputVolumeChanged() {
|
||||
if (!inputVolumeGuard && !AudioService.isSettingInputVolume && AudioService.source && AudioService.source.id === lastSourceId) {
|
||||
var vol = AudioService.inputVolume;
|
||||
localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: outputVolumeSlider
|
||||
function onSliderActiveChanged() {
|
||||
if (!outputVolumeSlider.sliderActive && AudioService.sink && AudioService.sink.id === lastSinkId) {
|
||||
var vol = AudioService.volume;
|
||||
localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: inputVolumeSlider
|
||||
function onSliderActiveChanged() {
|
||||
if (!inputVolumeSlider.sliderActive && AudioService.source && AudioService.source.id === lastSourceId) {
|
||||
var vol = AudioService.inputVolume;
|
||||
localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
// Output Volume Section
|
||||
ColumnLayout {
|
||||
spacing: Style.marginXXS
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 0
|
||||
opacity: AudioService.sink ? 1.0 : 0.5
|
||||
enabled: AudioService.sink
|
||||
|
||||
// Output Volume Header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
|
||||
NIconButton {
|
||||
icon: AudioService.muted ? "volume-off" : "volume-high"
|
||||
baseSize: Style.baseWidgetSize * 0.5
|
||||
colorFg: AudioService.muted ? Color.mError : Color.mOnSurface
|
||||
colorBg: "transparent"
|
||||
colorBgHover: Color.mHover
|
||||
colorFgHover: Color.mOnHover
|
||||
onClicked: AudioService.setOutputMuted(!AudioService.muted)
|
||||
}
|
||||
|
||||
NText {
|
||||
text: AudioService.sink ? AudioService.sink.description : "No output device"
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mOnSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Output Volume Slider
|
||||
NSlider {
|
||||
id: outputVolumeSlider
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
|
||||
value: localOutputVolume
|
||||
stepSize: 0.01
|
||||
heightRatio: 0.5
|
||||
onMoved: localOutputVolume = value
|
||||
onPressedChanged: localOutputVolumeChanging = pressed
|
||||
tooltipText: `${Math.round((outputVolumeGuard ? localOutputVolume : AudioService.volume) * 100)}%`
|
||||
tooltipDirection: "bottom"
|
||||
|
||||
// MouseArea to handle wheel events when hovering over the slider
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
propagateComposedEvents: true
|
||||
|
||||
onWheel: wheel => {
|
||||
if (outputVolumeSlider.enabled && AudioService.sink) {
|
||||
const delta = wheel.angleDelta.y || wheel.angleDelta.x;
|
||||
const step = Settings.data.audio.volumeStep / 100.0; // Convert percentage to 0-1 range
|
||||
const increment = delta > 0 ? step : -step;
|
||||
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
|
||||
const newValue = Math.max(0, Math.min(maxVolume, localOutputVolume + increment));
|
||||
localOutputVolume = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input Volume Section
|
||||
ColumnLayout {
|
||||
spacing: Style.marginXXS
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 0
|
||||
opacity: AudioService.source ? 1.0 : 0.5
|
||||
enabled: AudioService.source
|
||||
|
||||
// Input Volume Header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
|
||||
NIconButton {
|
||||
icon: AudioService.inputMuted ? "microphone-off" : "microphone"
|
||||
baseSize: Style.baseWidgetSize * 0.5
|
||||
colorFg: AudioService.inputMuted ? Color.mError : Color.mOnSurface
|
||||
colorBg: "transparent"
|
||||
colorBgHover: Color.mHover
|
||||
colorFgHover: Color.mOnHover
|
||||
onClicked: AudioService.setInputMuted(!AudioService.inputMuted)
|
||||
}
|
||||
|
||||
NText {
|
||||
text: AudioService.source ? AudioService.source.description : "No input device"
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mOnSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Input Volume Slider
|
||||
NSlider {
|
||||
id: inputVolumeSlider
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
|
||||
value: localInputVolume
|
||||
stepSize: 0.01
|
||||
heightRatio: 0.5
|
||||
onMoved: localInputVolume = value
|
||||
onPressedChanged: localInputVolumeChanging = pressed
|
||||
tooltipText: `${Math.round((inputVolumeGuard ? localInputVolume : AudioService.inputVolume) * 100)}%`
|
||||
tooltipDirection: "bottom"
|
||||
|
||||
// MouseArea to handle wheel events when hovering over the slider
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
propagateComposedEvents: true
|
||||
|
||||
onWheel: wheel => {
|
||||
if (inputVolumeSlider.enabled && AudioService.source) {
|
||||
const delta = wheel.angleDelta.y || wheel.angleDelta.x;
|
||||
const step = Settings.data.audio.volumeStep / 100.0; // Convert percentage to 0-1 range
|
||||
const increment = delta > 0 ? step : -step;
|
||||
const maxVolume = Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
|
||||
const newValue = Math.max(0, Math.min(maxVolume, localInputVolume + increment));
|
||||
localInputVolume = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Hardware
|
||||
import qs.Widgets
|
||||
|
||||
// Brightness control card for the ControlCenter
|
||||
NBox {
|
||||
id: root
|
||||
|
||||
Layout.fillWidth: true
|
||||
clip: true
|
||||
|
||||
// Get the primary monitor (first screen)
|
||||
readonly property var brightnessMonitor: {
|
||||
if (Quickshell.screens.length > 0) {
|
||||
return BrightnessService.getMonitorForScreen(Quickshell.screens[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
property real localBrightness: 0
|
||||
property bool localBrightnessChanging: false
|
||||
|
||||
Component.onCompleted: {
|
||||
if (brightnessMonitor) {
|
||||
localBrightness = brightnessMonitor.brightness || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Update local brightness when monitor changes
|
||||
Connections {
|
||||
target: BrightnessService
|
||||
function onMonitorBrightnessChanged(monitor, newBrightness) {
|
||||
if (monitor === brightnessMonitor && !localBrightnessChanging) {
|
||||
localBrightness = newBrightness;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update local brightness when monitor's brightness property changes
|
||||
Connections {
|
||||
target: brightnessMonitor
|
||||
ignoreUnknownSignals: true
|
||||
function onBrightnessUpdated() {
|
||||
if (brightnessMonitor && !localBrightnessChanging) {
|
||||
localBrightness = brightnessMonitor.brightness || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to debounce brightness changes - only runs when user is changing slider
|
||||
Timer {
|
||||
id: debounceTimer
|
||||
interval: 100
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (brightnessMonitor && Math.abs(localBrightness - brightnessMonitor.brightness) >= 0.01) {
|
||||
brightnessMonitor.setBrightness(localBrightness);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
// Brightness Section
|
||||
ColumnLayout {
|
||||
spacing: Style.marginXXS
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 0
|
||||
opacity: brightnessMonitor && brightnessMonitor.brightnessControlAvailable ? 1.0 : 0.5
|
||||
enabled: brightnessMonitor && brightnessMonitor.brightnessControlAvailable
|
||||
|
||||
// Brightness Header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
|
||||
NIconButton {
|
||||
icon: {
|
||||
if (!brightnessMonitor)
|
||||
return "brightness-low";
|
||||
const brightness = brightnessMonitor.brightness || 0;
|
||||
if (brightness <= 0.001)
|
||||
return "sun-off";
|
||||
return brightness <= 0.5 ? "brightness-low" : "brightness-high";
|
||||
}
|
||||
baseSize: Style.baseWidgetSize * 0.5
|
||||
colorFg: Color.mOnSurface
|
||||
colorBg: "transparent"
|
||||
colorBgHover: Color.mHover
|
||||
colorFgHover: Color.mOnHover
|
||||
}
|
||||
|
||||
NText {
|
||||
text: brightnessMonitor ? I18n.tr("common.brightness") : "No display"
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mOnSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 0
|
||||
}
|
||||
|
||||
NText {
|
||||
text: brightnessMonitor ? Math.round(localBrightness * 100) + "%" : "N/A"
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mOnSurfaceVariant
|
||||
opacity: brightnessMonitor && brightnessMonitor.brightnessControlAvailable ? 1.0 : 0.5
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness Slider
|
||||
NSlider {
|
||||
id: brightnessSlider
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: 1
|
||||
value: localBrightness
|
||||
stepSize: 0.01
|
||||
heightRatio: 0.5
|
||||
onMoved: {
|
||||
localBrightness = value;
|
||||
debounceTimer.restart();
|
||||
}
|
||||
onPressedChanged: localBrightnessChanging = pressed
|
||||
tooltipText: `${Math.round(localBrightness * 100)}%`
|
||||
tooltipDirection: "bottom"
|
||||
|
||||
// MouseArea to handle wheel events when hovering over the slider
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
propagateComposedEvents: true
|
||||
|
||||
onWheel: wheel => {
|
||||
if (brightnessSlider.enabled && brightnessMonitor && brightnessMonitor.brightnessControlAvailable) {
|
||||
const delta = wheel.angleDelta.y || wheel.angleDelta.x;
|
||||
const step = Settings.data.brightness.brightnessStep / 100.0; // Convert percentage to 0-1 range
|
||||
const increment = delta > 0 ? step : -step;
|
||||
const newValue = Math.max(0, Math.min(1, localBrightness + increment));
|
||||
localBrightness = newValue;
|
||||
debounceTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Location
|
||||
import qs.Widgets
|
||||
|
||||
// Calendar header with date, month/year, location, and clock
|
||||
Rectangle {
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumHeight: (60 * Style.uiScaleRatio) + Style.margin2M
|
||||
Layout.preferredHeight: (60 * Style.uiScaleRatio) + Style.margin2M
|
||||
implicitHeight: (60 * Style.uiScaleRatio) + Style.margin2M
|
||||
radius: Style.radiusL
|
||||
color: Color.mPrimary
|
||||
|
||||
// Internal state
|
||||
readonly property var now: Time.now
|
||||
readonly property bool weatherReady: Settings.data.location.weatherEnabled && (LocationService.data.weather !== null)
|
||||
|
||||
// Expose current month/year for potential synchronization with CalendarMonthCard
|
||||
readonly property int currentMonth: now.getMonth()
|
||||
readonly property int currentYear: now.getFullYear()
|
||||
|
||||
ColumnLayout {
|
||||
id: capsuleColumn
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.topMargin: Style.marginM
|
||||
anchors.bottomMargin: Style.marginM
|
||||
anchors.rightMargin: clockLoader.width + Style.margin2XL
|
||||
anchors.leftMargin: Style.marginXL
|
||||
spacing: 0
|
||||
|
||||
// Combined layout for date, month year, location and time-zone
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
height: 60 * Style.uiScaleRatio
|
||||
clip: true
|
||||
spacing: Style.marginS
|
||||
|
||||
// Today day number
|
||||
NText {
|
||||
Layout.preferredWidth: implicitWidth
|
||||
elide: Text.ElideNone
|
||||
clip: true
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
|
||||
text: root.now.getDate()
|
||||
pointSize: Style.fontSizeXXXL * 1.5
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnPrimary
|
||||
}
|
||||
|
||||
// Month, year, location
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
|
||||
Layout.bottomMargin: Style.marginXXS
|
||||
Layout.topMargin: -Style.marginXXS
|
||||
spacing: -Style.marginXS
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginS
|
||||
|
||||
NText {
|
||||
text: I18n.locale.monthName(root.currentMonth, Locale.LongFormat).toUpperCase()
|
||||
pointSize: Style.fontSizeXL * 1.1
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnPrimary
|
||||
Layout.alignment: Qt.AlignBaseline
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
NText {
|
||||
text: `${root.currentYear}`
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Qt.alpha(Color.mOnPrimary, 0.7)
|
||||
Layout.alignment: Qt.AlignBaseline
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: 0
|
||||
|
||||
NText {
|
||||
text: {
|
||||
if (!Settings.data.location.weatherEnabled)
|
||||
return "";
|
||||
if (!LocationService.locationConfigured)
|
||||
return I18n.tr("common.weather-no-location");
|
||||
if (!root.weatherReady)
|
||||
return I18n.tr("common.weather-loading");
|
||||
if (Settings.data.location.hideWeatherCityName)
|
||||
return "";
|
||||
const chunks = Settings.data.location.name.split(",");
|
||||
return chunks[0];
|
||||
}
|
||||
pointSize: Style.fontSizeM
|
||||
color: Color.mOnPrimary
|
||||
Layout.maximumWidth: 150
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
NText {
|
||||
text: root.weatherReady && !Settings.data.location.hideWeatherTimezone ? `${Settings.data.location.hideWeatherCityName ? "" : " "}(${LocationService.data.weather.timezone_abbreviation})` : ""
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Qt.alpha(Color.mOnPrimary, 0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analog/Digital clock
|
||||
NClock {
|
||||
id: clockLoader
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.marginXL
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clockStyle: Settings.data.location.analogClockInCalendar ? "analog" : "digital"
|
||||
progressColor: Color.mOnPrimary
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
now: root.now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Location
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
// Calendar month grid with navigation
|
||||
NBox {
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: calendarContent.implicitHeight + Style.margin2M
|
||||
|
||||
// Internal state - independent from header
|
||||
readonly property var now: Time.now
|
||||
property int calendarMonth: now.getMonth()
|
||||
property int calendarYear: now.getFullYear()
|
||||
readonly property int firstDayOfWeek: Settings.data.location.firstDayOfWeek === -1 ? I18n.locale.firstDayOfWeek : Settings.data.location.firstDayOfWeek
|
||||
|
||||
// Helper function to calculate ISO week number
|
||||
function getISOWeekNumber(date) {
|
||||
const target = new Date(date.valueOf());
|
||||
const dayNr = (date.getDay() + 6) % 7;
|
||||
target.setDate(target.getDate() - dayNr + 3);
|
||||
const firstThursday = new Date(target.getFullYear(), 0, 4);
|
||||
const diff = target - firstThursday;
|
||||
const oneWeek = 1000 * 60 * 60 * 24 * 7;
|
||||
const weekNumber = 1 + Math.round(diff / oneWeek);
|
||||
return weekNumber;
|
||||
}
|
||||
|
||||
// Helper function to check if an event is all-day
|
||||
function isAllDayEvent(event) {
|
||||
const duration = event.end - event.start;
|
||||
const startDate = new Date(event.start * 1000);
|
||||
const isAtMidnight = startDate.getHours() === 0 && startDate.getMinutes() === 0;
|
||||
return duration === 86400 && isAtMidnight;
|
||||
}
|
||||
|
||||
// Navigation functions
|
||||
function navigateToPreviousMonth() {
|
||||
let newDate = new Date(root.calendarYear, root.calendarMonth - 1, 1);
|
||||
root.calendarYear = newDate.getFullYear();
|
||||
root.calendarMonth = newDate.getMonth();
|
||||
const now = new Date();
|
||||
const monthStart = new Date(root.calendarYear, root.calendarMonth, 1);
|
||||
const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0);
|
||||
const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000)));
|
||||
const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000)));
|
||||
CalendarService.loadEvents(daysAhead + 30, daysBehind + 30);
|
||||
}
|
||||
|
||||
function navigateToNextMonth() {
|
||||
let newDate = new Date(root.calendarYear, root.calendarMonth + 1, 1);
|
||||
root.calendarYear = newDate.getFullYear();
|
||||
root.calendarMonth = newDate.getMonth();
|
||||
const now = new Date();
|
||||
const monthStart = new Date(root.calendarYear, root.calendarMonth, 1);
|
||||
const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0);
|
||||
const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000)));
|
||||
const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000)));
|
||||
CalendarService.loadEvents(daysAhead + 30, daysBehind + 30);
|
||||
}
|
||||
|
||||
// Wheel handler for month navigation
|
||||
WheelHandler {
|
||||
id: wheelHandler
|
||||
target: root
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: function (event) {
|
||||
if (event.angleDelta.y > 0) {
|
||||
// Scroll up - go to previous month
|
||||
root.navigateToPreviousMonth();
|
||||
event.accepted = true;
|
||||
} else if (event.angleDelta.y < 0) {
|
||||
// Scroll down - go to next month
|
||||
root.navigateToNextMonth();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: calendarContent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
// Navigation row
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: Style.marginS
|
||||
}
|
||||
|
||||
NText {
|
||||
text: I18n.locale.monthName(root.calendarMonth, Locale.LongFormat).toUpperCase() + " " + root.calendarYear
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "chevron-left"
|
||||
onClicked: root.navigateToPreviousMonth()
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "calendar"
|
||||
onClicked: {
|
||||
root.calendarMonth = root.now.getMonth();
|
||||
root.calendarYear = root.now.getFullYear();
|
||||
CalendarService.loadEvents();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "chevron-right"
|
||||
onClicked: root.navigateToNextMonth()
|
||||
}
|
||||
}
|
||||
|
||||
// Day names header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
visible: Settings.data.location.showWeekNumberInCalendar
|
||||
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.fontSizeS * 2
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
text: {
|
||||
let dayIndex = (root.firstDayOfWeek + index) % 7;
|
||||
const dayName = I18n.locale.dayName(dayIndex, Locale.ShortFormat);
|
||||
return dayName.substring(0, 2).toUpperCase();
|
||||
}
|
||||
color: Color.mPrimary
|
||||
pointSize: Style.fontSizeS
|
||||
font.weight: Style.fontWeightBold
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar grid with week numbers
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
// Helper functions
|
||||
function hasEventsOnDate(year, month, day) {
|
||||
if (!CalendarService.available || CalendarService.events.length === 0)
|
||||
return false;
|
||||
const targetDate = new Date(year, month, day);
|
||||
const targetStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000;
|
||||
const targetEnd = targetStart + 86400;
|
||||
return CalendarService.events.some(event => {
|
||||
return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd);
|
||||
});
|
||||
}
|
||||
|
||||
function getEventsForDate(year, month, day) {
|
||||
if (!CalendarService.available || CalendarService.events.length === 0)
|
||||
return [];
|
||||
const targetDate = new Date(year, month, day);
|
||||
const targetStart = Math.floor(new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000);
|
||||
const targetEnd = targetStart + 86400;
|
||||
return CalendarService.events.filter(event => {
|
||||
return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd);
|
||||
});
|
||||
}
|
||||
|
||||
function isMultiDayEvent(event) {
|
||||
if (root.isAllDayEvent(event)) {
|
||||
return false;
|
||||
}
|
||||
const startDate = new Date(event.start * 1000);
|
||||
const endDate = new Date(event.end * 1000);
|
||||
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
|
||||
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
|
||||
return startDateOnly.getTime() !== endDateOnly.getTime();
|
||||
}
|
||||
|
||||
function getEventColor(event, isToday) {
|
||||
if (isMultiDayEvent(event)) {
|
||||
return isToday ? Color.mOnSecondary : Color.mTertiary;
|
||||
} else if (root.isAllDayEvent(event)) {
|
||||
return isToday ? Color.mOnSecondary : Color.mSecondary;
|
||||
} else {
|
||||
return isToday ? Color.mOnSecondary : Color.mPrimary;
|
||||
}
|
||||
}
|
||||
|
||||
// Week numbers column
|
||||
ColumnLayout {
|
||||
visible: Settings.data.location.showWeekNumberInCalendar
|
||||
Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0
|
||||
Layout.alignment: Qt.AlignTop
|
||||
spacing: Style.marginXXS
|
||||
|
||||
property var weekNumbers: {
|
||||
if (!grid.daysModel || grid.daysModel.length === 0)
|
||||
return [];
|
||||
const weeks = [];
|
||||
const numWeeks = Math.ceil(grid.daysModel.length / 7);
|
||||
for (var i = 0; i < numWeeks; i++) {
|
||||
const dayIndex = i * 7;
|
||||
if (dayIndex < grid.daysModel.length) {
|
||||
const weekDay = grid.daysModel[dayIndex];
|
||||
const date = new Date(weekDay.year, weekDay.month, weekDay.day);
|
||||
let thursday = new Date(date);
|
||||
if (root.firstDayOfWeek === 0) {
|
||||
thursday.setDate(date.getDate() + 4);
|
||||
} else if (root.firstDayOfWeek === 1) {
|
||||
thursday.setDate(date.getDate() + 3);
|
||||
} else {
|
||||
let daysToThursday = (4 - root.firstDayOfWeek + 7) % 7;
|
||||
thursday.setDate(date.getDate() + daysToThursday);
|
||||
}
|
||||
weeks.push(root.getISOWeekNumber(thursday));
|
||||
}
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: parent.weekNumbers
|
||||
Item {
|
||||
Layout.preferredWidth: Style.baseWidgetSize * 0.7
|
||||
Layout.preferredHeight: Style.baseWidgetSize * 0.9
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
color: Qt.alpha(Color.mPrimary, 0.7)
|
||||
pointSize: Style.fontSizeXXS
|
||||
text: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar grid
|
||||
GridLayout {
|
||||
id: grid
|
||||
Layout.fillWidth: true
|
||||
columns: 7
|
||||
columnSpacing: Style.marginXXS
|
||||
rowSpacing: Style.marginXXS
|
||||
|
||||
property int month: root.calendarMonth
|
||||
property int year: root.calendarYear
|
||||
|
||||
property var daysModel: {
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const lastOfMonth = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastOfMonth.getDate();
|
||||
const firstDayOfWeek = root.firstDayOfWeek;
|
||||
const firstOfMonthDayOfWeek = firstOfMonth.getDay();
|
||||
let daysBefore = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7;
|
||||
const lastOfMonthDayOfWeek = lastOfMonth.getDay();
|
||||
const daysAfter = (firstDayOfWeek - lastOfMonthDayOfWeek - 1 + 7) % 7;
|
||||
const days = [];
|
||||
const today = new Date();
|
||||
|
||||
// Previous month days
|
||||
const prevMonth = new Date(year, month, 0);
|
||||
const prevMonthDays = prevMonth.getDate();
|
||||
for (var i = daysBefore - 1; i >= 0; i--) {
|
||||
const day = prevMonthDays - i;
|
||||
days.push({
|
||||
"day": day,
|
||||
"month": month - 1,
|
||||
"year": month === 0 ? year - 1 : year,
|
||||
"today": false,
|
||||
"currentMonth": false
|
||||
});
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (var day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(year, month, day);
|
||||
const isToday = date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate();
|
||||
days.push({
|
||||
"day": day,
|
||||
"month": month,
|
||||
"year": year,
|
||||
"today": isToday,
|
||||
"currentMonth": true
|
||||
});
|
||||
}
|
||||
|
||||
// Next month days
|
||||
for (var i = 1; i <= daysAfter; i++) {
|
||||
days.push({
|
||||
"day": i,
|
||||
"month": month + 1,
|
||||
"year": month === 11 ? year + 1 : year,
|
||||
"today": false,
|
||||
"currentMonth": false
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: grid.daysModel
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Style.baseWidgetSize * 0.9
|
||||
|
||||
Rectangle {
|
||||
width: Style.baseWidgetSize * 0.9
|
||||
height: Style.baseWidgetSize * 0.9
|
||||
anchors.centerIn: parent
|
||||
radius: Style.radiusM
|
||||
color: modelData.today ? Color.mSecondary : "transparent"
|
||||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.day
|
||||
color: {
|
||||
if (modelData.today)
|
||||
return Color.mOnSecondary;
|
||||
if (modelData.currentMonth)
|
||||
return Color.mOnSurface;
|
||||
return Color.mOnSurfaceVariant;
|
||||
}
|
||||
opacity: modelData.currentMonth ? 1.0 : 0.4
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: modelData.today ? Style.fontWeightBold : Style.fontWeightMedium
|
||||
}
|
||||
|
||||
// Event indicator dots
|
||||
Row {
|
||||
visible: Settings.data.location.showCalendarEvents && parent.parent.parent.parent.hasEventsOnDate(modelData.year, modelData.month, modelData.day)
|
||||
spacing: 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: Style.marginXS
|
||||
|
||||
Repeater {
|
||||
model: parent.parent.parent.parent.parent.getEventsForDate(modelData.year, modelData.month, modelData.day)
|
||||
|
||||
Rectangle {
|
||||
width: 4
|
||||
height: width
|
||||
radius: Style.radiusXXS
|
||||
color: parent.parent.parent.parent.parent.getEventColor(modelData, modelData.today)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: Settings.data.location.showCalendarEvents
|
||||
|
||||
onEntered: {
|
||||
const events = parent.parent.parent.parent.getEventsForDate(modelData.year, modelData.month, modelData.day);
|
||||
if (events.length > 0) {
|
||||
const summaries = events.map(event => {
|
||||
if (root.isAllDayEvent(event)) {
|
||||
return event.summary;
|
||||
} else {
|
||||
const timeFormat = Settings.data.location.use12hourFormat ? "hh:mm AP" : "HH:mm";
|
||||
const start = new Date(event.start * 1000);
|
||||
const startFormatted = I18n.locale.toString(start, timeFormat);
|
||||
const end = new Date(event.end * 1000);
|
||||
const endFormatted = I18n.locale.toString(end, timeFormat);
|
||||
return `${startFormatted}-${endFormatted} ${event.summary}`;
|
||||
}
|
||||
}).join('\n');
|
||||
TooltipService.show(parent, summaries, "auto", Style.tooltipDelay, Settings.data.ui.fontFixed);
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
const dateWithSlashes = `${(modelData.month + 1).toString().padStart(2, '0')}/${modelData.day.toString().padStart(2, '0')}/${modelData.year.toString().substring(2)}`;
|
||||
if (ProgramCheckerService.gnomeCalendarAvailable) {
|
||||
Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]);
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
TooltipService.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
import qs.Widgets.AudioSpectrum
|
||||
|
||||
NBox {
|
||||
id: root
|
||||
|
||||
// Track whether we have an active media player
|
||||
readonly property bool hasActivePlayer: MediaService.currentPlayer && MediaService.canPlay
|
||||
|
||||
// SpectrumService registration for visualizer
|
||||
readonly property bool needsSpectrum: Settings.data.audio.visualizerType !== "" && Settings.data.audio.visualizerType !== "none"
|
||||
|
||||
onNeedsSpectrumChanged: {
|
||||
if (root.needsSpectrum) {
|
||||
SpectrumService.registerComponent("mediacard");
|
||||
} else {
|
||||
SpectrumService.unregisterComponent("mediacard");
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.needsSpectrum) {
|
||||
SpectrumService.registerComponent("mediacard");
|
||||
}
|
||||
updateCachedWallpaper();
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
SpectrumService.unregisterComponent("mediacard");
|
||||
}
|
||||
|
||||
property string wallpaper: WallpaperService.getWallpaper(screen.name)
|
||||
property string cachedWallpaper: ""
|
||||
|
||||
// External state management
|
||||
Connections {
|
||||
target: WallpaperService
|
||||
function onWallpaperChanged(screenName, path) {
|
||||
if (screenName === screen.name) {
|
||||
wallpaper = path;
|
||||
updateCachedWallpaper();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateCachedWallpaper() {
|
||||
// Handle solid color mode - no wallpaper to cache
|
||||
if (Settings.data.wallpaper.useSolidColor || WallpaperService.isSolidColorPath(wallpaper)) {
|
||||
cachedWallpaper = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wallpaper) {
|
||||
cachedWallpaper = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ImageCacheService.initialized) {
|
||||
cachedWallpaper = wallpaper;
|
||||
return;
|
||||
}
|
||||
|
||||
ImageCacheService.getThumbnail(wallpaper, function (cachedPath, success) {
|
||||
if (!root)
|
||||
return;
|
||||
cachedWallpaper = success ? cachedPath : wallpaper;
|
||||
});
|
||||
}
|
||||
|
||||
// Wrapper - rounded rect clipper
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
layer.enabled: true
|
||||
layer.smooth: true
|
||||
layer.effect: MultiEffect {
|
||||
maskEnabled: true
|
||||
maskThresholdMin: 0.95
|
||||
maskSpreadAtMin: 0.15
|
||||
maskSource: ShaderEffectSource {
|
||||
sourceItem: Rectangle {
|
||||
width: root.width
|
||||
height: root.height
|
||||
radius: Style.radiusM
|
||||
color: "white"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Solid color background (always present as base layer)
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Settings.data.wallpaper.useSolidColor ? Settings.data.wallpaper.solidColor : Color.mSurface
|
||||
}
|
||||
|
||||
// Background image that covers everything
|
||||
Image {
|
||||
id: bgImage
|
||||
readonly property int dim: Math.round(256 * Style.uiScaleRatio)
|
||||
anchors.fill: parent
|
||||
visible: source.toString() !== ""
|
||||
source: MediaService.trackArtUrl || (Settings.data.wallpaper.enabled && !Settings.data.wallpaper.useSolidColor ? root.cachedWallpaper : "")
|
||||
sourceSize: Qt.size(dim, dim)
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
layer.enabled: true
|
||||
layer.smooth: true
|
||||
layer.effect: MultiEffect {
|
||||
blurEnabled: true
|
||||
blurMax: 8
|
||||
blur: 0.33
|
||||
}
|
||||
}
|
||||
|
||||
// Dark overlay for readability
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Color.mSurface
|
||||
opacity: 0.65
|
||||
radius: Style.radiusM
|
||||
}
|
||||
|
||||
// Border
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
border.color: Style.boxBorderColor
|
||||
border.width: Style.borderS
|
||||
radius: Style.radiusM
|
||||
}
|
||||
|
||||
// Background visualizer on top of the artwork
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: Settings.data.audio.visualizerType !== "" && Settings.data.audio.visualizerType !== "none"
|
||||
|
||||
sourceComponent: {
|
||||
switch (Settings.data.audio.visualizerType) {
|
||||
case "linear":
|
||||
return linearComponent;
|
||||
case "mirrored":
|
||||
return mirroredComponent;
|
||||
case "wave":
|
||||
return waveComponent;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: linearComponent
|
||||
NLinearSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.8
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: mirroredComponent
|
||||
NMirroredSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.8
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: waveComponent
|
||||
NWaveSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.8
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Player selector
|
||||
Rectangle {
|
||||
id: playerSelectorButton
|
||||
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.baseWidgetSize
|
||||
visible: MediaService.getAvailablePlayers().length > 1
|
||||
radius: Style.radiusM
|
||||
color: "transparent"
|
||||
|
||||
property var currentPlayer: MediaService.getAvailablePlayers()[MediaService.selectedPlayerIndex]
|
||||
|
||||
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 = MediaService.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
|
||||
verticalPolicy: ScrollBar.AlwaysOff
|
||||
|
||||
onTriggered: function (action) {
|
||||
var index = parseInt(action);
|
||||
if (!isNaN(index)) {
|
||||
MediaService.switchToPlayer(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content container that adjusts for player selector
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: playerSelectorButton.visible ? (playerSelectorButton.height + Style.marginXS + Style.marginM) : Style.marginM
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
anchors.bottomMargin: Style.marginM
|
||||
|
||||
// No media player detected - centered disc icon
|
||||
NIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: !root.hasActivePlayer && SpectrumService.isIdle
|
||||
icon: "disc"
|
||||
pointSize: Style.fontSizeXXXL * 3
|
||||
color: Color.mOnSurfaceVariant
|
||||
opacity: 1.0
|
||||
}
|
||||
|
||||
// MediaPlayer Main Content - use Loader for performance
|
||||
Loader {
|
||||
id: mainLoader
|
||||
anchors.fill: parent
|
||||
active: root.hasActivePlayer
|
||||
|
||||
sourceComponent: Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
// Exceptionaly we put shadow on text and controls to ease readability
|
||||
NDropShadow {
|
||||
anchors.fill: main
|
||||
source: main
|
||||
autoPaddingEnabled: true
|
||||
shadowBlur: 1.0
|
||||
shadowOpacity: 0.9
|
||||
shadowHorizontalOffset: 0
|
||||
shadowVerticalOffset: 0
|
||||
shadowColor: Settings.data.colorSchemes.darkMode ? "black" : "white"
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: main
|
||||
anchors.fill: parent
|
||||
spacing: Style.marginS
|
||||
|
||||
// Spacer to push content down
|
||||
Item {
|
||||
Layout.preferredHeight: Style.marginM
|
||||
}
|
||||
|
||||
// Metadata
|
||||
ColumnLayout {
|
||||
id: metadata
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
spacing: Style.marginXS
|
||||
|
||||
NText {
|
||||
visible: MediaService.trackTitle !== ""
|
||||
text: MediaService.trackTitle
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: MediaService.trackArtist !== ""
|
||||
text: MediaService.trackArtist
|
||||
color: Color.mSecondary
|
||||
pointSize: Style.fontSizeS
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: MediaService.trackAlbum !== ""
|
||||
text: MediaService.trackAlbum
|
||||
color: Color.mOnSurfaceVariant
|
||||
pointSize: Style.fontSizeM
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// Progress slider
|
||||
Item {
|
||||
id: progressWrapper
|
||||
visible: (MediaService.currentPlayer && MediaService.trackLength > 0)
|
||||
Layout.fillWidth: true
|
||||
height: Style.baseWidgetSize * 0.5
|
||||
|
||||
property real localSeekRatio: -1
|
||||
property real lastSentSeekRatio: -1
|
||||
property real seekEpsilon: 0.01
|
||||
property real progressRatio: {
|
||||
if (!MediaService.currentPlayer || MediaService.trackLength <= 0)
|
||||
return 0;
|
||||
const r = MediaService.currentPosition / MediaService.trackLength;
|
||||
if (isNaN(r) || !isFinite(r))
|
||||
return 0;
|
||||
return Math.max(0, Math.min(1, r));
|
||||
}
|
||||
property real effectiveRatio: (MediaService.isSeeking && localSeekRatio >= 0) ? Math.max(0, Math.min(1, localSeekRatio)) : progressRatio
|
||||
|
||||
Timer {
|
||||
id: seekDebounce
|
||||
interval: 75
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (MediaService.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) {
|
||||
MediaService.seekByRatio(next);
|
||||
progressWrapper.lastSentSeekRatio = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NSlider {
|
||||
id: progressSlider
|
||||
anchors.fill: parent
|
||||
from: 0
|
||||
to: 1
|
||||
stepSize: 0
|
||||
snapAlways: false
|
||||
enabled: MediaService.trackLength > 0 && MediaService.canSeek
|
||||
heightRatio: 0.6
|
||||
|
||||
onMoved: {
|
||||
progressWrapper.localSeekRatio = value;
|
||||
seekDebounce.restart();
|
||||
}
|
||||
onPressedChanged: {
|
||||
if (pressed) {
|
||||
MediaService.isSeeking = true;
|
||||
progressWrapper.localSeekRatio = value;
|
||||
MediaService.seekByRatio(value);
|
||||
progressWrapper.lastSentSeekRatio = value;
|
||||
} else {
|
||||
seekDebounce.stop();
|
||||
MediaService.seekByRatio(value);
|
||||
MediaService.isSeeking = false;
|
||||
progressWrapper.localSeekRatio = -1;
|
||||
progressWrapper.lastSentSeekRatio = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: progressSlider
|
||||
property: "value"
|
||||
value: progressWrapper.progressRatio
|
||||
when: !MediaService.isSeeking
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer to push media controls down
|
||||
Item {
|
||||
Layout.preferredHeight: Style.marginL
|
||||
}
|
||||
|
||||
// Media controls
|
||||
RowLayout {
|
||||
spacing: Style.marginS
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
|
||||
NIconButton {
|
||||
icon: "media-prev"
|
||||
visible: MediaService.canGoPrevious
|
||||
onClicked: MediaService.canGoPrevious ? MediaService.previous() : {}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: MediaService.isPlaying ? "media-pause" : "media-play"
|
||||
visible: (MediaService.canPlay || MediaService.canPause)
|
||||
onClicked: (MediaService.canPlay || MediaService.canPause) ? MediaService.playPause() : {}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "media-next"
|
||||
visible: MediaService.canGoNext
|
||||
onClicked: MediaService.canGoNext ? MediaService.next() : {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
// Header card with avatar, user and quick actions
|
||||
NBox {
|
||||
id: root
|
||||
|
||||
property string uptimeText: "--"
|
||||
|
||||
RowLayout {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
NImageRounded {
|
||||
Layout.preferredWidth: Math.round(Style.baseWidgetSize * 1.25 * Style.uiScaleRatio)
|
||||
Layout.preferredHeight: Math.round(Style.baseWidgetSize * 1.25 * Style.uiScaleRatio)
|
||||
radius: Layout.preferredWidth / 2
|
||||
imagePath: Settings.preprocessPath(Settings.data.general.avatarImage)
|
||||
fallbackIcon: "person"
|
||||
borderColor: Color.mPrimary
|
||||
borderWidth: Style.borderS * 1.5
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumWidth: 0
|
||||
spacing: Style.marginXXS
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumWidth: 0
|
||||
text: HostService.displayName
|
||||
font.weight: Style.fontWeightBold
|
||||
}
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumWidth: 0
|
||||
text: I18n.tr("system.uptime", {
|
||||
"uptime": uptimeText
|
||||
})
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginS
|
||||
Layout.fillWidth: false
|
||||
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
NIconButton {
|
||||
icon: "settings"
|
||||
tooltipText: I18n.tr("tooltips.open-settings")
|
||||
onClicked: {
|
||||
// Better close the control center in case the settings open in a separate window
|
||||
PanelService.openedPanel?.close();
|
||||
|
||||
var panel = PanelService.getPanel("settingsPanel", screen);
|
||||
panel.requestedTab = SettingsPanel.Tab.General;
|
||||
panel.open();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "power"
|
||||
tooltipText: I18n.tr("tooltips.session-menu")
|
||||
onClicked: {
|
||||
PanelService.getPanel("sessionMenuPanel", screen)?.open();
|
||||
PanelService.getPanel("controlCenterPanel", screen)?.close();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
onClicked: {
|
||||
PanelService.getPanel("controlCenterPanel", screen)?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// 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]);
|
||||
uptimeText = Time.formatVagueHumanReadableDuration(uptimeSeconds);
|
||||
uptimeProcess.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSystemInfo() {
|
||||
uptimeProcess.running = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.ControlCenter
|
||||
import qs.Widgets
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginL
|
||||
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: root.shortcutsHeight
|
||||
visible: Settings.data.controlCenter.shortcuts.left.length > 0
|
||||
|
||||
RowLayout {
|
||||
id: leftContent
|
||||
anchors.fill: parent
|
||||
spacing: Style.marginS
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Settings.data.controlCenter.shortcuts.left
|
||||
delegate: ControlCenterWidgetLoader {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
Layout.fillWidth: false
|
||||
widgetId: (modelData.id !== undefined ? modelData.id : "")
|
||||
widgetScreen: root.screen
|
||||
widgetProps: {
|
||||
"widgetId": modelData.id,
|
||||
"section": "quickSettings",
|
||||
"sectionWidgetIndex": index,
|
||||
"sectionWidgetsCount": Settings.data.controlCenter.shortcuts.left.length,
|
||||
"widgetSettings": modelData
|
||||
}
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: root.shortcutsHeight
|
||||
visible: Settings.data.controlCenter.shortcuts.right.length > 0
|
||||
|
||||
RowLayout {
|
||||
id: rightContent
|
||||
anchors.fill: parent
|
||||
spacing: Style.marginS
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Settings.data.controlCenter.shortcuts.right
|
||||
delegate: ControlCenterWidgetLoader {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
Layout.fillWidth: false
|
||||
widgetId: (modelData.id !== undefined ? modelData.id : "")
|
||||
widgetScreen: root.screen
|
||||
widgetProps: {
|
||||
"widgetId": modelData.id,
|
||||
"section": "quickSettings",
|
||||
"sectionWidgetIndex": index,
|
||||
"sectionWidgetsCount": Settings.data.controlCenter.shortcuts.right.length,
|
||||
"widgetSettings": modelData
|
||||
}
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
// Unified system card: monitors CPU, temp, memory, disk
|
||||
NBox {
|
||||
id: root
|
||||
|
||||
Component.onCompleted: SystemStatService.registerComponent("card-sysmonitor")
|
||||
Component.onDestruction: SystemStatService.unregisterComponent("card-sysmonitor")
|
||||
|
||||
readonly property string diskPath: Settings.data.controlCenter.diskPath || "/"
|
||||
readonly property real contentScale: 0.95 * Style.uiScaleRatio
|
||||
|
||||
Item {
|
||||
id: content
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height / 4
|
||||
|
||||
NCircleStat {
|
||||
id: cpuUsageGauge
|
||||
anchors.centerIn: parent
|
||||
ratio: SystemStatService.cpuUsage / 100
|
||||
icon: "cpu-usage"
|
||||
contentScale: root.contentScale
|
||||
fillColor: SystemStatService.cpuColor
|
||||
tooltipText: I18n.tr("system-monitor.cpu-usage") + `: ${Math.round(SystemStatService.cpuUsage)}%`
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SystemStatService
|
||||
function onCpuUsageChanged() {
|
||||
if (TooltipService.activeTooltip && TooltipService.activeTooltip.targetItem === cpuUsageGauge) {
|
||||
TooltipService.updateText(I18n.tr("system-monitor.cpu-usage") + `: ${Math.round(SystemStatService.cpuUsage)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height / 4
|
||||
|
||||
NCircleStat {
|
||||
id: cpuTempGauge
|
||||
anchors.centerIn: parent
|
||||
ratio: SystemStatService.cpuTemp / 100
|
||||
suffix: "°C"
|
||||
icon: "cpu-temperature"
|
||||
contentScale: root.contentScale
|
||||
fillColor: SystemStatService.tempColor
|
||||
tooltipText: I18n.tr("system-monitor.cpu-temp") + `: ${Math.round(SystemStatService.cpuTemp)}°C`
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SystemStatService
|
||||
function onCpuTempChanged() {
|
||||
if (TooltipService.activeTooltip && TooltipService.activeTooltip.targetItem === cpuTempGauge) {
|
||||
TooltipService.updateText(I18n.tr("system-monitor.cpu-temp") + `: ${Math.round(SystemStatService.cpuTemp)}°C`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height / 4
|
||||
|
||||
NCircleStat {
|
||||
id: memPercentGauge
|
||||
anchors.centerIn: parent
|
||||
ratio: SystemStatService.memPercent / 100
|
||||
icon: "memory"
|
||||
contentScale: root.contentScale
|
||||
fillColor: SystemStatService.memColor
|
||||
tooltipText: I18n.tr("common.memory") + `: ${Math.round(SystemStatService.memPercent)}%`
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SystemStatService
|
||||
function onMemPercentChanged() {
|
||||
if (TooltipService.activeTooltip && TooltipService.activeTooltip.targetItem === memPercentGauge) {
|
||||
TooltipService.updateText(I18n.tr("common.memory") + `: ${Math.round(SystemStatService.memPercent)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height / 4
|
||||
|
||||
NCircleStat {
|
||||
id: diskPercentsGauge
|
||||
anchors.centerIn: parent
|
||||
ratio: (SystemStatService.diskPercents[root.diskPath] ?? 0) / 100
|
||||
icon: "storage"
|
||||
contentScale: root.contentScale
|
||||
fillColor: SystemStatService.getDiskColor(root.diskPath)
|
||||
tooltipText: I18n.tr("system-monitor.disk") + `: ${SystemStatService.diskPercents[root.diskPath] || 0}%\n${root.diskPath}`
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SystemStatService
|
||||
function onDiskPercentsChanged() {
|
||||
if (TooltipService.activeTooltip && TooltipService.activeTooltip.targetItem === diskPercentsGauge) {
|
||||
TooltipService.updateText(I18n.tr("system-monitor.disk") + `: ${SystemStatService.diskPercents[root.diskPath] || 0}%\n${root.diskPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Location
|
||||
import qs.Widgets
|
||||
|
||||
// Weather overview card (placeholder data)
|
||||
NBox {
|
||||
id: root
|
||||
|
||||
property int forecastDays: 6
|
||||
property bool showLocation: true
|
||||
property bool showEffects: Settings.data.location.weatherShowEffects
|
||||
readonly property bool weatherReady: Settings.data.location.weatherEnabled && (LocationService.data.weather !== null)
|
||||
|
||||
// Test mode: set to "clear_day", "clear_night", "rain", "snow", "cloud" or "fog"
|
||||
property string testEffects: ""
|
||||
|
||||
// Weather condition detection
|
||||
readonly property int currentWeatherCode: weatherReady ? LocationService.data.weather.current_weather.weathercode : 0
|
||||
readonly property bool isDayTime: weatherReady ? LocationService.data.weather.current_weather.is_day : true
|
||||
readonly property bool isRaining: testEffects === "rain" || (testEffects === "" && ((currentWeatherCode >= 51 && currentWeatherCode <= 67) || (currentWeatherCode >= 80 && currentWeatherCode <= 82)))
|
||||
readonly property bool isSnowing: testEffects === "snow" || (testEffects === "" && ((currentWeatherCode >= 71 && currentWeatherCode <= 77) || (currentWeatherCode >= 85 && currentWeatherCode <= 86)))
|
||||
readonly property bool isCloudy: testEffects === "cloud" || (testEffects === "" && (currentWeatherCode === 3))
|
||||
readonly property bool isFoggy: testEffects === "fog" || (testEffects === "" && (currentWeatherCode >= 40 && currentWeatherCode <= 49))
|
||||
readonly property bool isClearDay: testEffects === "clear_day" || (testEffects === "" && (currentWeatherCode === 0 && isDayTime))
|
||||
readonly property bool isClearNight: testEffects === "clear_night" || (testEffects === "" && (currentWeatherCode === 0 && !isDayTime))
|
||||
|
||||
visible: Settings.data.location.weatherEnabled
|
||||
implicitHeight: Math.max(100 * Style.uiScaleRatio, content.implicitHeight + Style.margin2XL)
|
||||
|
||||
// Weather effect layer (rain/snow)
|
||||
Loader {
|
||||
id: weatherEffectLoader
|
||||
anchors.fill: parent
|
||||
active: root.showEffects && (root.isRaining || root.isSnowing || root.isCloudy || root.isFoggy || root.isClearDay || root.isClearNight)
|
||||
|
||||
sourceComponent: Item {
|
||||
anchors.fill: parent
|
||||
|
||||
// Animated time for shaders
|
||||
property real shaderTime: 0
|
||||
NumberAnimation on shaderTime {
|
||||
loops: Animation.Infinite
|
||||
from: 0
|
||||
to: root.isSnowing ? 900 : 3000
|
||||
duration: 300000
|
||||
}
|
||||
|
||||
ShaderEffect {
|
||||
id: weatherEffect
|
||||
anchors.fill: parent
|
||||
// Rain matches content margins, everything else fills the box
|
||||
anchors.margins: root.isRaining ? Style.marginXL : root.border.width
|
||||
|
||||
property var source: ShaderEffectSource {
|
||||
sourceItem: content
|
||||
hideSource: root.isRaining // Only hide for rain (distortion), show for snow
|
||||
}
|
||||
|
||||
// Limit update rate to avoid using too much processing power
|
||||
property real time: parent.shaderTime - (parent.shaderTime % (1 / (root.isRaining ? 3 : root.isSnowing ? 6 : 2)))
|
||||
property real itemWidth: weatherEffect.width
|
||||
property real itemHeight: weatherEffect.height
|
||||
property color bgColor: root.color
|
||||
property real cornerRadius: root.isRaining ? 0 : (root.radius - root.border.width)
|
||||
property real alternative: root.isFoggy
|
||||
|
||||
fragmentShader: {
|
||||
let shaderName;
|
||||
if (root.isSnowing)
|
||||
shaderName = "weather_snow";
|
||||
else if (root.isRaining)
|
||||
shaderName = "weather_rain";
|
||||
else if (root.isCloudy || root.isFoggy)
|
||||
shaderName = "weather_cloud";
|
||||
else if (root.isClearDay)
|
||||
shaderName = "weather_sun";
|
||||
else if (root.isClearNight)
|
||||
shaderName = "weather_stars";
|
||||
else
|
||||
shaderName = "";
|
||||
|
||||
return Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/" + shaderName + ".frag.qsb");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginXL
|
||||
spacing: Style.marginM
|
||||
clip: true
|
||||
|
||||
RowLayout {
|
||||
visible: weatherReady
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: Style.marginXXS
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: Style.marginL
|
||||
Layout.fillWidth: true
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: mainWeatherIconSide
|
||||
Layout.preferredHeight: mainWeatherIconSide
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
readonly property int mainWeatherIconSide: Math.round(Style.fontSizeXXXL * 1.75 * Style.uiScaleRatio * 1.6)
|
||||
|
||||
NIcon {
|
||||
visible: !LocationService.taliaWeatherMascotActive
|
||||
anchors.centerIn: parent
|
||||
icon: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode) : ""
|
||||
pointSize: Style.fontSizeXXXL * 1.75
|
||||
color: Color.mPrimary
|
||||
}
|
||||
Loader {
|
||||
active: LocationService.taliaWeatherMascotActive
|
||||
anchors.fill: parent
|
||||
asynchronous: true
|
||||
sourceComponent: Component {
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
mipmap: true
|
||||
asynchronous: true
|
||||
source: Qt.resolvedUrl(LocationService.taliaWeatherImageFromCode(currentWeatherCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Style.marginXXS
|
||||
NText {
|
||||
text: {
|
||||
// Ensure the name is not too long if one had to specify the country
|
||||
const chunks = Settings.data.location.name.split(",");
|
||||
return chunks[0];
|
||||
}
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
visible: showLocation && !Settings.data.location.hideWeatherCityName
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
NText {
|
||||
text: {
|
||||
if (!weatherReady)
|
||||
return "";
|
||||
var temp = LocationService.data.weather.current_weather.temperature;
|
||||
var suffix = "C";
|
||||
if (Settings.data.location.useFahrenheit) {
|
||||
temp = LocationService.celsiusToFahrenheit(temp);
|
||||
var suffix = "F";
|
||||
}
|
||||
temp = Math.round(temp);
|
||||
return `${temp}°${suffix}`;
|
||||
}
|
||||
pointSize: showLocation ? Style.fontSizeXL : Style.fontSizeXL * 1.6
|
||||
font.weight: Style.fontWeightBold
|
||||
}
|
||||
|
||||
NText {
|
||||
text: weatherReady ? `(${LocationService.data.weather.timezone_abbreviation})` : ""
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mOnSurfaceVariant
|
||||
visible: LocationService.data.weather && showLocation && !Settings.data.location.hideWeatherTimezone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NDivider {
|
||||
visible: weatherReady
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
visible: weatherReady
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: Style.marginM
|
||||
|
||||
Repeater {
|
||||
model: weatherReady ? Math.min(root.forecastDays, LocationService.data.weather.daily.time.length) : 0
|
||||
delegate: ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
NText {
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
|
||||
text: {
|
||||
var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/"));
|
||||
return I18n.locale.toString(weatherDate, "ddd");
|
||||
}
|
||||
color: Color.mOnSurface
|
||||
}
|
||||
Item {
|
||||
Layout.preferredWidth: forecastWeatherIconSide
|
||||
Layout.preferredHeight: forecastWeatherIconSide
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
|
||||
readonly property int forecastWeatherIconSide: Math.round(Style.fontSizeXXL * 1.6 * Style.uiScaleRatio * 1.6)
|
||||
|
||||
NIcon {
|
||||
visible: !LocationService.taliaWeatherMascotActive
|
||||
anchors.centerIn: parent
|
||||
icon: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index])
|
||||
pointSize: Style.fontSizeXXL * 1.6
|
||||
color: Color.mPrimary
|
||||
}
|
||||
Loader {
|
||||
active: LocationService.taliaWeatherMascotActive
|
||||
anchors.fill: parent
|
||||
asynchronous: true
|
||||
sourceComponent: Component {
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
mipmap: true
|
||||
asynchronous: true
|
||||
source: Qt.resolvedUrl(LocationService.taliaWeatherImageFromCode(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];
|
||||
if (Settings.data.location.useFahrenheit) {
|
||||
max = LocationService.celsiusToFahrenheit(max);
|
||||
min = LocationService.celsiusToFahrenheit(min);
|
||||
}
|
||||
max = Math.round(max);
|
||||
min = Math.round(min);
|
||||
return `${max}°/${min}°`;
|
||||
}
|
||||
pointSize: Style.fontSizeXS
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
visible: !weatherReady
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
spacing: Style.marginS
|
||||
|
||||
NBusyIndicator {
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
visible: LocationService.locationConfigured
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: !LocationService.locationConfigured
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
text: I18n.tr("common.weather-no-location")
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Noctalia
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Variants {
|
||||
id: root
|
||||
model: Quickshell.screens
|
||||
|
||||
// Direct binding to registry's widgets property for reactivity
|
||||
readonly property var registeredWidgets: DesktopWidgetRegistry.widgets
|
||||
|
||||
// Force reload counter - incremented when plugin widget registry changes
|
||||
property int pluginReloadCounter: 0
|
||||
|
||||
Connections {
|
||||
target: DesktopWidgetRegistry
|
||||
|
||||
function onPluginWidgetRegistryUpdated() {
|
||||
root.pluginReloadCounter++;
|
||||
Logger.d("DesktopWidgets", "Plugin widget registry updated, reload counter:", root.pluginReloadCounter);
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Loader {
|
||||
id: screenLoader
|
||||
required property ShellScreen modelData
|
||||
|
||||
// Reactive property for widgets on this specific screen
|
||||
// Returns a fresh array whenever Settings changes
|
||||
property var screenWidgets: {
|
||||
if (!modelData || !modelData.name) {
|
||||
return [];
|
||||
}
|
||||
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
|
||||
for (var i = 0; i < monitorWidgets.length; i++) {
|
||||
if (monitorWidgets[i].name === modelData.name) {
|
||||
return monitorWidgets[i].widgets || [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Only create PanelWindow if enabled AND (screen has widgets OR in edit mode)
|
||||
// During compositor overview, show widgets only when overviewEnabled is true.
|
||||
active: modelData && Settings.data.desktopWidgets.enabled && (screenWidgets.length > 0 || DesktopWidgetRegistry.editMode) && (!CompositorService.overviewActive || Settings.data.desktopWidgets.overviewEnabled) && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableDesktopWidgets) && !PanelService.lockScreen?.active
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
id: window
|
||||
color: "transparent"
|
||||
screen: screenLoader.modelData
|
||||
mask: DesktopWidgetRegistry.editMode ? null : widgetsMask
|
||||
|
||||
// Dynamic mask: combine clickable regions for each loaded widget
|
||||
property var _maskRegions: []
|
||||
|
||||
Component {
|
||||
id: maskRegionComponent
|
||||
Region {}
|
||||
}
|
||||
|
||||
Region {
|
||||
id: widgetsMask
|
||||
regions: window._maskRegions
|
||||
}
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Bottom
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.namespace: "noctalia-desktop-widgets-" + (screen?.name || "unknown")
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
right: true
|
||||
left: true
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.d("DesktopWidgets", "Created panel window for", screen?.name);
|
||||
}
|
||||
|
||||
// Add a new widget to the current screen
|
||||
function addWidgetToCurrentScreen(widgetId) {
|
||||
var monitorName = window.screen.name;
|
||||
var newWidget = {
|
||||
"id": widgetId
|
||||
};
|
||||
|
||||
// Load default metadata if available
|
||||
var metadata = DesktopWidgetRegistry.widgetMetadata[widgetId];
|
||||
if (metadata) {
|
||||
Object.keys(metadata).forEach(function (key) {
|
||||
newWidget[key] = metadata[key];
|
||||
});
|
||||
}
|
||||
|
||||
// Place at screen center
|
||||
newWidget.x = (window.screen.width / 2) - 100;
|
||||
newWidget.y = (window.screen.height / 2) - 100;
|
||||
newWidget.scale = 1.0;
|
||||
|
||||
// Get current widgets and add new one
|
||||
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
|
||||
var newMonitorWidgets = monitorWidgets.slice();
|
||||
var found = false;
|
||||
|
||||
for (var i = 0; i < newMonitorWidgets.length; i++) {
|
||||
if (newMonitorWidgets[i].name === monitorName) {
|
||||
var widgets = (newMonitorWidgets[i].widgets || []).slice();
|
||||
widgets.push(newWidget);
|
||||
newMonitorWidgets[i] = {
|
||||
"name": monitorName,
|
||||
"widgets": widgets
|
||||
};
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
newMonitorWidgets.push({
|
||||
"name": monitorName,
|
||||
"widgets": [newWidget]
|
||||
});
|
||||
}
|
||||
|
||||
Settings.data.desktopWidgets.monitorWidgets = newMonitorWidgets;
|
||||
Logger.i("DesktopWidgets", "Added widget", widgetId, "to", monitorName);
|
||||
}
|
||||
|
||||
Item {
|
||||
id: widgetsContainer
|
||||
anchors.fill: parent
|
||||
|
||||
// Visual grid overlay - shown when grid snap is enabled in edit mode
|
||||
// Using Loader to properly unload Canvas when not needed
|
||||
Loader {
|
||||
id: gridOverlayLoader
|
||||
active: DesktopWidgetRegistry.editMode && Settings.data.desktopWidgets.enabled && Settings.data.desktopWidgets.gridSnap
|
||||
anchors.fill: parent
|
||||
z: -1 // Behind widgets but above background
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: Canvas {
|
||||
id: gridOverlay
|
||||
anchors.fill: parent
|
||||
opacity: 0.3
|
||||
|
||||
// Grid size calculated based on screen resolution - matches DraggableDesktopWidget
|
||||
// Ensures grid lines pass through the screen center on both axes
|
||||
readonly property int gridSize: {
|
||||
if (!window.screen)
|
||||
return 30; // Fallback
|
||||
var baseSize = Math.round(window.screen.width * 0.015);
|
||||
baseSize = Math.max(20, Math.min(60, baseSize));
|
||||
|
||||
// Calculate center coordinates
|
||||
var centerX = window.screen.width / 2;
|
||||
var centerY = window.screen.height / 2;
|
||||
|
||||
// Find a grid size that divides evenly into both center coordinates
|
||||
// This ensures a grid line crosses through the center on both axes
|
||||
var bestSize = baseSize;
|
||||
var bestDistance = Infinity;
|
||||
|
||||
// Try values around baseSize to find one that divides evenly into both centers
|
||||
for (var offset = -10; offset <= 10; offset++) {
|
||||
var candidate = baseSize + offset;
|
||||
if (candidate < 20 || candidate > 60)
|
||||
continue;
|
||||
|
||||
// Check if this size divides evenly into both center coordinates
|
||||
var remainderX = centerX % candidate;
|
||||
var remainderY = centerY % candidate;
|
||||
|
||||
// If both remainders are 0, this is perfect - center is on grid lines
|
||||
if (remainderX === 0 && remainderY === 0) {
|
||||
return candidate; // Perfect match, use it immediately
|
||||
}
|
||||
|
||||
// Otherwise, find the closest to perfect alignment
|
||||
var distance = Math.abs(remainderX) + Math.abs(remainderY);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestSize = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a perfect match, it would have returned already
|
||||
// Otherwise, try to find a divisor of both centerX and centerY
|
||||
// that's close to our best size
|
||||
var gcd = function (a, b) {
|
||||
while (b !== 0) {
|
||||
var temp = b;
|
||||
b = a % b;
|
||||
a = temp;
|
||||
}
|
||||
return a;
|
||||
};
|
||||
|
||||
// Find common divisors of centerX and centerY
|
||||
var centerGcd = gcd(Math.round(centerX), Math.round(centerY));
|
||||
if (centerGcd > 0) {
|
||||
// Find a divisor of centerGcd that's close to bestSize
|
||||
for (var divisor = Math.floor(centerGcd / 60); divisor <= Math.ceil(centerGcd / 20); divisor++) {
|
||||
if (centerGcd % divisor !== 0)
|
||||
continue;
|
||||
var candidate = centerGcd / divisor;
|
||||
if (candidate >= 20 && candidate <= 60) {
|
||||
if (Math.abs(candidate - baseSize) < Math.abs(bestSize - baseSize)) {
|
||||
bestSize = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestSize;
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d");
|
||||
ctx.reset();
|
||||
ctx.strokeStyle = Color.mPrimary;
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Draw vertical lines
|
||||
for (let x = 0; x <= width; x += gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw horizontal lines
|
||||
for (let y = 0; y <= height; y += gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Repaint when size changes
|
||||
onWidthChanged: requestPaint()
|
||||
onHeightChanged: requestPaint()
|
||||
|
||||
Component.onCompleted: {
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Settings.data.desktopWidgets
|
||||
function onGridSnapChanged() {
|
||||
if (gridOverlayLoader.active) {
|
||||
gridOverlay.requestPaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DesktopWidgetRegistry
|
||||
function onEditModeChanged() {
|
||||
if (gridOverlayLoader.active) {
|
||||
gridOverlay.requestPaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load widgets dynamically from per-monitor array
|
||||
Repeater {
|
||||
model: screenLoader.screenWidgets
|
||||
|
||||
delegate: Loader {
|
||||
id: widgetLoader
|
||||
// Bind to registeredWidgets and pluginReloadCounter to re-evaluate when plugins register/unregister
|
||||
active: (modelData.id in root.registeredWidgets) && (root.pluginReloadCounter >= 0)
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
property var _maskRegion: null
|
||||
readonly property bool _isPlugin: DesktopWidgetRegistry.isPluginWidget(modelData.id)
|
||||
|
||||
// All widgets use setSource() so that screen, widgetData, and
|
||||
// widgetIndex are set as initial properties, available during
|
||||
// Component.onCompleted. This prevents registration-key
|
||||
// mismatches in widgets that build IDs from screen.name.
|
||||
Component.onCompleted: _loadWidget()
|
||||
|
||||
onActiveChanged: {
|
||||
if (active)
|
||||
_loadWidget();
|
||||
}
|
||||
|
||||
function _loadWidget() {
|
||||
var widgetId = modelData.id;
|
||||
var comp = root.registeredWidgets[widgetId];
|
||||
if (!comp)
|
||||
return;
|
||||
|
||||
var props = {
|
||||
"screen": window.screen,
|
||||
"widgetData": modelData,
|
||||
"widgetIndex": index
|
||||
};
|
||||
|
||||
if (_isPlugin) {
|
||||
var pluginId = widgetId.replace("plugin:", "");
|
||||
var api = PluginService.getPluginAPI(pluginId);
|
||||
if (api)
|
||||
props.pluginApi = api;
|
||||
setSource(comp.url, props);
|
||||
} else {
|
||||
// Core widgets: use explicit URL (inline Component.url
|
||||
// returns the registry file, not the widget file)
|
||||
var url = DesktopWidgetRegistry.widgetUrls[widgetId];
|
||||
if (url)
|
||||
setSource(url, props);
|
||||
}
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (item) {
|
||||
item.parent = widgetsContainer;
|
||||
|
||||
// Create mask region so this widget receives mouse input
|
||||
_maskRegion = maskRegionComponent.createObject(window);
|
||||
_maskRegion.item = item;
|
||||
var newRegions = window._maskRegions.slice();
|
||||
newRegions.push(_maskRegion);
|
||||
window._maskRegions = newRegions;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up mask region when widget unloads
|
||||
onItemChanged: {
|
||||
if (!item && _maskRegion) {
|
||||
var region = _maskRegion;
|
||||
_maskRegion = null;
|
||||
window._maskRegions = window._maskRegions.filter(function (r) {
|
||||
return r !== region;
|
||||
});
|
||||
region.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit mode controls panel
|
||||
Rectangle {
|
||||
id: editModeControlsPanel
|
||||
visible: DesktopWidgetRegistry.editMode && Settings.data.desktopWidgets.enabled
|
||||
|
||||
readonly property string barPos: Settings.getBarPositionForScreen(window.screen?.name)
|
||||
readonly property bool barFloating: Settings.data.bar.barType === "floating"
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(window.screen?.name)
|
||||
|
||||
readonly property int barOffsetTop: {
|
||||
if (barPos !== "top")
|
||||
return Style.marginM;
|
||||
const floatMarginV = barFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
|
||||
return barHeight + floatMarginV + Style.marginM;
|
||||
}
|
||||
readonly property int barOffsetRight: {
|
||||
if (barPos !== "right")
|
||||
return Style.marginM;
|
||||
const floatMarginH = barFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
|
||||
return barHeight + floatMarginH + Style.marginM;
|
||||
}
|
||||
|
||||
// Internal state for drag tracking (session-only, resets on restart)
|
||||
QtObject {
|
||||
id: panelInternal
|
||||
property bool isDragging: false
|
||||
property real dragOffsetX: 0
|
||||
property real dragOffsetY: 0
|
||||
// Default position: top-right corner accounting for bar
|
||||
property real baseX: widgetsContainer.width - editModeControlsPanel.width - editModeControlsPanel.barOffsetRight
|
||||
property real baseY: editModeControlsPanel.barOffsetTop
|
||||
}
|
||||
|
||||
// Reset position when bar position changes
|
||||
Connections {
|
||||
target: Settings.data.bar
|
||||
function onPositionChanged() {
|
||||
panelInternal.baseX = widgetsContainer.width - editModeControlsPanel.width - editModeControlsPanel.barOffsetRight;
|
||||
panelInternal.baseY = editModeControlsPanel.barOffsetTop;
|
||||
}
|
||||
}
|
||||
|
||||
x: panelInternal.isDragging ? panelInternal.dragOffsetX : panelInternal.baseX
|
||||
y: panelInternal.isDragging ? panelInternal.dragOffsetY : panelInternal.baseY
|
||||
|
||||
width: controlsLayout.implicitWidth + Style.margin2XL
|
||||
height: controlsLayout.implicitHeight + Style.margin2XL
|
||||
|
||||
color: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b, 0.85)
|
||||
radius: Style.radiusL
|
||||
border {
|
||||
width: Style.borderS
|
||||
color: Color.mOutline
|
||||
}
|
||||
z: 9999
|
||||
|
||||
// Drag area for relocating the panel
|
||||
MouseArea {
|
||||
id: dragArea
|
||||
anchors.fill: parent
|
||||
cursorShape: panelInternal.isDragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor
|
||||
|
||||
property point pressPos: Qt.point(0, 0)
|
||||
|
||||
onPressed: mouse => {
|
||||
pressPos = mapToItem(widgetsContainer, mouse.x, mouse.y);
|
||||
panelInternal.dragOffsetX = editModeControlsPanel.x;
|
||||
panelInternal.dragOffsetY = editModeControlsPanel.y;
|
||||
panelInternal.isDragging = true;
|
||||
}
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
if (panelInternal.isDragging && pressed) {
|
||||
var currentPos = mapToItem(widgetsContainer, mouse.x, mouse.y);
|
||||
var deltaX = currentPos.x - pressPos.x;
|
||||
var deltaY = currentPos.y - pressPos.y;
|
||||
|
||||
var newX = panelInternal.baseX + deltaX;
|
||||
var newY = panelInternal.baseY + deltaY;
|
||||
|
||||
// Boundary clamping
|
||||
newX = Math.max(0, Math.min(newX, widgetsContainer.width - editModeControlsPanel.width));
|
||||
newY = Math.max(0, Math.min(newY, widgetsContainer.height - editModeControlsPanel.height));
|
||||
|
||||
panelInternal.dragOffsetX = newX;
|
||||
panelInternal.dragOffsetY = newY;
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: {
|
||||
if (panelInternal.isDragging) {
|
||||
panelInternal.baseX = panelInternal.dragOffsetX;
|
||||
panelInternal.baseY = panelInternal.dragOffsetY;
|
||||
panelInternal.isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
panelInternal.isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: controlsLayout
|
||||
anchors {
|
||||
fill: parent
|
||||
margins: Style.marginXL
|
||||
}
|
||||
spacing: Style.marginL
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignRight
|
||||
spacing: Style.marginS
|
||||
|
||||
NIconButton {
|
||||
id: addWidgetButton
|
||||
icon: "layout-grid-add"
|
||||
tooltipText: I18n.tr("tooltips.add-widget")
|
||||
onClicked: {
|
||||
var popupMenuWindow = PanelService.getPopupMenuWindow(window.screen);
|
||||
if (popupMenuWindow) {
|
||||
// Build menu items from registry
|
||||
var items = [];
|
||||
var widgets = DesktopWidgetRegistry.widgets;
|
||||
for (var id in widgets) {
|
||||
items.push({
|
||||
action: id,
|
||||
text: DesktopWidgetRegistry.getWidgetDisplayName(id),
|
||||
icon: "layout-grid-add"
|
||||
});
|
||||
}
|
||||
var globalPos = addWidgetButton.mapToItem(null, 0, addWidgetButton.height + Style.marginS);
|
||||
popupMenuWindow.showDynamicContextMenu(items, globalPos.x, globalPos.y, function (widgetId) {
|
||||
addWidgetToCurrentScreen(widgetId);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "grid-3x3"
|
||||
visible: Settings.data.desktopWidgets.gridSnap
|
||||
tooltipText: I18n.tr("panels.desktop-widgets.edit-mode-grid-snap-scale-label")
|
||||
colorBg: Settings.data.desktopWidgets.gridSnapScale ? Color.mPrimary : Color.mSurfaceVariant
|
||||
colorFg: Settings.data.desktopWidgets.gridSnapScale ? Color.mOnPrimary : Color.mPrimary
|
||||
onClicked: Settings.data.desktopWidgets.gridSnapScale = !Settings.data.desktopWidgets.gridSnapScale
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "grid-4x4"
|
||||
tooltipText: I18n.tr("panels.desktop-widgets.edit-mode-grid-snap-label")
|
||||
colorBg: Settings.data.desktopWidgets.gridSnap ? Color.mPrimary : Color.mSurfaceVariant
|
||||
colorFg: Settings.data.desktopWidgets.gridSnap ? Color.mOnPrimary : Color.mPrimary
|
||||
onClicked: Settings.data.desktopWidgets.gridSnap = !Settings.data.desktopWidgets.gridSnap
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "settings"
|
||||
tooltipText: I18n.tr("actions.open-settings")
|
||||
onClicked: {
|
||||
SettingsPanelService.toggle(SettingsPanel.Tab.DesktopWidgets, -1, screenLoader.modelData);
|
||||
}
|
||||
}
|
||||
|
||||
NButton {
|
||||
text: I18n.tr("panels.desktop-widgets.edit-mode-exit-button")
|
||||
icon: "logout"
|
||||
outlined: false
|
||||
fontSize: Style.fontSizeS
|
||||
iconSize: Style.fontSizeM
|
||||
onClicked: DesktopWidgetRegistry.editMode = false
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.maximumWidth: 300 * Style.uiScaleRatio
|
||||
text: I18n.tr("panels.desktop-widgets.edit-mode-controls-explanation")
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
horizontalAlignment: Text.AlignRight
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Noctalia
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ShellScreen screen
|
||||
property var widgetData: null
|
||||
property int widgetIndex: -1
|
||||
|
||||
property real defaultX: 100
|
||||
property real defaultY: 100
|
||||
|
||||
default property alias content: contentContainer.data
|
||||
|
||||
readonly property bool isDragging: internal.isDragging
|
||||
readonly property bool isScaling: internal.isScaling
|
||||
|
||||
// All Desktop widgets have these settings, but fallback just in case
|
||||
readonly property var _metadata: widgetData?.id ? DesktopWidgetRegistry.widgetMetadata[widgetData.id] : null
|
||||
property bool showBackground: widgetData.showBackground !== undefined ? widgetData.showBackground : (_metadata?.showBackground ?? true)
|
||||
property bool roundedCorners: widgetData.roundedCorners !== undefined ? widgetData.roundedCorners : (_metadata?.roundedCorners ?? true)
|
||||
|
||||
property real widgetScale: 1.0
|
||||
property real minScale: 0.5
|
||||
property real maxScale: 5.0
|
||||
|
||||
readonly property real scaleSensitivity: 0.0015
|
||||
readonly property real scaleUpdateThreshold: 0.015
|
||||
readonly property real cornerScaleSensitivity: 0.0003 // Much lower sensitivity for corner handles
|
||||
|
||||
// Grid size ensures lines pass through screen center on both axes
|
||||
readonly property int gridSize: {
|
||||
if (!screen)
|
||||
return 30;
|
||||
var baseSize = Math.round(screen.width * 0.015);
|
||||
baseSize = Math.max(20, Math.min(60, baseSize));
|
||||
|
||||
var centerX = screen.width / 2;
|
||||
var centerY = screen.height / 2;
|
||||
var bestSize = baseSize;
|
||||
var bestDistance = Infinity;
|
||||
|
||||
for (var offset = -10; offset <= 10; offset++) {
|
||||
var candidate = baseSize + offset;
|
||||
if (candidate < 20 || candidate > 60)
|
||||
continue;
|
||||
|
||||
var remainderX = centerX % candidate;
|
||||
var remainderY = centerY % candidate;
|
||||
|
||||
if (remainderX === 0 && remainderY === 0) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var distance = Math.abs(remainderX) + Math.abs(remainderY);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestSize = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
var gcd = function (a, b) {
|
||||
while (b !== 0) {
|
||||
var temp = b;
|
||||
b = a % b;
|
||||
a = temp;
|
||||
}
|
||||
return a;
|
||||
};
|
||||
|
||||
var centerGcd = gcd(Math.round(centerX), Math.round(centerY));
|
||||
if (centerGcd > 0) {
|
||||
for (var divisor = Math.floor(centerGcd / 60); divisor <= Math.ceil(centerGcd / 20); divisor++) {
|
||||
if (centerGcd % divisor !== 0)
|
||||
continue;
|
||||
var candidate = centerGcd / divisor;
|
||||
if (candidate >= 20 && candidate <= 60) {
|
||||
if (Math.abs(candidate - baseSize) < Math.abs(bestSize - baseSize)) {
|
||||
bestSize = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestSize;
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: internal
|
||||
property bool isDragging: false
|
||||
property bool isScaling: false
|
||||
property real dragOffsetX: 0
|
||||
property real dragOffsetY: 0
|
||||
property real baseX: (root.widgetData && root.widgetData.x !== undefined) ? root.widgetData.x : root.defaultX
|
||||
property real baseY: (root.widgetData && root.widgetData.y !== undefined) ? root.widgetData.y : root.defaultY
|
||||
property real initialWidth: 0
|
||||
property real initialHeight: 0
|
||||
property point initialMousePos: Qt.point(0, 0)
|
||||
property real initialScale: 1.0
|
||||
property real lastScale: 1.0
|
||||
// Locks operation type to prevent switching between drag/scale mid-operation
|
||||
property string operationType: "" // "drag" or "scale" or ""
|
||||
}
|
||||
|
||||
function snapToGrid(coord) {
|
||||
if (!Settings.data.desktopWidgets.gridSnap) {
|
||||
return coord;
|
||||
}
|
||||
return Math.round(coord / root.gridSize) * root.gridSize;
|
||||
}
|
||||
|
||||
function snapScaleToGrid(scale) {
|
||||
if (!Settings.data.desktopWidgets.gridSnap || !Settings.data.desktopWidgets.gridSnapScale) {
|
||||
return scale;
|
||||
}
|
||||
|
||||
// Get widget's base width
|
||||
var initialWidth = internal.initialWidth;
|
||||
var initialScale = internal.initialScale;
|
||||
if (initialWidth <= 0 || initialScale <= 0) {
|
||||
return scale;
|
||||
}
|
||||
|
||||
// Since initialWidth = baseWidth * initialScale
|
||||
var baseWidth = initialWidth / initialScale;
|
||||
|
||||
// Snap the resulting width with the scale
|
||||
var resultingWidth = baseWidth * scale;
|
||||
var snappedWidth = root.snapToGrid(resultingWidth);
|
||||
|
||||
// Check that the snappedWidth isn't smaller than one grid size
|
||||
if (snappedWidth < root.gridSize) {
|
||||
snappedWidth = root.gridSize;
|
||||
}
|
||||
|
||||
// Return the ratio of the snappedWidth and the baseWidth, which is the new snapped scale
|
||||
var snappedScale = snappedWidth / baseWidth;
|
||||
return Math.max(minScale, Math.min(maxScale, snappedScale));
|
||||
}
|
||||
|
||||
function updateWidgetData(properties) {
|
||||
if (widgetIndex < 0 || !screen || !screen.name) {
|
||||
return;
|
||||
}
|
||||
DesktopWidgetRegistry.updateWidgetData(screen.name, widgetIndex, properties);
|
||||
}
|
||||
|
||||
function removeWidget() {
|
||||
if (widgetIndex < 0 || !screen || !screen.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
|
||||
var newMonitorWidgets = monitorWidgets.slice();
|
||||
|
||||
for (var i = 0; i < newMonitorWidgets.length; i++) {
|
||||
if (newMonitorWidgets[i].name === screen.name) {
|
||||
var widgets = (newMonitorWidgets[i].widgets || []).slice();
|
||||
if (widgetIndex >= 0 && widgetIndex < widgets.length) {
|
||||
widgets.splice(widgetIndex, 1);
|
||||
newMonitorWidgets[i] = Object.assign({}, newMonitorWidgets[i], {
|
||||
"widgets": widgets
|
||||
});
|
||||
Settings.data.desktopWidgets.monitorWidgets = newMonitorWidgets;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function raiseToTop() {
|
||||
if (widgetIndex < 0 || !screen || !screen.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
|
||||
var newMonitorWidgets = monitorWidgets.slice();
|
||||
|
||||
for (var i = 0; i < newMonitorWidgets.length; i++) {
|
||||
if (newMonitorWidgets[i].name === screen.name) {
|
||||
var widgets = (newMonitorWidgets[i].widgets || []).slice();
|
||||
if (widgetIndex < widgets.length && widgetIndex < widgets.length - 1) {
|
||||
var widget = widgets.splice(widgetIndex, 1)[0];
|
||||
widgets.push(widget);
|
||||
newMonitorWidgets[i] = Object.assign({}, newMonitorWidgets[i], {
|
||||
"widgets": widgets
|
||||
});
|
||||
Settings.data.desktopWidgets.monitorWidgets = newMonitorWidgets;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lowerToBottom() {
|
||||
if (widgetIndex < 0 || !screen || !screen.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
|
||||
var newMonitorWidgets = monitorWidgets.slice();
|
||||
|
||||
for (var i = 0; i < newMonitorWidgets.length; i++) {
|
||||
if (newMonitorWidgets[i].name === screen.name) {
|
||||
var widgets = (newMonitorWidgets[i].widgets || []).slice();
|
||||
if (widgetIndex < widgets.length && widgetIndex > 0) {
|
||||
var widget = widgets.splice(widgetIndex, 1)[0];
|
||||
widgets.unshift(widget);
|
||||
newMonitorWidgets[i] = Object.assign({}, newMonitorWidgets[i], {
|
||||
"widgets": widgets
|
||||
});
|
||||
Settings.data.desktopWidgets.monitorWidgets = newMonitorWidgets;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openWidgetSettings() {
|
||||
DesktopWidgetRegistry.openWidgetSettings(screen, widgetIndex, widgetData.id, widgetData);
|
||||
}
|
||||
|
||||
function handleContextMenuAction(action) {
|
||||
if (action === "widget-settings") {
|
||||
// Don't close - openWidgetSettings will use the popup window for the dialog
|
||||
root.openWidgetSettings();
|
||||
return true; // Signal that we're handling close ourselves
|
||||
} else if (action === "reset") {
|
||||
// Reset scale and position to defaults
|
||||
root.widgetScale = 1.0;
|
||||
internal.baseX = root.defaultX;
|
||||
internal.baseY = root.defaultY;
|
||||
root.updateWidgetData({
|
||||
"scale": 1.0,
|
||||
"x": Math.round(root.defaultX),
|
||||
"y": Math.round(root.defaultY)
|
||||
});
|
||||
return false;
|
||||
} else if (action === "raise-to-top") {
|
||||
root.raiseToTop();
|
||||
return false;
|
||||
} else if (action === "lower-to-bottom") {
|
||||
root.lowerToBottom();
|
||||
return false;
|
||||
} else if (action === "delete") {
|
||||
root.removeWidget();
|
||||
return false; // Let caller close the popup
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
x: Math.round(internal.isDragging ? internal.dragOffsetX : internal.baseX)
|
||||
y: Math.round(internal.isDragging ? internal.dragOffsetY : internal.baseY)
|
||||
|
||||
// Note: We no longer use transform-based scaling (scale property)
|
||||
// Instead, child widgets multiply their dimensions by widgetScale
|
||||
// This prevents blurry text at fractional scale values
|
||||
|
||||
Component.onCompleted: {
|
||||
// Initialize scale from widgetData when component is first created
|
||||
if (widgetData && widgetData.scale !== undefined) {
|
||||
widgetScale = widgetData.scale;
|
||||
}
|
||||
}
|
||||
|
||||
onWidgetDataChanged: {
|
||||
if (!internal.isDragging && !internal.isScaling) {
|
||||
internal.baseX = (widgetData && widgetData.x !== undefined) ? widgetData.x : defaultX;
|
||||
internal.baseY = (widgetData && widgetData.y !== undefined) ? widgetData.y : defaultY;
|
||||
if (widgetData && widgetData.scale !== undefined) {
|
||||
widgetScale = widgetData.scale;
|
||||
} else if (widgetData) {
|
||||
// If widgetData exists but scale is not set, default to 1.0
|
||||
widgetScale = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: decorationRect
|
||||
anchors.fill: parent
|
||||
anchors.margins: -outlineMargin
|
||||
color: DesktopWidgetRegistry.editMode ? Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.1) : "transparent"
|
||||
border.color: (DesktopWidgetRegistry.editMode || internal.isDragging) ? (internal.isDragging ? Color.mOutline : Color.mPrimary) : "transparent"
|
||||
border.width: DesktopWidgetRegistry.editMode ? 3 : 0
|
||||
radius: Math.min(Math.round(Style.radiusL * root.widgetScale), Style.radiusL, width / 2, height / 2)
|
||||
z: -1
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: container
|
||||
anchors.fill: parent
|
||||
radius: root.roundedCorners ? Math.min(Math.round(Style.radiusL * root.widgetScale), Style.radiusL, width / 2, height / 2) : 0
|
||||
color: Qt.alpha(Color.mSurface, Settings.data.ui.panelBackgroundOpacity)
|
||||
border {
|
||||
width: 1
|
||||
color: Qt.alpha(Color.mOutline, 0.12)
|
||||
}
|
||||
clip: true
|
||||
visible: root.showBackground
|
||||
|
||||
layer.enabled: Settings.data.general.enableShadows && !internal.isDragging && root.showBackground
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowBlur: Style.shadowBlur * 1.5
|
||||
shadowOpacity: Style.shadowOpacity * 0.6
|
||||
shadowColor: "black"
|
||||
shadowHorizontalOffset: Settings.data.general.shadowOffsetX
|
||||
shadowVerticalOffset: Settings.data.general.shadowOffsetY
|
||||
blurMax: Style.shadowBlurMax
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: contentContainer
|
||||
anchors.fill: parent
|
||||
z: 1
|
||||
clip: true
|
||||
}
|
||||
|
||||
// Context menu model and handler - menu is created dynamically in PopupMenuWindow
|
||||
property var contextMenuModel: {
|
||||
var hasSettings = false;
|
||||
if (widgetData && widgetData.id) {
|
||||
var widgetId = widgetData.id;
|
||||
if (DesktopWidgetRegistry.isPluginWidget(widgetId)) {
|
||||
var pluginId = widgetId.replace("plugin:", "");
|
||||
var manifest = PluginRegistry.getPluginManifest(pluginId);
|
||||
hasSettings = manifest && manifest.entryPoints && (manifest.entryPoints.settings || manifest.entryPoints.desktopWidgetSettings);
|
||||
} else {
|
||||
hasSettings = DesktopWidgetRegistry.widgetSettingsMap[widgetId] !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
var items = [];
|
||||
if (hasSettings) {
|
||||
items.push({
|
||||
"label": I18n.tr("actions.widget-settings"),
|
||||
"action": "widget-settings",
|
||||
"icon": "settings"
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
"label": I18n.tr("common.reset"),
|
||||
"action": "reset",
|
||||
"icon": "restore"
|
||||
});
|
||||
items.push({
|
||||
"label": I18n.tr("actions.raise-to-top"),
|
||||
"action": "raise-to-top",
|
||||
"icon": "stack-front"
|
||||
});
|
||||
items.push({
|
||||
"label": I18n.tr("actions.lower-to-bottom"),
|
||||
"action": "lower-to-bottom",
|
||||
"icon": "stack-back"
|
||||
});
|
||||
items.push({
|
||||
"label": I18n.tr("common.delete"),
|
||||
"action": "delete",
|
||||
"icon": "trash"
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// Drag MouseArea - handles dragging (left-click)
|
||||
MouseArea {
|
||||
id: dragArea
|
||||
anchors.fill: parent
|
||||
z: 1000
|
||||
visible: DesktopWidgetRegistry.editMode
|
||||
cursorShape: internal.isDragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton
|
||||
|
||||
property point pressPos: Qt.point(0, 0)
|
||||
|
||||
onPressed: mouse => {
|
||||
// Prevent starting new operation if one is already in progress
|
||||
if (internal.operationType !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
pressPos = Qt.point(mouse.x, mouse.y);
|
||||
internal.operationType = "drag";
|
||||
internal.dragOffsetX = root.x;
|
||||
internal.dragOffsetY = root.y;
|
||||
internal.isDragging = true;
|
||||
}
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
if (internal.isDragging && pressed && internal.operationType === "drag") {
|
||||
var globalPressPos = mapToItem(root.parent, pressPos.x, pressPos.y);
|
||||
var globalCurrentPos = mapToItem(root.parent, mouse.x, mouse.y);
|
||||
|
||||
var deltaX = globalCurrentPos.x - globalPressPos.x;
|
||||
var deltaY = globalCurrentPos.y - globalPressPos.y;
|
||||
|
||||
var newX = internal.dragOffsetX + deltaX;
|
||||
var newY = internal.dragOffsetY + deltaY;
|
||||
|
||||
// Boundary clamping - allow widgets to go partially off-screen (75% can clip)
|
||||
// This gives users more control over positioning while keeping widgets accessible
|
||||
var scaledWidth = root.width;
|
||||
var scaledHeight = root.height;
|
||||
if (root.parent && scaledWidth > 0 && scaledHeight > 0) {
|
||||
var minVisibleX = scaledWidth * 0.25;
|
||||
var minVisibleY = scaledHeight * 0.25;
|
||||
newX = Math.max(-scaledWidth + minVisibleX, Math.min(newX, root.parent.width - minVisibleX));
|
||||
newY = Math.max(-scaledHeight + minVisibleY, Math.min(newY, root.parent.height - minVisibleY));
|
||||
}
|
||||
|
||||
if (Settings.data.desktopWidgets.gridSnap) {
|
||||
newX = root.snapToGrid(newX);
|
||||
newY = root.snapToGrid(newY);
|
||||
// Re-clamp after snapping
|
||||
if (root.parent && scaledWidth > 0 && scaledHeight > 0) {
|
||||
var minVisibleX = scaledWidth * 0.25;
|
||||
var minVisibleY = scaledHeight * 0.25;
|
||||
newX = Math.max(-scaledWidth + minVisibleX, Math.min(newX, root.parent.width - minVisibleX));
|
||||
newY = Math.max(-scaledHeight + minVisibleY, Math.min(newY, root.parent.height - minVisibleY));
|
||||
}
|
||||
}
|
||||
|
||||
internal.dragOffsetX = newX;
|
||||
internal.dragOffsetY = newY;
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: mouse => {
|
||||
if (internal.isDragging && internal.operationType === "drag" && widgetIndex >= 0 && screen && screen.name) {
|
||||
var roundedX = Math.round(internal.dragOffsetX);
|
||||
var roundedY = Math.round(internal.dragOffsetY);
|
||||
root.updateWidgetData({
|
||||
"x": roundedX,
|
||||
"y": roundedY
|
||||
});
|
||||
|
||||
internal.baseX = roundedX;
|
||||
internal.baseY = roundedY;
|
||||
internal.isDragging = false;
|
||||
internal.operationType = "";
|
||||
}
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
internal.isDragging = false;
|
||||
internal.operationType = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Right-click MouseArea for context menu
|
||||
MouseArea {
|
||||
id: contextMenuArea
|
||||
anchors.fill: parent
|
||||
z: 1001
|
||||
visible: DesktopWidgetRegistry.editMode
|
||||
acceptedButtons: Qt.RightButton
|
||||
hoverEnabled: true
|
||||
|
||||
onPressed: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
var popupMenuWindow = PanelService.getPopupMenuWindow(root.screen);
|
||||
if (popupMenuWindow) {
|
||||
// Map click position to screen coordinates
|
||||
var globalPos = root.mapToItem(null, mouse.x, mouse.y);
|
||||
// Use dynamic context menu (created in PopupMenuWindow's Top layer)
|
||||
// This ensures input events work correctly for desktop widgets (Bottom layer)
|
||||
popupMenuWindow.showDynamicContextMenu(root.contextMenuModel, globalPos.x, globalPos.y, root.handleContextMenuAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Corner handles for scaling - using Repeater to avoid code duplication
|
||||
readonly property real cornerHandleSize: 8 * widgetScale
|
||||
readonly property real outlineMargin: Style.marginS * widgetScale
|
||||
readonly property color colorHandle: Color.mSecondary
|
||||
|
||||
// Corner handle model: defines position, direction, cursor, and triangle points for each corner
|
||||
// xMult/yMult: multipliers for position (0 = left/top edge, 1 = right/bottom edge)
|
||||
// xDir/yDir: direction multiplier for scaling (-1 = left/up increases, 1 = right/down increases)
|
||||
// cursor: resize cursor type (FDiag for TL-BR diagonal, BDiag for TR-BL diagonal)
|
||||
// points: triangle vertices as [x, y] pairs normalized to cornerHandleSize
|
||||
readonly property var cornerHandleModel: [
|
||||
{
|
||||
xMult: 0,
|
||||
yMult: 0,
|
||||
xDir: -1,
|
||||
yDir: -1,
|
||||
cursor: Qt.SizeFDiagCursor,
|
||||
points: [[0, 0], [1, 0], [0, 1]]
|
||||
},
|
||||
{
|
||||
xMult: 1,
|
||||
yMult: 0,
|
||||
xDir: 1,
|
||||
yDir: -1,
|
||||
cursor: Qt.SizeBDiagCursor,
|
||||
points: [[1, 0], [1, 1], [0, 0]]
|
||||
},
|
||||
{
|
||||
xMult: 0,
|
||||
yMult: 1,
|
||||
xDir: -1,
|
||||
yDir: 1,
|
||||
cursor: Qt.SizeBDiagCursor,
|
||||
points: [[0, 1], [0, 0], [1, 1]]
|
||||
},
|
||||
{
|
||||
xMult: 1,
|
||||
yMult: 1,
|
||||
xDir: 1,
|
||||
yDir: 1,
|
||||
cursor: Qt.SizeFDiagCursor,
|
||||
points: [[1, 1], [1, 0], [0, 1]]
|
||||
}
|
||||
]
|
||||
|
||||
Repeater {
|
||||
model: root.cornerHandleModel
|
||||
|
||||
delegate: Canvas {
|
||||
id: cornerHandle
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
visible: DesktopWidgetRegistry.editMode && !internal.isDragging
|
||||
// Position handles at corners of decoration rectangle (which extends by outlineMargin)
|
||||
x: modelData.xMult === 0 ? -outlineMargin : (root.width + outlineMargin - cornerHandleSize)
|
||||
y: modelData.yMult === 0 ? -outlineMargin : (root.height + outlineMargin - cornerHandleSize)
|
||||
width: cornerHandleSize
|
||||
height: cornerHandleSize
|
||||
z: 2000
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d");
|
||||
ctx.reset();
|
||||
ctx.fillStyle = colorHandle;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(modelData.points[0][0] * cornerHandleSize, modelData.points[0][1] * cornerHandleSize);
|
||||
ctx.lineTo(modelData.points[1][0] * cornerHandleSize, modelData.points[1][1] * cornerHandleSize);
|
||||
ctx.lineTo(modelData.points[2][0] * cornerHandleSize, modelData.points[2][1] * cornerHandleSize);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
Component.onCompleted: requestPaint()
|
||||
onVisibleChanged: if (visible)
|
||||
requestPaint()
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onWidthChanged() {
|
||||
if (cornerHandle.visible)
|
||||
cornerHandle.requestPaint();
|
||||
}
|
||||
function onHeightChanged() {
|
||||
if (cornerHandle.visible)
|
||||
cornerHandle.requestPaint();
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: scaleMouseArea
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton
|
||||
cursorShape: cornerHandle.modelData.cursor
|
||||
property point pressPos: Qt.point(0, 0)
|
||||
|
||||
onPressed: mouse => {
|
||||
if (internal.operationType !== "") {
|
||||
return;
|
||||
}
|
||||
pressPos = mapToItem(root.parent, mouse.x, mouse.y);
|
||||
internal.operationType = "scale";
|
||||
internal.isScaling = true;
|
||||
internal.initialScale = root.widgetScale;
|
||||
internal.lastScale = root.widgetScale;
|
||||
internal.initialWidth = root.width;
|
||||
internal.initialHeight = root.height;
|
||||
}
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
if (internal.isScaling && pressed && internal.operationType === "scale") {
|
||||
var currentPos = mapToItem(root.parent, mouse.x, mouse.y);
|
||||
var deltaX = currentPos.x - pressPos.x;
|
||||
var deltaY = currentPos.y - pressPos.y;
|
||||
|
||||
// Project delta onto the diagonal direction for this corner
|
||||
// xDir/yDir indicate which direction increases scale
|
||||
var diagonalDelta = (deltaX * cornerHandle.modelData.xDir + deltaY * cornerHandle.modelData.yDir) / Math.sqrt(2);
|
||||
|
||||
// Scale sensitivity: pixels of drag per 1.0 scale change
|
||||
var sensitivity = 150;
|
||||
var scaleDelta = diagonalDelta / sensitivity;
|
||||
var newScale = Math.max(root.minScale, Math.min(root.maxScale, internal.initialScale + scaleDelta));
|
||||
|
||||
newScale = root.snapScaleToGrid(newScale);
|
||||
|
||||
if (!isNaN(newScale) && newScale > 0) {
|
||||
root.widgetScale = newScale;
|
||||
internal.lastScale = newScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: mouse => {
|
||||
if (internal.isScaling && internal.operationType === "scale") {
|
||||
root.updateWidgetData({
|
||||
"scale": root.widgetScale
|
||||
});
|
||||
internal.isScaling = false;
|
||||
internal.operationType = "";
|
||||
root.widgetScale = root.snapScaleToGrid(root.widgetScale);
|
||||
internal.lastScale = root.widgetScale;
|
||||
}
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
internal.isScaling = false;
|
||||
internal.operationType = "";
|
||||
internal.lastScale = root.widgetScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.DesktopWidgets
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
import qs.Widgets.AudioSpectrum
|
||||
|
||||
DraggableDesktopWidget {
|
||||
id: root
|
||||
|
||||
defaultY: 280
|
||||
|
||||
readonly property var widgetMetadata: DesktopWidgetRegistry.widgetMetadata["AudioVisualizer"]
|
||||
|
||||
readonly property int visualizerWidth: (widgetData && widgetData.width !== undefined) ? widgetData.width : (widgetMetadata?.width ?? 320)
|
||||
readonly property int visualizerHeight: (widgetData && widgetData.height !== undefined) ? widgetData.height : (widgetMetadata?.height ?? 72)
|
||||
readonly property string visualizerType: (widgetData && widgetData.visualizerType !== undefined) ? widgetData.visualizerType : (widgetMetadata?.visualizerType ?? "linear")
|
||||
readonly property bool hideWhenIdle: (widgetData && widgetData.hideWhenIdle !== undefined) ? widgetData.hideWhenIdle : (widgetMetadata?.hideWhenIdle ?? false)
|
||||
readonly property string colorName: (widgetData && widgetData.colorName !== undefined) ? widgetData.colorName : (widgetMetadata?.colorName ?? "primary")
|
||||
|
||||
readonly property color fillColor: Color.resolveColorKey(colorName)
|
||||
|
||||
readonly property bool shouldShow: visualizerType !== "" && visualizerType !== "none" && (!hideWhenIdle || MediaService.isPlaying)
|
||||
readonly property bool isHidden: !shouldShow
|
||||
readonly property bool shouldRegisterSpectrum: shouldShow
|
||||
|
||||
// Keep widget visible in edit mode so users can move/configure it
|
||||
visible: !root.isHidden || DesktopWidgetRegistry.editMode
|
||||
|
||||
readonly property string spectrumComponentId: "desktop:audiovisualizer:" + (root.screen ? root.screen.name : "unknown") + ":" + root.widgetIndex
|
||||
|
||||
onShouldRegisterSpectrumChanged: {
|
||||
if (root.shouldRegisterSpectrum) {
|
||||
SpectrumService.registerComponent(root.spectrumComponentId);
|
||||
} else {
|
||||
SpectrumService.unregisterComponent(root.spectrumComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.shouldRegisterSpectrum) {
|
||||
SpectrumService.registerComponent(root.spectrumComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
SpectrumService.unregisterComponent(root.spectrumComponentId);
|
||||
}
|
||||
|
||||
implicitWidth: Math.round(visualizerWidth * widgetScale)
|
||||
implicitHeight: Math.round(visualizerHeight * widgetScale)
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
Rectangle {
|
||||
id: visualizerMask
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
radius: root.roundedCorners ? Math.min(Math.round(Style.radiusL * root.widgetScale), Style.radiusL, width / 2, height / 2) : 0
|
||||
clip: true
|
||||
|
||||
Loader {
|
||||
id: visualizerLoader
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.showBackground ? Math.round(Style.marginXS * root.widgetScale) : 0
|
||||
active: root.shouldShow
|
||||
asynchronous: true
|
||||
|
||||
sourceComponent: {
|
||||
switch (root.visualizerType) {
|
||||
case "linear":
|
||||
return linearComponent;
|
||||
case "mirrored":
|
||||
return mirroredComponent;
|
||||
case "wave":
|
||||
return waveComponent;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: linearComponent
|
||||
NLinearSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: root.fillColor
|
||||
showMinimumSignal: true
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: mirroredComponent
|
||||
NMirroredSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: root.fillColor
|
||||
showMinimumSignal: true
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: waveComponent
|
||||
NWaveSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: root.fillColor
|
||||
showMinimumSignal: true
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.DesktopWidgets
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
DraggableDesktopWidget {
|
||||
id: root
|
||||
|
||||
readonly property var now: Time.now
|
||||
readonly property var widgetMetadata: DesktopWidgetRegistry.widgetMetadata["Clock"]
|
||||
|
||||
readonly property color clockTextColor: Color.resolveColorKey(clockColor)
|
||||
readonly property real fontSize: Math.round(Style.fontSizeXXXL * 2.5 * widgetScale)
|
||||
readonly property real widgetOpacity: widgetData.opacity !== undefined ? widgetData.opacity : 1.0
|
||||
readonly property string clockStyle: widgetData.clockStyle !== undefined ? widgetData.clockStyle : widgetMetadata.clockStyle
|
||||
readonly property string clockColor: widgetData.clockColor !== undefined ? widgetData.clockColor : widgetMetadata.clockColor
|
||||
readonly property bool useCustomFont: widgetData.useCustomFont !== undefined ? widgetData.useCustomFont : widgetMetadata.useCustomFont
|
||||
readonly property string customFont: widgetData.customFont !== undefined ? widgetData.customFont : widgetMetadata.customFont
|
||||
readonly property string format: widgetData.format !== undefined ? widgetData.format : widgetMetadata.format
|
||||
|
||||
readonly property real contentPadding: Math.round((clockStyle === "minimal" ? Style.marginL : Style.marginXL) * widgetScale)
|
||||
implicitWidth: contentLoader.item ? Math.round((contentLoader.item.implicitWidth || contentLoader.item.width || 0) + contentPadding * 2) : 0
|
||||
implicitHeight: contentLoader.item ? Math.round((contentLoader.item.implicitHeight || contentLoader.item.height || 0) + contentPadding * 2) : 0
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
Component {
|
||||
id: nclockComponent
|
||||
NClock {
|
||||
now: root.now
|
||||
clockStyle: root.clockStyle
|
||||
backgroundColor: "transparent"
|
||||
clockColor: clockTextColor
|
||||
progressColor: Color.mPrimary
|
||||
opacity: root.widgetOpacity
|
||||
height: Math.round(fontSize * 1.9)
|
||||
width: height
|
||||
hoursFontSize: fontSize * 0.6
|
||||
minutesFontSize: fontSize * 0.4
|
||||
scaleRatio: root.widgetScale
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: minimalClockComponent
|
||||
ColumnLayout {
|
||||
spacing: -2
|
||||
opacity: root.widgetOpacity
|
||||
|
||||
Repeater {
|
||||
model: I18n.locale.toString(root.now, root.format.trim()).split("\\n")
|
||||
delegate: NText {
|
||||
visible: text !== ""
|
||||
text: modelData
|
||||
family: root.useCustomFont && root.customFont ? root.customFont : Settings.data.ui.fontDefault
|
||||
pointSize: {
|
||||
if (model.length == 1) {
|
||||
return Math.round(Style.fontSizeXXL * root.widgetScale);
|
||||
} else {
|
||||
return Math.round((index == 0) ? Style.fontSizeXXL * root.widgetScale : Style.fontSizeM * root.widgetScale);
|
||||
}
|
||||
}
|
||||
font.weight: Style.fontWeightBold
|
||||
color: root.clockTextColor
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: contentLoader
|
||||
anchors.centerIn: parent
|
||||
z: 2
|
||||
sourceComponent: clockStyle === "minimal" ? minimalClockComponent : nclockComponent
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.DesktopWidgets
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
import qs.Widgets.AudioSpectrum
|
||||
|
||||
DraggableDesktopWidget {
|
||||
id: root
|
||||
|
||||
defaultY: 200
|
||||
|
||||
// Widget settings - check widgetData exists before accessing properties
|
||||
readonly property string hideMode: (widgetData && widgetData.hideMode !== undefined) ? widgetData.hideMode : "visible"
|
||||
readonly property bool showButtons: (widgetData && widgetData.showButtons !== undefined) ? widgetData.showButtons : true
|
||||
readonly property bool showAlbumArt: (widgetData && widgetData.showAlbumArt !== undefined) ? widgetData.showAlbumArt : true
|
||||
readonly property bool showVisualizer: (widgetData && widgetData.showVisualizer !== undefined) ? widgetData.showVisualizer : true
|
||||
readonly property string visualizerType: (widgetData && widgetData.visualizerType && widgetData.visualizerType !== "") ? widgetData.visualizerType : "linear"
|
||||
readonly property bool roundedCorners: (widgetData && widgetData.roundedCorners !== undefined) ? widgetData.roundedCorners : true
|
||||
readonly property bool hasPlayer: MediaService.currentPlayer !== null
|
||||
readonly property bool isPlaying: MediaService.isPlaying
|
||||
readonly property bool hasActiveTrack: hasPlayer && (MediaService.trackTitle || MediaService.trackArtist)
|
||||
|
||||
// State
|
||||
// Hide when idle when playback is not active
|
||||
readonly property bool shouldHideIdle: (hideMode === "idle") && !isPlaying
|
||||
readonly property bool shouldHideEmpty: !hasPlayer && hideMode === "hidden"
|
||||
readonly property bool isHidden: (shouldHideIdle || shouldHideEmpty) && !DesktopWidgetRegistry.editMode
|
||||
visible: !isHidden
|
||||
|
||||
// SpectrumService registration for visualizer
|
||||
readonly property string spectrumComponentId: "desktopmediaplayer:" + (root.screen ? root.screen.name : "unknown")
|
||||
readonly property bool needsSpectrum: root.shouldShowVisualizer && !root.isHidden
|
||||
|
||||
onNeedsSpectrumChanged: {
|
||||
if (root.needsSpectrum) {
|
||||
SpectrumService.registerComponent(root.spectrumComponentId);
|
||||
} else {
|
||||
SpectrumService.unregisterComponent(root.spectrumComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.needsSpectrum) {
|
||||
SpectrumService.registerComponent(root.spectrumComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
SpectrumService.unregisterComponent(root.spectrumComponentId);
|
||||
}
|
||||
|
||||
readonly property bool showPrev: hasPlayer && MediaService.canGoPrevious
|
||||
readonly property bool showNext: hasPlayer && MediaService.canGoNext
|
||||
readonly property int visibleButtonCount: root.showButtons ? (1 + (showPrev ? 1 : 0) + (showNext ? 1 : 0)) : 0
|
||||
|
||||
implicitWidth: Math.round(400 * widgetScale)
|
||||
implicitHeight: Math.round(64 * widgetScale + Style.margin2M * widgetScale)
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
// Visualizer visibility mode
|
||||
readonly property bool shouldShowVisualizer: {
|
||||
if (!root.showVisualizer)
|
||||
return false;
|
||||
if (root.visualizerType === "" || root.visualizerType === "none")
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Visualizer overlay (visibility controlled by visualizerVisibility setting)
|
||||
// Completely disabled during scaling to avoid expensive canvas redraws
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Math.round(Style.marginXS * widgetScale)
|
||||
anchors.rightMargin: Math.round(Style.marginXS * widgetScale)
|
||||
anchors.topMargin: Math.round(Style.marginXS * widgetScale)
|
||||
anchors.bottomMargin: 0
|
||||
z: 0
|
||||
clip: true
|
||||
active: needsSpectrum
|
||||
layer.enabled: true
|
||||
layer.smooth: true
|
||||
layer.effect: MultiEffect {
|
||||
maskEnabled: root.roundedCorners
|
||||
maskSource: ShaderEffectSource {
|
||||
sourceItem: Rectangle {
|
||||
width: root.width - Math.round(Style.marginXS * widgetScale) * 2
|
||||
height: root.height - Math.round(Style.marginXS * widgetScale)
|
||||
radius: root.roundedCorners ? Math.round(Math.max(0, (Style.radiusL - Style.marginXS) * widgetScale)) : 0
|
||||
color: "white"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: {
|
||||
switch (root.visualizerType) {
|
||||
case "linear":
|
||||
return linearComponent;
|
||||
case "mirrored":
|
||||
return mirroredComponent;
|
||||
case "wave":
|
||||
return waveComponent;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: linearComponent
|
||||
NLinearSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.5
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: mirroredComponent
|
||||
NMirroredSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.5
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: waveComponent
|
||||
NWaveSpectrum {
|
||||
anchors.fill: parent
|
||||
values: SpectrumService.values
|
||||
fillColor: Color.mPrimary
|
||||
opacity: 0.5
|
||||
mirrored: Settings.data.audio.spectrumMirrored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop shadow for text and controls readability over visualizer
|
||||
// Disabled during scaling to avoid expensive recomputations
|
||||
NDropShadow {
|
||||
visible: !root.isScaling
|
||||
anchors.fill: contentLayout
|
||||
source: contentLayout
|
||||
z: 1
|
||||
autoPaddingEnabled: true
|
||||
shadowBlur: 1.0
|
||||
shadowOpacity: 0.9
|
||||
shadowHorizontalOffset: 0
|
||||
shadowVerticalOffset: 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: contentLayout
|
||||
states: [
|
||||
State {
|
||||
when: root.showButtons
|
||||
AnchorChanges {
|
||||
target: contentLayout
|
||||
anchors.horizontalCenter: undefined
|
||||
anchors.verticalCenter: undefined
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
}
|
||||
},
|
||||
State {
|
||||
when: !root.showButtons
|
||||
AnchorChanges {
|
||||
target: contentLayout
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.top: undefined
|
||||
anchors.bottom: undefined
|
||||
anchors.left: undefined
|
||||
anchors.right: undefined
|
||||
}
|
||||
}
|
||||
]
|
||||
anchors.margins: Math.round(Style.marginM * widgetScale)
|
||||
spacing: Math.round(Style.marginS * widgetScale)
|
||||
z: 2
|
||||
|
||||
Item {
|
||||
visible: root.showAlbumArt
|
||||
Layout.preferredWidth: Math.round(48 * widgetScale)
|
||||
Layout.preferredHeight: Math.round(48 * widgetScale)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
NImageRounded {
|
||||
visible: hasPlayer
|
||||
anchors.fill: parent
|
||||
radius: Math.round(Style.radiusM * widgetScale)
|
||||
imagePath: MediaService.trackArtUrl
|
||||
imageFillMode: Image.PreserveAspectCrop
|
||||
fallbackIcon: isPlaying ? "media-pause" : "media-play"
|
||||
fallbackIconSize: Math.round(20 * widgetScale)
|
||||
borderWidth: 0
|
||||
}
|
||||
|
||||
NIcon {
|
||||
visible: !hasPlayer
|
||||
anchors.centerIn: parent
|
||||
icon: "disc"
|
||||
pointSize: Math.round(24 * widgetScale)
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
visible: root.showAlbumArt
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: root.showButtons ? Qt.AlignVCenter : Qt.AlignCenter
|
||||
spacing: 0
|
||||
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
text: hasPlayer ? (MediaService.trackTitle || "Unknown Track") : "No media playing"
|
||||
pointSize: Math.round(Style.fontSizeS * widgetScale)
|
||||
font.weight: Style.fontWeightSemiBold
|
||||
color: Color.mOnSurface
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: hasPlayer && MediaService.trackArtist
|
||||
Layout.fillWidth: true
|
||||
text: MediaService.trackArtist || ""
|
||||
pointSize: Math.round(Style.fontSizeXS * widgetScale)
|
||||
font.weight: Style.fontWeightRegular
|
||||
color: Color.mSecondary
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: controlsRow
|
||||
spacing: Math.round(Style.marginXS * widgetScale)
|
||||
z: 10
|
||||
visible: root.showButtons
|
||||
Layout.alignment: root.showAlbumArt ? Qt.AlignVCenter : Qt.AlignCenter
|
||||
|
||||
NIconButton {
|
||||
opacity: showPrev ? 1 : 0
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationSlow
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
baseSize: Math.round(32 * widgetScale)
|
||||
icon: "media-prev"
|
||||
enabled: hasPlayer && MediaService.canGoPrevious
|
||||
colorBg: Color.mSurfaceVariant
|
||||
colorFg: enabled ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
customRadius: Math.round(Style.radiusS * widgetScale)
|
||||
onClicked: {
|
||||
if (enabled)
|
||||
MediaService.previous();
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
baseSize: Math.round(36 * widgetScale)
|
||||
icon: isPlaying ? "media-pause" : "media-play"
|
||||
enabled: hasPlayer && (MediaService.canPlay || MediaService.canPause)
|
||||
colorBg: Color.mPrimary
|
||||
colorFg: Color.mOnPrimary
|
||||
colorBgHover: Qt.lighter(Color.mPrimary, 1.1)
|
||||
colorFgHover: Color.mOnPrimary
|
||||
customRadius: Math.round(Style.radiusS * widgetScale)
|
||||
onClicked: {
|
||||
if (enabled) {
|
||||
MediaService.playPause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
opacity: showNext ? 1 : 0
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationSlow
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
baseSize: Math.round(32 * widgetScale)
|
||||
icon: "media-next"
|
||||
enabled: hasPlayer && MediaService.canGoNext
|
||||
colorBg: Color.mSurfaceVariant
|
||||
colorFg: enabled ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
customRadius: Math.round(Style.radiusS * widgetScale)
|
||||
onClicked: {
|
||||
if (enabled)
|
||||
MediaService.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.DesktopWidgets
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
DraggableDesktopWidget {
|
||||
id: root
|
||||
|
||||
// Widget settings
|
||||
readonly property var widgetMetadata: DesktopWidgetRegistry.widgetMetadata["SystemStat"]
|
||||
readonly property string statType: (widgetData && widgetData.statType !== undefined) ? widgetData.statType : (widgetMetadata.statType !== undefined ? widgetMetadata.statType : "CPU")
|
||||
readonly property string diskPath: (widgetData && widgetData.diskPath !== undefined) ? widgetData.diskPath : "/"
|
||||
readonly property string layout: (widgetData && widgetData.layout !== undefined) ? widgetData.layout : (widgetMetadata.layout !== undefined ? widgetMetadata.layout : "side")
|
||||
|
||||
// Fixed colors
|
||||
readonly property color color: Color.mPrimary
|
||||
readonly property color color2: Color.mSecondary
|
||||
|
||||
// Legend items model - each item has: text, color, icon (optional), bold (optional), opacity (optional), elide (optional)
|
||||
readonly property var legendItems: {
|
||||
switch (root.statType) {
|
||||
case "CPU":
|
||||
return [
|
||||
{
|
||||
icon: "cpu-usage",
|
||||
text: Math.round(SystemStatService.cpuUsage) + "%",
|
||||
color: root.color
|
||||
},
|
||||
{
|
||||
text: "cpu-usage",
|
||||
text: SystemStatService.cpuFreq,
|
||||
color: root.color,
|
||||
opacity: 0.8
|
||||
},
|
||||
{
|
||||
icon: "cpu-temperature",
|
||||
text: SystemStatService.cpuTemp + "°C",
|
||||
color: root.color2
|
||||
}
|
||||
];
|
||||
case "GPU":
|
||||
return [
|
||||
{
|
||||
icon: "gpu-temperature",
|
||||
text: Math.round(SystemStatService.gpuTemp) + "°C",
|
||||
color: root.color
|
||||
}
|
||||
];
|
||||
case "Memory":
|
||||
return [
|
||||
{
|
||||
icon: "memory",
|
||||
text: Math.round(SystemStatService.memPercent) + "%",
|
||||
color: root.color
|
||||
}
|
||||
];
|
||||
case "Disk":
|
||||
var items = [
|
||||
{
|
||||
icon: "storage",
|
||||
text: Math.round(SystemStatService.diskPercents[root.diskPath] || 0) + "%",
|
||||
color: root.color
|
||||
}
|
||||
];
|
||||
if (root.diskPath !== "/") {
|
||||
items.push({
|
||||
text: root.diskPath,
|
||||
color: root.color,
|
||||
opacity: 0.8,
|
||||
elide: true
|
||||
});
|
||||
}
|
||||
return items;
|
||||
case "Network":
|
||||
return [
|
||||
{
|
||||
icon: "download-speed",
|
||||
text: SystemStatService.formatSpeed(SystemStatService.rxSpeed),
|
||||
color: root.color
|
||||
},
|
||||
{
|
||||
icon: "upload-speed",
|
||||
text: SystemStatService.formatSpeed(SystemStatService.txSpeed),
|
||||
color: root.color2
|
||||
}
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// History from service
|
||||
readonly property var history: {
|
||||
switch (root.statType) {
|
||||
case "CPU":
|
||||
return SystemStatService.cpuHistory;
|
||||
case "GPU":
|
||||
return SystemStatService.gpuTempHistory;
|
||||
case "Memory":
|
||||
return SystemStatService.memHistory;
|
||||
case "Disk":
|
||||
return SystemStatService.diskHistories[root.diskPath] || [];
|
||||
case "Network":
|
||||
return SystemStatService.rxSpeedHistory;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary history (CPU temp for CPU, Tx for Network)
|
||||
readonly property var history2: {
|
||||
switch (root.statType) {
|
||||
case "CPU":
|
||||
return SystemStatService.cpuTempHistory;
|
||||
case "Network":
|
||||
return SystemStatService.txSpeedHistory;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Graph min/max values
|
||||
readonly property real graphMinValue: root.statType === "GPU" ? Math.max(SystemStatService.gpuTempHistoryMin - 5, 0) : 0
|
||||
readonly property real graphMaxValue: {
|
||||
switch (root.statType) {
|
||||
case "CPU":
|
||||
case "Memory":
|
||||
case "Disk":
|
||||
return 100; // Percentage-based stats use fixed 0-100 range
|
||||
case "GPU":
|
||||
return Math.max(SystemStatService.gpuTempHistoryMax + 5, 1);
|
||||
case "Network":
|
||||
return SystemStatService.rxMaxSpeed;
|
||||
default:
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
readonly property real graphMinValue2: {
|
||||
switch (root.statType) {
|
||||
case "CPU":
|
||||
return Math.max(SystemStatService.cpuTempHistoryMin - 5, 0);
|
||||
default:
|
||||
return graphMinValue;
|
||||
}
|
||||
}
|
||||
readonly property real graphMaxValue2: {
|
||||
switch (root.statType) {
|
||||
case "CPU":
|
||||
return Math.max(SystemStatService.cpuTempHistoryMax + 5, 1);
|
||||
case "Network":
|
||||
return SystemStatService.txMaxSpeed;
|
||||
default:
|
||||
return graphMaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: SystemStatService.registerComponent("desktop-sysstat:" + root.statType)
|
||||
Component.onDestruction: SystemStatService.unregisterComponent("desktop-sysstat:" + root.statType)
|
||||
|
||||
implicitWidth: Math.round(240 * widgetScale)
|
||||
implicitHeight: Math.round(120 * widgetScale)
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
// Update interval per stat type
|
||||
readonly property int graphUpdateInterval: {
|
||||
switch (root.statType) {
|
||||
case "CPU":
|
||||
return SystemStatService.cpuUsageIntervalMs;
|
||||
case "GPU":
|
||||
return SystemStatService.gpuIntervalMs;
|
||||
case "Memory":
|
||||
return SystemStatService.memIntervalMs;
|
||||
case "Disk":
|
||||
return SystemStatService.diskIntervalMs;
|
||||
case "Network":
|
||||
return SystemStatService.networkIntervalMs;
|
||||
default:
|
||||
return 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Graph component (shared between layouts)
|
||||
Component {
|
||||
id: graphComponent
|
||||
NGraph {
|
||||
values: root.history
|
||||
values2: root.history2
|
||||
minValue: root.graphMinValue
|
||||
maxValue: root.graphMaxValue
|
||||
minValue2: root.graphMinValue2
|
||||
maxValue2: root.graphMaxValue2
|
||||
color: root.color
|
||||
color2: root.color2
|
||||
fill: true
|
||||
updateInterval: root.graphUpdateInterval
|
||||
strokeWidth: Math.max(1, root.widgetScale)
|
||||
animateScale: root.statType === "Network"
|
||||
}
|
||||
}
|
||||
|
||||
// Side layout: icon + legend on left, graph on right
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Math.round(Style.marginM * widgetScale)
|
||||
spacing: Math.round(Style.marginL * widgetScale)
|
||||
visible: root.layout === "side"
|
||||
|
||||
ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: Math.round(64 * widgetScale)
|
||||
spacing: Style.marginXS * root.widgetScale
|
||||
|
||||
Repeater {
|
||||
model: root.legendItems
|
||||
delegate: RowLayout {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: Math.round(Style.marginXXS * root.widgetScale)
|
||||
|
||||
NIcon {
|
||||
visible: !!modelData.icon
|
||||
icon: modelData.icon || ""
|
||||
color: modelData.color
|
||||
pointSize: Style.fontSizeS * root.widgetScale
|
||||
opacity: modelData.opacity !== undefined ? modelData.opacity : 1.0
|
||||
}
|
||||
|
||||
NText {
|
||||
text: modelData.text
|
||||
color: modelData.color
|
||||
pointSize: Style.fontSizeS * root.widgetScale
|
||||
font.family: Settings.data.ui.fontFixed
|
||||
font.weight: modelData.bold ? Style.fontWeightBold : Style.fontWeightRegular
|
||||
opacity: modelData.opacity !== undefined ? modelData.opacity : 1.0
|
||||
elide: modelData.elide ? Text.ElideMiddle : Text.ElideNone
|
||||
Layout.maximumWidth: modelData.elide ? Math.round(56 * root.widgetScale) : -1
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: root.layout === "side"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
sourceComponent: graphComponent
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom layout: full-width graph, horizontal legend at bottom
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Math.round(Style.marginM * widgetScale)
|
||||
spacing: Math.round(Style.marginS * widgetScale)
|
||||
visible: root.layout === "bottom"
|
||||
|
||||
Loader {
|
||||
active: root.layout === "bottom"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
sourceComponent: graphComponent
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: Math.round(Style.marginM * widgetScale)
|
||||
|
||||
Repeater {
|
||||
model: root.legendItems
|
||||
delegate: RowLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: Math.round(Style.marginXXS * root.widgetScale)
|
||||
|
||||
NIcon {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: !!modelData.icon
|
||||
icon: modelData.icon || ""
|
||||
color: modelData.color
|
||||
pointSize: Style.fontSizeS * root.widgetScale
|
||||
opacity: modelData.opacity !== undefined ? modelData.opacity : 1.0
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: modelData.text
|
||||
color: modelData.color
|
||||
pointSize: Style.fontSizeS * root.widgetScale
|
||||
font.family: Settings.data.ui.fontFixed
|
||||
font.weight: modelData.bold ? Style.fontWeightBold : Style.fontWeightRegular
|
||||
opacity: modelData.opacity !== undefined ? modelData.opacity : 1.0
|
||||
elide: modelData.elide ? Text.ElideMiddle : Text.ElideNone
|
||||
Layout.maximumWidth: modelData.elide ? Math.round(56 * root.widgetScale) : -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.DesktopWidgets
|
||||
import qs.Services.Location
|
||||
import qs.Widgets
|
||||
|
||||
DraggableDesktopWidget {
|
||||
id: root
|
||||
|
||||
readonly property bool weatherReady: Settings.data.location.weatherEnabled && (LocationService.data.weather !== null)
|
||||
readonly property int currentWeatherCode: weatherReady ? LocationService.data.weather.current_weather.weathercode : 0
|
||||
readonly property real currentTemp: {
|
||||
if (!weatherReady)
|
||||
return 0;
|
||||
var temp = LocationService.data.weather.current_weather.temperature;
|
||||
if (Settings.data.location.useFahrenheit) {
|
||||
temp = LocationService.celsiusToFahrenheit(temp);
|
||||
}
|
||||
return Math.round(temp);
|
||||
}
|
||||
readonly property real todayMax: {
|
||||
if (!weatherReady || !LocationService.data.weather.daily || LocationService.data.weather.daily.temperature_2m_max.length === 0)
|
||||
return 0;
|
||||
var temp = LocationService.data.weather.daily.temperature_2m_max[0];
|
||||
if (Settings.data.location.useFahrenheit) {
|
||||
temp = LocationService.celsiusToFahrenheit(temp);
|
||||
}
|
||||
return Math.round(temp);
|
||||
}
|
||||
readonly property real todayMin: {
|
||||
if (!weatherReady || !LocationService.data.weather.daily || LocationService.data.weather.daily.temperature_2m_min.length === 0)
|
||||
return 0;
|
||||
var temp = LocationService.data.weather.daily.temperature_2m_min[0];
|
||||
if (Settings.data.location.useFahrenheit) {
|
||||
temp = LocationService.celsiusToFahrenheit(temp);
|
||||
}
|
||||
return Math.round(temp);
|
||||
}
|
||||
readonly property string tempUnit: Settings.data.location.useFahrenheit ? "F" : "C"
|
||||
readonly property string locationName: {
|
||||
const chunks = Settings.data.location.name.split(",");
|
||||
return chunks[0];
|
||||
}
|
||||
|
||||
implicitWidth: Math.round(Math.max(240 * widgetScale, contentLayout.implicitWidth + Style.margin2M * widgetScale))
|
||||
implicitHeight: Math.round(64 * widgetScale + Style.margin2M * widgetScale)
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
RowLayout {
|
||||
id: contentLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Math.round(Style.marginM * widgetScale)
|
||||
spacing: Math.round(Style.marginM * widgetScale)
|
||||
z: 2
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: Math.round(64 * widgetScale)
|
||||
Layout.preferredHeight: Math.round(64 * widgetScale)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
NIcon {
|
||||
visible: !LocationService.taliaWeatherMascotActive || !weatherReady
|
||||
anchors.centerIn: parent
|
||||
icon: weatherReady ? LocationService.weatherSymbolFromCode(currentWeatherCode) : (LocationService.locationConfigured ? "weather-cloud-off" : "map-pin-off")
|
||||
pointSize: Math.round(Style.fontSizeXXXL * 2 * widgetScale)
|
||||
color: weatherReady ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
}
|
||||
Loader {
|
||||
active: LocationService.taliaWeatherMascotActive && weatherReady
|
||||
anchors.fill: parent
|
||||
asynchronous: true
|
||||
sourceComponent: Component {
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
mipmap: true
|
||||
asynchronous: true
|
||||
source: Qt.resolvedUrl(LocationService.taliaWeatherImageFromCode(currentWeatherCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: weatherReady ? `${currentTemp}°${tempUnit}` : "--"
|
||||
pointSize: Math.round(Style.fontSizeXXXL * widgetScale)
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Math.round(Style.marginXXS * widgetScale)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
text: locationName || I18n.tr("common.weather-no-location")
|
||||
pointSize: Math.round(Style.fontSizeS * widgetScale)
|
||||
font.weight: Style.fontWeightRegular
|
||||
color: Color.mOnSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
visible: !Settings.data.location.hideWeatherCityName
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: Math.round(Style.marginXS * widgetScale)
|
||||
visible: weatherReady && todayMax > 0 && todayMin > 0
|
||||
|
||||
NText {
|
||||
text: "H:"
|
||||
pointSize: Math.round(Style.fontSizeXS * widgetScale)
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
NText {
|
||||
text: `${todayMax}°`
|
||||
pointSize: Math.round(Style.fontSizeXS * widgetScale)
|
||||
color: Color.mOnSurface
|
||||
}
|
||||
|
||||
NText {
|
||||
text: "•"
|
||||
pointSize: Math.round(Style.fontSizeXXS * widgetScale)
|
||||
color: Color.mOnSurfaceVariant
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
NText {
|
||||
text: "L:"
|
||||
pointSize: Math.round(Style.fontSizeXS * widgetScale)
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
NText {
|
||||
text: `${todayMin}°`
|
||||
pointSize: Math.round(Style.fontSizeXS * widgetScale)
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Loader {
|
||||
|
||||
active: Settings.data.dock.enabled
|
||||
sourceComponent: Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
delegate: Item {
|
||||
id: root
|
||||
|
||||
required property ShellScreen modelData
|
||||
|
||||
property bool barIsReady: modelData ? BarService.isBarReady(modelData.name) : false
|
||||
|
||||
Connections {
|
||||
target: BarService
|
||||
function onBarReadyChanged(screenName) {
|
||||
if (screenName === modelData.name) {
|
||||
barIsReady = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update dock apps when window list change
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onWindowListChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
}
|
||||
|
||||
// Update dock apps when toplevels change
|
||||
Connections {
|
||||
target: ToplevelManager ? ToplevelManager.toplevels : null
|
||||
function onValuesChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
}
|
||||
|
||||
// Update dock apps when pinned apps change
|
||||
Connections {
|
||||
target: Settings.data.dock
|
||||
function onPinnedAppsChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
function onOnlySameOutputChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
function onGroupAppsChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
}
|
||||
|
||||
// Initial update when component is ready
|
||||
Component.onCompleted: {
|
||||
if (ToplevelManager) {
|
||||
updateDockApps();
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh icons and names when DesktopEntries becomes available (or updates)
|
||||
Connections {
|
||||
target: DesktopEntries.applications
|
||||
function onValuesChanged() {
|
||||
root.iconRevision++;
|
||||
root._desktopEntryIdCache = {};
|
||||
updateDockApps();
|
||||
}
|
||||
}
|
||||
|
||||
// Shared properties between peek and dock windows
|
||||
readonly property string displayMode: Settings.data.dock.displayMode
|
||||
readonly property bool autoHide: displayMode === "auto_hide"
|
||||
readonly property bool exclusive: displayMode === "exclusive"
|
||||
readonly property bool isAttachedMode: Settings.data.dock.dockType === "attached"
|
||||
readonly property int hideDelay: 500
|
||||
readonly property int showDelay: 100
|
||||
readonly property int hideAnimationDuration: Math.max(0, Math.round(Style.animationFast / (Settings.data.dock.animationSpeed || 1.0)))
|
||||
readonly property int showAnimationDuration: Math.max(0, Math.round(Style.animationFast / (Settings.data.dock.animationSpeed || 1.0)))
|
||||
readonly property int peekThickness: 1
|
||||
readonly property int indicatorThickness: Settings.data.dock.indicatorThickness || 3
|
||||
readonly property string indicatorColorKey: Settings.data.dock.indicatorColor || "primary"
|
||||
readonly property real indicatorOpacity: Settings.data.dock.indicatorOpacity !== undefined ? Settings.data.dock.indicatorOpacity : 0.6
|
||||
readonly property int iconSize: Math.round(12 + 24 * (Settings.data.dock.size ?? 1))
|
||||
readonly property int floatingMargin: Settings.data.dock.floatingRatio * Style.marginL
|
||||
readonly property int maxWidth: modelData ? modelData.width * 0.8 : 1000
|
||||
readonly property int maxHeight: modelData ? modelData.height * 0.8 : 1000
|
||||
|
||||
// Dock position properties
|
||||
readonly property string dockPosition: Settings.data.dock.position
|
||||
readonly property bool isVertical: dockPosition === "left" || dockPosition === "right"
|
||||
|
||||
// Bar detection and positioning properties
|
||||
readonly property bool hasBar: modelData && modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) || (Settings.data.bar.monitors.length === 0)) : false
|
||||
readonly property bool barAtSameEdge: hasBar && Settings.getBarPositionForScreen(modelData?.name) === dockPosition
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(modelData?.name)
|
||||
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property bool barIsFramed: Settings.data.bar.barType === "framed" && hasBar
|
||||
readonly property bool barFloating: Settings.data.bar.barType === "floating"
|
||||
readonly property real barMarginH: barFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0
|
||||
readonly property real barMarginV: barFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0
|
||||
readonly property int barHeight: Style.getBarHeightForScreen(modelData?.name)
|
||||
readonly property bool staticPanelOpen: {
|
||||
if (!isAttachedMode)
|
||||
return false;
|
||||
var panel = getStaticDockPanel();
|
||||
if (panel && panel.isPanelOpen !== undefined)
|
||||
return panel.isPanelOpen;
|
||||
return false;
|
||||
}
|
||||
readonly property int peekEdgeLength: {
|
||||
const edgeSize = isVertical ? Math.round(modelData?.height || maxHeight) : Math.round(modelData?.width || maxWidth);
|
||||
const minLength = Math.max(1, Math.round(edgeSize * (Settings.data.dock.showDockIndicator ? 0.1 : 0.25)));
|
||||
return Math.max(minLength, dockIndicatorLength);
|
||||
}
|
||||
readonly property int peekCenterOffsetX: {
|
||||
if (isVertical)
|
||||
return 0;
|
||||
const edgeSize = Math.round(modelData?.width || maxWidth);
|
||||
if (barIsVertical) {
|
||||
if (barPosition === "left") {
|
||||
const availableStart = (barIsFramed ? 0 : barMarginH) + barHeight;
|
||||
const availableWidth = edgeSize - availableStart - (barIsFramed ? Settings.data.bar.frameThickness : 0);
|
||||
return Math.max(0, Math.round(availableStart + (availableWidth - peekEdgeLength) / 2));
|
||||
}
|
||||
if (barPosition === "right") {
|
||||
const availableWidth = edgeSize - (barIsFramed ? 0 : barMarginH) - barHeight - (barIsFramed ? Settings.data.bar.frameThickness : 0);
|
||||
return Math.max(0, Math.round((barIsFramed ? Settings.data.bar.frameThickness : 0) + (availableWidth - peekEdgeLength) / 2));
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.round((edgeSize - peekEdgeLength) / 2));
|
||||
}
|
||||
readonly property int peekCenterOffsetY: {
|
||||
if (!isVertical)
|
||||
return 0;
|
||||
const edgeSize = Math.round(modelData?.height || maxHeight);
|
||||
if (!barIsVertical) {
|
||||
if (barPosition === "top") {
|
||||
const availableStart = (barIsFramed ? 0 : barMarginV) + barHeight;
|
||||
const availableHeight = edgeSize - availableStart - (barIsFramed ? Settings.data.bar.frameThickness : 0);
|
||||
return Math.max(0, Math.round(availableStart + (availableHeight - peekEdgeLength) / 2));
|
||||
}
|
||||
if (barPosition === "bottom") {
|
||||
const availableHeight = edgeSize - (barIsFramed ? 0 : barMarginV) - barHeight - (barIsFramed ? Settings.data.bar.frameThickness : 0);
|
||||
return Math.max(0, Math.round((barIsFramed ? Settings.data.bar.frameThickness : 0) + (availableHeight - peekEdgeLength) / 2));
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.round((edgeSize - peekEdgeLength) / 2));
|
||||
}
|
||||
readonly property bool showDockIndicator: {
|
||||
if (!Settings.data.dock.showDockIndicator || (!autoHide && !isAttachedMode) || !hidden)
|
||||
return false;
|
||||
return !staticPanelOpen;
|
||||
}
|
||||
readonly property int dockItemCount: dockApps.length + (Settings.data.dock.showLauncherIcon ? 1 : 0)
|
||||
readonly property bool indicatorVisible: showDockIndicator && dockIndicatorLength > 0
|
||||
readonly property int dockIndicatorLength: {
|
||||
if (dockItemCount <= 0)
|
||||
return 0;
|
||||
const spacing = Style.marginS;
|
||||
const layoutLength = (iconSize * dockItemCount) + (spacing * Math.max(0, dockItemCount - 1));
|
||||
const padded = layoutLength + Style.marginXL;
|
||||
return Math.min(padded, isVertical ? maxHeight : maxWidth);
|
||||
}
|
||||
|
||||
// Shared state between windows
|
||||
property bool dockHovered: false
|
||||
property bool anyAppHovered: false
|
||||
property bool menuHovered: false
|
||||
property bool hidden: autoHide
|
||||
property bool peekHovered: false
|
||||
|
||||
// Separate property to control Loader - stays true during animations
|
||||
property bool dockLoaded: !autoHide // Start loaded if autoHide is off
|
||||
|
||||
// Track the currently open context menu
|
||||
property var currentContextMenu: null
|
||||
|
||||
// Combined model of running apps and pinned apps
|
||||
property var dockApps: []
|
||||
property var groupCycleIndices: ({})
|
||||
|
||||
// Track the session order of apps (transient reordering)
|
||||
property var sessionAppOrder: []
|
||||
|
||||
// Drag and Drop state for visual feedback
|
||||
property int dragSourceIndex: -1
|
||||
property int dragTargetIndex: -1
|
||||
|
||||
// when dragging ended but the cursor is outside the dock area, restart the timer
|
||||
onDragSourceIndexChanged: {
|
||||
if (dragSourceIndex === -1) {
|
||||
if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) {
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Revision counter to force icon re-evaluation
|
||||
property int iconRevision: 0
|
||||
|
||||
// Function to close any open context menu
|
||||
function closeAllContextMenus() {
|
||||
if (currentContextMenu && currentContextMenu.visible) {
|
||||
currentContextMenu.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function getStaticDockPanel() {
|
||||
return PanelService.getPanel("staticDockPanel", modelData, false);
|
||||
}
|
||||
|
||||
function getAppKey(appData) {
|
||||
if (!appData)
|
||||
return null;
|
||||
|
||||
if (Settings.data.dock.groupApps) {
|
||||
return appData.appId;
|
||||
}
|
||||
|
||||
// Use stable appId for pinned apps to maintain their slot regardless of running state
|
||||
if (appData.type === "pinned" || appData.type === "pinned-running") {
|
||||
return appData.appId;
|
||||
}
|
||||
|
||||
// prefer toplevel object identity for unpinned running apps to distinguish instances
|
||||
if (appData.toplevel)
|
||||
return appData.toplevel;
|
||||
|
||||
// fallback to appId
|
||||
return appData.appId;
|
||||
}
|
||||
|
||||
function sortDockApps(apps) {
|
||||
if (!sessionAppOrder || sessionAppOrder.length === 0) {
|
||||
return apps;
|
||||
}
|
||||
|
||||
const sorted = [];
|
||||
const remaining = [...apps];
|
||||
|
||||
// Pick apps that are in the session order
|
||||
for (let i = 0; i < sessionAppOrder.length; i++) {
|
||||
const key = sessionAppOrder[i];
|
||||
|
||||
// Pick ALL matching apps (e.g. all instances of a pinned app)
|
||||
while (true) {
|
||||
const idx = remaining.findIndex(app => getAppKey(app) === key);
|
||||
if (idx !== -1) {
|
||||
sorted.push(remaining[idx]);
|
||||
remaining.splice(idx, 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append any new/remaining apps
|
||||
remaining.forEach(app => sorted.push(app));
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function reorderApps(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= dockApps.length || toIndex >= dockApps.length)
|
||||
return;
|
||||
|
||||
const list = [...dockApps];
|
||||
const item = list.splice(fromIndex, 1)[0];
|
||||
list.splice(toIndex, 0, item);
|
||||
|
||||
dockApps = list;
|
||||
sessionAppOrder = dockApps.map(getAppKey);
|
||||
savePinnedOrder();
|
||||
}
|
||||
|
||||
function savePinnedOrder() {
|
||||
const currentPinned = Settings.data.dock.pinnedApps || [];
|
||||
const newPinned = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Extract pinned apps in their current visual order
|
||||
dockApps.forEach(app => {
|
||||
if (app.appId && !seen.has(app.appId)) {
|
||||
const isPinned = currentPinned.some(p => normalizeAppId(p) === normalizeAppId(app.appId));
|
||||
|
||||
if (isPinned) {
|
||||
newPinned.push(app.appId);
|
||||
seen.add(app.appId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check if any pinned apps were missed (unlikely if dockApps is correct)
|
||||
currentPinned.forEach(p => {
|
||||
if (!seen.has(p)) {
|
||||
newPinned.push(p);
|
||||
seen.add(p);
|
||||
}
|
||||
});
|
||||
|
||||
if (JSON.stringify(currentPinned) !== JSON.stringify(newPinned)) {
|
||||
Settings.data.dock.pinnedApps = newPinned;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to normalize app IDs for case-insensitive matching
|
||||
function normalizeAppId(appId) {
|
||||
if (!appId || typeof appId !== 'string')
|
||||
return "";
|
||||
let id = appId.toLowerCase().trim();
|
||||
if (id.endsWith(".desktop"))
|
||||
id = id.substring(0, id.length - 8);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Helper function to check if an app ID matches a pinned app (case-insensitive)
|
||||
function isAppIdPinned(appId, pinnedApps) {
|
||||
if (!appId || !pinnedApps || pinnedApps.length === 0)
|
||||
return false;
|
||||
const normalizedId = normalizeAppId(appId);
|
||||
// Direct match
|
||||
if (pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId))
|
||||
return true;
|
||||
// Resolve via desktop entry lookup (handles StartupWMClass != .desktop filename)
|
||||
const resolved = resolveToDesktopEntryId(appId);
|
||||
if (resolved !== appId) {
|
||||
const normalizedResolved = normalizeAppId(resolved);
|
||||
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedResolved);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Desktop entry ID resolution cache (cleared when DesktopEntries change)
|
||||
property var _desktopEntryIdCache: ({})
|
||||
|
||||
// Resolve a toplevel appId to its canonical .desktop entry ID via heuristic lookup.
|
||||
// This handles cases where the Wayland appId (e.g. "zen" from StartupWMClass)
|
||||
// differs from the .desktop filename (e.g. "zen-browser-bin").
|
||||
function resolveToDesktopEntryId(appId) {
|
||||
if (!appId)
|
||||
return appId;
|
||||
if (_desktopEntryIdCache.hasOwnProperty(appId))
|
||||
return _desktopEntryIdCache[appId];
|
||||
try {
|
||||
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
|
||||
const entry = DesktopEntries.heuristicLookup(appId);
|
||||
if (entry && entry.id) {
|
||||
_desktopEntryIdCache[appId] = entry.id;
|
||||
return entry.id;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
_desktopEntryIdCache[appId] = appId;
|
||||
return appId;
|
||||
}
|
||||
|
||||
// Helper function to get app name from desktop entry
|
||||
function getAppNameFromDesktopEntry(appId) {
|
||||
if (!appId)
|
||||
return appId;
|
||||
|
||||
try {
|
||||
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
|
||||
const entry = DesktopEntries.heuristicLookup(appId);
|
||||
if (entry && entry.name) {
|
||||
return entry.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) {
|
||||
const entry = DesktopEntries.byId(appId);
|
||||
if (entry && entry.name) {
|
||||
return entry.name;
|
||||
}
|
||||
}
|
||||
} catch (e)
|
||||
// Fall through to return original appId
|
||||
{}
|
||||
|
||||
// Return original appId if we can't find a desktop entry
|
||||
return appId;
|
||||
}
|
||||
|
||||
function getToplevelsForEntry(appData) {
|
||||
if (!appData)
|
||||
return [];
|
||||
|
||||
if (appData.toplevels && appData.toplevels.length > 0) {
|
||||
return appData.toplevels.filter(toplevel => toplevel && (!Settings.data.dock.onlySameOutput || !toplevel.screens || toplevel.screens.includes(modelData)));
|
||||
}
|
||||
|
||||
if (!appData.toplevel)
|
||||
return [];
|
||||
|
||||
if (Settings.data.dock.onlySameOutput && appData.toplevel.screens && !appData.toplevel.screens.includes(modelData))
|
||||
return [];
|
||||
|
||||
return [appData.toplevel];
|
||||
}
|
||||
|
||||
function getPrimaryToplevelForEntry(appData) {
|
||||
const toplevels = getToplevelsForEntry(appData);
|
||||
if (toplevels.length === 0)
|
||||
return null;
|
||||
|
||||
if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel))
|
||||
return ToplevelManager.activeToplevel;
|
||||
|
||||
return toplevels[0];
|
||||
}
|
||||
|
||||
// Build grouped render model without mutating the raw toplevel list.
|
||||
function buildGroupedDockApps(apps) {
|
||||
if (!Settings.data.dock.groupApps) {
|
||||
return apps.map(app => {
|
||||
const entry = Object.assign({}, app);
|
||||
entry.toplevels = getToplevelsForEntry(app);
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
const grouped = [];
|
||||
const groupedById = new Map();
|
||||
|
||||
apps.forEach(app => {
|
||||
const appId = app.appId;
|
||||
const toplevels = getToplevelsForEntry(app);
|
||||
const existing = groupedById.get(appId);
|
||||
|
||||
if (existing) {
|
||||
toplevels.forEach(toplevel => {
|
||||
if (!existing.toplevels.includes(toplevel)) {
|
||||
existing.toplevels.push(toplevel);
|
||||
}
|
||||
});
|
||||
if (app.type === "pinned" || app.type === "pinned-running") {
|
||||
existing.isPinned = true;
|
||||
}
|
||||
} else {
|
||||
const entry = {
|
||||
"type": app.type,
|
||||
"appId": appId,
|
||||
"title": app.title,
|
||||
"toplevels": toplevels.slice(),
|
||||
"isPinned": app.type === "pinned" || app.type === "pinned-running"
|
||||
};
|
||||
grouped.push(entry);
|
||||
groupedById.set(appId, entry);
|
||||
}
|
||||
});
|
||||
|
||||
grouped.forEach(entry => {
|
||||
entry.toplevel = getPrimaryToplevelForEntry(entry);
|
||||
if (entry.toplevels.length > 0 && entry.isPinned) {
|
||||
entry.type = "pinned-running";
|
||||
} else if (entry.toplevels.length > 0) {
|
||||
entry.type = "running";
|
||||
} else {
|
||||
entry.type = "pinned";
|
||||
}
|
||||
if (entry.toplevel && entry.toplevel.title && entry.toplevel.title.trim() !== "") {
|
||||
entry.title = entry.toplevel.title;
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Function to update the combined dock apps model
|
||||
function updateDockApps() {
|
||||
const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : [];
|
||||
const pinnedApps = Settings.data.dock.pinnedApps || [];
|
||||
const combined = [];
|
||||
const processedToplevels = new Set();
|
||||
const processedPinnedAppIds = new Set();
|
||||
|
||||
//push an app onto combined with the given appType
|
||||
function pushApp(appType, toplevel, appId, title) {
|
||||
// Use canonical ID for pinned apps to ensure key stability
|
||||
const canonicalId = isAppIdPinned(appId, pinnedApps) ? (pinnedApps.find(p => normalizeAppId(p) === normalizeAppId(appId)) || appId) : appId;
|
||||
|
||||
// For running apps, track by toplevel object to allow multiple instances
|
||||
if (toplevel) {
|
||||
if (processedToplevels.has(toplevel)) {
|
||||
return; // Already processed this toplevel instance
|
||||
}
|
||||
if (Settings.data.dock.onlySameOutput && toplevel.screens && !toplevel.screens.includes(modelData)) {
|
||||
return; // Filtered out by onlySameOutput setting
|
||||
}
|
||||
combined.push({
|
||||
"type": appType,
|
||||
"toplevel": toplevel,
|
||||
"toplevels": toplevel ? [toplevel] : [],
|
||||
"appId": canonicalId,
|
||||
"title": title
|
||||
});
|
||||
processedToplevels.add(toplevel);
|
||||
} else {
|
||||
// For pinned apps that aren't running, track by appId to avoid duplicates
|
||||
if (processedPinnedAppIds.has(canonicalId)) {
|
||||
return; // Already processed this pinned app
|
||||
}
|
||||
combined.push({
|
||||
"type": appType,
|
||||
"toplevel": toplevel,
|
||||
"toplevels": [],
|
||||
"appId": canonicalId,
|
||||
"title": title
|
||||
});
|
||||
processedPinnedAppIds.add(canonicalId);
|
||||
}
|
||||
}
|
||||
|
||||
function pushRunning(first) {
|
||||
runningApps.forEach(toplevel => {
|
||||
if (toplevel) {
|
||||
// Use robust matching to check if pinned
|
||||
const isPinned = isAppIdPinned(toplevel.appId, pinnedApps);
|
||||
if (!first && isPinned && processedToplevels.has(toplevel)) {
|
||||
return; // Already added by pushPinned()
|
||||
}
|
||||
pushApp((first && isPinned) ? "pinned-running" : "running", toplevel, toplevel.appId, toplevel.title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushPinned() {
|
||||
pinnedApps.forEach(pinnedAppId => {
|
||||
// Find all running instances of this pinned app using robust matching
|
||||
// Also resolve toplevel appId via desktop entry lookup to handle
|
||||
// StartupWMClass != .desktop filename (e.g. zen -> zen-browser-bin)
|
||||
const normalizedPinned = normalizeAppId(pinnedAppId);
|
||||
const matchingToplevels = runningApps.filter(app => {
|
||||
if (!app)
|
||||
return false;
|
||||
if (normalizeAppId(app.appId) === normalizedPinned)
|
||||
return true;
|
||||
const resolved = resolveToDesktopEntryId(app.appId);
|
||||
return resolved !== app.appId && normalizeAppId(resolved) === normalizedPinned;
|
||||
});
|
||||
|
||||
if (matchingToplevels.length > 0) {
|
||||
// Add all running instances as pinned-running
|
||||
matchingToplevels.forEach(toplevel => {
|
||||
pushApp("pinned-running", toplevel, pinnedAppId, toplevel.title);
|
||||
});
|
||||
} else {
|
||||
// App is pinned but not running - add once
|
||||
pushApp("pinned", null, pinnedAppId, getAppNameFromDesktopEntry(pinnedAppId) || pinnedAppId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//if pinnedStatic then push all pinned and then all remaining running apps
|
||||
if (Settings.data.dock.pinnedStatic) {
|
||||
pushPinned();
|
||||
pushRunning(false);
|
||||
|
||||
//else add all running apps and then remaining pinned apps
|
||||
} else {
|
||||
pushRunning(true);
|
||||
pushPinned();
|
||||
}
|
||||
|
||||
const sortedApps = sortDockApps(combined);
|
||||
dockApps = buildGroupedDockApps(sortedApps);
|
||||
const cycleState = root.groupCycleIndices || {};
|
||||
const nextCycleState = {};
|
||||
dockApps.forEach(app => {
|
||||
if (app && app.appId && cycleState[app.appId] !== undefined) {
|
||||
nextCycleState[app.appId] = cycleState[app.appId];
|
||||
}
|
||||
});
|
||||
root.groupCycleIndices = nextCycleState;
|
||||
|
||||
// Sync session order if needed
|
||||
// Instead of resetting everything when length changes, we reconcile the keys
|
||||
if (!sessionAppOrder || sessionAppOrder.length === 0) {
|
||||
sessionAppOrder = dockApps.map(getAppKey);
|
||||
} else {
|
||||
const currentKeys = new Set(dockApps.map(getAppKey));
|
||||
const existingKeys = new Set();
|
||||
const newOrder = [];
|
||||
|
||||
// Keep existing keys that are still present
|
||||
sessionAppOrder.forEach(key => {
|
||||
if (currentKeys.has(key)) {
|
||||
newOrder.push(key);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new keys at the end
|
||||
dockApps.forEach(app => {
|
||||
const key = getAppKey(app);
|
||||
if (!existingKeys.has(key)) {
|
||||
newOrder.push(key);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (JSON.stringify(newOrder) !== JSON.stringify(sessionAppOrder)) {
|
||||
sessionAppOrder = newOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to unload dock after hide animation completes
|
||||
Timer {
|
||||
id: unloadTimer
|
||||
interval: hideAnimationDuration + 50 // Add small buffer
|
||||
onTriggered: {
|
||||
if (hidden && autoHide) {
|
||||
dockLoaded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property alias hideTimer: hideTimer
|
||||
property alias showTimer: showTimer
|
||||
property alias unloadTimer: unloadTimer
|
||||
|
||||
// Timer for auto-hide delay
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: hideDelay
|
||||
onTriggered: {
|
||||
// do not hide if dragging
|
||||
if (root.dragSourceIndex !== -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force menuHovered to false if no menu is current or visible
|
||||
if (!root.currentContextMenu || !root.currentContextMenu.visible) {
|
||||
menuHovered = false;
|
||||
}
|
||||
if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) {
|
||||
if (isAttachedMode) {
|
||||
const panel = getStaticDockPanel();
|
||||
if (panel && (panel.menuHovered || (panel.currentContextMenu && panel.currentContextMenu.visible))) {
|
||||
restart();
|
||||
return;
|
||||
}
|
||||
if (panel && (panel.isDockHovered || panel.dockHovered || panel.anyAppHovered)) {
|
||||
restart();
|
||||
return;
|
||||
}
|
||||
if (panel)
|
||||
panel.close();
|
||||
} else {
|
||||
closeAllContextMenus();
|
||||
}
|
||||
hidden = true;
|
||||
unloadTimer.restart(); // Start unload timer when hiding
|
||||
} else if (autoHide && !dockHovered && !peekHovered) {
|
||||
// Restart timer if menu is closing (handles race condition)
|
||||
restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timer for show delay
|
||||
Timer {
|
||||
id: showTimer
|
||||
interval: showDelay
|
||||
onTriggered: {
|
||||
if (autoHide) {
|
||||
if (!isAttachedMode) {
|
||||
dockLoaded = true; // Load dock immediately
|
||||
}
|
||||
hidden = false; // Then trigger show animation
|
||||
unloadTimer.stop(); // Cancel any pending unload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for autoHide setting changes
|
||||
onAutoHideChanged: {
|
||||
if (!autoHide) {
|
||||
hidden = false;
|
||||
dockLoaded = true;
|
||||
hideTimer.stop();
|
||||
showTimer.stop();
|
||||
unloadTimer.stop();
|
||||
} else {
|
||||
hidden = true;
|
||||
unloadTimer.restart(); // Schedule unload after animation
|
||||
}
|
||||
}
|
||||
|
||||
// PEEK WINDOW — only needed when dock can auto-hide or is in attached mode
|
||||
Loader {
|
||||
active: (autoHide || isAttachedMode) && (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name))
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
id: peekWindow
|
||||
|
||||
screen: modelData
|
||||
// Dynamic anchors based on dock position
|
||||
anchors.top: dockPosition === "top" || isVertical
|
||||
anchors.bottom: dockPosition === "bottom"
|
||||
anchors.left: dockPosition === "left" || !isVertical
|
||||
anchors.right: dockPosition === "right"
|
||||
focusable: false
|
||||
color: "transparent"
|
||||
|
||||
margins.top: peekCenterOffsetY
|
||||
margins.left: peekCenterOffsetX
|
||||
|
||||
WlrLayershell.namespace: "noctalia-dock-peek-" + (screen?.name || "unknown")
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
// Larger peek area when bar is at same edge, normal 1px otherwise
|
||||
implicitHeight: isVertical ? peekEdgeLength : peekThickness
|
||||
implicitWidth: isVertical ? peekThickness : peekEdgeLength
|
||||
|
||||
MouseArea {
|
||||
id: peekArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
peekHovered = true;
|
||||
if (isAttachedMode) {
|
||||
if (dockItemCount <= 0)
|
||||
return;
|
||||
const panel = getStaticDockPanel();
|
||||
if (panel && !panel.isPanelOpen)
|
||||
panel.open();
|
||||
return;
|
||||
}
|
||||
if (hidden) {
|
||||
showTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
peekHovered = false;
|
||||
showTimer.stop();
|
||||
if (isAttachedMode) {
|
||||
// Start hideTimer which checks panel.isDockHovered before closing
|
||||
if (!dockHovered && !anyAppHovered && !menuHovered) {
|
||||
hideTimer.restart();
|
||||
}
|
||||
} else if (!hidden && !dockHovered && !anyAppHovered && !menuHovered) {
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DOCK INDICATOR WINDOW — only needed when dock can auto-hide/attach and indicator is enabled
|
||||
Loader {
|
||||
active: (autoHide || isAttachedMode) && Settings.data.dock.showDockIndicator && (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name))
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
id: dockIndicatorWindow
|
||||
|
||||
screen: modelData
|
||||
// Dynamic anchors based on dock position
|
||||
anchors.top: dockPosition === "top" || isVertical
|
||||
anchors.bottom: dockPosition === "bottom"
|
||||
anchors.left: dockPosition === "left" || !isVertical
|
||||
anchors.right: dockPosition === "right"
|
||||
focusable: false
|
||||
color: "transparent"
|
||||
|
||||
margins.top: peekCenterOffsetY
|
||||
margins.left: peekCenterOffsetX
|
||||
|
||||
WlrLayershell.namespace: "noctalia-dock-indicator-" + (screen?.name || "unknown")
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
implicitHeight: isVertical ? peekEdgeLength : indicatorThickness
|
||||
implicitWidth: isVertical ? indicatorThickness : peekEdgeLength
|
||||
|
||||
// Hide the window surface when indicator is not visible, so the compositor
|
||||
// can skip compositing this layer-shell surface entirely (saves GPU on NVIDIA)
|
||||
visible: indicatorRect.opacity > 0 || indicatorVisible
|
||||
|
||||
Rectangle {
|
||||
id: indicatorRect
|
||||
anchors.fill: parent
|
||||
radius: indicatorThickness
|
||||
color: Qt.alpha(Color.resolveColorKey(indicatorColorKey), indicatorOpacity)
|
||||
opacity: indicatorVisible ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force dock reload when position changes to fix anchor/layout issues
|
||||
// Force dock reload when position/mode changes to fix anchor/layout issues
|
||||
property bool _reloading: false
|
||||
function handleReload() {
|
||||
if (!autoHide && dockLoaded && !_reloading) {
|
||||
_reloading = true;
|
||||
// Brief unload/reload cycle to reset layout
|
||||
Qt.callLater(() => {
|
||||
dockLoaded = false;
|
||||
Qt.callLater(() => {
|
||||
dockLoaded = true;
|
||||
_reloading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onDockPositionChanged: handleReload()
|
||||
onExclusiveChanged: handleReload()
|
||||
|
||||
Loader {
|
||||
id: dockWindowLoader
|
||||
active: Settings.data.dock.enabled && !isAttachedMode && (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name)) && dockLoaded && ToplevelManager && (dockApps.length > 0)
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
id: dockWindow
|
||||
|
||||
screen: modelData
|
||||
|
||||
focusable: false
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.namespace: "noctalia-dock-" + (screen?.name || "unknown")
|
||||
WlrLayershell.exclusionMode: exclusive ? ExclusionMode.Auto : ExclusionMode.Ignore
|
||||
|
||||
// Slide animation: content slides inside a fixed window, no margin animation.
|
||||
// Only reserve extra space for sliding when auto-hide is enabled
|
||||
property int slideDistance: autoHide ? ((isVertical ? dockContainerWrapper.contentWidth : dockContainerWrapper.contentHeight) + floatingMargin + 10) : 0
|
||||
property real slideOffset: hidden ? slideDistance : 0
|
||||
|
||||
Behavior on slideOffset {
|
||||
NumberAnimation {
|
||||
duration: hidden ? hideAnimationDuration : showAnimationDuration
|
||||
easing.type: hidden ? Easing.InCubic : Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Signed slide: positive pushes content toward its edge (off-screen)
|
||||
readonly property real slideX: dockPosition === "left" ? -slideOffset : dockPosition === "right" ? slideOffset : 0
|
||||
readonly property real slideY: dockPosition === "top" ? -slideOffset : dockPosition === "bottom" ? slideOffset : 0
|
||||
|
||||
// Blur behind dock — offset by slide so it follows the content
|
||||
BackgroundEffect.blurRegion: Settings.data.general.enableBlurBehind ? dockBlurRegion : null
|
||||
Region {
|
||||
id: dockBlurRegion
|
||||
Region {
|
||||
x: Math.round(dockContainerWrapper.x + dockContent.dockContainer.x + dockWindow.slideX)
|
||||
y: Math.round(dockContainerWrapper.y + dockContent.dockContainer.y + dockWindow.slideY)
|
||||
width: Math.round(dockContent.dockContainer.width)
|
||||
height: Math.round(dockContent.dockContainer.height)
|
||||
radius: Style.radiusL
|
||||
}
|
||||
}
|
||||
|
||||
// Window sized to fit content + slide distance so content can slide off-edge.
|
||||
// When auto-hide is disabled, slideDistance is 0 so the window (and thus
|
||||
// the exclusion zone) matches the dock content size.
|
||||
implicitWidth: dockContainerWrapper.width + (isVertical ? slideDistance : 0)
|
||||
implicitHeight: dockContainerWrapper.height + (!isVertical ? slideDistance : 0)
|
||||
|
||||
// Position based on dock setting
|
||||
anchors.top: dockPosition === "top"
|
||||
anchors.bottom: dockPosition === "bottom"
|
||||
anchors.left: dockPosition === "left"
|
||||
anchors.right: dockPosition === "right"
|
||||
|
||||
// Static margins — no animation, window stays put
|
||||
margins.top: dockPosition === "top" ? (barAtSameEdge && !exclusive ? barHeight + (barFloating ? Settings.data.bar.marginVertical : 0) + floatingMargin : floatingMargin) : 0
|
||||
margins.bottom: dockPosition === "bottom" ? (barAtSameEdge && !exclusive ? barHeight + (barFloating ? Settings.data.bar.marginVertical : 0) + floatingMargin : floatingMargin) : 0
|
||||
margins.left: dockPosition === "left" ? (barAtSameEdge && !exclusive ? barHeight + (barFloating ? Settings.data.bar.marginHorizontal : 0) + floatingMargin : floatingMargin) : 0
|
||||
margins.right: dockPosition === "right" ? (barAtSameEdge && !exclusive ? barHeight + (barFloating ? Settings.data.bar.marginHorizontal : 0) + floatingMargin : floatingMargin) : 0
|
||||
|
||||
// Container wrapper for animations
|
||||
Item {
|
||||
id: dockContainerWrapper
|
||||
|
||||
// Helper properties for orthogonal bar detection
|
||||
readonly property string screenBarPosition: Settings.getBarPositionForScreen(modelData?.name)
|
||||
readonly property bool barOnLeft: hasBar && screenBarPosition === "left" && !barFloating
|
||||
readonly property bool barOnRight: hasBar && screenBarPosition === "right" && !barFloating
|
||||
readonly property bool barOnTop: hasBar && screenBarPosition === "top" && !barFloating
|
||||
readonly property bool barOnBottom: hasBar && screenBarPosition === "bottom" && !barFloating
|
||||
|
||||
// Calculate padding needed to shift center to match exclusive mode
|
||||
readonly property int extraTop: (isVertical && !exclusive && barOnTop) ? barHeight : 0
|
||||
readonly property int extraBottom: (isVertical && !exclusive && barOnBottom) ? barHeight : 0
|
||||
readonly property int extraLeft: (!isVertical && !exclusive && barOnLeft) ? barHeight : 0
|
||||
readonly property int extraRight: (!isVertical && !exclusive && barOnRight) ? barHeight : 0
|
||||
|
||||
// Expose content size for window sizing (before slide padding)
|
||||
readonly property int contentWidth: dockContent.dockContainer.width + extraLeft + extraRight + 2
|
||||
readonly property int contentHeight: dockContent.dockContainer.height + extraTop + extraBottom + 2
|
||||
|
||||
// Add +2 buffer for fractional scaling issues
|
||||
width: contentWidth
|
||||
height: contentHeight
|
||||
|
||||
anchors.horizontalCenter: isVertical ? undefined : parent.horizontalCenter
|
||||
anchors.verticalCenter: isVertical ? parent.verticalCenter : undefined
|
||||
|
||||
anchors.top: dockPosition === "top" ? parent.top : undefined
|
||||
anchors.bottom: dockPosition === "bottom" ? parent.bottom : undefined
|
||||
anchors.left: dockPosition === "left" ? parent.left : undefined
|
||||
anchors.right: dockPosition === "right" ? parent.right : undefined
|
||||
|
||||
// Slide content inside the fixed window
|
||||
transform: Translate {
|
||||
x: dockWindow.slideX
|
||||
y: dockWindow.slideY
|
||||
}
|
||||
|
||||
// Enable layer caching to reduce GPU usage from continuous animations
|
||||
layer.enabled: true
|
||||
|
||||
DockContent {
|
||||
id: dockContent
|
||||
anchors.fill: parent
|
||||
dockRoot: root
|
||||
extraTop: dockContainerWrapper.extraTop
|
||||
extraBottom: dockContainerWrapper.extraBottom
|
||||
extraLeft: dockContainerWrapper.extraLeft
|
||||
extraRight: dockContainerWrapper.extraRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
required property var dockRoot
|
||||
required property int extraTop
|
||||
required property int extraBottom
|
||||
required property int extraLeft
|
||||
required property int extraRight
|
||||
property alias dockContainer: dockContainer
|
||||
readonly property bool isAttachedMode: Settings.data.dock.dockType === "attached"
|
||||
readonly property string tooltipDirection: dockRoot.dockPosition === "left" ? "right" : (dockRoot.dockPosition === "right" ? "left" : (dockRoot.dockPosition === "top" ? "bottom" : "top"))
|
||||
|
||||
Rectangle {
|
||||
id: dockContainer
|
||||
// For vertical dock, swap width and height logic
|
||||
width: dockRoot.isVertical ? Math.round(dockRoot.iconSize * 1.5) : Math.min(dockLayout.implicitWidth + Style.marginXL, dockRoot.maxWidth)
|
||||
height: dockRoot.isVertical ? Math.min(dockLayout.implicitHeight + Style.marginXL, dockRoot.maxHeight) : Math.round(dockRoot.iconSize * 1.5)
|
||||
color: Qt.alpha(Color.mSurface, (isAttachedMode ? 0 : Color.adaptiveOpacity(Settings.data.dock.backgroundOpacity)))
|
||||
|
||||
// Anchor based on padding to achieve centering shift
|
||||
anchors.horizontalCenter: extraLeft > 0 || extraRight > 0 ? undefined : parent.horizontalCenter
|
||||
anchors.right: extraLeft > 0 ? parent.right : undefined
|
||||
anchors.left: extraRight > 0 ? parent.left : undefined
|
||||
|
||||
anchors.verticalCenter: extraTop > 0 || extraBottom > 0 ? undefined : parent.verticalCenter
|
||||
anchors.bottom: extraTop > 0 ? parent.bottom : undefined
|
||||
anchors.top: extraBottom > 0 ? parent.top : undefined
|
||||
|
||||
radius: Style.radiusL
|
||||
border.width: Style.borderS
|
||||
border.color: Qt.alpha(Color.mOutline, (isAttachedMode ? 0 : Color.adaptiveOpacity(Settings.data.dock.backgroundOpacity)))
|
||||
|
||||
MouseArea {
|
||||
id: dockMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
dockRoot.dockHovered = true;
|
||||
if (dockRoot.autoHide) {
|
||||
dockRoot.showTimer.stop();
|
||||
dockRoot.hideTimer.stop();
|
||||
dockRoot.unloadTimer.stop(); // Cancel unload if hovering
|
||||
dockRoot.hidden = false; // Make sure dock is visible
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
dockRoot.dockHovered = false;
|
||||
if (dockRoot.autoHide && !dockRoot.anyAppHovered && !dockRoot.peekHovered && !dockRoot.menuHovered && dockRoot.dragSourceIndex === -1) {
|
||||
dockRoot.hideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
// Close any open context menu when clicking on the dock background
|
||||
dockRoot.closeAllContextMenus();
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: dock
|
||||
// Use parent dimensions more directly to avoid clipping
|
||||
width: dockRoot.isVertical ? parent.width : Math.min(dockLayout.implicitWidth, parent.width - Style.marginXL)
|
||||
height: !dockRoot.isVertical ? parent.height : Math.min(dockLayout.implicitHeight, parent.height - Style.marginXL)
|
||||
contentWidth: dockLayout.implicitWidth
|
||||
contentHeight: dockLayout.implicitHeight
|
||||
anchors.centerIn: parent
|
||||
clip: true
|
||||
|
||||
flickableDirection: dockRoot.isVertical ? Flickable.VerticalFlick : Flickable.HorizontalFlick
|
||||
|
||||
// Keep interactive dependent on overflow
|
||||
interactive: dockRoot.isVertical ? contentHeight > height : contentWidth > width
|
||||
|
||||
// Centering margins
|
||||
contentX: dockRoot.isVertical && contentWidth < width ? (contentWidth - width) / 2 : 0
|
||||
contentY: !dockRoot.isVertical && contentHeight < height ? (contentHeight - height) / 2 : 0
|
||||
|
||||
WheelHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: event => {
|
||||
var delta = (event.angleDelta.y !== 0) ? event.angleDelta.y : event.angleDelta.x;
|
||||
if (dockRoot.isVertical) {
|
||||
dock.contentY = Math.max(-dock.topMargin, Math.min(dock.contentHeight - dock.height + dock.bottomMargin, dock.contentY - delta));
|
||||
} else {
|
||||
// For horizontal dock, we want to scroll contentX with BOTH x and y wheels
|
||||
var hDelta = (event.angleDelta.x !== 0) ? event.angleDelta.x : event.angleDelta.y;
|
||||
dock.contentX = Math.max(-dock.leftMargin, Math.min(dock.contentWidth - dock.width + dock.rightMargin, dock.contentX - hDelta));
|
||||
}
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
ScrollBar.horizontal: ScrollBar {
|
||||
visible: !dockRoot.isVertical && dock.interactive
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
visible: dockRoot.isVertical && dock.interactive
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
|
||||
function getAppIcon(appData): string {
|
||||
if (!appData || !appData.appId)
|
||||
return "";
|
||||
return ThemeIcons.iconForAppId(appData.appId?.toLowerCase());
|
||||
}
|
||||
|
||||
function getValidToplevels(appData) {
|
||||
if (!appData || !ToplevelManager || !ToplevelManager.toplevels)
|
||||
return [];
|
||||
const source = appData.toplevels && appData.toplevels.length > 0 ? appData.toplevels : (appData.toplevel ? [appData.toplevel] : []);
|
||||
const allToplevels = ToplevelManager.toplevels.values || [];
|
||||
return source.filter(toplevel => toplevel && allToplevels.includes(toplevel));
|
||||
}
|
||||
|
||||
function getPrimaryToplevel(appData) {
|
||||
const toplevels = getValidToplevels(appData);
|
||||
if (toplevels.length === 0)
|
||||
return null;
|
||||
if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel))
|
||||
return ToplevelManager.activeToplevel;
|
||||
return toplevels[0];
|
||||
}
|
||||
|
||||
function launchAppById(appId) {
|
||||
if (!appId)
|
||||
return;
|
||||
|
||||
const app = ThemeIcons.findAppEntry(appId);
|
||||
if (!app) {
|
||||
Logger.w("Dock", `Could not find desktop entry for pinned app: ${appId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) {
|
||||
const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" ");
|
||||
|
||||
if (app.runInTerminal) {
|
||||
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
|
||||
const command = prefix.concat(terminal.concat(app.command));
|
||||
Quickshell.execDetached(command);
|
||||
} else {
|
||||
const command = prefix.concat(app.command);
|
||||
Quickshell.execDetached(command);
|
||||
}
|
||||
} else {
|
||||
if (app.runInTerminal) {
|
||||
Logger.d("Dock", "Executing terminal app manually: " + app.name);
|
||||
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
|
||||
const command = terminal.concat(app.command);
|
||||
CompositorService.spawn(command);
|
||||
} else if (app.command && app.command.length > 0) {
|
||||
CompositorService.spawn(app.command);
|
||||
} else if (app.execute) {
|
||||
app.execute();
|
||||
} else {
|
||||
Logger.w("Dock", `Could not launch: ${app.name}. No valid launch method.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use GridLayout for flexible horizontal/vertical arrangement
|
||||
GridLayout {
|
||||
id: dockLayout
|
||||
columns: dockRoot.isVertical ? 1 : -1
|
||||
rows: dockRoot.isVertical ? -1 : 1
|
||||
rowSpacing: Style.marginS
|
||||
columnSpacing: Style.marginS
|
||||
|
||||
// Ensure the layout takes its full implicit size
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
Component {
|
||||
id: launcherButtonComponent
|
||||
|
||||
Item {
|
||||
id: launcherButton
|
||||
anchors.fill: parent
|
||||
readonly property string screenName: dockRoot.modelData ? dockRoot.modelData.name : (dockRoot.screen ? dockRoot.screen.name : "")
|
||||
readonly property var launcherWidgetSettings: {
|
||||
const widgetsBySection = screenName ? Settings.getBarWidgetsForScreen(screenName) : Settings.data.bar.widgets;
|
||||
if (!widgetsBySection)
|
||||
return {};
|
||||
const sections = ["left", "center", "right"];
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const sectionWidgets = widgetsBySection[sections[i]] || [];
|
||||
for (let j = 0; j < sectionWidgets.length; j++) {
|
||||
const widget = sectionWidgets[j];
|
||||
if (widget && widget.id === "Launcher")
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
readonly property string launcherWidgetSection: {
|
||||
const widgetsBySection = screenName ? Settings.getBarWidgetsForScreen(screenName) : Settings.data.bar.widgets;
|
||||
if (!widgetsBySection)
|
||||
return "";
|
||||
const sections = ["left", "center", "right"];
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const sectionWidgets = widgetsBySection[sections[i]] || [];
|
||||
for (let j = 0; j < sectionWidgets.length; j++) {
|
||||
const widget = sectionWidgets[j];
|
||||
if (widget && widget.id === "Launcher")
|
||||
return sections[i];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
readonly property int launcherWidgetIndex: {
|
||||
const widgetsBySection = screenName ? Settings.getBarWidgetsForScreen(screenName) : Settings.data.bar.widgets;
|
||||
if (!widgetsBySection)
|
||||
return -1;
|
||||
const sections = ["left", "center", "right"];
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const sectionWidgets = widgetsBySection[sections[i]] || [];
|
||||
for (let j = 0; j < sectionWidgets.length; j++) {
|
||||
const widget = sectionWidgets[j];
|
||||
if (widget && widget.id === "Launcher")
|
||||
return j;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
readonly property var launcherMetadata: BarWidgetRegistry.widgetMetadata["Launcher"]
|
||||
readonly property string launcherIcon: {
|
||||
if (Settings.data.dock.launcherIcon !== undefined && Settings.data.dock.launcherIcon !== "")
|
||||
return Settings.data.dock.launcherIcon;
|
||||
if (launcherWidgetSettings.icon !== undefined && launcherWidgetSettings.icon !== "")
|
||||
return launcherWidgetSettings.icon;
|
||||
return (launcherMetadata && launcherMetadata.icon) ? launcherMetadata.icon : "search";
|
||||
}
|
||||
readonly property string launcherIconColorKey: {
|
||||
if (Settings.data.dock.launcherIconColor !== undefined)
|
||||
return Settings.data.dock.launcherIconColor;
|
||||
if (launcherWidgetSettings.iconColor !== undefined)
|
||||
return launcherWidgetSettings.iconColor;
|
||||
if (launcherMetadata && launcherMetadata.iconColor !== undefined)
|
||||
return launcherMetadata.iconColor;
|
||||
return "none";
|
||||
}
|
||||
readonly property bool launcherUseDistroLogo: {
|
||||
if (Settings.data.dock.launcherUseDistroLogo !== undefined)
|
||||
return Settings.data.dock.launcherUseDistroLogo;
|
||||
if (launcherWidgetSettings.useDistroLogo !== undefined)
|
||||
return launcherWidgetSettings.useDistroLogo;
|
||||
if (launcherMetadata && launcherMetadata.useDistroLogo !== undefined)
|
||||
return launcherMetadata.useDistroLogo;
|
||||
return false;
|
||||
}
|
||||
|
||||
Item {
|
||||
id: launcherIconContainer
|
||||
width: dockRoot.iconSize
|
||||
height: dockRoot.iconSize
|
||||
anchors.centerIn: parent
|
||||
|
||||
scale: launcherMouseArea.containsMouse ? 1.15 : 1.0
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
easing.overshoot: 1.2
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
anchors.centerIn: parent
|
||||
icon: launcherButton.launcherIcon
|
||||
pointSize: dockRoot.iconSize * 0.7
|
||||
color: Color.resolveColorKey(launcherButton.launcherIconColorKey)
|
||||
visible: !launcherButton.launcherUseDistroLogo
|
||||
}
|
||||
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
width: dockRoot.iconSize * 0.8
|
||||
height: width
|
||||
source: launcherButton.launcherUseDistroLogo ? HostService.osLogo : ""
|
||||
visible: source !== ""
|
||||
smooth: true
|
||||
asynchronous: true
|
||||
layer.enabled: visible
|
||||
layer.effect: ShaderEffect {
|
||||
property color targetColor: Color.resolveColorKey(launcherButton.launcherIconColorKey)
|
||||
property real colorizeMode: 2.0
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: launcherMouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
|
||||
onEntered: {
|
||||
dockRoot.anyAppHovered = true;
|
||||
TooltipService.show(launcherButton, I18n.tr("actions.open-launcher"), tooltipDirection);
|
||||
if (dockRoot.autoHide) {
|
||||
dockRoot.showTimer.stop();
|
||||
dockRoot.hideTimer.stop();
|
||||
dockRoot.unloadTimer.stop();
|
||||
dockRoot.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
dockRoot.anyAppHovered = false;
|
||||
TooltipService.hide();
|
||||
if (dockRoot.autoHide && !dockRoot.dockHovered && !dockRoot.peekHovered && !dockRoot.menuHovered && dockRoot.dragSourceIndex === -1) {
|
||||
dockRoot.hideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: mouse => {
|
||||
const targetScreen = dockRoot.modelData || dockRoot.screen || null;
|
||||
if (!targetScreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
if (dockRoot.currentContextMenu === launcherContextMenu && launcherContextMenu.visible) {
|
||||
dockRoot.closeAllContextMenus();
|
||||
return;
|
||||
}
|
||||
dockRoot.closeAllContextMenus();
|
||||
TooltipService.hideImmediately();
|
||||
launcherContextMenu.show(launcherButton, null, targetScreen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mouse.button === Qt.LeftButton || mouse.button === Qt.MiddleButton) {
|
||||
dockRoot.closeAllContextMenus();
|
||||
PanelService.toggleLauncher(targetScreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DockMenu {
|
||||
id: launcherContextMenu
|
||||
dockPosition: dockRoot.dockPosition
|
||||
menuMode: "launcher"
|
||||
launcherWidgetSection: launcherButton.launcherWidgetSection
|
||||
launcherWidgetIndex: launcherButton.launcherWidgetIndex
|
||||
launcherWidgetSettings: launcherButton.launcherWidgetSettings
|
||||
|
||||
onHoveredChanged: {
|
||||
if (dockRoot.currentContextMenu === launcherContextMenu && launcherContextMenu.visible) {
|
||||
dockRoot.menuHovered = hovered;
|
||||
} else {
|
||||
dockRoot.menuHovered = false;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: launcherContextMenu
|
||||
function onRequestClose() {
|
||||
dockRoot.currentContextMenu = null;
|
||||
dockRoot.hideTimer.stop();
|
||||
launcherContextMenu.hide();
|
||||
dockRoot.menuHovered = false;
|
||||
dockRoot.anyAppHovered = false;
|
||||
}
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
dockRoot.currentContextMenu = launcherContextMenu;
|
||||
} else if (dockRoot.currentContextMenu === launcherContextMenu) {
|
||||
dockRoot.currentContextMenu = null;
|
||||
dockRoot.hideTimer.stop();
|
||||
dockRoot.menuHovered = false;
|
||||
if (dockRoot.autoHide && !dockRoot.dockHovered && !dockRoot.anyAppHovered && !dockRoot.peekHovered && !dockRoot.menuHovered) {
|
||||
dockRoot.hideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: launcherButtonStart
|
||||
active: Settings.data.dock.showLauncherIcon && Settings.data.dock.launcherPosition === "start"
|
||||
visible: active
|
||||
sourceComponent: launcherButtonComponent
|
||||
readonly property real indicatorMargin: Math.max(3, Math.round(dockRoot.iconSize * 0.18))
|
||||
Layout.preferredWidth: active ? (dockRoot.isVertical ? dockRoot.iconSize + indicatorMargin * 2 : dockRoot.iconSize) : 0
|
||||
Layout.preferredHeight: active ? (dockRoot.isVertical ? dockRoot.iconSize : dockRoot.iconSize + indicatorMargin * 2) : 0
|
||||
Layout.minimumWidth: active ? Layout.preferredWidth : 0
|
||||
Layout.minimumHeight: active ? Layout.preferredHeight : 0
|
||||
Layout.maximumWidth: active ? Layout.preferredWidth : 0
|
||||
Layout.maximumHeight: active ? Layout.preferredHeight : 0
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: dockRoot.dockApps
|
||||
|
||||
delegate: Item {
|
||||
id: appButton
|
||||
readonly property real indicatorMargin: Math.max(3, Math.round(dockRoot.iconSize * 0.18))
|
||||
Layout.preferredWidth: dockRoot.isVertical ? dockRoot.iconSize + indicatorMargin * 2 : dockRoot.iconSize
|
||||
Layout.preferredHeight: dockRoot.isVertical ? dockRoot.iconSize : dockRoot.iconSize + indicatorMargin * 2
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
|
||||
property var toplevels: dock.getValidToplevels(modelData)
|
||||
property bool isActive: ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel)
|
||||
property bool hovered: appMouseArea.containsMouse
|
||||
property string appId: modelData ? modelData.appId : ""
|
||||
property int groupedCount: toplevels.length
|
||||
property int focusedWindowIndex: {
|
||||
if (!ToplevelManager || !ToplevelManager.activeToplevel)
|
||||
return -1;
|
||||
return toplevels.indexOf(ToplevelManager.activeToplevel);
|
||||
}
|
||||
property string groupedIndicatorText: focusedWindowIndex >= 0 ? (focusedWindowIndex + 1) + "/" + groupedCount : groupedCount.toString()
|
||||
property string appTitle: {
|
||||
if (!modelData)
|
||||
return "";
|
||||
const primaryToplevel = dock.getPrimaryToplevel(modelData);
|
||||
if (primaryToplevel) {
|
||||
const toplevelTitle = primaryToplevel.title || "";
|
||||
// If title is "Loading..." or empty, use desktop entry name
|
||||
if (!toplevelTitle || toplevelTitle === "Loading..." || toplevelTitle.trim() === "") {
|
||||
return dockRoot.getAppNameFromDesktopEntry(modelData.appId) || modelData.appId;
|
||||
}
|
||||
return toplevelTitle;
|
||||
}
|
||||
// For pinned apps that aren't running, use the stored title
|
||||
return modelData.title || modelData.appId || "";
|
||||
}
|
||||
property bool isRunning: toplevels.length > 0
|
||||
readonly property bool baseIndicatorVisible: Settings.data.dock.inactiveIndicators ? isRunning : isActive
|
||||
// Grouped indicators should be visible whenever grouped windows are running, even if none is focused.
|
||||
readonly property bool showGroupedIndicator: Settings.data.dock.groupApps && groupedCount > 1 && isRunning
|
||||
|
||||
// Store index for drag-and-drop
|
||||
property int modelIndex: index
|
||||
objectName: "dockAppButton"
|
||||
|
||||
DropArea {
|
||||
anchors.fill: parent
|
||||
keys: ["dock-app"]
|
||||
onEntered: function (drag) {
|
||||
if (drag.source && drag.source.objectName === "dockAppButton") {
|
||||
dockRoot.dragTargetIndex = appButton.modelIndex;
|
||||
}
|
||||
}
|
||||
onExited: function () {
|
||||
if (dockRoot.dragTargetIndex === appButton.modelIndex) {
|
||||
dockRoot.dragTargetIndex = -1;
|
||||
}
|
||||
}
|
||||
onDropped: function (drop) {
|
||||
dockRoot.dragSourceIndex = -1;
|
||||
dockRoot.dragTargetIndex = -1;
|
||||
if (drop.source && drop.source.objectName === "dockAppButton" && drop.source !== appButton) {
|
||||
dockRoot.reorderApps(drop.source.modelIndex, appButton.modelIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for the toplevel being closed
|
||||
Connections {
|
||||
target: modelData?.toplevel
|
||||
function onClosed() {
|
||||
Qt.callLater(dockRoot.updateDockApps);
|
||||
}
|
||||
}
|
||||
|
||||
// Draggable container for the icon
|
||||
Item {
|
||||
id: iconContainer
|
||||
width: dockRoot.iconSize
|
||||
height: dockRoot.iconSize
|
||||
|
||||
// When dragging, remove anchors so MouseArea can position it
|
||||
anchors.centerIn: dragging ? undefined : parent
|
||||
|
||||
property bool dragging: appMouseArea.drag.active
|
||||
onDraggingChanged: {
|
||||
if (dragging) {
|
||||
dockRoot.dragSourceIndex = index;
|
||||
} else {
|
||||
// Reset if not handled by drop (e.g. dropped outside)
|
||||
Qt.callLater(() => {
|
||||
if (!appMouseArea.drag.active && dockRoot.dragSourceIndex === index) {
|
||||
dockRoot.dragSourceIndex = -1;
|
||||
dockRoot.dragTargetIndex = -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Drag.active: dragging
|
||||
Drag.source: appButton
|
||||
Drag.hotSpot.x: width / 2
|
||||
Drag.hotSpot.y: height / 2
|
||||
Drag.keys: ["dock-app"]
|
||||
|
||||
z: (dockRoot.dragSourceIndex === index) ? 1000 : ((dragging ? 1000 : 0))
|
||||
scale: dragging ? 1.1 : (appButton.hovered ? 1.15 : 1.0)
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutBack
|
||||
easing.overshoot: 1.2
|
||||
}
|
||||
}
|
||||
|
||||
// Visual shifting logic
|
||||
readonly property bool isDragged: dockRoot.dragSourceIndex === index
|
||||
property real shiftOffset: 0
|
||||
|
||||
Binding on shiftOffset {
|
||||
value: {
|
||||
if (dockRoot.dragSourceIndex !== -1 && dockRoot.dragTargetIndex !== -1 && !iconContainer.isDragged) {
|
||||
if (dockRoot.dragSourceIndex < dockRoot.dragTargetIndex) {
|
||||
// Dragging Forward: Items between source and target shift Backward
|
||||
if (index > dockRoot.dragSourceIndex && index <= dockRoot.dragTargetIndex) {
|
||||
return -1 * (dockRoot.isVertical ? dockRoot.iconSize + Style.marginS : dockRoot.iconSize + Style.marginS);
|
||||
}
|
||||
} else if (dockRoot.dragSourceIndex > dockRoot.dragTargetIndex) {
|
||||
// Dragging Backward: Items between target and source shift Forward
|
||||
if (index >= dockRoot.dragTargetIndex && index < dockRoot.dragSourceIndex) {
|
||||
return (dockRoot.isVertical ? dockRoot.iconSize + Style.marginS : dockRoot.iconSize + Style.marginS);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
transform: Translate {
|
||||
x: !dockRoot.isVertical ? iconContainer.shiftOffset : 0
|
||||
y: dockRoot.isVertical ? iconContainer.shiftOffset : 0
|
||||
|
||||
Behavior on x {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
Behavior on y {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: appIcon
|
||||
anchors.fill: parent
|
||||
source: {
|
||||
dockRoot.iconRevision; // Force re-evaluation when revision changes
|
||||
return dock.getAppIcon(modelData);
|
||||
}
|
||||
visible: source.toString() !== ""
|
||||
smooth: true
|
||||
asynchronous: true
|
||||
|
||||
// Dim pinned apps that aren't running
|
||||
opacity: appButton.isRunning ? 1.0 : Settings.data.dock.deadOpacity
|
||||
|
||||
// Apply dock-specific colorization shader only to non-focused apps
|
||||
layer.enabled: !appButton.isActive && Settings.data.dock.colorizeIcons
|
||||
layer.effect: ShaderEffect {
|
||||
property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant
|
||||
property real colorizeMode: 0.0 // Dock mode (grayscale)
|
||||
|
||||
fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb")
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back if no icon
|
||||
NIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: !appIcon.visible
|
||||
icon: "question-mark"
|
||||
pointSize: dockRoot.iconSize * 0.7
|
||||
color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
opacity: appButton.isRunning ? 1.0 : 0.6
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu popup
|
||||
DockMenu {
|
||||
id: contextMenu
|
||||
dockPosition: dockRoot.dockPosition // Pass dock position for menu placement
|
||||
onHoveredChanged: {
|
||||
// Only update menuHovered if this menu is current and visible
|
||||
if (dockRoot.currentContextMenu === contextMenu && contextMenu.visible) {
|
||||
dockRoot.menuHovered = hovered;
|
||||
} else {
|
||||
dockRoot.menuHovered = false;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: contextMenu
|
||||
function onRequestClose() {
|
||||
// Clear current menu immediately to prevent hover updates
|
||||
dockRoot.currentContextMenu = null;
|
||||
dockRoot.hideTimer.stop();
|
||||
contextMenu.hide();
|
||||
dockRoot.menuHovered = false;
|
||||
dockRoot.anyAppHovered = false;
|
||||
}
|
||||
}
|
||||
onAppClosed: dockRoot.updateDockApps // Force immediate dock update when app is closed
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
dockRoot.currentContextMenu = contextMenu;
|
||||
} else if (dockRoot.currentContextMenu === contextMenu) {
|
||||
dockRoot.currentContextMenu = null;
|
||||
dockRoot.hideTimer.stop();
|
||||
dockRoot.menuHovered = false;
|
||||
// Restart hide timer after menu closes
|
||||
if (dockRoot.autoHide && !dockRoot.dockHovered && !dockRoot.anyAppHovered && !dockRoot.peekHovered && !dockRoot.menuHovered) {
|
||||
dockRoot.hideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: appMouseArea
|
||||
objectName: "appMouseArea"
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
|
||||
// Only allow left-click dragging via axis control
|
||||
drag.target: iconContainer
|
||||
drag.axis: (pressedButtons & Qt.LeftButton) ? (dockRoot.isVertical ? Drag.YAxis : Drag.XAxis) : Drag.None
|
||||
|
||||
onPressed: {
|
||||
var p1 = appButton.mapFromItem(dockContainer, 0, 0);
|
||||
var p2 = appButton.mapFromItem(dockContainer, dockContainer.width, dockContainer.height);
|
||||
drag.minimumX = p1.x;
|
||||
drag.maximumX = p2.x - iconContainer.width;
|
||||
drag.minimumY = p1.y;
|
||||
drag.maximumY = p2.y - iconContainer.height;
|
||||
}
|
||||
|
||||
onReleased: {
|
||||
if (iconContainer.Drag.active) {
|
||||
iconContainer.Drag.drop();
|
||||
}
|
||||
}
|
||||
|
||||
onEntered: {
|
||||
dockRoot.anyAppHovered = true;
|
||||
const appName = appButton.appTitle || appButton.appId || "Unknown";
|
||||
const tooltipText = appName.length > 40 ? appName.substring(0, 37) + "..." : appName;
|
||||
if (!contextMenu.visible) {
|
||||
TooltipService.show(appButton, tooltipText, tooltipDirection);
|
||||
}
|
||||
if (dockRoot.autoHide) {
|
||||
dockRoot.showTimer.stop();
|
||||
dockRoot.hideTimer.stop();
|
||||
dockRoot.unloadTimer.stop(); // Cancel unload if hovering app
|
||||
dockRoot.hidden = false; // Make sure dock is visible
|
||||
}
|
||||
}
|
||||
|
||||
onExited: {
|
||||
dockRoot.anyAppHovered = false;
|
||||
TooltipService.hide();
|
||||
// Clear menuHovered if no current menu or menu not visible
|
||||
if (!dockRoot.currentContextMenu || !dockRoot.currentContextMenu.visible) {
|
||||
dockRoot.menuHovered = false;
|
||||
}
|
||||
if (dockRoot.autoHide && !dockRoot.dockHovered && !dockRoot.peekHovered && !dockRoot.menuHovered && dockRoot.dragSourceIndex === -1) {
|
||||
dockRoot.hideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
const targetScreen = dockRoot.modelData || dockRoot.screen || null;
|
||||
// If right-clicking on the same app with an open context menu, close it
|
||||
if (dockRoot.currentContextMenu === contextMenu && contextMenu.visible) {
|
||||
dockRoot.closeAllContextMenus();
|
||||
return;
|
||||
}
|
||||
// Close any other existing context menu first
|
||||
dockRoot.closeAllContextMenus();
|
||||
// Hide tooltip when showing context menu
|
||||
TooltipService.hideImmediately();
|
||||
contextMenu.show(appButton, modelData, targetScreen);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing context menu for non-right-click actions
|
||||
dockRoot.closeAllContextMenus();
|
||||
|
||||
const runningToplevels = dock.getValidToplevels(modelData);
|
||||
const primaryToplevel = dock.getPrimaryToplevel(modelData);
|
||||
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
if (primaryToplevel && primaryToplevel.close) {
|
||||
primaryToplevel.close();
|
||||
Qt.callLater(dockRoot.updateDockApps);
|
||||
}
|
||||
} else if (mouse.button === Qt.LeftButton) {
|
||||
if (runningToplevels.length === 0) {
|
||||
dock.launchAppById(modelData?.appId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Settings.data.dock.groupApps || runningToplevels.length <= 1) {
|
||||
if (primaryToplevel && primaryToplevel.activate) {
|
||||
primaryToplevel.activate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const clickAction = Settings.data.dock.groupClickAction || "cycle";
|
||||
if (clickAction === "list") {
|
||||
const targetScreen = dockRoot.modelData || dockRoot.screen || null;
|
||||
TooltipService.hideImmediately();
|
||||
// Left-click list should always open the grouped window list view.
|
||||
contextMenu.show(appButton, modelData, targetScreen, "list");
|
||||
} else {
|
||||
const appKey = modelData?.appId || "";
|
||||
const state = dockRoot.groupCycleIndices || {};
|
||||
const nextIndex = (state[appKey] || 0) % runningToplevels.length;
|
||||
const nextToplevel = runningToplevels[nextIndex];
|
||||
if (nextToplevel && nextToplevel.activate) {
|
||||
nextToplevel.activate();
|
||||
}
|
||||
state[appKey] = (nextIndex + 1) % runningToplevels.length;
|
||||
dockRoot.groupCycleIndices = Object.assign({}, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Active indicator - positioned at the edge of the delegate area
|
||||
Rectangle {
|
||||
visible: baseIndicatorVisible && !showGroupedIndicator
|
||||
width: dockRoot.isVertical ? indicatorMargin * 0.6 : dockRoot.iconSize * 0.2
|
||||
height: dockRoot.isVertical ? dockRoot.iconSize * 0.2 : indicatorMargin * 0.6
|
||||
color: Color.mPrimary
|
||||
radius: Style.radiusXS
|
||||
|
||||
// Anchor to the edge facing the screen center
|
||||
anchors.bottom: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? parent.bottom : undefined
|
||||
anchors.top: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? parent.top : undefined
|
||||
anchors.left: dockRoot.isVertical && dockRoot.dockPosition === "left" ? parent.left : undefined
|
||||
anchors.right: dockRoot.isVertical && dockRoot.dockPosition === "right" ? parent.right : undefined
|
||||
|
||||
anchors.horizontalCenter: dockRoot.isVertical ? undefined : parent.horizontalCenter
|
||||
anchors.verticalCenter: dockRoot.isVertical ? parent.verticalCenter : undefined
|
||||
|
||||
// Offset slightly from the edge
|
||||
anchors.bottomMargin: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? 2 : 0
|
||||
anchors.topMargin: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? 2 : 0
|
||||
anchors.leftMargin: dockRoot.isVertical && dockRoot.dockPosition === "left" ? 2 : 0
|
||||
anchors.rightMargin: dockRoot.isVertical && dockRoot.dockPosition === "right" ? 2 : 0
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: groupedIndicatorLoader
|
||||
active: showGroupedIndicator
|
||||
anchors.bottom: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? parent.bottom : undefined
|
||||
anchors.top: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? parent.top : undefined
|
||||
anchors.left: dockRoot.isVertical && dockRoot.dockPosition === "left" ? parent.left : undefined
|
||||
anchors.right: dockRoot.isVertical && dockRoot.dockPosition === "right" ? parent.right : undefined
|
||||
anchors.horizontalCenter: dockRoot.isVertical ? undefined : parent.horizontalCenter
|
||||
anchors.verticalCenter: dockRoot.isVertical ? parent.verticalCenter : undefined
|
||||
anchors.bottomMargin: !dockRoot.isVertical && dockRoot.dockPosition === "bottom" ? 1 : 0
|
||||
anchors.topMargin: !dockRoot.isVertical && dockRoot.dockPosition === "top" ? 1 : 0
|
||||
anchors.leftMargin: dockRoot.isVertical && dockRoot.dockPosition === "left" ? 1 : 0
|
||||
anchors.rightMargin: dockRoot.isVertical && dockRoot.dockPosition === "right" ? 1 : 0
|
||||
|
||||
sourceComponent: Settings.data.dock.groupIndicatorStyle === "dots" ? groupDotsIndicatorComponent : groupNumberIndicatorComponent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: groupNumberIndicatorComponent
|
||||
Rectangle {
|
||||
radius: Style.radiusS
|
||||
color: Qt.alpha(Color.mSurface, 0.9)
|
||||
border.color: Qt.alpha(Color.mOutline, 0.7)
|
||||
border.width: Style.borderS
|
||||
width: Math.max(14, numberLabel.implicitWidth + Style.marginXS)
|
||||
height: Math.max(10, numberLabel.implicitHeight + 2)
|
||||
|
||||
NText {
|
||||
id: numberLabel
|
||||
anchors.centerIn: parent
|
||||
text: appButton.groupedIndicatorText
|
||||
pointSize: Style.fontSizeXS
|
||||
color: appButton.focusedWindowIndex >= 0 ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: groupDotsIndicatorComponent
|
||||
Item {
|
||||
readonly property int maxVisibleDots: 5
|
||||
readonly property int totalCount: Math.max(0, appButton.groupedCount)
|
||||
readonly property int focusedIndex: appButton.focusedWindowIndex >= 0 ? appButton.focusedWindowIndex : 0
|
||||
readonly property int visibleCount: Math.min(totalCount, maxVisibleDots)
|
||||
readonly property int dotSize: Math.max(2, Math.round(dockRoot.iconSize * 0.1))
|
||||
readonly property int dotSpacing: Math.max(1, Math.round(dotSize * 0.7))
|
||||
readonly property int pitch: dotSize + dotSpacing
|
||||
readonly property int windowStart: {
|
||||
if (totalCount <= maxVisibleDots)
|
||||
return 0;
|
||||
const centeredStart = focusedIndex - Math.floor(maxVisibleDots / 2);
|
||||
const maxStart = totalCount - maxVisibleDots;
|
||||
return Math.max(0, Math.min(maxStart, centeredStart));
|
||||
}
|
||||
readonly property bool hasHiddenLeft: windowStart > 0
|
||||
readonly property bool hasHiddenRight: (windowStart + visibleCount) < totalCount
|
||||
width: dockRoot.isVertical ? dotSize : (visibleCount * dotSize + Math.max(0, visibleCount - 1) * dotSpacing)
|
||||
height: dockRoot.isVertical ? (visibleCount * dotSize + Math.max(0, visibleCount - 1) * dotSpacing) : dotSize
|
||||
|
||||
Repeater {
|
||||
model: parent.visibleCount
|
||||
delegate: Rectangle {
|
||||
readonly property int absoluteIndex: parent.windowStart + index
|
||||
readonly property bool isFocusedDot: appButton.focusedWindowIndex >= 0 && absoluteIndex === appButton.focusedWindowIndex
|
||||
readonly property bool isOverflowHint: (index === 0 && parent.hasHiddenLeft) || (index === parent.visibleCount - 1 && parent.hasHiddenRight)
|
||||
width: isOverflowHint && !isFocusedDot ? Math.max(2, Math.round(parent.dotSize * 0.72)) : parent.dotSize
|
||||
height: width
|
||||
radius: width / 2
|
||||
x: dockRoot.isVertical ? Math.round((parent.dotSize - width) / 2) : (index * parent.pitch + Math.round((parent.dotSize - width) / 2))
|
||||
y: dockRoot.isVertical ? (index * parent.pitch + Math.round((parent.dotSize - width) / 2)) : Math.round((parent.dotSize - width) / 2)
|
||||
color: isFocusedDot ? Color.mPrimary : Qt.alpha(Color.mOutline, 0.9)
|
||||
opacity: isOverflowHint && !isFocusedDot ? 0.55 : 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: launcherButtonEnd
|
||||
active: Settings.data.dock.showLauncherIcon && Settings.data.dock.launcherPosition === "end"
|
||||
visible: active
|
||||
sourceComponent: launcherButtonComponent
|
||||
readonly property real indicatorMargin: Math.max(3, Math.round(dockRoot.iconSize * 0.18))
|
||||
Layout.preferredWidth: active ? (dockRoot.isVertical ? dockRoot.iconSize + indicatorMargin * 2 : dockRoot.iconSize) : 0
|
||||
Layout.preferredHeight: active ? (dockRoot.isVertical ? dockRoot.iconSize : dockRoot.iconSize + indicatorMargin * 2) : 0
|
||||
Layout.minimumWidth: active ? Layout.preferredWidth : 0
|
||||
Layout.minimumHeight: active ? Layout.preferredHeight : 0
|
||||
Layout.maximumWidth: active ? Layout.preferredWidth : 0
|
||||
Layout.maximumHeight: active ? Layout.preferredHeight : 0
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
PopupWindow {
|
||||
id: root
|
||||
|
||||
property var toplevel: null
|
||||
property var appData: null
|
||||
property Item anchorItem: null
|
||||
property ShellScreen targetScreen: null
|
||||
|
||||
property string menuMode: "app" // "app" or "launcher"
|
||||
property string launcherWidgetSection: ""
|
||||
property int launcherWidgetIndex: -1
|
||||
property var launcherWidgetSettings: ({})
|
||||
|
||||
property bool hovered: menuHoverHandler.hovered
|
||||
property var onAppClosed: null // Callback function for when an app is closed
|
||||
property bool canAutoClose: false
|
||||
|
||||
// Track which menu item is hovered
|
||||
property int hoveredItem: -1 // -1: none, otherwise the index of the item in `items`
|
||||
|
||||
property var items: []
|
||||
|
||||
signal requestClose
|
||||
|
||||
property real menuContentWidth: 160
|
||||
property real menuMinWidth: 120
|
||||
property real menuMaxWidth: 360
|
||||
property real menuMaxHeight: Math.max(180, Math.min(420, Math.round((targetScreen ? targetScreen.height : 600) * 0.3)))
|
||||
property int separatorCompactHeight: Style.borderS + Style.margin2S
|
||||
property string forcedGroupMenuMode: ""
|
||||
readonly property int separatorIndex: {
|
||||
for (let i = 0; i < root.items.length; i++) {
|
||||
if (root.items[i] && root.items[i].separator === true)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
readonly property bool splitExtendedLayout: separatorIndex >= 0
|
||||
readonly property var scrollItems: splitExtendedLayout ? root.items.slice(0, separatorIndex) : root.items
|
||||
readonly property var fixedItems: splitExtendedLayout ? root.items.slice(separatorIndex + 1) : []
|
||||
readonly property real menuInnerHeight: Math.max(0, implicitHeight - Style.margin2M)
|
||||
readonly property real fixedActionsHeight: listHeight(fixedItems)
|
||||
readonly property real separatorBlockHeight: splitExtendedLayout ? separatorCompactHeight : 0
|
||||
readonly property real scrollAreaHeight: splitExtendedLayout ? Math.max(0, menuInnerHeight - fixedActionsHeight - separatorBlockHeight) : menuInnerHeight
|
||||
readonly property bool listOverflowing: menuFlick && menuFlick.contentHeight > menuFlick.height
|
||||
readonly property real menuBodyHeight: {
|
||||
if (splitExtendedLayout) {
|
||||
return listHeight(scrollItems) + separatorBlockHeight + fixedActionsHeight;
|
||||
}
|
||||
return listHeight(root.items);
|
||||
}
|
||||
|
||||
implicitWidth: menuContentWidth + Style.margin2M
|
||||
implicitHeight: Math.min(menuBodyHeight + Style.margin2M, menuMaxHeight)
|
||||
color: "transparent"
|
||||
visible: false
|
||||
|
||||
// Hidden text element for measuring text width
|
||||
NText {
|
||||
id: textMeasure
|
||||
visible: false
|
||||
pointSize: Style.fontSizeS
|
||||
family: "Sans Serif" // Match your NText font if different
|
||||
wrapMode: Text.NoWrap
|
||||
elide: Text.ElideNone
|
||||
}
|
||||
|
||||
// Calculate the maximum width needed for all menu items
|
||||
function calculateMenuWidth() {
|
||||
let maxWidth = 0; // Start with 0, we'll apply minimum later
|
||||
if (root.items && root.items.length > 0) {
|
||||
for (let i = 0; i < root.items.length; i++) {
|
||||
const item = root.items[i];
|
||||
if (item && item.text) {
|
||||
// Calculate width: margins + icon (if present) + spacing + text width
|
||||
let itemWidth = Style.margin2S; // left and right margins
|
||||
|
||||
if (item.icon && item.icon !== "") {
|
||||
itemWidth += Style.fontSizeL + Style.marginS; // icon + spacing
|
||||
}
|
||||
|
||||
// Measure actual text width
|
||||
textMeasure.text = item.text;
|
||||
const textWidth = textMeasure.contentWidth;
|
||||
itemWidth += textWidth;
|
||||
|
||||
if (itemWidth > maxWidth) {
|
||||
maxWidth = itemWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep menu readable without allowing extremely wide labels.
|
||||
menuContentWidth = Math.max(menuMinWidth, Math.min(menuMaxWidth, Math.ceil(maxWidth)));
|
||||
}
|
||||
|
||||
function getCurrentAppId() {
|
||||
return appData?.appId || toplevel?.appId || "";
|
||||
}
|
||||
|
||||
function getValidToplevels() {
|
||||
if (!ToplevelManager || !ToplevelManager.toplevels)
|
||||
return [];
|
||||
const source = appData?.toplevels && appData.toplevels.length > 0 ? appData.toplevels : (toplevel ? [toplevel] : []);
|
||||
const allToplevels = ToplevelManager.toplevels.values || [];
|
||||
return source.filter(window => window && allToplevels.includes(window));
|
||||
}
|
||||
|
||||
function getPrimaryToplevel() {
|
||||
const windows = getValidToplevels();
|
||||
if (windows.length === 0)
|
||||
return null;
|
||||
if (ToplevelManager && ToplevelManager.activeToplevel && windows.includes(ToplevelManager.activeToplevel))
|
||||
return ToplevelManager.activeToplevel;
|
||||
return windows[0];
|
||||
}
|
||||
|
||||
function isItemActionable(index) {
|
||||
if (index < 0 || index >= root.items.length)
|
||||
return false;
|
||||
const item = root.items[index];
|
||||
return item && typeof item.action === "function";
|
||||
}
|
||||
|
||||
function rowHeightForItem(item) {
|
||||
return item && item.separator === true ? 16 : 32;
|
||||
}
|
||||
|
||||
function listHeight(items) {
|
||||
let total = 0;
|
||||
if (!items)
|
||||
return total;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
total += rowHeightForItem(items[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function initItems() {
|
||||
if (menuMode === "launcher") {
|
||||
root.items = [
|
||||
{
|
||||
"icon": "adjustments",
|
||||
"text": I18n.tr("actions.dock-settings"),
|
||||
"action": function () {
|
||||
handleDockSettings();
|
||||
}
|
||||
},
|
||||
{
|
||||
"icon": "adjustments",
|
||||
"text": I18n.tr("actions.launcher-settings"),
|
||||
"action": function () {
|
||||
handleLauncherSettings();
|
||||
}
|
||||
}
|
||||
];
|
||||
calculateMenuWidth();
|
||||
return;
|
||||
}
|
||||
|
||||
const windows = getValidToplevels();
|
||||
const primaryToplevel = getPrimaryToplevel();
|
||||
const appId = getCurrentAppId();
|
||||
const isRunning = windows.length > 0;
|
||||
const isPinned = isAppPinned(appId);
|
||||
const grouped = Settings.data.dock.groupApps && windows.length > 1;
|
||||
const rawGroupMenuMode = forcedGroupMenuMode || Settings.data.dock.groupContextMenuMode || "extended";
|
||||
const menuModeForGroup = grouped ? ((rawGroupMenuMode === "list" || rawGroupMenuMode === "extended") ? rawGroupMenuMode : "extended") : "single";
|
||||
|
||||
var next = [];
|
||||
|
||||
if (!grouped || menuModeForGroup === "single") {
|
||||
if (isRunning) {
|
||||
next.push({
|
||||
"icon": "eye",
|
||||
"text": I18n.tr("common.focus"),
|
||||
"action": function () {
|
||||
handleFocus(primaryToplevel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
next.push({
|
||||
"icon": !isPinned ? "pin" : "unpin",
|
||||
"text": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"),
|
||||
"action": function () {
|
||||
handlePin(appId);
|
||||
}
|
||||
});
|
||||
|
||||
if (isRunning) {
|
||||
next.push({
|
||||
"icon": "close",
|
||||
"text": I18n.tr("common.close"),
|
||||
"action": function () {
|
||||
handleClose(primaryToplevel);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
windows.forEach((window, index) => {
|
||||
const windowTitle = (window.title && window.title.trim() !== "") ? window.title : (appId || ("Window " + (index + 1)));
|
||||
next.push({
|
||||
"icon": window === ToplevelManager?.activeToplevel ? "circle-filled" : "square-rounded",
|
||||
"text": windowTitle,
|
||||
"action": function () {
|
||||
handleFocus(window);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (menuModeForGroup === "extended") {
|
||||
next.push({
|
||||
"separator": true
|
||||
});
|
||||
next.push({
|
||||
"icon": "eye",
|
||||
"text": I18n.tr("common.focus"),
|
||||
"action": function () {
|
||||
handleFocus(primaryToplevel);
|
||||
}
|
||||
});
|
||||
next.push({
|
||||
"icon": !isPinned ? "pin" : "unpin",
|
||||
"text": !isPinned ? I18n.tr("common.pin") : I18n.tr("common.unpin"),
|
||||
"action": function () {
|
||||
handlePin(appId);
|
||||
}
|
||||
});
|
||||
next.push({
|
||||
"icon": "close",
|
||||
"text": I18n.tr("common.close") + " All",
|
||||
"action": function () {
|
||||
handleCloseAll(windows);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keep grouped list mode as a clean window switcher.
|
||||
const canAddDesktopActions = !grouped || menuModeForGroup === "extended";
|
||||
|
||||
// Create a menu entry for each app-specific action defined in its .desktop file
|
||||
if (canAddDesktopActions && typeof DesktopEntries !== 'undefined' && DesktopEntries.byId && appId) {
|
||||
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId);
|
||||
if (entry != null) {
|
||||
entry.actions.forEach(function (action) {
|
||||
next.push({
|
||||
"icon": "chevron-right",
|
||||
"text": action.name,
|
||||
"action": function () {
|
||||
if (action.command && action.command.length > 0) {
|
||||
Quickshell.execDetached(action.command);
|
||||
} else if (action.execute) {
|
||||
action.execute();
|
||||
}
|
||||
if (Settings.data.dock.dockType === "attached") {
|
||||
const panel = PanelService.getPanel("staticDockPanel", root.screen, false);
|
||||
if (panel)
|
||||
panel.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
root.items = next;
|
||||
// Force width recalculation when items change
|
||||
calculateMenuWidth();
|
||||
}
|
||||
|
||||
// Helper function to normalize app IDs for case-insensitive matching
|
||||
function normalizeAppId(appId) {
|
||||
if (!appId || typeof appId !== 'string')
|
||||
return "";
|
||||
return appId.toLowerCase().trim();
|
||||
}
|
||||
|
||||
// Helper function to get desktop entry ID from an app ID
|
||||
function getDesktopEntryId(appId) {
|
||||
if (!appId)
|
||||
return appId;
|
||||
|
||||
// Try to find the desktop entry using heuristic lookup
|
||||
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
|
||||
try {
|
||||
const entry = DesktopEntries.heuristicLookup(appId);
|
||||
if (entry && entry.id) {
|
||||
return entry.id;
|
||||
}
|
||||
} catch (e)
|
||||
// Fall through to return original appId
|
||||
{}
|
||||
}
|
||||
|
||||
// Try direct lookup
|
||||
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) {
|
||||
try {
|
||||
const entry = DesktopEntries.byId(appId);
|
||||
if (entry && entry.id) {
|
||||
return entry.id;
|
||||
}
|
||||
} catch (e)
|
||||
// Fall through to return original appId
|
||||
{}
|
||||
}
|
||||
|
||||
// Return original appId if we can't find a desktop entry
|
||||
return appId;
|
||||
}
|
||||
|
||||
// Helper functions for pin/unpin functionality
|
||||
function isAppPinned(appId) {
|
||||
if (!appId)
|
||||
return false;
|
||||
const pinnedApps = Settings.data.dock.pinnedApps || [];
|
||||
const normalizedId = normalizeAppId(appId);
|
||||
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId);
|
||||
}
|
||||
|
||||
function toggleAppPin(appId) {
|
||||
if (!appId)
|
||||
return;
|
||||
|
||||
// Get the desktop entry ID for consistent pinning
|
||||
const desktopEntryId = getDesktopEntryId(appId);
|
||||
const normalizedId = normalizeAppId(desktopEntryId);
|
||||
|
||||
let pinnedApps = (Settings.data.dock.pinnedApps || []).slice(); // Create a copy
|
||||
|
||||
// Find existing pinned app with case-insensitive matching
|
||||
const existingIndex = pinnedApps.findIndex(pinnedId => normalizeAppId(pinnedId) === normalizedId);
|
||||
const isPinned = existingIndex >= 0;
|
||||
|
||||
if (isPinned) {
|
||||
// Unpin: remove from array
|
||||
pinnedApps.splice(existingIndex, 1);
|
||||
} else {
|
||||
// Pin: add desktop entry ID to array
|
||||
pinnedApps.push(desktopEntryId);
|
||||
}
|
||||
|
||||
// Update the settings
|
||||
Settings.data.dock.pinnedApps = pinnedApps;
|
||||
}
|
||||
|
||||
// Dock position for context menu placement
|
||||
property string dockPosition: "bottom"
|
||||
|
||||
anchor.item: anchorItem
|
||||
// Position menu on opposite side of dock with comfortable spacing
|
||||
anchor.rect.x: {
|
||||
if (!anchorItem)
|
||||
return 0;
|
||||
switch (dockPosition) {
|
||||
case "left":
|
||||
return anchorItem.width + Style.marginL; // Open to right of dock
|
||||
case "right":
|
||||
return -implicitWidth - Style.marginL; // Open to left of dock
|
||||
default:
|
||||
return (anchorItem.width - implicitWidth) / 2; // Center horizontally
|
||||
}
|
||||
}
|
||||
anchor.rect.y: {
|
||||
if (!anchorItem)
|
||||
return 0;
|
||||
switch (dockPosition) {
|
||||
case "top":
|
||||
return anchorItem.height + Style.marginL; // Open below dock
|
||||
case "bottom":
|
||||
return -implicitHeight - Style.marginL; // Open above dock (default)
|
||||
case "left":
|
||||
case "right":
|
||||
return (anchorItem.height - implicitHeight) / 2; // Center vertically
|
||||
default:
|
||||
return -implicitHeight - Style.marginL;
|
||||
}
|
||||
}
|
||||
|
||||
function show(item, toplevelData, screen, groupModeOverride) {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First hide completely
|
||||
visible = false;
|
||||
|
||||
// Then set up new data
|
||||
anchorItem = item;
|
||||
if (toplevelData && typeof toplevelData === "object" && (toplevelData.appId !== undefined || toplevelData.toplevels !== undefined)) {
|
||||
appData = toplevelData;
|
||||
toplevel = toplevelData.toplevel || null;
|
||||
} else {
|
||||
appData = toplevelData ? {
|
||||
"appId": toplevelData.appId,
|
||||
"toplevel": toplevelData,
|
||||
"toplevels": toplevelData ? [toplevelData] : []
|
||||
} : null;
|
||||
toplevel = toplevelData;
|
||||
}
|
||||
targetScreen = screen || null;
|
||||
forcedGroupMenuMode = groupModeOverride || "";
|
||||
initItems();
|
||||
|
||||
visible = true;
|
||||
canAutoClose = false;
|
||||
gracePeriodTimer.restart();
|
||||
}
|
||||
|
||||
// Helper function to determine which menu item is under the mouse
|
||||
function getHoveredItem(mouseY) {
|
||||
const startY = Style.marginM;
|
||||
const localY = mouseY - startY;
|
||||
|
||||
if (localY < 0)
|
||||
return -1;
|
||||
|
||||
function findIndexInList(items, relativeY, baseIndex) {
|
||||
let offset = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const h = rowHeightForItem(items[i]);
|
||||
if (relativeY >= offset && relativeY < offset + h)
|
||||
return baseIndex + i;
|
||||
offset += h;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (splitExtendedLayout) {
|
||||
if (localY < scrollAreaHeight) {
|
||||
return findIndexInList(scrollItems, localY + (menuFlick ? menuFlick.contentY : 0), 0);
|
||||
}
|
||||
if (localY < scrollAreaHeight + separatorBlockHeight) {
|
||||
return -1;
|
||||
}
|
||||
return findIndexInList(fixedItems, localY - scrollAreaHeight - separatorBlockHeight, separatorIndex + 1);
|
||||
} else {
|
||||
return findIndexInList(scrollItems, localY + (menuFlick ? menuFlick.contentY : 0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function fixedItemGlobalIndex(localIndex) {
|
||||
if (!splitExtendedLayout)
|
||||
return localIndex;
|
||||
return separatorIndex + 1 + localIndex;
|
||||
}
|
||||
|
||||
function isScrollableHovered(mouseY) {
|
||||
const localY = mouseY - Style.marginM;
|
||||
return localY >= 0 && localY < scrollAreaHeight;
|
||||
}
|
||||
|
||||
function onWheelScroll(deltaY) {
|
||||
if (!menuFlick || menuFlick.contentHeight <= menuFlick.height)
|
||||
return;
|
||||
const nextY = menuFlick.contentY - deltaY;
|
||||
menuFlick.contentY = Math.max(0, Math.min(nextY, menuFlick.contentHeight - menuFlick.height));
|
||||
}
|
||||
|
||||
function resetMenuState() {
|
||||
root.items.length = 0;
|
||||
root.appData = null;
|
||||
root.toplevel = null;
|
||||
root.forcedGroupMenuMode = "";
|
||||
menuContentWidth = menuMinWidth;
|
||||
hoveredItem = -1;
|
||||
if (menuFlick)
|
||||
menuFlick.contentY = 0;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible = false;
|
||||
resetMenuState();
|
||||
}
|
||||
|
||||
function hideWithoutReset() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function closeAndReset() {
|
||||
hide();
|
||||
root.requestClose();
|
||||
}
|
||||
|
||||
function handleFocus(targetToplevel) {
|
||||
if (targetToplevel?.activate) {
|
||||
targetToplevel.activate();
|
||||
}
|
||||
closeAndReset();
|
||||
}
|
||||
|
||||
function handlePin(appId) {
|
||||
if (appId) {
|
||||
root.toggleAppPin(appId);
|
||||
}
|
||||
Qt.callLater(() => closeAndReset());
|
||||
}
|
||||
|
||||
function handleClose(targetToplevel) {
|
||||
const isValidToplevel = targetToplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(targetToplevel);
|
||||
|
||||
if (isValidToplevel && targetToplevel.close) {
|
||||
targetToplevel.close();
|
||||
if (root.onAppClosed && typeof root.onAppClosed === "function") {
|
||||
Qt.callLater(root.onAppClosed);
|
||||
}
|
||||
}
|
||||
closeAndReset();
|
||||
}
|
||||
|
||||
function handleCloseAll(windows) {
|
||||
windows.forEach(window => {
|
||||
if (window && ToplevelManager && ToplevelManager.toplevels.values.includes(window) && window.close) {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
if (root.onAppClosed && typeof root.onAppClosed === "function") {
|
||||
Qt.callLater(root.onAppClosed);
|
||||
}
|
||||
closeAndReset();
|
||||
}
|
||||
|
||||
function handleLauncherSettings() {
|
||||
if (targetScreen) {
|
||||
var panel = PanelService.getPanel("settingsPanel", targetScreen);
|
||||
panel.requestedTab = SettingsPanel.Tab.Launcher;
|
||||
panel.toggle();
|
||||
}
|
||||
closeAndReset();
|
||||
}
|
||||
|
||||
function handleDockSettings() {
|
||||
if (targetScreen) {
|
||||
var panel = PanelService.getPanel("settingsPanel", targetScreen);
|
||||
panel.requestedTab = SettingsPanel.Tab.Dock;
|
||||
panel.toggle();
|
||||
}
|
||||
closeAndReset();
|
||||
}
|
||||
|
||||
function handleLauncherWidgetSettings() {
|
||||
if (targetScreen && launcherWidgetSection && launcherWidgetIndex >= 0) {
|
||||
BarService.openWidgetSettings(targetScreen, launcherWidgetSection, launcherWidgetIndex, "Launcher", launcherWidgetSettings || {});
|
||||
}
|
||||
closeAndReset();
|
||||
}
|
||||
|
||||
// Short delay to ignore spurious events
|
||||
Timer {
|
||||
id: gracePeriodTimer
|
||||
interval: 1500
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.canAutoClose = true;
|
||||
if (!menuHoverHandler.hovered) {
|
||||
closeTimer.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: closeTimer
|
||||
interval: 500
|
||||
repeat: false
|
||||
running: false
|
||||
onTriggered: {
|
||||
root.hideWithoutReset();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: border.width
|
||||
color: Color.mSurfaceVariant
|
||||
radius: Style.radiusS
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
HoverHandler {
|
||||
id: menuHoverHandler
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onHoveredChanged: {
|
||||
if (hovered) {
|
||||
closeTimer.stop();
|
||||
} else {
|
||||
root.hoveredItem = -1;
|
||||
if (root.canAutoClose) {
|
||||
closeTimer.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: event => {
|
||||
if (!root.isScrollableHovered(event.y))
|
||||
return;
|
||||
const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2;
|
||||
root.onWheelScroll(delta);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: menuFlick
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
anchors.topMargin: Style.marginM
|
||||
height: root.scrollAreaHeight
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: scrollColumn.height
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
interactive: contentHeight > height
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
id: menuScrollBar
|
||||
policy: ScrollBar.AsNeeded
|
||||
visible: root.listOverflowing
|
||||
interactive: true
|
||||
hoverEnabled: true
|
||||
}
|
||||
|
||||
Column {
|
||||
id: scrollColumn
|
||||
width: menuFlick.width
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.scrollItems
|
||||
|
||||
Rectangle {
|
||||
readonly property bool isSeparator: modelData && modelData.separator === true
|
||||
width: scrollColumn.width
|
||||
height: root.rowHeightForItem(modelData)
|
||||
color: (!isSeparator && root.hoveredItem === index) ? Color.mHover : "transparent"
|
||||
radius: Style.radiusXS
|
||||
|
||||
Row {
|
||||
id: rowLayout
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Style.marginS
|
||||
anchors.rightMargin: Style.marginS
|
||||
spacing: Style.marginS
|
||||
visible: !isSeparator
|
||||
|
||||
NIcon {
|
||||
icon: modelData.icon
|
||||
pointSize: Style.fontSizeL
|
||||
color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant
|
||||
visible: icon !== ""
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: modelData.text
|
||||
pointSize: Style.fontSizeS
|
||||
color: root.hoveredItem === index ? Color.mOnHover : Color.mOnSurfaceVariant
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: rowLayout.width - ((modelData.icon && modelData.icon !== "") ? (Style.fontSizeL + Style.marginS) : 0)
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !parent.isSeparator && root.isItemActionable(index)
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton
|
||||
|
||||
onEntered: {
|
||||
root.hoveredItem = index;
|
||||
}
|
||||
|
||||
onExited: {
|
||||
if (root.hoveredItem === index) {
|
||||
root.hoveredItem = -1;
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (root.isItemActionable(index)) {
|
||||
root.items[index].action.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: separator
|
||||
visible: root.splitExtendedLayout
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: menuFlick.bottom
|
||||
anchors.leftMargin: Style.marginS
|
||||
anchors.rightMargin: Style.marginS
|
||||
anchors.topMargin: Style.marginS
|
||||
height: Style.borderS
|
||||
color: Qt.alpha(Color.mOutline, 0.7)
|
||||
radius: Style.radiusXS
|
||||
}
|
||||
|
||||
Column {
|
||||
id: fixedColumn
|
||||
visible: root.splitExtendedLayout && root.fixedItems.length > 0
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: Style.marginM
|
||||
anchors.rightMargin: Style.marginM
|
||||
anchors.topMargin: Style.marginS
|
||||
anchors.bottomMargin: Style.marginM
|
||||
anchors.top: separator.bottom
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.fixedItems
|
||||
|
||||
Rectangle {
|
||||
id: fixedItemRect
|
||||
readonly property int globalIndex: root.fixedItemGlobalIndex(index)
|
||||
width: fixedColumn.width
|
||||
height: root.rowHeightForItem(modelData)
|
||||
color: root.hoveredItem === globalIndex ? Color.mHover : "transparent"
|
||||
radius: Style.radiusXS
|
||||
|
||||
Row {
|
||||
id: fixedRowLayout
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Style.marginS
|
||||
anchors.rightMargin: Style.marginS
|
||||
spacing: Style.marginS
|
||||
|
||||
NIcon {
|
||||
icon: modelData.icon
|
||||
pointSize: Style.fontSizeL
|
||||
color: root.hoveredItem === fixedItemRect.globalIndex ? Color.mOnHover : Color.mOnSurfaceVariant
|
||||
visible: icon !== ""
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: modelData.text
|
||||
pointSize: Style.fontSizeS
|
||||
color: root.hoveredItem === fixedItemRect.globalIndex ? Color.mOnHover : Color.mOnSurfaceVariant
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: fixedRowLayout.width - ((modelData.icon && modelData.icon !== "") ? (Style.fontSizeL + Style.marginS) : 0)
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.isItemActionable(parent.globalIndex)
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton
|
||||
|
||||
onEntered: {
|
||||
root.hoveredItem = parent.globalIndex;
|
||||
}
|
||||
|
||||
onExited: {
|
||||
if (root.hoveredItem === parent.globalIndex) {
|
||||
root.hoveredItem = -1;
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (root.isItemActionable(parent.globalIndex)) {
|
||||
root.items[parent.globalIndex].action.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pam
|
||||
import qs.Commons
|
||||
import qs.Services.System
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
signal unlocked
|
||||
signal failed
|
||||
|
||||
property string currentText: ""
|
||||
property bool waitingForPassword: false
|
||||
property bool unlockInProgress: false
|
||||
property bool showFailure: false
|
||||
property bool showInfo: false
|
||||
property string errorMessage: ""
|
||||
property string infoMessage: ""
|
||||
|
||||
readonly property string pamConfigDirectory: "/etc/pam.d"
|
||||
property string pamConfig: Quickshell.env("NOCTALIA_PAM_SERVICE") || "login"
|
||||
property bool pamReady: false
|
||||
|
||||
Component.onCompleted: {
|
||||
if (Quickshell.env("NOCTALIA_PAM_SERVICE")) {
|
||||
Logger.i("LockContext", "NOCTALIA_PAM_SERVICE is set, using system PAM config: /etc/pam.d/" + pamConfig);
|
||||
pamReady = true;
|
||||
} else {
|
||||
Logger.i("LockContext", "Probing for best PAM service...");
|
||||
detectPamServiceProc.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: detectPamServiceProc
|
||||
command: ["sh", "-c", "
|
||||
if [ -f /etc/pam.d/login ]; then echo 'login'; exit 0; fi;
|
||||
if [ -f /etc/pam.d/system-auth ]; then echo 'system-auth'; exit 0; fi;
|
||||
if [ -f /etc/pam.d/common-auth ]; then echo 'common-auth'; exit 0; fi;
|
||||
echo 'login';
|
||||
"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const service = String(text || "").trim();
|
||||
if (service.length > 0) {
|
||||
root.pamConfig = service;
|
||||
Logger.i("LockContext", "Detected PAM service: " + service);
|
||||
} else {
|
||||
Logger.w("LockContext", "Failed to detect PAM service, defaulting to login");
|
||||
}
|
||||
root.pamReady = true;
|
||||
}
|
||||
}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
|
||||
onPamReadyChanged: {
|
||||
if (pamReady) {
|
||||
if (Settings.data.general.autoStartAuth && currentText === "") {
|
||||
pam.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onShowInfoChanged: {
|
||||
if (showInfo) {
|
||||
showFailure = false;
|
||||
}
|
||||
}
|
||||
|
||||
onShowFailureChanged: {
|
||||
if (showFailure) {
|
||||
showInfo = false;
|
||||
}
|
||||
}
|
||||
|
||||
onCurrentTextChanged: {
|
||||
if (currentText !== "") {
|
||||
showInfo = false;
|
||||
showFailure = false;
|
||||
if (!waitingForPassword) {
|
||||
pam.abort();
|
||||
}
|
||||
if (Settings.data.general.allowPasswordWithFprintd) {
|
||||
occupyFingerprintSensorProc.running = true;
|
||||
}
|
||||
} else {
|
||||
occupyFingerprintSensorProc.running = false;
|
||||
if (pamReady && Settings.data.general.autoStartAuth) {
|
||||
pam.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryUnlock() {
|
||||
if (!pamReady) {
|
||||
Logger.w("LockContext", "PAM not ready yet, ignoring unlock attempt");
|
||||
return;
|
||||
}
|
||||
|
||||
if (waitingForPassword) {
|
||||
pam.respond(currentText);
|
||||
unlockInProgress = true;
|
||||
waitingForPassword = false;
|
||||
showInfo = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.i("LockContext", "Starting PAM authentication for user:", pam.user);
|
||||
pam.start();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: occupyFingerprintSensorProc
|
||||
command: ["fprintd-verify"]
|
||||
}
|
||||
|
||||
PamContext {
|
||||
id: pam
|
||||
configDirectory: root.pamConfigDirectory
|
||||
config: root.pamConfig
|
||||
user: HostService.username
|
||||
|
||||
onPamMessage: {
|
||||
Logger.i("LockContext", "PAM message:", message, "isError:", messageIsError, "responseRequired:", responseRequired);
|
||||
|
||||
if (this.responseRequired) {
|
||||
Logger.i("LockContext", "Responding to PAM with password");
|
||||
if (root.currentText !== "") {
|
||||
this.respond(root.currentText);
|
||||
unlockInProgress = true;
|
||||
} else {
|
||||
root.waitingForPassword = true;
|
||||
infoMessage = I18n.tr("lock-screen.password");
|
||||
showInfo = true;
|
||||
}
|
||||
} else if (messageIsError) {
|
||||
errorMessage = message;
|
||||
showFailure = true;
|
||||
} else {
|
||||
infoMessage = message;
|
||||
showInfo = true;
|
||||
}
|
||||
}
|
||||
|
||||
onCompleted: result => {
|
||||
Logger.i("LockContext", "PAM completed with result:", result);
|
||||
if (result === PamResult.Success) {
|
||||
Logger.i("LockContext", "Authentication successful");
|
||||
root.unlocked();
|
||||
} else {
|
||||
Logger.i("LockContext", "Authentication failed");
|
||||
root.currentText = "";
|
||||
errorMessage = I18n.tr("authentication.failed");
|
||||
showFailure = true;
|
||||
root.failed();
|
||||
}
|
||||
root.unlockInProgress = false;
|
||||
}
|
||||
|
||||
onError: {
|
||||
Logger.i("LockContext", "PAM error:", error, "message:", message);
|
||||
errorMessage = message || "Authentication error";
|
||||
showFailure = true;
|
||||
root.unlockInProgress = false;
|
||||
root.failed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pam
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Hardware
|
||||
import qs.Services.Keyboard
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
Loader {
|
||||
id: root
|
||||
active: false
|
||||
|
||||
// Track if the visualizer should be shown (lockscreen active + media playing + non-compact mode)
|
||||
readonly property bool needsSpectrum: root.active && !Settings.data.general.compactLockScreen && Settings.data.audio.visualizerType !== "" && Settings.data.audio.visualizerType !== "none"
|
||||
|
||||
onActiveChanged: {
|
||||
if (root.active && root.needsSpectrum) {
|
||||
SpectrumService.registerComponent("lockscreen");
|
||||
} else {
|
||||
SpectrumService.unregisterComponent("lockscreen");
|
||||
}
|
||||
|
||||
if (root.active) {
|
||||
LockKeysService.registerComponent("lockscreen");
|
||||
} else {
|
||||
LockKeysService.unregisterComponent("lockscreen");
|
||||
}
|
||||
}
|
||||
|
||||
onNeedsSpectrumChanged: {
|
||||
if (root.needsSpectrum) {
|
||||
SpectrumService.registerComponent("lockscreen");
|
||||
} else {
|
||||
SpectrumService.unregisterComponent("lockscreen");
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
// Register with panel service
|
||||
PanelService.lockScreen = this;
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
SpectrumService.unregisterComponent("lockscreen");
|
||||
LockKeysService.unregisterComponent("lockscreen");
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: unloadAfterUnlockTimer
|
||||
interval: 250
|
||||
repeat: false
|
||||
onTriggered: root.active = false
|
||||
}
|
||||
|
||||
function scheduleUnloadAfterUnlock() {
|
||||
unloadAfterUnlockTimer.start();
|
||||
}
|
||||
|
||||
sourceComponent: Component {
|
||||
Item {
|
||||
id: lockContainer
|
||||
|
||||
LockContext {
|
||||
id: lockContext
|
||||
onUnlocked: {
|
||||
lockSession.locked = false;
|
||||
root.scheduleUnloadAfterUnlock();
|
||||
lockContext.currentText = "";
|
||||
}
|
||||
onFailed: {
|
||||
lockContext.currentText = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Whether any monitor from the user's lockScreenMonitors list is currently connected.
|
||||
readonly property bool anyConfiguredMonitorConnected: {
|
||||
const configured = Settings.data.general.lockScreenMonitors;
|
||||
if (!configured || configured.length === 0)
|
||||
return false;
|
||||
return (Quickshell.screens || []).some(s => configured.includes(s.name));
|
||||
}
|
||||
|
||||
WlSessionLock {
|
||||
id: lockSession
|
||||
locked: root.active
|
||||
|
||||
WlSessionLockSurface {
|
||||
id: lockSurface
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: true
|
||||
sourceComponent: (!lockContainer.anyConfiguredMonitorConnected || Settings.data.general.lockScreenMonitors.includes(lockSurface.screen?.name)) ? fullLockScreenComponent : blackScreenComponent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: fullLockScreenComponent
|
||||
|
||||
Item {
|
||||
Item {
|
||||
id: batteryIndicator
|
||||
|
||||
property bool isReady: BatteryService.batteryReady
|
||||
property real percent: BatteryService.batteryPercentage
|
||||
property bool charging: BatteryService.batteryCharging
|
||||
property bool pluggedIn: BatteryService.batteryPluggedIn
|
||||
property bool batteryVisible: isReady
|
||||
property string icon: BatteryService.batteryIcon
|
||||
}
|
||||
|
||||
Item {
|
||||
id: keyboardLayout
|
||||
property string currentLayout: KeyboardLayoutService.currentLayout
|
||||
}
|
||||
|
||||
// Background with wallpaper, gradient, and screen corners
|
||||
LockScreenBackground {
|
||||
id: backgroundComponent
|
||||
screen: lockSurface.screen
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
// Mouse area to trigger focus on cursor movement (workaround for Hyprland focus issues)
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
onEntered: {
|
||||
// Avoid repeatedly forcing focus on every mouse move.
|
||||
// This can churn text-input surface state during monitor/suspend transitions.
|
||||
if (passwordInput && !passwordInput.activeFocus) {
|
||||
passwordInput.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header with avatar, welcome, time, date
|
||||
LockScreenHeader {
|
||||
id: headerComponent
|
||||
}
|
||||
|
||||
// Info notification
|
||||
Rectangle {
|
||||
width: infoRowLayout.implicitWidth + Style.marginXL * 1.5
|
||||
height: 50
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: (Settings.data.general.compactLockScreen ? 280 : 360) * Style.uiScaleRatio
|
||||
radius: Style.radiusL
|
||||
color: Color.mTertiary
|
||||
visible: lockContext.showInfo && lockContext.infoMessage && !panelComponent.timerActive
|
||||
opacity: visible ? 1.0 : 0.0
|
||||
|
||||
RowLayout {
|
||||
id: infoRowLayout
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: "circle-key"
|
||||
pointSize: Style.fontSizeXL
|
||||
color: Color.mOnTertiary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: lockContext.infoMessage
|
||||
color: Color.mOnTertiary
|
||||
pointSize: Style.fontSizeL
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error notification
|
||||
Rectangle {
|
||||
width: errorRowLayout.implicitWidth + Style.marginXL * 1.5
|
||||
height: 50
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: (Settings.data.general.compactLockScreen ? 280 : 360) * Style.uiScaleRatio
|
||||
radius: Style.radiusL
|
||||
color: Color.mError
|
||||
visible: lockContext.showFailure && lockContext.errorMessage && !panelComponent.timerActive
|
||||
opacity: visible ? 1.0 : 0.0
|
||||
|
||||
RowLayout {
|
||||
id: errorRowLayout
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: "alert-circle"
|
||||
pointSize: Style.fontSizeXL
|
||||
color: Color.mOnError
|
||||
}
|
||||
|
||||
NText {
|
||||
text: lockContext.errorMessage || "Authentication failed"
|
||||
color: Color.mOnError
|
||||
pointSize: Style.fontSizeL
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Countdown notification
|
||||
Rectangle {
|
||||
width: countdownRowLayout.implicitWidth + Style.marginXL * 1.5
|
||||
height: 50
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: (Settings.data.general.compactLockScreen ? 280 : 360) * Style.uiScaleRatio
|
||||
radius: Style.radiusL
|
||||
color: Color.mSurface
|
||||
visible: panelComponent.timerActive
|
||||
opacity: visible ? 1.0 : 0.0
|
||||
|
||||
RowLayout {
|
||||
id: countdownRowLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: "clock"
|
||||
pointSize: Style.fontSizeXL
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: I18n.tr("session-menu.action-in-seconds", {
|
||||
"action": I18n.tr("common." + panelComponent.pendingAction),
|
||||
"seconds": Math.ceil(panelComponent.timeRemaining / 1000)
|
||||
})
|
||||
color: Color.mOnSurface
|
||||
pointSize: Style.fontSizeL
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.weight: Style.fontWeightBold
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "x"
|
||||
tooltipText: I18n.tr("session-menu.cancel-timer")
|
||||
baseSize: 32
|
||||
colorBg: Qt.alpha(Color.mPrimary, 0.1)
|
||||
colorFg: Color.mPrimary
|
||||
colorBgHover: Color.mPrimary
|
||||
onClicked: panelComponent.cancelTimer()
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden input that receives actual text
|
||||
TextInput {
|
||||
id: passwordInput
|
||||
width: 0
|
||||
height: 0
|
||||
visible: false
|
||||
enabled: !lockContext.unlockInProgress
|
||||
echoMode: TextInput.Password
|
||||
passwordMaskDelay: 0
|
||||
|
||||
// Bidirectional sync — avoids a declarative binding which breaks on input
|
||||
onTextChanged: {
|
||||
if (lockContext.currentText !== text)
|
||||
lockContext.currentText = text;
|
||||
}
|
||||
Connections {
|
||||
target: lockContext
|
||||
function onCurrentTextChanged() {
|
||||
if (passwordInput.text !== lockContext.currentText)
|
||||
passwordInput.text = lockContext.currentText;
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: function (event) {
|
||||
if (Keybinds.checkKey(event, 'enter', Settings)) {
|
||||
lockContext.tryUnlock();
|
||||
event.accepted = true;
|
||||
}
|
||||
if (Keybinds.checkKey(event, 'escape', Settings) && panelComponent.timerActive) {
|
||||
panelComponent.cancelTimer();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: forceActiveFocus()
|
||||
}
|
||||
|
||||
// Main panel with password, weather, media, session controls
|
||||
LockScreenPanel {
|
||||
id: panelComponent
|
||||
lockControl: lockContext
|
||||
batteryIndicator: batteryIndicator
|
||||
keyboardLayout: keyboardLayout
|
||||
passwordInput: passwordInput
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: blackScreenComponent
|
||||
|
||||
// Black surface for disabled monitors — still captures keyboard for password entry
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "black"
|
||||
|
||||
TextInput {
|
||||
id: blackScreenPasswordInput
|
||||
width: 0
|
||||
height: 0
|
||||
visible: false
|
||||
enabled: !lockContext.unlockInProgress
|
||||
echoMode: TextInput.Password
|
||||
passwordMaskDelay: 0
|
||||
|
||||
// Bidirectional sync — avoids a declarative binding which breaks on input
|
||||
onTextChanged: {
|
||||
if (lockContext.currentText !== text)
|
||||
lockContext.currentText = text;
|
||||
}
|
||||
Connections {
|
||||
target: lockContext
|
||||
function onCurrentTextChanged() {
|
||||
if (blackScreenPasswordInput.text !== lockContext.currentText)
|
||||
blackScreenPasswordInput.text = lockContext.currentText;
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: function (event) {
|
||||
if (Keybinds.checkKey(event, 'enter', Settings)) {
|
||||
lockContext.tryUnlock();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: forceActiveFocus()
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
onPositionChanged: blackScreenPasswordInput.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
|
||||
// Cached wallpaper path - exposed for parent components
|
||||
property string resolvedWallpaperPath: ""
|
||||
property color tintColor: Settings.data.colorSchemes.darkMode ? Color.mSurface : Color.mOnSurface
|
||||
|
||||
required property var screen
|
||||
|
||||
// Request preprocessed wallpaper when lock screen becomes active or dimensions change
|
||||
Component.onCompleted: {
|
||||
if (screen) {
|
||||
Qt.callLater(requestCachedWallpaper);
|
||||
}
|
||||
}
|
||||
|
||||
onWidthChanged: {
|
||||
if (screen && width > 0 && height > 0) {
|
||||
Qt.callLater(requestCachedWallpaper);
|
||||
}
|
||||
}
|
||||
|
||||
onHeightChanged: {
|
||||
if (screen && width > 0 && height > 0) {
|
||||
Qt.callLater(requestCachedWallpaper);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for wallpaper changes
|
||||
Connections {
|
||||
target: WallpaperService
|
||||
function onWallpaperChanged(screenName, path) {
|
||||
if (screen && screenName === screen.name) {
|
||||
Qt.callLater(requestCachedWallpaper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for display scale changes
|
||||
Connections {
|
||||
target: CompositorService
|
||||
function onDisplayScalesChanged() {
|
||||
if (screen && width > 0 && height > 0) {
|
||||
Qt.callLater(requestCachedWallpaper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requestCachedWallpaper() {
|
||||
if (!screen || width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for solid color mode first
|
||||
if (Settings.data.wallpaper.useSolidColor) {
|
||||
resolvedWallpaperPath = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const originalPath = WallpaperService.getWallpaper(screen.name) || "";
|
||||
if (originalPath === "") {
|
||||
resolvedWallpaperPath = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle solid color paths
|
||||
if (WallpaperService.isSolidColorPath(originalPath)) {
|
||||
resolvedWallpaperPath = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ImageCacheService || !ImageCacheService.initialized) {
|
||||
// Fallback to original if services not ready
|
||||
resolvedWallpaperPath = originalPath;
|
||||
return;
|
||||
}
|
||||
|
||||
const compositorScale = CompositorService.getDisplayScale(screen.name);
|
||||
const targetWidth = Math.round(width * compositorScale);
|
||||
const targetHeight = Math.round(height * compositorScale);
|
||||
if (targetWidth <= 0 || targetHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't set resolvedWallpaperPath until cache is ready
|
||||
// This prevents loading the original huge image
|
||||
ImageCacheService.getLarge(originalPath, targetWidth, targetHeight, function (cachedPath, success) {
|
||||
if (success) {
|
||||
resolvedWallpaperPath = cachedPath;
|
||||
} else {
|
||||
// Only fall back to original if caching failed
|
||||
resolvedWallpaperPath = originalPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Background - solid color or black fallback
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Settings.data.wallpaper.useSolidColor ? Settings.data.wallpaper.solidColor : "#000000"
|
||||
}
|
||||
|
||||
Image {
|
||||
id: lockBgImage
|
||||
visible: source !== "" && Settings.data.wallpaper.enabled && !Settings.data.wallpaper.useSolidColor && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableWallpaper)
|
||||
anchors.fill: parent
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
source: resolvedWallpaperPath
|
||||
cache: false
|
||||
smooth: true
|
||||
mipmap: false
|
||||
antialiasing: true
|
||||
|
||||
layer.enabled: Settings.data.general.lockScreenBlur > 0 && !PowerProfileService.noctaliaPerformanceMode
|
||||
layer.smooth: false
|
||||
layer.effect: MultiEffect {
|
||||
blurEnabled: true
|
||||
blur: Settings.data.general.lockScreenBlur
|
||||
blurMax: 48
|
||||
}
|
||||
|
||||
// Tint overlay
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.tintColor
|
||||
opacity: Settings.data.general.lockScreenTint
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: !Settings.data.wallpaper.useSolidColor
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
GradientStop {
|
||||
position: 0.0
|
||||
color: Qt.alpha(Color.mShadow, 0.4)
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.3
|
||||
color: Qt.alpha(Color.mShadow, 0.2)
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.7
|
||||
color: Qt.alpha(Color.mShadow, 0.25)
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.0
|
||||
color: Qt.alpha(Color.mShadow, 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Screen corners for lock screen
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: Settings.data.general.showScreenCorners
|
||||
|
||||
property color cornerColor: Settings.data.general.forceBlackScreenCorners ? "black" : Color.mSurface
|
||||
property real cornerRadius: Style.screenRadius
|
||||
property real cornerSize: Style.screenRadius
|
||||
|
||||
// Top-left concave corner
|
||||
Canvas {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
width: parent.cornerSize
|
||||
height: parent.cornerSize
|
||||
antialiasing: true
|
||||
renderTarget: Canvas.FramebufferObject
|
||||
smooth: false
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d");
|
||||
if (!ctx)
|
||||
return;
|
||||
ctx.reset();
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
ctx.fillStyle = parent.cornerColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(width, height, parent.cornerRadius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
onWidthChanged: if (available)
|
||||
requestPaint()
|
||||
onHeightChanged: if (available)
|
||||
requestPaint()
|
||||
}
|
||||
|
||||
// Top-right concave corner
|
||||
Canvas {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
width: parent.cornerSize
|
||||
height: parent.cornerSize
|
||||
antialiasing: true
|
||||
renderTarget: Canvas.FramebufferObject
|
||||
smooth: true
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d");
|
||||
if (!ctx)
|
||||
return;
|
||||
ctx.reset();
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
ctx.fillStyle = parent.cornerColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, height, parent.cornerRadius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
onWidthChanged: if (available)
|
||||
requestPaint()
|
||||
onHeightChanged: if (available)
|
||||
requestPaint()
|
||||
}
|
||||
|
||||
// Bottom-left concave corner
|
||||
Canvas {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
width: parent.cornerSize
|
||||
height: parent.cornerSize
|
||||
antialiasing: true
|
||||
renderTarget: Canvas.FramebufferObject
|
||||
smooth: true
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d");
|
||||
if (!ctx)
|
||||
return;
|
||||
ctx.reset();
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
ctx.fillStyle = parent.cornerColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(width, 0, parent.cornerRadius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
onWidthChanged: if (available)
|
||||
requestPaint()
|
||||
onHeightChanged: if (available)
|
||||
requestPaint()
|
||||
}
|
||||
|
||||
// Bottom-right concave corner
|
||||
Canvas {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
width: parent.cornerSize
|
||||
height: parent.cornerSize
|
||||
antialiasing: true
|
||||
renderTarget: Canvas.FramebufferObject
|
||||
smooth: true
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d");
|
||||
if (!ctx)
|
||||
return;
|
||||
ctx.reset();
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
ctx.fillStyle = parent.cornerColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, parent.cornerRadius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
onWidthChanged: if (available)
|
||||
requestPaint()
|
||||
onHeightChanged: if (available)
|
||||
requestPaint()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.System
|
||||
import qs.Widgets
|
||||
|
||||
// Time, Date, and User Profile Container
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
readonly property bool animationsEnabled: Settings.data.general.lockScreenAnimations || false
|
||||
|
||||
// Use timer-driven properties instead of Time.now to avoid per-frame repaints.
|
||||
// Time.now updates every frame (~60+ Hz); these update only when needed.
|
||||
property date currentTime: new Date()
|
||||
property date currentDate: new Date()
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.currentTime = new Date()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 60000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.currentDate = new Date()
|
||||
}
|
||||
|
||||
width: Math.max(500, contentRow.implicitWidth + 32)
|
||||
height: Math.max(120, contentRow.implicitHeight + 32)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 100
|
||||
radius: Style.radiusL
|
||||
color: Color.mSurface
|
||||
border.color: Qt.alpha(Color.mOutline, 0.2)
|
||||
border.width: Style.borderS
|
||||
|
||||
RowLayout {
|
||||
id: contentRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.margin2XL
|
||||
|
||||
// Left side: Avatar
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 70
|
||||
Layout.preferredHeight: 70
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: width / 2
|
||||
color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.color: Qt.alpha(Color.mPrimary, 0.8)
|
||||
border.width: Style.borderM
|
||||
|
||||
SequentialAnimation on border.color {
|
||||
loops: Animation.Infinite
|
||||
running: root.animationsEnabled
|
||||
ColorAnimation {
|
||||
to: Qt.alpha(Color.mPrimary, 1.0)
|
||||
duration: 2000
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
ColorAnimation {
|
||||
to: Qt.alpha(Color.mPrimary, 0.8)
|
||||
duration: 2000
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NImageRounded {
|
||||
anchors.centerIn: parent
|
||||
width: 66
|
||||
height: 66
|
||||
radius: width / 2
|
||||
imagePath: Settings.preprocessPath(Settings.data.general.avatarImage)
|
||||
fallbackIcon: "person"
|
||||
|
||||
SequentialAnimation on scale {
|
||||
loops: Animation.Infinite
|
||||
running: root.animationsEnabled
|
||||
NumberAnimation {
|
||||
to: 1.02
|
||||
duration: 4000
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
NumberAnimation {
|
||||
to: 1.0
|
||||
duration: 4000
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center: User Info Column (left-aligned text)
|
||||
ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: Style.marginXXS
|
||||
|
||||
// Welcome back + Username on one line
|
||||
NText {
|
||||
text: I18n.tr("system.welcome-back") + " " + HostService.displayName + "!"
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: Color.mOnSurface
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
// Date below
|
||||
NText {
|
||||
text: {
|
||||
var dateString = I18n.locale.toString(root.currentDate, I18n.dateFormat());
|
||||
return dateString.charAt(0).toUpperCase() + dateString.slice(1);
|
||||
}
|
||||
pointSize: Style.fontSizeXL
|
||||
color: Color.mOnSurfaceVariant
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer to push time to the right
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// Clock
|
||||
Item {
|
||||
Layout.preferredWidth: Settings.data.general.clockStyle === "analog" ? 70 : (Settings.data.general.clockStyle === "custom" ? 90 : 70)
|
||||
Layout.preferredHeight: Settings.data.general.clockStyle === "analog" ? 70 : (Settings.data.general.clockStyle === "custom" ? 90 : 70)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
// Analog Clock
|
||||
NClock {
|
||||
anchors.centerIn: parent
|
||||
width: 70
|
||||
height: 70
|
||||
visible: Settings.data.general.clockStyle === "analog"
|
||||
now: root.currentTime
|
||||
clockStyle: "analog"
|
||||
backgroundColor: "transparent"
|
||||
clockColor: Color.mOnSurface
|
||||
secondHandColor: Color.mPrimary
|
||||
}
|
||||
|
||||
// Digital Clock (Standard)
|
||||
NClock {
|
||||
anchors.centerIn: parent
|
||||
width: 70
|
||||
height: 70
|
||||
visible: Settings.data.general.clockStyle === "digital"
|
||||
now: root.currentTime
|
||||
clockStyle: "digital"
|
||||
showProgress: true
|
||||
progressColor: Color.mPrimary
|
||||
backgroundColor: "transparent"
|
||||
clockColor: Color.mOnSurface
|
||||
hoursFontSize: Style.fontSizeL
|
||||
minutesFontSize: Style.fontSizeL
|
||||
hoursFontWeight: Style.fontWeightBold
|
||||
minutesFontWeight: Style.fontWeightBold
|
||||
}
|
||||
|
||||
// Custom Clock (Stacked)
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width
|
||||
visible: Settings.data.general.clockStyle === "custom"
|
||||
spacing: -3
|
||||
|
||||
Repeater {
|
||||
model: I18n.locale.toString(root.currentTime, (Settings.data.general.clockFormat || "hh\\nmm").replace(/\\n/g, "\n")).split("\n")
|
||||
NText {
|
||||
text: modelData
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.maximumWidth: parent.width
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
|
||||
import qs.Commons
|
||||
import qs.Modules.MainScreen
|
||||
import qs.Services.UI
|
||||
|
||||
// ------------------------------
|
||||
// MainScreen for each screen (manages bar + all panels)
|
||||
// Wrapped in Loader to optimize memory - only loads when screen needs it
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
delegate: Item {
|
||||
id: windowItem
|
||||
required property ShellScreen modelData
|
||||
|
||||
property bool shouldBeActive: {
|
||||
if (!modelData || !modelData.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let shouldLoad = true;
|
||||
if (!Settings.data.general.allowPanelsOnScreenWithoutBar) {
|
||||
// Check if bar is configured for this screen
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
shouldLoad = monitors.length === 0 || monitors.includes(modelData?.name);
|
||||
}
|
||||
|
||||
if (shouldLoad) {
|
||||
Logger.d("AllScreens", "Screen activated: ", modelData?.name);
|
||||
}
|
||||
return shouldLoad;
|
||||
}
|
||||
|
||||
property bool windowLoaded: false
|
||||
|
||||
// Main Screen loader - Bar and panels backgrounds
|
||||
Loader {
|
||||
id: windowLoader
|
||||
active: parent.shouldBeActive
|
||||
asynchronous: false
|
||||
|
||||
property ShellScreen loaderScreen: modelData
|
||||
|
||||
onLoaded: {
|
||||
// Signal that window is loaded so exclusion zone can be created
|
||||
parent.windowLoaded = true;
|
||||
}
|
||||
|
||||
sourceComponent: MainScreen {
|
||||
screen: windowLoader.loaderScreen
|
||||
}
|
||||
}
|
||||
|
||||
// Bar content in separate windows to prevent fullscreen redraws
|
||||
// Note: Window stays alive when bar is hidden (visible=false) to avoid
|
||||
// rapid Wayland surface destruction/creation that can crash compositors.
|
||||
// Content is debounce-unloaded inside BarContentWindow.
|
||||
Loader {
|
||||
active: {
|
||||
if (!parent.windowLoaded || !parent.shouldBeActive)
|
||||
return false;
|
||||
|
||||
// Check if bar is configured for this screen
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
return monitors.length === 0 || monitors.includes(modelData?.name);
|
||||
}
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: BarContentWindow {
|
||||
screen: modelData
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
Logger.d("AllScreens", "BarContentWindow created for", modelData?.name);
|
||||
}
|
||||
}
|
||||
|
||||
// BarTriggerZone - thin invisible zone to reveal hidden bar
|
||||
// Always loaded when auto-hide is enabled (it's just 1px, no performance impact)
|
||||
Loader {
|
||||
active: {
|
||||
if (!parent.windowLoaded || !parent.shouldBeActive)
|
||||
return false;
|
||||
if (!BarService.effectivelyVisible)
|
||||
return false;
|
||||
if (Settings.getBarDisplayModeForScreen(modelData?.name) !== "auto_hide")
|
||||
return false;
|
||||
|
||||
// Check if bar is configured for this screen
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
return monitors.length === 0 || monitors.includes(modelData?.name);
|
||||
}
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: BarTriggerZone {
|
||||
screen: modelData
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
Logger.d("AllScreens", "BarTriggerZone created for", modelData?.name);
|
||||
}
|
||||
}
|
||||
|
||||
// BarExclusionZone - created after MainScreen has fully loaded
|
||||
// Note: Exclusion zone should NOT be affected by hideOnOverview setting.
|
||||
// When bar is hidden during overview, the exclusion zone should remain to prevent
|
||||
// windows from moving into the bar area. Auto-hide is handled by the component
|
||||
// itself via ExclusionMode.Ignore/Auto.
|
||||
Repeater {
|
||||
model: Settings.data.bar.barType === "framed" ? ["top", "bottom", "left", "right"] : [Settings.getBarPositionForScreen(windowItem.modelData?.name)]
|
||||
delegate: Loader {
|
||||
active: {
|
||||
if (!windowItem.windowLoaded || !windowItem.shouldBeActive)
|
||||
return false;
|
||||
|
||||
// Check if bar is configured for this screen
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
return monitors.length === 0 || monitors.includes(windowItem.modelData?.name);
|
||||
}
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: BarExclusionZone {
|
||||
screen: windowItem.modelData
|
||||
edge: modelData
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
Logger.d("AllScreens", "BarExclusionZone (" + modelData + ") created for", windowItem.modelData?.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PopupMenuWindow - reusable popup window for both tray menus and context menus
|
||||
// Stays alive when bar is hidden to avoid Wayland surface churn crashes.
|
||||
// PopupMenuWindow manages its own visibility internally.
|
||||
Loader {
|
||||
active: {
|
||||
// Desktop widgets edit mode needs popup window on ALL screens
|
||||
if (DesktopWidgetRegistry.editMode && Settings.data.desktopWidgets.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normal bar-based condition
|
||||
if (!parent.windowLoaded || !parent.shouldBeActive)
|
||||
return false;
|
||||
|
||||
// Check if bar is configured for this screen
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
return monitors.length === 0 || monitors.includes(modelData?.name);
|
||||
}
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: PopupMenuWindow {
|
||||
screen: modelData
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
Logger.d("AllScreens", "PopupMenuWindow created for", modelData?.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
/**
|
||||
* AllBackgrounds - Unified Shape container for all bar and panel backgrounds
|
||||
*
|
||||
* Unified shadow system. This component contains a single Shape
|
||||
* with multiple ShapePath children (one for bar, one for each panel type).
|
||||
*
|
||||
* Benefits:
|
||||
* - Single GPU-accelerated rendering pass for all backgrounds
|
||||
* - Unified shadow system (one MultiEffect for everything)
|
||||
*/
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Reference Bar
|
||||
required property var bar
|
||||
|
||||
// Reference to MainScreen (for panel access)
|
||||
required property var windowRoot
|
||||
|
||||
readonly property color panelBackgroundColor: Color.mSurface
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
// Unified background container
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
// When not using separate bar opacity, use unified approach (original behavior)
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: !Settings.data.bar.useSeparateOpacity
|
||||
|
||||
// Enable layer caching to prevent continuous re-rendering
|
||||
layer.enabled: true
|
||||
opacity: Color.adaptiveOpacity(Settings.data.ui.panelBackgroundOpacity)
|
||||
|
||||
Shape {
|
||||
id: unifiedBackgroundsShape
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
asynchronous: true
|
||||
enabled: false
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.d("AllBackgrounds", "AllBackgrounds initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar
|
||||
*/
|
||||
BarBackground {
|
||||
bar: root.bar
|
||||
shapeContainer: unifiedBackgroundsShape
|
||||
windowRoot: root.windowRoot
|
||||
backgroundColor: panelBackgroundColor
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel Background Slots
|
||||
* Only 2 slots needed: one for currently open/opening panel, one for closing panel
|
||||
*/
|
||||
|
||||
// Slot 0: Currently open/opening panel
|
||||
PanelBackground {
|
||||
assignedPanel: {
|
||||
var p = PanelService.backgroundSlotAssignments[0];
|
||||
// Only render if this panel belongs to this screen
|
||||
return (p && p.screen === root.windowRoot.screen) ? p : null;
|
||||
}
|
||||
shapeContainer: unifiedBackgroundsShape
|
||||
defaultBackgroundColor: panelBackgroundColor
|
||||
}
|
||||
|
||||
// Slot 1: Closing panel (during transitions)
|
||||
PanelBackground {
|
||||
assignedPanel: {
|
||||
var p = PanelService.backgroundSlotAssignments[1];
|
||||
// Only render if this panel belongs to this screen
|
||||
return (p && p.screen === root.windowRoot.screen) ? p : null;
|
||||
}
|
||||
shapeContainer: unifiedBackgroundsShape
|
||||
defaultBackgroundColor: panelBackgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
// Apply shadow to the unified backgrounds
|
||||
NDropShadow {
|
||||
anchors.fill: parent
|
||||
source: unifiedBackgroundsShape
|
||||
}
|
||||
}
|
||||
|
||||
// When using separate bar opacity, separate the rendering
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
visible: Settings.data.bar.useSeparateOpacity
|
||||
|
||||
// Panel backgrounds with panel opacity
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
layer.enabled: true
|
||||
opacity: Color.adaptiveOpacity(Settings.data.ui.panelBackgroundOpacity)
|
||||
|
||||
Shape {
|
||||
id: panelBackgroundsShape
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
asynchronous: true
|
||||
enabled: false
|
||||
|
||||
/**
|
||||
* Panel Background Slots
|
||||
* Only 2 slots needed: one for currently open/opening panel, one for closing panel
|
||||
*/
|
||||
|
||||
// Slot 0: Currently open/opening panel
|
||||
PanelBackground {
|
||||
assignedPanel: {
|
||||
var p = PanelService.backgroundSlotAssignments[0];
|
||||
// Only render if this panel belongs to this screen
|
||||
return (p && p.screen === root.windowRoot.screen) ? p : null;
|
||||
}
|
||||
shapeContainer: panelBackgroundsShape
|
||||
defaultBackgroundColor: panelBackgroundColor
|
||||
}
|
||||
|
||||
// Slot 1: Closing panel (during transitions)
|
||||
PanelBackground {
|
||||
assignedPanel: {
|
||||
var p = PanelService.backgroundSlotAssignments[1];
|
||||
// Only render if this panel belongs to this screen
|
||||
return (p && p.screen === root.windowRoot.screen) ? p : null;
|
||||
}
|
||||
shapeContainer: panelBackgroundsShape
|
||||
defaultBackgroundColor: panelBackgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
// Apply shadow to the panel backgrounds
|
||||
NDropShadow {
|
||||
anchors.fill: parent
|
||||
source: panelBackgroundsShape
|
||||
}
|
||||
}
|
||||
|
||||
// Bar background with separate opacity
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
layer.enabled: true
|
||||
opacity: Settings.data.bar.backgroundOpacity
|
||||
|
||||
Shape {
|
||||
id: barBackgroundShape
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
asynchronous: true
|
||||
enabled: false
|
||||
|
||||
BarBackground {
|
||||
bar: root.bar
|
||||
shapeContainer: barBackgroundShape
|
||||
windowRoot: root.windowRoot
|
||||
backgroundColor: panelBackgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
NDropShadow {
|
||||
anchors.fill: parent
|
||||
source: barBackgroundShape
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.Commons
|
||||
import qs.Modules.MainScreen.Backgrounds
|
||||
import qs.Services.UI
|
||||
|
||||
/**
|
||||
* BarBackground - ShapePath component for rendering the bar background
|
||||
*
|
||||
* Unified shadow system. This component is a ShapePath that will be
|
||||
* a child of the unified AllBackgrounds Shape container.
|
||||
*
|
||||
* Uses 4-state per-corner system for flexible corner rendering:
|
||||
* - State -1: No radius (flat/square corner)
|
||||
* - State 0: Normal (inner curve)
|
||||
* - State 1: Horizontal inversion (outer curve on X-axis)
|
||||
* - State 2: Vertical inversion (outer curve on Y-axis)
|
||||
*/
|
||||
ShapePath {
|
||||
id: root
|
||||
|
||||
// Required reference to the bar component
|
||||
required property var bar
|
||||
|
||||
// Required reference to AllBackgrounds shapeContainer
|
||||
required property var shapeContainer
|
||||
|
||||
// Required reference to windowRoot for screen access
|
||||
required property var windowRoot
|
||||
|
||||
required property color backgroundColor
|
||||
|
||||
// Check if bar should be visible on this screen
|
||||
readonly property bool shouldShow: {
|
||||
// Check global bar visibility (includes overview state)
|
||||
if (!BarService.effectivelyVisible)
|
||||
return false;
|
||||
|
||||
// Check screen-specific configuration
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
var screenName = windowRoot?.screen?.name || "";
|
||||
|
||||
// If no monitors specified, show on all screens
|
||||
// If monitors specified, only show if this screen is in the list
|
||||
return monitors.length === 0 || monitors.includes(screenName);
|
||||
}
|
||||
|
||||
// Corner radius (from Style)
|
||||
readonly property real radius: Style.radiusL
|
||||
|
||||
// Framed bar properties
|
||||
readonly property bool isFramed: Settings.data.bar.barType === "framed"
|
||||
readonly property real frameThickness: Settings.data.bar.frameThickness ?? 12
|
||||
readonly property real frameRadius: Settings.data.bar.frameRadius ?? 20
|
||||
|
||||
// Bar position - since bar's parent fills the screen and Shape also fills the screen,
|
||||
// we can use bar.x and bar.y directly (they're already in screen coordinates)
|
||||
readonly property point barMappedPos: bar ? Qt.point(bar.x, bar.y) : Qt.point(0, 0)
|
||||
|
||||
// Effective dimensions - 0 when bar shouldn't show (similar to panel behavior)
|
||||
readonly property real barWidth: (bar && shouldShow) ? bar.width : 0
|
||||
readonly property real barHeight: (bar && shouldShow) ? bar.height : 0
|
||||
|
||||
// Screen dimensions for frame
|
||||
readonly property real screenWidth: windowRoot?.screen?.width || 0
|
||||
readonly property real screenHeight: windowRoot?.screen?.height || 0
|
||||
|
||||
// Inner hole dimensions for framed mode - always relative to screen
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(windowRoot?.screen?.name)
|
||||
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property real holeX: (barPosition === "left") ? barWidth : frameThickness
|
||||
readonly property real holeY: (barPosition === "top") ? barHeight : frameThickness
|
||||
readonly property real holeWidth: screenWidth - (barPosition === "left" || barPosition === "right" ? (barWidth + frameThickness) : (frameThickness * 2))
|
||||
readonly property real holeHeight: screenHeight - (barPosition === "top" || barPosition === "bottom" ? (barHeight + frameThickness) : (frameThickness * 2))
|
||||
|
||||
// Flatten corners if bar is too small (handle null bar)
|
||||
readonly property bool shouldFlatten: bar ? ShapeCornerHelper.shouldFlatten(barWidth, barHeight, radius) : false
|
||||
readonly property real effectiveRadius: shouldFlatten ? (bar ? ShapeCornerHelper.getFlattenedRadius(Math.min(barWidth, barHeight), radius) : 0) : radius
|
||||
|
||||
// Minimum safe arc radius — prevents zero-displacement zero-radius PathArcs
|
||||
// that crash qTriangulate in CurveRenderer. 0.01px is sub-pixel and invisible.
|
||||
readonly property real _minR: 0.01
|
||||
|
||||
// Helper function for getting corner radius based on state
|
||||
function getCornerRadius(cornerState) {
|
||||
// State -1 = flat corner — use minimum safe radius instead of 0
|
||||
// to prevent degenerate PathArc (zero displacement + zero radius)
|
||||
if (cornerState === -1)
|
||||
return _minR;
|
||||
// All other states use effectiveRadius (clamped to safe minimum)
|
||||
return Math.max(_minR, effectiveRadius);
|
||||
}
|
||||
|
||||
// Per-corner multipliers and radii based on bar's corner states (handle null bar)
|
||||
readonly property real tlMultX: bar ? ShapeCornerHelper.getMultX(bar.topLeftCornerState) : 1
|
||||
readonly property real tlMultY: bar ? ShapeCornerHelper.getMultY(bar.topLeftCornerState) : 1
|
||||
readonly property real tlRadius: bar ? getCornerRadius(bar.topLeftCornerState) : 0
|
||||
|
||||
readonly property real trMultX: bar ? ShapeCornerHelper.getMultX(bar.topRightCornerState) : 1
|
||||
readonly property real trMultY: bar ? ShapeCornerHelper.getMultY(bar.topRightCornerState) : 1
|
||||
readonly property real trRadius: bar ? getCornerRadius(bar.topRightCornerState) : 0
|
||||
|
||||
readonly property real brMultX: bar ? ShapeCornerHelper.getMultX(bar.bottomRightCornerState) : 1
|
||||
readonly property real brMultY: bar ? ShapeCornerHelper.getMultY(bar.bottomRightCornerState) : 1
|
||||
readonly property real brRadius: bar ? getCornerRadius(bar.bottomRightCornerState) : 0
|
||||
|
||||
readonly property real blMultX: bar ? ShapeCornerHelper.getMultX(bar.bottomLeftCornerState) : 1
|
||||
readonly property real blMultY: bar ? ShapeCornerHelper.getMultY(bar.bottomLeftCornerState) : 1
|
||||
readonly property real blRadius: bar ? getCornerRadius(bar.bottomLeftCornerState) : 0
|
||||
|
||||
// True when the bar path has valid, non-degenerate geometry to render.
|
||||
// Mirrors PanelBackground.isRenderable — prevents CurveRenderer crash on zero-area paths.
|
||||
readonly property bool isRenderable: bar !== null && shouldShow && (isFramed ? (screenWidth > 0 && screenHeight > 0) : (barWidth > 0 && barHeight > 0))
|
||||
|
||||
// Edge overshoot: extend bar background beyond screen edges where both adjacent
|
||||
// corners are flat (state -1) to prevent CurveRenderer antialiasing artifacts.
|
||||
// Uses corner state checks instead of radius === 0 since flat corners now have _minR.
|
||||
readonly property real screenEdgeOvershoot: 2
|
||||
readonly property real topEdgeOvs: (!isFramed && shouldShow && bar && bar.topLeftCornerState === -1 && bar.topRightCornerState === -1 && barMappedPos.y <= 0) ? -screenEdgeOvershoot : 0
|
||||
readonly property real bottomEdgeOvs: (!isFramed && shouldShow && bar && bar.bottomLeftCornerState === -1 && bar.bottomRightCornerState === -1 && (barMappedPos.y + barHeight) >= screenHeight) ? screenEdgeOvershoot : 0
|
||||
readonly property real leftEdgeOvs: (!isFramed && shouldShow && bar && bar.topLeftCornerState === -1 && bar.bottomLeftCornerState === -1 && barMappedPos.x <= 0) ? -screenEdgeOvershoot : 0
|
||||
readonly property real rightEdgeOvs: (!isFramed && shouldShow && bar && bar.topRightCornerState === -1 && bar.bottomRightCornerState === -1 && (barMappedPos.x + barWidth) >= screenWidth) ? screenEdgeOvershoot : 0
|
||||
|
||||
// Auto-hide opacity factor for background fade
|
||||
property real opacityFactor: (bar && bar.isHidden) ? 0 : 1
|
||||
|
||||
Behavior on opacityFactor {
|
||||
enabled: bar && bar.autoHide
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
// ShapePath configuration
|
||||
strokeWidth: -1 // No stroke, fill only
|
||||
fillColor: isRenderable ? Qt.rgba(backgroundColor.r, backgroundColor.g, backgroundColor.b, backgroundColor.a * opacityFactor) : "transparent"
|
||||
fillRule: isFramed ? ShapePath.OddEvenFill : ShapePath.WindingFill
|
||||
|
||||
// Starting position — falls back to off-screen when not renderable so that
|
||||
// all subsequent path elements form a valid non-degenerate off-screen square.
|
||||
// Each edge is split between PathLine and PathArc so no arc has zero displacement,
|
||||
// preventing CurveRenderer triangulation crashes on degenerate arcs.
|
||||
// For framed mode the outer path is a full-screen rectangle; _minR offsets at each
|
||||
// corner prevent zero-displacement zero-radius arcs that crash qTriangulate.
|
||||
startX: isRenderable ? (isFramed ? _minR : (barMappedPos.x + leftEdgeOvs + tlRadius * tlMultX)) : -0.75
|
||||
startY: isRenderable ? (isFramed ? 0 : (barMappedPos.y + topEdgeOvs)) : -1
|
||||
|
||||
// ========== PATH DEFINITION ==========
|
||||
|
||||
// 1. Main Bar / Outer Screen Rectangle
|
||||
// When !isRenderable all elements use fallback coordinates forming a valid 1×1
|
||||
// off-screen square with non-degenerate arcs so CurveRenderer never receives
|
||||
// a zero-area, bare-moveto, or zero-displacement arc path.
|
||||
PathLine {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.screenWidth - root._minR) : (root.barMappedPos.x + root.barWidth + root.rightEdgeOvs - root.trRadius * root.trMultX)) : 0
|
||||
y: root.isRenderable ? (root.isFramed ? 0 : (root.barMappedPos.y + root.topEdgeOvs)) : -1
|
||||
}
|
||||
|
||||
// Top-right corner
|
||||
PathArc {
|
||||
x: root.isRenderable ? (root.isFramed ? root.screenWidth : (root.barMappedPos.x + root.barWidth + root.rightEdgeOvs)) : 0
|
||||
y: root.isRenderable ? (root.isFramed ? root._minR : (root.barMappedPos.y + root.topEdgeOvs + root.trRadius * root.trMultY)) : -0.75
|
||||
radiusX: root.isRenderable ? (root.isFramed ? root._minR : root.trRadius) : 0
|
||||
radiusY: root.isRenderable ? (root.isFramed ? root._minR : root.trRadius) : 0
|
||||
direction: ShapeCornerHelper.getArcDirection(root.trMultX, root.trMultY)
|
||||
}
|
||||
|
||||
PathLine {
|
||||
x: root.isRenderable ? (root.isFramed ? root.screenWidth : (root.barMappedPos.x + root.barWidth + root.rightEdgeOvs)) : 0
|
||||
y: root.isRenderable ? (root.isFramed ? (root.screenHeight - root._minR) : (root.barMappedPos.y + root.barHeight + root.bottomEdgeOvs - root.brRadius * root.brMultY)) : 0
|
||||
}
|
||||
|
||||
// Bottom-right corner
|
||||
PathArc {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.screenWidth - root._minR) : (root.barMappedPos.x + root.barWidth + root.rightEdgeOvs - root.brRadius * root.brMultX)) : -0.25
|
||||
y: root.isRenderable ? (root.isFramed ? root.screenHeight : (root.barMappedPos.y + root.barHeight + root.bottomEdgeOvs)) : 0
|
||||
radiusX: root.isRenderable ? (root.isFramed ? root._minR : root.brRadius) : 0
|
||||
radiusY: root.isRenderable ? (root.isFramed ? root._minR : root.brRadius) : 0
|
||||
direction: ShapeCornerHelper.getArcDirection(root.brMultX, root.brMultY)
|
||||
}
|
||||
|
||||
PathLine {
|
||||
x: root.isRenderable ? (root.isFramed ? root._minR : (root.barMappedPos.x + root.leftEdgeOvs + root.blRadius * root.blMultX)) : -1
|
||||
y: root.isRenderable ? (root.isFramed ? root.screenHeight : (root.barMappedPos.y + root.barHeight + root.bottomEdgeOvs)) : 0
|
||||
}
|
||||
|
||||
// Bottom-left corner
|
||||
PathArc {
|
||||
x: root.isRenderable ? (root.isFramed ? 0 : (root.barMappedPos.x + root.leftEdgeOvs)) : -1
|
||||
y: root.isRenderable ? (root.isFramed ? (root.screenHeight - root._minR) : (root.barMappedPos.y + root.barHeight + root.bottomEdgeOvs - root.blRadius * root.blMultY)) : -0.25
|
||||
radiusX: root.isRenderable ? (root.isFramed ? root._minR : root.blRadius) : 0
|
||||
radiusY: root.isRenderable ? (root.isFramed ? root._minR : root.blRadius) : 0
|
||||
direction: ShapeCornerHelper.getArcDirection(root.blMultX, root.blMultY)
|
||||
}
|
||||
|
||||
PathLine {
|
||||
x: root.isRenderable ? (root.isFramed ? 0 : (root.barMappedPos.x + root.leftEdgeOvs)) : -1
|
||||
y: root.isRenderable ? (root.isFramed ? root._minR : (root.barMappedPos.y + root.topEdgeOvs + root.tlRadius * root.tlMultY)) : -1
|
||||
}
|
||||
|
||||
// Top-left corner (back to start)
|
||||
PathArc {
|
||||
x: root.isRenderable ? (root.isFramed ? root._minR : (root.barMappedPos.x + root.leftEdgeOvs + root.tlRadius * root.tlMultX)) : -0.75
|
||||
y: root.isRenderable ? (root.isFramed ? 0 : (root.barMappedPos.y + root.topEdgeOvs)) : -1
|
||||
radiusX: root.isRenderable ? (root.isFramed ? root._minR : root.tlRadius) : 0
|
||||
radiusY: root.isRenderable ? (root.isFramed ? root._minR : root.tlRadius) : 0
|
||||
direction: ShapeCornerHelper.getArcDirection(root.tlMultX, root.tlMultY)
|
||||
}
|
||||
|
||||
// 2. Inner Hole for Framed Mode (Clockwise)
|
||||
// When !isFramed, draws a tiny 1x1 rectangle inside the bar as a non-degenerate WindingFill
|
||||
// no-op to prevent a zero-area degenerate subpath crashing qTriangulate.
|
||||
// Note: an exact duplicate of the outer path cannot be used here because Qt's CurveRenderer
|
||||
// has issues with exactly coincident subpaths, causing the fill to not render on some systems.
|
||||
// When !isRenderable, falls back to a valid 1×1 off-screen square at (-3,-3)→(-2,-2).
|
||||
readonly property real _nhX: barMappedPos.x + barWidth / 2
|
||||
readonly property real _nhY: barMappedPos.y + barHeight / 2
|
||||
PathMove {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.holeX + root.frameRadius) : (root._nhX + 0.25)) : -2.75
|
||||
y: root.isRenderable ? (root.isFramed ? root.holeY : root._nhY) : -3
|
||||
}
|
||||
|
||||
// Top edge
|
||||
PathLine {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.holeX + root.holeWidth - root.frameRadius) : (root._nhX + 1)) : -2
|
||||
y: root.isRenderable ? (root.isFramed ? root.holeY : root._nhY) : -3
|
||||
}
|
||||
|
||||
// Top-right corner
|
||||
PathArc {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.holeX + root.holeWidth) : (root._nhX + 1)) : -2
|
||||
y: root.isRenderable ? (root.isFramed ? (root.holeY + root.frameRadius) : (root._nhY + 0.25)) : -2.75
|
||||
radiusX: root.isRenderable ? (root.isFramed ? root.frameRadius : 0) : 0
|
||||
radiusY: root.isRenderable ? (root.isFramed ? root.frameRadius : 0) : 0
|
||||
direction: PathArc.Clockwise
|
||||
}
|
||||
|
||||
// Right edge
|
||||
PathLine {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.holeX + root.holeWidth) : (root._nhX + 1)) : -2
|
||||
y: root.isRenderable ? (root.isFramed ? (root.holeY + root.holeHeight - root.frameRadius) : (root._nhY + 1)) : -2
|
||||
}
|
||||
|
||||
// Bottom-right corner
|
||||
PathArc {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.holeX + root.holeWidth - root.frameRadius) : (root._nhX + 0.75)) : -2.25
|
||||
y: root.isRenderable ? (root.isFramed ? (root.holeY + root.holeHeight) : (root._nhY + 1)) : -2
|
||||
radiusX: root.isRenderable ? (root.isFramed ? root.frameRadius : 0) : 0
|
||||
radiusY: root.isRenderable ? (root.isFramed ? root.frameRadius : 0) : 0
|
||||
direction: PathArc.Clockwise
|
||||
}
|
||||
|
||||
// Bottom edge
|
||||
PathLine {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.holeX + root.frameRadius) : root._nhX) : -3
|
||||
y: root.isRenderable ? (root.isFramed ? (root.holeY + root.holeHeight) : (root._nhY + 1)) : -2
|
||||
}
|
||||
|
||||
// Bottom-left corner
|
||||
PathArc {
|
||||
x: root.isRenderable ? (root.isFramed ? root.holeX : root._nhX) : -3
|
||||
y: root.isRenderable ? (root.isFramed ? (root.holeY + root.holeHeight - root.frameRadius) : (root._nhY + 0.75)) : -2.25
|
||||
radiusX: root.isRenderable ? (root.isFramed ? root.frameRadius : 0) : 0
|
||||
radiusY: root.isRenderable ? (root.isFramed ? root.frameRadius : 0) : 0
|
||||
direction: PathArc.Clockwise
|
||||
}
|
||||
|
||||
// Left edge
|
||||
PathLine {
|
||||
x: root.isRenderable ? (root.isFramed ? root.holeX : root._nhX) : -3
|
||||
y: root.isRenderable ? (root.isFramed ? (root.holeY + root.frameRadius) : root._nhY) : -3
|
||||
}
|
||||
|
||||
// Top-left corner (back to start)
|
||||
PathArc {
|
||||
x: root.isRenderable ? (root.isFramed ? (root.holeX + root.frameRadius) : (root._nhX + 0.25)) : -2.75
|
||||
y: root.isRenderable ? (root.isFramed ? root.holeY : root._nhY) : -3
|
||||
radiusX: root.isRenderable ? (root.isFramed ? root.frameRadius : 0) : 0
|
||||
radiusY: root.isRenderable ? (root.isFramed ? root.frameRadius : 0) : 0
|
||||
direction: PathArc.Clockwise
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.Commons
|
||||
import qs.Modules.MainScreen.Backgrounds
|
||||
|
||||
/**
|
||||
* PanelBackground - Dynamic ShapePath for rendering panel backgrounds
|
||||
*
|
||||
* Dynamically switches between panels based on which panel is currently
|
||||
* assigned by PanelService. Only 2 instances are needed: one for the
|
||||
* currently open panel and one for a closing panel during transitions.
|
||||
*
|
||||
* Uses 4-state per-corner system for flexible corner rendering:
|
||||
* - State -1: No radius (flat/square corner)
|
||||
* - State 0: Normal (inner curve)
|
||||
* - State 1: Horizontal inversion (outer curve on X-axis)
|
||||
* - State 2: Vertical inversion (outer curve on Y-axis)
|
||||
*/
|
||||
ShapePath {
|
||||
id: root
|
||||
|
||||
// Dynamically assigned panel (null if slot is unused)
|
||||
property var assignedPanel: null
|
||||
|
||||
// Required reference to AllBackgrounds shapeContainer
|
||||
required property var shapeContainer
|
||||
|
||||
// Default background color (used if panel doesn't specify one)
|
||||
property color defaultBackgroundColor: Color.mSurface
|
||||
|
||||
// Corner radius (from Style)
|
||||
readonly property real radius: Style.radiusL
|
||||
|
||||
// Get panel's panelRegion (geometry placeholder)
|
||||
readonly property var panelRegion: assignedPanel?.panelRegion ?? null
|
||||
|
||||
// Get the actual panelBackground Item from panelRegion
|
||||
// Only access panelItem if panelRegion exists and is visible
|
||||
readonly property var panelBg: (panelRegion && panelRegion.visible) ? panelRegion.panelItem : null
|
||||
|
||||
// Effective background color: use panel's if defined, else default
|
||||
readonly property color effectiveBackgroundColor: {
|
||||
if (!assignedPanel)
|
||||
return "transparent";
|
||||
if (assignedPanel.panelBackgroundColor !== undefined) {
|
||||
return assignedPanel.panelBackgroundColor;
|
||||
}
|
||||
return defaultBackgroundColor;
|
||||
}
|
||||
|
||||
// Panel position - panelBg is in screen coordinates already
|
||||
readonly property real panelX: panelBg ? panelBg.x : 0
|
||||
readonly property real panelY: panelBg ? panelBg.y : 0
|
||||
readonly property real panelWidth: panelBg ? panelBg.width : 0
|
||||
readonly property real panelHeight: panelBg ? panelBg.height : 0
|
||||
readonly property bool isRenderable: assignedPanel && panelBg && panelWidth > 0 && panelHeight > 0
|
||||
|
||||
// Flatten corners if panel is too small
|
||||
readonly property bool shouldFlatten: panelBg ? ShapeCornerHelper.shouldFlatten(panelWidth, panelHeight, radius) : false
|
||||
readonly property real effectiveRadius: shouldFlatten ? ShapeCornerHelper.getFlattenedRadius(Math.min(panelWidth, panelHeight), radius) : radius
|
||||
|
||||
// Minimum safe arc radius — prevents zero-displacement zero-radius PathArcs
|
||||
// that crash qTriangulate in CurveRenderer. 0.01px is sub-pixel and invisible.
|
||||
readonly property real _minR: 0.01
|
||||
|
||||
// Helper function for getting corner radius based on state
|
||||
function getCornerRadius(cornerState) {
|
||||
// State -1 = flat corner — use minimum safe radius instead of 0
|
||||
// to prevent degenerate PathArc (zero displacement + zero radius)
|
||||
if (cornerState === -1)
|
||||
return _minR;
|
||||
// All other states use effectiveRadius (clamped to safe minimum)
|
||||
return Math.max(_minR, effectiveRadius);
|
||||
}
|
||||
|
||||
// Per-corner multipliers and radii based on panelBg's corner states
|
||||
readonly property real tlMultX: panelBg ? ShapeCornerHelper.getMultX(panelBg.topLeftCornerState) : 1
|
||||
readonly property real tlMultY: panelBg ? ShapeCornerHelper.getMultY(panelBg.topLeftCornerState) : 1
|
||||
readonly property real tlRadius: panelBg ? getCornerRadius(panelBg.topLeftCornerState) : 0
|
||||
|
||||
readonly property real trMultX: panelBg ? ShapeCornerHelper.getMultX(panelBg.topRightCornerState) : 1
|
||||
readonly property real trMultY: panelBg ? ShapeCornerHelper.getMultY(panelBg.topRightCornerState) : 1
|
||||
readonly property real trRadius: panelBg ? getCornerRadius(panelBg.topRightCornerState) : 0
|
||||
|
||||
readonly property real brMultX: panelBg ? ShapeCornerHelper.getMultX(panelBg.bottomRightCornerState) : 1
|
||||
readonly property real brMultY: panelBg ? ShapeCornerHelper.getMultY(panelBg.bottomRightCornerState) : 1
|
||||
readonly property real brRadius: panelBg ? getCornerRadius(panelBg.bottomRightCornerState) : 0
|
||||
|
||||
readonly property real blMultX: panelBg ? ShapeCornerHelper.getMultX(panelBg.bottomLeftCornerState) : 1
|
||||
readonly property real blMultY: panelBg ? ShapeCornerHelper.getMultY(panelBg.bottomLeftCornerState) : 1
|
||||
readonly property real blRadius: panelBg ? getCornerRadius(panelBg.bottomLeftCornerState) : 0
|
||||
|
||||
// ShapePath configuration
|
||||
strokeWidth: -1 // No stroke, fill only
|
||||
|
||||
// Start point - use tiny off-screen non-degenerate fallback when not renderable.
|
||||
// Fallback forms a 1×1 off-screen square where each edge is split between a PathLine
|
||||
// and a PathArc, ensuring no arc has zero displacement (which can crash qTriangulate).
|
||||
startX: isRenderable ? (panelX + tlRadius * tlMultX) : -0.75
|
||||
startY: isRenderable ? panelY : -1
|
||||
|
||||
fillColor: isRenderable ? effectiveBackgroundColor : "transparent"
|
||||
|
||||
// ========== PATH DEFINITION ==========
|
||||
// Draws a rectangle with potentially inverted corners
|
||||
// All coordinates are relative to startX/startY
|
||||
|
||||
// Top edge (moving right)
|
||||
PathLine {
|
||||
relativeX: root.isRenderable ? (root.panelWidth - root.tlRadius * root.tlMultX - root.trRadius * root.trMultX) : 0.75
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Top-right corner arc
|
||||
PathArc {
|
||||
relativeX: root.isRenderable ? (root.trRadius * root.trMultX) : 0
|
||||
relativeY: root.isRenderable ? (root.trRadius * root.trMultY) : 0.25
|
||||
radiusX: root.isRenderable ? root.trRadius : 0
|
||||
radiusY: root.isRenderable ? root.trRadius : 0
|
||||
direction: ShapeCornerHelper.getArcDirection(root.trMultX, root.trMultY)
|
||||
}
|
||||
|
||||
// Right edge (moving down)
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: root.isRenderable ? (root.panelHeight - root.trRadius * root.trMultY - root.brRadius * root.brMultY) : 0.75
|
||||
}
|
||||
|
||||
// Bottom-right corner arc
|
||||
PathArc {
|
||||
relativeX: root.isRenderable ? (-root.brRadius * root.brMultX) : -0.25
|
||||
relativeY: root.isRenderable ? (root.brRadius * root.brMultY) : 0
|
||||
radiusX: root.isRenderable ? root.brRadius : 0
|
||||
radiusY: root.isRenderable ? root.brRadius : 0
|
||||
direction: ShapeCornerHelper.getArcDirection(root.brMultX, root.brMultY)
|
||||
}
|
||||
|
||||
// Bottom edge (moving left)
|
||||
PathLine {
|
||||
relativeX: root.isRenderable ? (-(root.panelWidth - root.brRadius * root.brMultX - root.blRadius * root.blMultX)) : -0.75
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Bottom-left corner arc
|
||||
PathArc {
|
||||
relativeX: root.isRenderable ? (-root.blRadius * root.blMultX) : 0
|
||||
relativeY: root.isRenderable ? (-root.blRadius * root.blMultY) : -0.25
|
||||
radiusX: root.isRenderable ? root.blRadius : 0
|
||||
radiusY: root.isRenderable ? root.blRadius : 0
|
||||
direction: ShapeCornerHelper.getArcDirection(root.blMultX, root.blMultY)
|
||||
}
|
||||
|
||||
// Left edge (moving up) - closes the path back to start
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: root.isRenderable ? (-(root.panelHeight - root.blRadius * root.blMultY - root.tlRadius * root.tlMultY)) : -0.75
|
||||
}
|
||||
|
||||
// Top-left corner arc (back to start)
|
||||
PathArc {
|
||||
relativeX: root.isRenderable ? (root.tlRadius * root.tlMultX) : 0.25
|
||||
relativeY: root.isRenderable ? (-root.tlRadius * root.tlMultY) : 0
|
||||
radiusX: root.isRenderable ? root.tlRadius : 0
|
||||
radiusY: root.isRenderable ? root.tlRadius : 0
|
||||
direction: ShapeCornerHelper.getArcDirection(root.tlMultX, root.tlMultY)
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import Quickshell
|
||||
|
||||
/**
|
||||
* ShapeCornerHelper - Utility singleton for shape corner calculations
|
||||
*
|
||||
* Uses 4-state per-corner system for flexible corner rendering:
|
||||
* - State -1: No radius (flat/square corner)
|
||||
* - State 0: Normal (inner curve)
|
||||
* - State 1: Horizontal inversion (outer curve on X-axis)
|
||||
* - State 2: Vertical inversion (outer curve on Y-axis)
|
||||
*
|
||||
* The key technique: Using PathArc direction control (Clockwise vs Counterclockwise)
|
||||
* combined with multipliers to create both inner and outer corner curves.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
/**
|
||||
* Get X-axis multiplier for a corner state
|
||||
* State 1 (horizontal invert) returns -1, others return 1
|
||||
*/
|
||||
function getMultX(cornerState) {
|
||||
return cornerState === 1 ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Y-axis multiplier for a corner state
|
||||
* State 2 (vertical invert) returns -1, others return 1
|
||||
*/
|
||||
function getMultY(cornerState) {
|
||||
return cornerState === 2 ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PathArc direction for a corner based on its multipliers
|
||||
* Uses XOR logic: if X inverted differs from Y inverted, use Counterclockwise
|
||||
* This creates the outer curve effect for inverted corners
|
||||
*/
|
||||
function getArcDirection(multX, multY) {
|
||||
return ((multX < 0) !== (multY < 0)) ? PathArc.Counterclockwise : PathArc.Clockwise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get arc direction directly from corner state
|
||||
*/
|
||||
function getArcDirectionFromState(cornerState) {
|
||||
const multX = getMultX(cornerState);
|
||||
const multY = getMultY(cornerState);
|
||||
return getArcDirection(multX, multY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "flattening" radius when shape dimensions are too small
|
||||
* Prevents visual artifacts when radius exceeds dimensions
|
||||
*/
|
||||
function getFlattenedRadius(dimension, requestedRadius) {
|
||||
if (dimension < requestedRadius * 2) {
|
||||
return dimension / 2;
|
||||
}
|
||||
return requestedRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a shape should use flattened corners
|
||||
* Returns true if width or height is too small for the requested radius
|
||||
*/
|
||||
function shouldFlatten(width, height, radius) {
|
||||
return width < radius * 2 || height < radius * 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Bar
|
||||
import qs.Services.UI
|
||||
|
||||
/**
|
||||
* BarContentWindow - Separate transparent PanelWindow for bar content
|
||||
*
|
||||
* This window contains only the bar widgets (content), while the background
|
||||
* is rendered in MainScreen's unified Shape system. This separation prevents
|
||||
* fullscreen redraws when bar widgets redraw.
|
||||
*
|
||||
* This component should be instantiated once per screen by AllScreens.qml
|
||||
*/
|
||||
PanelWindow {
|
||||
id: barWindow
|
||||
|
||||
// Note: screen property is inherited from PanelWindow and should be set by parent
|
||||
color: "transparent" // Transparent - background is in MainScreen below
|
||||
|
||||
// Window invisible when auto-hidden (blocks input) or toggled off via IPC.
|
||||
// windowVisible stays true briefly after isHidden to allow fade-out animation.
|
||||
property bool windowVisible: !isHidden
|
||||
visible: contentLoaded && windowVisible && BarService.effectivelyVisible
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.d("BarContentWindow", "Bar content window created for screen:", barWindow.screen?.name);
|
||||
if (!isHidden)
|
||||
contentLoaded = true;
|
||||
}
|
||||
|
||||
// Wayland layer configuration
|
||||
WlrLayershell.namespace: "noctalia-bar-content-" + (barWindow.screen?.name || "unknown")
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore // Don't reserve space - BarExclusionZone in MainScreen handles that
|
||||
|
||||
// Position and size to match bar location (per-screen)
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(barWindow.screen?.name)
|
||||
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property bool isFramed: Settings.data.bar.barType === "framed"
|
||||
readonly property real frameThickness: Settings.data.bar.frameThickness ?? 12
|
||||
readonly property bool barFloating: Settings.data.bar.barType === "floating"
|
||||
readonly property real barMarginH: Math.ceil(barFloating ? Settings.data.bar.marginHorizontal : 0)
|
||||
readonly property real barMarginV: Math.ceil(barFloating ? Settings.data.bar.marginVertical : 0)
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(barWindow.screen?.name)
|
||||
|
||||
// Auto-hide properties
|
||||
readonly property bool autoHide: Settings.getBarDisplayModeForScreen(barWindow.screen?.name) === "auto_hide"
|
||||
readonly property int hideDelay: Settings.data.bar.autoHideDelay || 500
|
||||
readonly property int showDelay: Settings.data.bar.autoShowDelay || 100
|
||||
property bool isHidden: autoHide
|
||||
|
||||
// Hover tracking
|
||||
property bool barHovered: false
|
||||
|
||||
// Check if any panel is open on this screen
|
||||
readonly property bool panelOpen: PanelService.openedPanel !== null
|
||||
|
||||
// Timer for delayed hide
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: barWindow.hideDelay
|
||||
onTriggered: {
|
||||
if (barWindow.autoHide && !barWindow.barHovered && !barWindow.panelOpen && !BarService.popupOpen) {
|
||||
BarService.setScreenHidden(barWindow.screen?.name, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timer for delayed show
|
||||
Timer {
|
||||
id: showTimer
|
||||
interval: barWindow.showDelay
|
||||
onTriggered: {
|
||||
// Only show if still hovered (via trigger zone or bar itself)
|
||||
if (barWindow.autoHide && BarService.isBarHovered(barWindow.screen?.name)) {
|
||||
BarService.setScreenHidden(barWindow.screen?.name, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// React to auto-hide state changes from BarService
|
||||
Connections {
|
||||
target: BarService
|
||||
function onBarAutoHideStateChanged(screenName, hidden) {
|
||||
Logger.d("BarContentWindow", "onBarAutoHideStateChanged:", screenName, hidden, "my screen:", barWindow.screen?.name);
|
||||
if (screenName === barWindow.screen?.name) {
|
||||
barWindow.isHidden = hidden;
|
||||
}
|
||||
}
|
||||
function onBarHoverStateChanged(screenName, hovered) {
|
||||
if (screenName === barWindow.screen?.name && barWindow.autoHide) {
|
||||
if (hovered) {
|
||||
hideTimer.stop();
|
||||
// If bar is already visible, no need to delay
|
||||
if (!barWindow.isHidden) {
|
||||
showTimer.stop();
|
||||
} else {
|
||||
// Bar is hidden, use show delay
|
||||
showTimer.restart();
|
||||
}
|
||||
} else if (!barWindow.barHovered && !barWindow.panelOpen) {
|
||||
showTimer.stop();
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't hide when panel is open
|
||||
onPanelOpenChanged: {
|
||||
if (panelOpen && autoHide) {
|
||||
hideTimer.stop();
|
||||
BarService.setScreenHidden(barWindow.screen?.name, false);
|
||||
} else if (!panelOpen && autoHide && !barHovered) {
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
// React to popup menu closing
|
||||
Connections {
|
||||
target: BarService
|
||||
function onPopupOpenChanged() {
|
||||
if (!BarService.popupOpen && barWindow.autoHide && !barWindow.barHovered && !barWindow.panelOpen) {
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// React to displayMode changes
|
||||
onAutoHideChanged: {
|
||||
if (!autoHide) {
|
||||
// Show bar when auto-hide is disabled
|
||||
hideTimer.stop();
|
||||
showTimer.stop();
|
||||
barWindow.isHidden = false;
|
||||
}
|
||||
// When auto-hide is enabled, don't immediately hide - wait for mouse to leave
|
||||
}
|
||||
|
||||
// Anchor to the bar's edge
|
||||
anchors {
|
||||
top: barPosition === "top" || barIsVertical
|
||||
bottom: barPosition === "bottom" || barIsVertical
|
||||
left: barPosition === "left" || !barIsVertical
|
||||
right: barPosition === "right" || !barIsVertical
|
||||
}
|
||||
|
||||
// Content stays loaded once initialized — never unloaded during auto-hide.
|
||||
// Destroying and recreating widgets on every hide/show cycle caused nested
|
||||
// QML incubation crashes (SIGSEGV in QV4::Object::insertMember) because
|
||||
// async widget Loaders complete during incubateFor() and their onLoaded
|
||||
// handlers trigger signal cascades mid-incubation.
|
||||
// The bar is hidden via opacity + window visibility instead.
|
||||
property bool contentLoaded: false
|
||||
|
||||
// Delay window hide to allow fade-out animation to complete
|
||||
Timer {
|
||||
id: windowHideTimer
|
||||
interval: Style.animationFast
|
||||
onTriggered: {
|
||||
if (barWindow.isHidden)
|
||||
barWindow.windowVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
onIsHiddenChanged: {
|
||||
if (isHidden) {
|
||||
// Delay window hide so fade-out is visible
|
||||
windowHideTimer.restart();
|
||||
} else {
|
||||
windowHideTimer.stop();
|
||||
windowVisible = true;
|
||||
if (!contentLoaded)
|
||||
contentLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: BarService
|
||||
function onEffectivelyVisibleChanged() {
|
||||
if (BarService.effectivelyVisible && !barWindow.isHidden && !barWindow.contentLoaded) {
|
||||
barWindow.contentLoaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle floating margins and framed mode offsets
|
||||
margins {
|
||||
top: (barPosition === "top") ? barMarginV : (isFramed ? frameThickness : barMarginV)
|
||||
bottom: (barPosition === "bottom") ? barMarginV : (isFramed ? frameThickness : barMarginV)
|
||||
left: (barPosition === "left") ? barMarginH : (isFramed ? frameThickness : barMarginH)
|
||||
right: (barPosition === "right") ? barMarginH : (isFramed ? frameThickness : barMarginH)
|
||||
}
|
||||
|
||||
// Set a tight window size
|
||||
implicitWidth: barIsVertical ? barHeight : barWindow.screen.width
|
||||
implicitHeight: barIsVertical ? barWindow.screen.height : barHeight
|
||||
|
||||
// Bar content loader - loaded once, stays active for lifetime
|
||||
Loader {
|
||||
id: barLoader
|
||||
anchors.fill: parent
|
||||
active: barWindow.contentLoaded
|
||||
|
||||
sourceComponent: Item {
|
||||
anchors.fill: parent
|
||||
|
||||
// Fade animation
|
||||
opacity: barWindow.isHidden ? 0 : 1
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: barWindow.autoHide
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
Bar {
|
||||
id: barContent
|
||||
anchors.fill: parent
|
||||
screen: barWindow.screen
|
||||
|
||||
// Hover detection using HoverHandler (doesn't block child hover events)
|
||||
HoverHandler {
|
||||
id: hoverHandler
|
||||
|
||||
onHoveredChanged: {
|
||||
if (hovered) {
|
||||
barWindow.barHovered = true;
|
||||
BarService.setScreenHovered(barWindow.screen?.name, true);
|
||||
if (barWindow.autoHide) {
|
||||
hideTimer.stop();
|
||||
showTimer.restart();
|
||||
}
|
||||
} else {
|
||||
// Skip if already hidden (being destroyed)
|
||||
if (barWindow.isHidden)
|
||||
return;
|
||||
barWindow.barHovered = false;
|
||||
BarService.setScreenHovered(barWindow.screen?.name, false);
|
||||
if (barWindow.autoHide && !barWindow.panelOpen) {
|
||||
showTimer.stop();
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.UI
|
||||
|
||||
/**
|
||||
* BarExclusionZone - Invisible PanelWindow that reserves exclusive space for the bar
|
||||
*
|
||||
* This is a minimal window that works with the compositor to reserve space,
|
||||
* while the actual bar UI is rendered in MainScreen.
|
||||
*/
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
// Edge to anchor to and thickness to reserve
|
||||
property string edge: Settings.getBarPositionForScreen(screen?.name)
|
||||
property real thickness: (edge === Settings.getBarPositionForScreen(screen?.name)) ? Style.getBarHeightForScreen(screen?.name) : (Settings.data.bar.frameThickness ?? 12)
|
||||
|
||||
readonly property bool autoHide: Settings.getBarDisplayModeForScreen(screen?.name) === "auto_hide"
|
||||
readonly property bool nonExclusive: Settings.getBarDisplayModeForScreen(screen?.name) === "non_exclusive"
|
||||
readonly property bool barFloating: Settings.data.bar.barType === "floating"
|
||||
readonly property real barMarginH: (barFloating && edge === Settings.getBarPositionForScreen(screen?.name)) ? Math.ceil(Settings.data.bar.marginHorizontal) : 0
|
||||
readonly property real barMarginV: (barFloating && edge === Settings.getBarPositionForScreen(screen?.name)) ? Math.ceil(Settings.data.bar.marginVertical) : 0
|
||||
// Allow users to enable a 1-physical-pixel inset for the exclusion zone so window borders can bleed under the bar
|
||||
readonly property real bleedOffset: Settings.data.bar.enableExclusionZoneInset ? 1.0 : 0.0
|
||||
readonly property real bleedInset: {
|
||||
const info = CompositorService.displayScales[screen?.name];
|
||||
const scale = (info && info.scale) ? info.scale : 1.0;
|
||||
return bleedOffset / scale;
|
||||
}
|
||||
|
||||
// Invisible - just reserves space
|
||||
color: "transparent"
|
||||
|
||||
mask: Region {}
|
||||
|
||||
// Wayland layer shell configuration
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
WlrLayershell.namespace: "noctalia-bar-exclusion-" + edge + "-" + (screen?.name || "unknown")
|
||||
// When auto-hide, non-exclusive mode is enabled, OR bar is explicitly hidden via IPC, don't reserve space
|
||||
// Note: We check BarService.isVisible directly, NOT effectivelyVisible, because we want
|
||||
// the exclusion zone to stay during overview (effectivelyVisible is false during overview
|
||||
// when hideOnOverview is enabled, but isVisible remains true)
|
||||
WlrLayershell.exclusionMode: (autoHide || nonExclusive || !BarService.isVisible) ? ExclusionMode.Ignore : ExclusionMode.Auto
|
||||
|
||||
// Anchor based on specified edge
|
||||
anchors {
|
||||
top: edge === "top"
|
||||
bottom: edge === "bottom"
|
||||
left: edge === "left" || edge === "top" || edge === "bottom"
|
||||
right: edge === "right" || edge === "top" || edge === "bottom"
|
||||
}
|
||||
|
||||
// Size based on orientation
|
||||
implicitWidth: {
|
||||
if (edge === "left" || edge === "right") {
|
||||
return thickness + barMarginH - bleedInset;
|
||||
}
|
||||
return 0; // Auto-width when left/right anchors are true
|
||||
}
|
||||
|
||||
implicitHeight: {
|
||||
if (edge === "top" || edge === "bottom") {
|
||||
return thickness + barMarginV - bleedInset;
|
||||
}
|
||||
return 0; // Auto-height when top/bottom anchors are true
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.d("BarExclusionZone", "Created for screen:", screen?.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
|
||||
/**
|
||||
* BarTriggerZone - Thin invisible window at screen edge to reveal hidden bar
|
||||
*
|
||||
* This window is only active when the bar is in auto-hide mode and hidden.
|
||||
* When the mouse enters this zone, it triggers the bar to show.
|
||||
*/
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
|
||||
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property int triggerSize: 1
|
||||
|
||||
// Track if component is being destroyed to prevent signals during cleanup
|
||||
property bool isBeingDestroyed: false
|
||||
Component.onDestruction: isBeingDestroyed = true
|
||||
|
||||
// Invisible trigger zone
|
||||
color: "transparent"
|
||||
focusable: false
|
||||
|
||||
WlrLayershell.namespace: "noctalia-bar-trigger-" + (screen?.name || "unknown")
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Anchor to bar's edge
|
||||
anchors {
|
||||
top: barPosition === "top" || barIsVertical
|
||||
bottom: barPosition === "bottom" || barIsVertical
|
||||
left: barPosition === "left" || !barIsVertical
|
||||
right: barPosition === "right" || !barIsVertical
|
||||
}
|
||||
|
||||
// Size based on orientation - thin strip at edge
|
||||
implicitWidth: barIsVertical ? triggerSize : 0
|
||||
implicitHeight: !barIsVertical ? triggerSize : 0
|
||||
|
||||
MouseArea {
|
||||
id: triggerArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
onEntered: {
|
||||
if (root.isBeingDestroyed)
|
||||
return;
|
||||
// Signal hover - BarContentWindow will handle the show delay
|
||||
BarService.setScreenHovered(root.screen?.name, true);
|
||||
}
|
||||
|
||||
onExited: {
|
||||
if (root.isBeingDestroyed)
|
||||
return;
|
||||
BarService.setScreenHovered(root.screen?.name, false);
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.d("BarTriggerZone", "Created for screen:", screen?.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import "Backgrounds" as Backgrounds
|
||||
|
||||
import qs.Commons
|
||||
|
||||
// All panels
|
||||
import qs.Modules.Bar
|
||||
import qs.Modules.Bar.Extras
|
||||
import qs.Modules.Panels.Audio
|
||||
import qs.Modules.Panels.Battery
|
||||
import qs.Modules.Panels.Bluetooth
|
||||
import qs.Modules.Panels.Brightness
|
||||
import qs.Modules.Panels.Changelog
|
||||
import qs.Modules.Panels.Clock
|
||||
import qs.Modules.Panels.ControlCenter
|
||||
import qs.Modules.Panels.Dock
|
||||
import qs.Modules.Panels.Launcher
|
||||
import qs.Modules.Panels.Media
|
||||
import qs.Modules.Panels.Network
|
||||
import qs.Modules.Panels.NotificationHistory
|
||||
import qs.Modules.Panels.Plugins
|
||||
import qs.Modules.Panels.SessionMenu
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Modules.Panels.SetupWizard
|
||||
import qs.Modules.Panels.SystemStats
|
||||
import qs.Modules.Panels.Tray
|
||||
import qs.Modules.Panels.Wallpaper
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
|
||||
/**
|
||||
* MainScreen - Single PanelWindow per screen that manages all panels and the bar
|
||||
*/
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.d("MainScreen", "Initialized for screen:", screen?.name, "- Dimensions:", screen?.width, "x", screen?.height, "- Position:", screen?.x, ",", screen?.y);
|
||||
}
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
WlrLayershell.namespace: "noctalia-background-" + (screen?.name || "unknown")
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore // Don't reserve space - BarExclusionZone handles that
|
||||
WlrLayershell.keyboardFocus: {
|
||||
// No panel open anywhere: no keyboard focus needed
|
||||
if (!root.isAnyPanelOpen) {
|
||||
return WlrKeyboardFocus.None;
|
||||
}
|
||||
// Panel open on THIS screen: use panel's preferred focus mode
|
||||
if (root.isPanelOpen) {
|
||||
// Hyprland's Exclusive captures ALL input globally (including pointer),
|
||||
// preventing click-to-close from working on other monitors.
|
||||
// Workaround: briefly use Exclusive when panel opens (for text input focus),
|
||||
// then switch to OnDemand (for click-to-close on other screens).
|
||||
if (CompositorService.isHyprland) {
|
||||
return PanelService.isInitializingKeyboard ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.OnDemand;
|
||||
}
|
||||
return PanelService.openedPanel.exclusiveKeyboard ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.OnDemand;
|
||||
}
|
||||
// Panel open on ANOTHER screen: OnDemand allows receiving pointer events for click-to-close
|
||||
return WlrKeyboardFocus.OnDemand;
|
||||
}
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
// Desktop dimming when panels are open
|
||||
property real dimmerOpacity: Settings.data.general.dimmerOpacity ?? 0.8
|
||||
property bool isPanelOpen: (PanelService.openedPanel !== null) && (PanelService.openedPanel.screen === screen)
|
||||
property bool isPanelClosing: (PanelService.openedPanel !== null) && PanelService.openedPanel.isClosing
|
||||
property bool isAnyPanelOpen: PanelService.openedPanel !== null
|
||||
|
||||
color: {
|
||||
if (dimmerOpacity > 0 && isPanelOpen && !isPanelClosing) {
|
||||
return Qt.alpha(Color.mShadow, dimmerOpacity);
|
||||
}
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
enabled: !PanelService.closedImmediately
|
||||
ColorAnimation {
|
||||
duration: isPanelClosing ? Style.animationFaster : Style.animationNormal
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
// Reset closedImmediately flag after color change is applied
|
||||
onColorChanged: {
|
||||
if (PanelService.closedImmediately) {
|
||||
PanelService.closedImmediately = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if bar should be visible on this screen
|
||||
readonly property bool barShouldShow: {
|
||||
// Check global bar visibility (includes overview state)
|
||||
if (!BarService.effectivelyVisible)
|
||||
return false;
|
||||
|
||||
// Check screen-specific configuration
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
var screenName = screen?.name || "";
|
||||
|
||||
// If no monitors specified, show on all screens
|
||||
// If monitors specified, only show if this screen is in the list
|
||||
return monitors.length === 0 || monitors.includes(screenName);
|
||||
}
|
||||
|
||||
// Make everything click-through except bar
|
||||
mask: Region {
|
||||
id: clickableMask
|
||||
|
||||
// Cover entire window (everything is masked/click-through)
|
||||
x: 0
|
||||
y: 0
|
||||
width: root.width
|
||||
height: root.height
|
||||
intersection: Intersection.Xor
|
||||
|
||||
// Only include regions that are actually needed
|
||||
// panelRegions is handled by PanelService, bar is local to this screen
|
||||
regions: [barMaskRegion, backgroundMaskRegion]
|
||||
|
||||
// Bar region - subtract bar area from mask (only if bar should be shown on this screen)
|
||||
Region {
|
||||
id: barMaskRegion
|
||||
|
||||
readonly property bool isFramed: Settings.data.bar.barType === "framed"
|
||||
readonly property real barThickness: Style.barHeight
|
||||
readonly property real frameThickness: Settings.data.bar.frameThickness ?? 12
|
||||
readonly property string barPos: Settings.data.bar.position || "top"
|
||||
|
||||
// Bar / Frame Mask
|
||||
Region {
|
||||
// Mode: Simple or Floating
|
||||
x: barPlaceholder.x
|
||||
y: barPlaceholder.y
|
||||
width: (!barMaskRegion.isFramed && root.barShouldShow) ? barPlaceholder.width : 0
|
||||
height: (!barMaskRegion.isFramed && root.barShouldShow) ? barPlaceholder.height : 0
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
|
||||
// Mode: Framed - 4 sides
|
||||
Region {
|
||||
// Top side
|
||||
Region {
|
||||
x: 0
|
||||
y: 0
|
||||
width: (barMaskRegion.isFramed && root.barShouldShow) ? root.width : 0
|
||||
height: (barMaskRegion.isFramed && root.barShouldShow) ? (barMaskRegion.barPos === "top" ? barMaskRegion.barThickness : barMaskRegion.frameThickness) : 0
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
|
||||
// Bottom side
|
||||
Region {
|
||||
x: 0
|
||||
y: (barMaskRegion.isFramed && root.barShouldShow) ? (root.height - (barMaskRegion.barPos === "bottom" ? barMaskRegion.barThickness : barMaskRegion.frameThickness)) : 0
|
||||
width: (barMaskRegion.isFramed && root.barShouldShow) ? root.width : 0
|
||||
height: (barMaskRegion.isFramed && root.barShouldShow) ? (barMaskRegion.barPos === "bottom" ? barMaskRegion.barThickness : barMaskRegion.frameThickness) : 0
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
|
||||
// Left side
|
||||
Region {
|
||||
x: 0
|
||||
y: 0
|
||||
width: (barMaskRegion.isFramed && root.barShouldShow) ? (barMaskRegion.barPos === "left" ? barMaskRegion.barThickness : barMaskRegion.frameThickness) : 0
|
||||
height: (barMaskRegion.isFramed && root.barShouldShow) ? root.height : 0
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
|
||||
// Right side
|
||||
Region {
|
||||
x: (barMaskRegion.isFramed && root.barShouldShow) ? (root.width - (barMaskRegion.barPos === "right" ? barMaskRegion.barThickness : barMaskRegion.frameThickness)) : 0
|
||||
width: (barMaskRegion.isFramed && root.barShouldShow) ? (barMaskRegion.barPos === "right" ? barMaskRegion.barThickness : barMaskRegion.frameThickness) : 0
|
||||
height: (barMaskRegion.isFramed && root.barShouldShow) ? root.height : 0
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Background region for click-to-close - reactive sizing
|
||||
// Uses isAnyPanelOpen so clicking on any screen's background closes the panel
|
||||
Region {
|
||||
id: backgroundMaskRegion
|
||||
x: 0
|
||||
y: 0
|
||||
width: root.isAnyPanelOpen ? root.width : 0
|
||||
height: root.isAnyPanelOpen ? root.height : 0
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
}
|
||||
|
||||
// Blur behind the bar and open panels — attached to PanelWindow (required by BackgroundEffect API)
|
||||
BackgroundEffect.blurRegion: Settings.data.general.enableBlurBehind ? blurRegion : null
|
||||
Region {
|
||||
id: blurRegion
|
||||
// ── Non-framed bar (simple/floating): single rectangle with bar corner states ──
|
||||
Region {
|
||||
x: (!barPlaceholder.isFramed && root.barShouldShow && !barPlaceholder.isHidden) ? barPlaceholder.x : 0
|
||||
y: (!barPlaceholder.isFramed && root.barShouldShow && !barPlaceholder.isHidden) ? barPlaceholder.y : 0
|
||||
width: (!barPlaceholder.isFramed && root.barShouldShow && !barPlaceholder.isHidden) ? barPlaceholder.width : 0
|
||||
height: (!barPlaceholder.isFramed && root.barShouldShow && !barPlaceholder.isHidden) ? barPlaceholder.height : 0
|
||||
radius: Style.radiusL
|
||||
topLeftCorner: barPlaceholder.topLeftCornerState
|
||||
topRightCorner: barPlaceholder.topRightCornerState
|
||||
bottomLeftCorner: barPlaceholder.bottomLeftCornerState
|
||||
bottomRightCorner: barPlaceholder.bottomRightCornerState
|
||||
}
|
||||
|
||||
// ── Framed bar: full screen minus rounded hole ──
|
||||
Region {
|
||||
x: 0
|
||||
y: 0
|
||||
width: (barPlaceholder.isFramed && root.barShouldShow && !barPlaceholder.isHidden) ? root.width : 0
|
||||
height: (barPlaceholder.isFramed && root.barShouldShow && !barPlaceholder.isHidden) ? root.height : 0
|
||||
|
||||
Region {
|
||||
intersection: Intersection.Subtract
|
||||
x: backgroundBlur.frameHoleX
|
||||
y: backgroundBlur.frameHoleY
|
||||
width: backgroundBlur.frameHoleX2 - backgroundBlur.frameHoleX
|
||||
height: backgroundBlur.frameHoleY2 - backgroundBlur.frameHoleY
|
||||
radius: backgroundBlur.frameR
|
||||
}
|
||||
}
|
||||
|
||||
// ── Panel blur regions ──
|
||||
// Opening panel
|
||||
Region {
|
||||
x: backgroundBlur.panelBg ? Math.round(backgroundBlur.panelBg.x) : 0
|
||||
y: backgroundBlur.panelBg ? Math.round(backgroundBlur.panelBg.y) : 0
|
||||
width: backgroundBlur.panelBg ? Math.round(backgroundBlur.panelBg.width) : 0
|
||||
height: backgroundBlur.panelBg ? Math.round(backgroundBlur.panelBg.height) : 0
|
||||
radius: Style.radiusL
|
||||
topLeftCorner: backgroundBlur.panelBg ? backgroundBlur.panelBg.topLeftCornerState : CornerState.Normal
|
||||
topRightCorner: backgroundBlur.panelBg ? backgroundBlur.panelBg.topRightCornerState : CornerState.Normal
|
||||
bottomLeftCorner: backgroundBlur.panelBg ? backgroundBlur.panelBg.bottomLeftCornerState : CornerState.Normal
|
||||
bottomRightCorner: backgroundBlur.panelBg ? backgroundBlur.panelBg.bottomRightCornerState : CornerState.Normal
|
||||
}
|
||||
|
||||
// Closing panel (coexists with opening panel during transition)
|
||||
Region {
|
||||
x: backgroundBlur.closingPanelBg ? Math.round(backgroundBlur.closingPanelBg.x) : 0
|
||||
y: backgroundBlur.closingPanelBg ? Math.round(backgroundBlur.closingPanelBg.y) : 0
|
||||
width: backgroundBlur.closingPanelBg ? Math.round(backgroundBlur.closingPanelBg.width) : 0
|
||||
height: backgroundBlur.closingPanelBg ? Math.round(backgroundBlur.closingPanelBg.height) : 0
|
||||
radius: Style.radiusL
|
||||
topLeftCorner: backgroundBlur.closingPanelBg ? backgroundBlur.closingPanelBg.topLeftCornerState : CornerState.Normal
|
||||
topRightCorner: backgroundBlur.closingPanelBg ? backgroundBlur.closingPanelBg.topRightCornerState : CornerState.Normal
|
||||
bottomLeftCorner: backgroundBlur.closingPanelBg ? backgroundBlur.closingPanelBg.bottomLeftCornerState : CornerState.Normal
|
||||
bottomRightCorner: backgroundBlur.closingPanelBg ? backgroundBlur.closingPanelBg.bottomRightCornerState : CornerState.Normal
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------
|
||||
// Container for all UI elements
|
||||
Item {
|
||||
id: container
|
||||
width: root.width
|
||||
height: root.height
|
||||
|
||||
// Unified backgrounds container / unified shadow system
|
||||
// Renders all bar and panel backgrounds as ShapePaths within a single Shape
|
||||
// This allows the shadow effect to apply to all backgrounds in one render pass
|
||||
Backgrounds.AllBackgrounds {
|
||||
id: unifiedBackgrounds
|
||||
anchors.fill: parent
|
||||
bar: barPlaceholder.barItem || null
|
||||
windowRoot: root
|
||||
z: 0 // Behind all content
|
||||
}
|
||||
|
||||
// Background MouseArea for closing panels when clicking outside
|
||||
// Uses isAnyPanelOpen so clicking on any screen's background closes the panel
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.isAnyPanelOpen
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
onClicked: mouse => {
|
||||
if (PanelService.openedPanel) {
|
||||
PanelService.openedPanel.close();
|
||||
}
|
||||
}
|
||||
z: 0 // Behind panels and bar
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// All panels always exist
|
||||
// ---------------------------------------
|
||||
AudioPanel {
|
||||
id: audioPanel
|
||||
objectName: "audioPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
MediaPlayerPanel {
|
||||
id: mediaPlayerPanel
|
||||
objectName: "mediaPlayerPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
BatteryPanel {
|
||||
id: batteryPanel
|
||||
objectName: "batteryPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
BluetoothPanel {
|
||||
id: bluetoothPanel
|
||||
objectName: "bluetoothPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
BrightnessPanel {
|
||||
id: brightnessPanel
|
||||
objectName: "brightnessPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
ControlCenterPanel {
|
||||
id: controlCenterPanel
|
||||
objectName: "controlCenterPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
ChangelogPanel {
|
||||
id: changelogPanel
|
||||
objectName: "changelogPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
ClockPanel {
|
||||
id: clockPanel
|
||||
objectName: "clockPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
Launcher {
|
||||
id: launcherPanel
|
||||
objectName: "launcherPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
NotificationHistoryPanel {
|
||||
id: notificationHistoryPanel
|
||||
objectName: "notificationHistoryPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
SessionMenu {
|
||||
id: sessionMenuPanel
|
||||
objectName: "sessionMenuPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
SettingsPanel {
|
||||
id: settingsPanel
|
||||
objectName: "settingsPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
SetupWizard {
|
||||
id: setupWizardPanel
|
||||
objectName: "setupWizardPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
TrayDrawerPanel {
|
||||
id: trayDrawerPanel
|
||||
objectName: "trayDrawerPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
WallpaperPanel {
|
||||
id: wallpaperPanel
|
||||
objectName: "wallpaperPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
NetworkPanel {
|
||||
id: networkPanel
|
||||
objectName: "networkPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
SystemStatsPanel {
|
||||
id: systemStatsPanel
|
||||
objectName: "systemStatsPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
StaticDockPanel {
|
||||
id: staticDockPanel
|
||||
objectName: "staticDockPanel-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
}
|
||||
|
||||
// ----------------------------------------------
|
||||
// Plugin panel slots
|
||||
// ----------------------------------------------
|
||||
PluginPanelSlot {
|
||||
id: pluginPanel1
|
||||
objectName: "pluginPanel1-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
slotNumber: 1
|
||||
}
|
||||
|
||||
PluginPanelSlot {
|
||||
id: pluginPanel2
|
||||
objectName: "pluginPanel2-" + (root.screen?.name || "unknown")
|
||||
screen: root.screen
|
||||
slotNumber: 2
|
||||
}
|
||||
|
||||
// ----------------------------------------------
|
||||
// Bar background placeholder - just for background positioning (actual bar content is in BarContentWindow)
|
||||
Item {
|
||||
id: barPlaceholder
|
||||
|
||||
// Expose self as barItem for AllBackgrounds compatibility
|
||||
readonly property var barItem: barPlaceholder
|
||||
|
||||
// Screen reference
|
||||
property ShellScreen screen: root.screen
|
||||
|
||||
// Bar background positioning properties (per-screen)
|
||||
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
|
||||
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property bool isFramed: Settings.data.bar.barType === "framed"
|
||||
readonly property real frameThickness: Settings.data.bar.frameThickness ?? 12
|
||||
readonly property bool barFloating: Settings.data.bar.barType === "floating"
|
||||
readonly property real barMarginH: barFloating ? Math.floor(Settings.data.bar.marginHorizontal) : 0
|
||||
readonly property real barMarginV: barFloating ? Math.floor(Settings.data.bar.marginVertical) : 0
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(screen?.name)
|
||||
|
||||
// Auto-hide properties (read by AllBackgrounds for background fade)
|
||||
readonly property bool autoHide: Settings.getBarDisplayModeForScreen(screen?.name) === "auto_hide"
|
||||
property bool isHidden: autoHide
|
||||
|
||||
Connections {
|
||||
target: BarService
|
||||
function onBarAutoHideStateChanged(screenName, hidden) {
|
||||
if (screenName === barPlaceholder.screen?.name) {
|
||||
barPlaceholder.isHidden = hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose bar dimensions directly on this Item for BarBackground
|
||||
// Use screen dimensions directly
|
||||
x: {
|
||||
if (barPosition === "right")
|
||||
return (screen?.width ?? 0) - barHeight - barMarginH;
|
||||
if (isFramed && !barIsVertical)
|
||||
return frameThickness;
|
||||
return barMarginH;
|
||||
}
|
||||
y: {
|
||||
if (barPosition === "bottom")
|
||||
return (screen?.height ?? 0) - barHeight - barMarginV;
|
||||
if (isFramed && barIsVertical)
|
||||
return frameThickness;
|
||||
return barMarginV;
|
||||
}
|
||||
width: {
|
||||
if (barIsVertical) {
|
||||
return barHeight;
|
||||
}
|
||||
if (isFramed)
|
||||
return (screen?.width ?? 0) - frameThickness * 2;
|
||||
return (screen?.width ?? 0) - barMarginH * 2;
|
||||
}
|
||||
height: {
|
||||
if (!barIsVertical) {
|
||||
return barHeight;
|
||||
}
|
||||
if (isFramed)
|
||||
return (screen?.height ?? 0) - frameThickness * 2;
|
||||
return (screen?.height ?? 0) - barMarginV * 2;
|
||||
}
|
||||
|
||||
// Corner states (same as Bar.qml)
|
||||
readonly property int topLeftCornerState: {
|
||||
if (barFloating)
|
||||
return 0;
|
||||
if (barPosition === "top")
|
||||
return -1;
|
||||
if (barPosition === "left")
|
||||
return -1;
|
||||
if (Settings.data.bar.outerCorners && (barPosition === "bottom" || barPosition === "right")) {
|
||||
return barIsVertical ? 1 : 2;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
readonly property int topRightCornerState: {
|
||||
if (barFloating)
|
||||
return 0;
|
||||
if (barPosition === "top")
|
||||
return -1;
|
||||
if (barPosition === "right")
|
||||
return -1;
|
||||
if (Settings.data.bar.outerCorners && (barPosition === "bottom" || barPosition === "left")) {
|
||||
return barIsVertical ? 1 : 2;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
readonly property int bottomLeftCornerState: {
|
||||
if (barFloating)
|
||||
return 0;
|
||||
if (barPosition === "bottom")
|
||||
return -1;
|
||||
if (barPosition === "left")
|
||||
return -1;
|
||||
if (Settings.data.bar.outerCorners && (barPosition === "top" || barPosition === "right")) {
|
||||
return barIsVertical ? 1 : 2;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
readonly property int bottomRightCornerState: {
|
||||
if (barFloating)
|
||||
return 0;
|
||||
if (barPosition === "bottom")
|
||||
return -1;
|
||||
if (barPosition === "right")
|
||||
return -1;
|
||||
if (Settings.data.bar.outerCorners && (barPosition === "top" || barPosition === "left")) {
|
||||
return barIsVertical ? 1 : 2;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Screen Corners
|
||||
ScreenCorners {}
|
||||
|
||||
// Blur behind the bar and open panels
|
||||
// Helper object holding computed properties for blur regions
|
||||
QtObject {
|
||||
id: backgroundBlur
|
||||
|
||||
// Panel background geometry (from the currently open panel on this screen)
|
||||
readonly property var panelBg: {
|
||||
var op = PanelService.openedPanel;
|
||||
if (!op || op.screen !== root.screen || op.blurEnabled === false)
|
||||
return null;
|
||||
var region = op.panelRegion;
|
||||
return (region && region.visible) ? region.panelItem : null;
|
||||
}
|
||||
|
||||
// Panel background geometry for the closing panel (may coexist with panelBg)
|
||||
readonly property var closingPanelBg: {
|
||||
var cp = PanelService.closingPanel;
|
||||
if (!cp || cp.screen !== root.screen || cp.blurEnabled === false)
|
||||
return null;
|
||||
var region = cp.panelRegion;
|
||||
return (region && region.visible) ? region.panelItem : null;
|
||||
}
|
||||
|
||||
// Framed bar: inner hole boundary (where the hole begins on each axis)
|
||||
// These are the x/y coordinates of the 4 inner hole corners
|
||||
readonly property real frameHoleX: barPlaceholder.barPosition === "left" ? barPlaceholder.barHeight : barPlaceholder.frameThickness
|
||||
readonly property real frameHoleY: barPlaceholder.barPosition === "top" ? barPlaceholder.barHeight : barPlaceholder.frameThickness
|
||||
readonly property real frameHoleX2: root.width - (barPlaceholder.barPosition === "right" ? barPlaceholder.barHeight : barPlaceholder.frameThickness)
|
||||
readonly property real frameHoleY2: root.height - (barPlaceholder.barPosition === "bottom" ? barPlaceholder.barHeight : barPlaceholder.frameThickness)
|
||||
readonly property real frameR: Settings.data.bar.frameRadius ?? 20
|
||||
}
|
||||
|
||||
// Native idle inhibitor — one per active MainScreen window.
|
||||
// Multiple inhibitors bound to the same enabled state are harmless;
|
||||
// having one per screen is more robust than picking a "primary" screen.
|
||||
IdleInhibitor {
|
||||
window: root
|
||||
enabled: IdleInhibitorService.isInhibited
|
||||
|
||||
Component.onCompleted: {
|
||||
IdleInhibitorService.nativeInhibitorAvailable = true;
|
||||
Logger.d("IdleInhibitor", "Native IdleInhibitor active on screen:", root.screen?.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Centralized Keyboard Shortcuts
|
||||
|
||||
// These shortcuts delegate to the opened panel's handler functions
|
||||
// Panels can implement: onEscapePressed, onTabPressed, onBackTabPressed,
|
||||
// onUpPressed, onDownPressed, onReturnPressed, etc...
|
||||
Instantiator {
|
||||
model: Settings.data.general.keybinds.keyEscape || []
|
||||
Shortcut {
|
||||
sequence: modelData
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onEscapePressed !== undefined) && !PanelService.isKeybindRecording
|
||||
onActivated: PanelService.openedPanel.onEscapePressed()
|
||||
}
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Tab"
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onTabPressed !== undefined)
|
||||
onActivated: PanelService.openedPanel.onTabPressed()
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Backtab"
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onBackTabPressed !== undefined)
|
||||
onActivated: PanelService.openedPanel.onBackTabPressed()
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
model: Settings.data.general.keybinds.keyUp || []
|
||||
Shortcut {
|
||||
sequence: modelData
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onUpPressed !== undefined) && !PanelService.isKeybindRecording
|
||||
onActivated: PanelService.openedPanel.onUpPressed()
|
||||
}
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
model: Settings.data.general.keybinds.keyDown || []
|
||||
Shortcut {
|
||||
sequence: modelData
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onDownPressed !== undefined) && !PanelService.isKeybindRecording
|
||||
onActivated: PanelService.openedPanel.onDownPressed()
|
||||
}
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
model: Settings.data.general.keybinds.keyEnter || []
|
||||
Shortcut {
|
||||
sequence: modelData
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onEnterPressed !== undefined) && !PanelService.isKeybindRecording
|
||||
onActivated: PanelService.openedPanel.onEnterPressed()
|
||||
}
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
model: Settings.data.general.keybinds.keyLeft || []
|
||||
Shortcut {
|
||||
sequence: modelData
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onLeftPressed !== undefined) && !PanelService.isKeybindRecording
|
||||
onActivated: PanelService.openedPanel.onLeftPressed()
|
||||
}
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
model: Settings.data.general.keybinds.keyRight || []
|
||||
Shortcut {
|
||||
sequence: modelData
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onRightPressed !== undefined) && !PanelService.isKeybindRecording
|
||||
onActivated: PanelService.openedPanel.onRightPressed()
|
||||
}
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "Home"
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onHomePressed !== undefined)
|
||||
onActivated: PanelService.openedPanel.onHomePressed()
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "End"
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onEndPressed !== undefined)
|
||||
onActivated: PanelService.openedPanel.onEndPressed()
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "PgUp"
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onPageUpPressed !== undefined)
|
||||
onActivated: PanelService.openedPanel.onPageUpPressed()
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
sequence: "PgDown"
|
||||
enabled: root.isPanelOpen && (PanelService.openedPanel.onPageDownPressed !== undefined)
|
||||
onActivated: PanelService.openedPanel.onPageDownPressed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
// Generic full-screen popup window for menus and context menus
|
||||
// This is a top-level PanelWindow (sibling to MainScreen, not nested inside it)
|
||||
// Provides click-outside-to-close functionality for any popup content
|
||||
// Loads TrayMenu by default but can show context menus via showContextMenu()
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
required property ShellScreen screen
|
||||
property string windowType: "popupmenu" // Used for namespace and registration
|
||||
|
||||
// Content item to display (set by the popup that uses this window)
|
||||
property var contentItem: null
|
||||
|
||||
// Expose the trayMenu Loader directly (for backward compatibility)
|
||||
readonly property alias trayMenuLoader: trayMenuLoader
|
||||
|
||||
// Dynamic context menu callback for items in other windows (e.g., desktop widgets)
|
||||
property var dynamicMenuCallback: null
|
||||
|
||||
anchors.top: true
|
||||
anchors.left: true
|
||||
anchors.right: true
|
||||
anchors.bottom: true
|
||||
visible: false
|
||||
color: "transparent"
|
||||
|
||||
// Use Top layer for proper event handling, but on labwc use Bottom
|
||||
// to avoid stealing input from popups while still catching outside clicks.
|
||||
// However, when a dialog is open, always use Top so dialogs appear above apps.
|
||||
WlrLayershell.layer: (CompositorService.isLabwc && !hasDialog) ? WlrLayer.Bottom : WlrLayer.Top
|
||||
WlrLayershell.keyboardFocus: hasDialog ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
|
||||
WlrLayershell.namespace: "noctalia-" + windowType + "-" + (screen?.name || "unknown")
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Track if a dialog is currently open (needed for keyboard focus)
|
||||
property bool hasDialog: false
|
||||
|
||||
// Register with PanelService so widgets can find this window
|
||||
Component.onCompleted: {
|
||||
objectName = "popupMenuWindow-" + (screen?.name || "unknown");
|
||||
PanelService.registerPopupMenuWindow(screen, root);
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
PanelService.unregisterPopupMenuWindow(screen);
|
||||
}
|
||||
|
||||
// Load TrayMenu as the default content
|
||||
Loader {
|
||||
id: trayMenuLoader
|
||||
source: Quickshell.shellDir + "/Modules/Bar/Extras/TrayMenu.qml"
|
||||
onLoaded: {
|
||||
if (item) {
|
||||
item.screen = root.screen;
|
||||
// Set the loaded item as default content
|
||||
root.contentItem = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic context menu - created as child of this window (Top layer) so input works correctly
|
||||
// Used for items in other windows like desktop widgets (bottom layer)
|
||||
NPopupContextMenu {
|
||||
id: dynamicMenu
|
||||
visible: false
|
||||
screen: root.screen
|
||||
minWidth: 180
|
||||
|
||||
onTriggered: (action, item) => {
|
||||
if (root.dynamicMenuCallback) {
|
||||
// Callback returns true if it will handle closing (e.g., opening a dialog)
|
||||
var handled = root.dynamicMenuCallback(action);
|
||||
if (!handled) {
|
||||
root.close();
|
||||
}
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
visible = true;
|
||||
BarService.popupOpen = true;
|
||||
}
|
||||
|
||||
// Show a context menu (temporarily replaces TrayMenu as content)
|
||||
function showContextMenu(menu) {
|
||||
if (menu) {
|
||||
contentItem = menu;
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
// Show a dynamic context menu with model and callback at screen coordinates
|
||||
// Used for items in other window layers (e.g., desktop widgets in bottom layer)
|
||||
function showDynamicContextMenu(model, screenX, screenY, callback) {
|
||||
dynamicMenu.model = model;
|
||||
dynamicMenuCallback = callback;
|
||||
|
||||
// Use the anchor point item for positioning at absolute coordinates
|
||||
dynamicMenu.anchorItem = anchorPoint;
|
||||
anchorPoint.x = screenX;
|
||||
anchorPoint.y = screenY;
|
||||
|
||||
contentItem = dynamicMenu;
|
||||
dynamicMenu.visible = true;
|
||||
open();
|
||||
}
|
||||
|
||||
// Invisible anchor point for dynamic menu positioning
|
||||
Item {
|
||||
id: anchorPoint
|
||||
width: 1
|
||||
height: 1
|
||||
}
|
||||
|
||||
// Hide just the dynamic menu without closing the popup window
|
||||
// Used when transitioning from context menu to a dialog
|
||||
function hideDynamicMenu() {
|
||||
dynamicMenu.visible = false;
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible = false;
|
||||
BarService.popupOpen = false;
|
||||
// Call close/hide method on current content
|
||||
if (contentItem) {
|
||||
if (typeof contentItem.hideMenu === "function") {
|
||||
contentItem.hideMenu();
|
||||
} else if (typeof contentItem.close === "function") {
|
||||
contentItem.close();
|
||||
}
|
||||
}
|
||||
// Hide dynamic menu
|
||||
dynamicMenu.visible = false;
|
||||
dynamicMenuCallback = null;
|
||||
// Restore TrayMenu as default content
|
||||
if (trayMenuLoader.item) {
|
||||
contentItem = trayMenuLoader.item;
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen click catcher - click anywhere outside content closes the window
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
// Container for dialogs that need a full-screen Item parent (e.g., Qt Popup)
|
||||
Item {
|
||||
id: dialogContainer
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
// Expose the dialog container for external use
|
||||
readonly property alias dialogParent: dialogContainer
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs.Commons
|
||||
|
||||
/**
|
||||
* ScreenCorners - Shape component for rendering screen corners
|
||||
*
|
||||
* Renders concave corners at the screen edges to create a rounded screen effect.
|
||||
* Self-contained Shape component (no shadows).
|
||||
*/
|
||||
Item {
|
||||
id: root
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
// Wrapper with layer caching to reduce GPU tessellation overhead
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
// Cache the Shape to a texture to prevent continuous re-tessellation
|
||||
layer.enabled: true
|
||||
|
||||
Shape {
|
||||
id: cornersShape
|
||||
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
asynchronous: true
|
||||
enabled: false // Disable mouse input
|
||||
visible: cornersPath.cornerRadius > 0 && width > 0 && height > 0
|
||||
|
||||
ShapePath {
|
||||
id: cornersPath
|
||||
|
||||
// Corner configuration
|
||||
readonly property color cornerColor: Settings.data.general.forceBlackScreenCorners ? "black" : Color.mSurface
|
||||
readonly property real cornerRadius: Style.screenRadius
|
||||
readonly property real cornerSize: Style.screenRadius
|
||||
|
||||
// Determine margins based on bar position
|
||||
readonly property real topMargin: 0
|
||||
readonly property real bottomMargin: 0
|
||||
readonly property real leftMargin: 0
|
||||
readonly property real rightMargin: 0
|
||||
|
||||
// Screen dimensions
|
||||
readonly property real screenWidth: cornersShape.width
|
||||
readonly property real screenHeight: cornersShape.height
|
||||
|
||||
// Only show screen corners if enabled and appropriate conditions are met
|
||||
readonly property bool shouldShow: Settings.data.general.showScreenCorners
|
||||
|
||||
// ShapePath configuration
|
||||
strokeWidth: -1 // No stroke, fill only
|
||||
fillColor: shouldShow ? cornerColor : "transparent"
|
||||
|
||||
// Smooth color animation (disabled during theme transitions to sync with Color.qml)
|
||||
Behavior on fillColor {
|
||||
enabled: !Color.isTransitioning
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
// ========== PATH DEFINITION ==========
|
||||
// Draws 4 separate corner squares at screen edges
|
||||
// Each corner square has a concave arc on the inner diagonal
|
||||
|
||||
// ========== TOP-LEFT CORNER ==========
|
||||
// Arc is at the bottom-right of this square (inner diagonal)
|
||||
// Start at top-left screen corner
|
||||
startX: leftMargin
|
||||
startY: topMargin
|
||||
|
||||
// Top edge (moving right)
|
||||
PathLine {
|
||||
relativeX: cornersPath.cornerSize
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Right edge (moving down toward arc)
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: cornersPath.cornerSize - cornersPath.cornerRadius
|
||||
}
|
||||
|
||||
// Concave arc (bottom-right corner of square, curving inward toward screen center)
|
||||
PathArc {
|
||||
relativeX: -cornersPath.cornerRadius
|
||||
relativeY: cornersPath.cornerRadius
|
||||
radiusX: cornersPath.cornerRadius
|
||||
radiusY: cornersPath.cornerRadius
|
||||
direction: PathArc.Counterclockwise
|
||||
}
|
||||
|
||||
// Bottom edge (moving left)
|
||||
PathLine {
|
||||
relativeX: -(cornersPath.cornerSize - cornersPath.cornerRadius)
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Left edge (moving up) - closes back to start
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: -cornersPath.cornerSize
|
||||
}
|
||||
|
||||
// ========== TOP-RIGHT CORNER ==========
|
||||
// Arc is at the bottom-left of this square (inner diagonal)
|
||||
PathMove {
|
||||
x: cornersPath.screenWidth - cornersPath.rightMargin - cornersPath.cornerSize
|
||||
y: cornersPath.topMargin
|
||||
}
|
||||
|
||||
// Top edge (moving right)
|
||||
PathLine {
|
||||
relativeX: cornersPath.cornerSize
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Right edge (moving down)
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: cornersPath.cornerSize
|
||||
}
|
||||
|
||||
// Bottom edge (moving left toward arc)
|
||||
PathLine {
|
||||
relativeX: -(cornersPath.cornerSize - cornersPath.cornerRadius)
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Concave arc (bottom-left corner of square, curving inward toward screen center)
|
||||
PathArc {
|
||||
relativeX: -cornersPath.cornerRadius
|
||||
relativeY: -cornersPath.cornerRadius
|
||||
radiusX: cornersPath.cornerRadius
|
||||
radiusY: cornersPath.cornerRadius
|
||||
direction: PathArc.Counterclockwise
|
||||
}
|
||||
|
||||
// Left edge (moving up) - closes back to start
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: -(cornersPath.cornerSize - cornersPath.cornerRadius)
|
||||
}
|
||||
|
||||
// ========== BOTTOM-LEFT CORNER ==========
|
||||
// Arc is at the top-right of this square (inner diagonal)
|
||||
PathMove {
|
||||
x: cornersPath.leftMargin
|
||||
y: cornersPath.screenHeight - cornersPath.bottomMargin - cornersPath.cornerSize
|
||||
}
|
||||
|
||||
// Top edge (moving right toward arc)
|
||||
PathLine {
|
||||
relativeX: cornersPath.cornerSize - cornersPath.cornerRadius
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Concave arc (top-right corner of square, curving inward toward screen center)
|
||||
PathArc {
|
||||
relativeX: cornersPath.cornerRadius
|
||||
relativeY: cornersPath.cornerRadius
|
||||
radiusX: cornersPath.cornerRadius
|
||||
radiusY: cornersPath.cornerRadius
|
||||
direction: PathArc.Counterclockwise
|
||||
}
|
||||
|
||||
// Right edge (moving down)
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: cornersPath.cornerSize - cornersPath.cornerRadius
|
||||
}
|
||||
|
||||
// Bottom edge (moving left)
|
||||
PathLine {
|
||||
relativeX: -cornersPath.cornerSize
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Left edge (moving up) - closes back to start
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: -cornersPath.cornerSize
|
||||
}
|
||||
|
||||
// ========== BOTTOM-RIGHT CORNER ==========
|
||||
// Arc is at the top-left of this square (inner diagonal)
|
||||
// Start at bottom-right of square (different from other corners!)
|
||||
PathMove {
|
||||
x: cornersPath.screenWidth - cornersPath.rightMargin
|
||||
y: cornersPath.screenHeight - cornersPath.bottomMargin
|
||||
}
|
||||
|
||||
// Bottom edge (moving left)
|
||||
PathLine {
|
||||
relativeX: -cornersPath.cornerSize
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Left edge (moving up toward arc)
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: -(cornersPath.cornerSize - cornersPath.cornerRadius)
|
||||
}
|
||||
|
||||
// Concave arc (top-left corner of square, curving inward toward screen center)
|
||||
PathArc {
|
||||
relativeX: cornersPath.cornerRadius
|
||||
relativeY: -cornersPath.cornerRadius
|
||||
radiusX: cornersPath.cornerRadius
|
||||
radiusY: cornersPath.cornerRadius
|
||||
direction: PathArc.Counterclockwise
|
||||
}
|
||||
|
||||
// Top edge (moving right)
|
||||
PathLine {
|
||||
relativeX: cornersPath.cornerSize - cornersPath.cornerRadius
|
||||
relativeY: 0
|
||||
}
|
||||
|
||||
// Right edge (moving down) - closes back to start
|
||||
PathLine {
|
||||
relativeX: 0
|
||||
relativeY: cornersPath.cornerSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,812 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.Notifications
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Services.System
|
||||
import qs.Widgets
|
||||
|
||||
// Simple notification popup - displays multiple notifications
|
||||
Variants {
|
||||
|
||||
model: {
|
||||
const screens = Quickshell.screens.filter(screen => Settings.data.notifications.monitors.includes(screen.name));
|
||||
// Empty list can mean two things :
|
||||
// - No (visible) notification display activated in settings
|
||||
// - One or more (not visible) displays are activated but unplugged
|
||||
// In both cases we fallback to show notification on all screens
|
||||
return screens.length === 0 ? Quickshell.screens : screens;
|
||||
}
|
||||
|
||||
delegate: Loader {
|
||||
id: root
|
||||
|
||||
required property ShellScreen modelData
|
||||
|
||||
property ListModel notificationModel: NotificationService.popupModel
|
||||
|
||||
// Deferred activation via Qt.callLater to avoid activating the Loader
|
||||
// synchronously during ListModel.insert() (which would cause nested
|
||||
// incubation with the inner Repeater).
|
||||
property bool shouldBeActive: false
|
||||
active: shouldBeActive || delayTimer.running
|
||||
|
||||
// Keep loader active briefly after last notification to allow animations to complete
|
||||
Timer {
|
||||
id: delayTimer
|
||||
interval: Style.animationSlow + 200
|
||||
repeat: false
|
||||
}
|
||||
|
||||
function activate() {
|
||||
shouldBeActive = true;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: notificationModel
|
||||
function onCountChanged() {
|
||||
if (notificationModel.count > 0) {
|
||||
if (!root.shouldBeActive) {
|
||||
Qt.callLater(root.activate);
|
||||
}
|
||||
} else if (root.shouldBeActive) {
|
||||
root.shouldBeActive = false;
|
||||
delayTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
id: notifWindow
|
||||
screen: modelData
|
||||
|
||||
WlrLayershell.namespace: "noctalia-notifications-" + (screen?.name || "unknown")
|
||||
WlrLayershell.layer: (Settings.data.notifications?.overlayLayer) ? WlrLayer.Overlay : WlrLayer.Top
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
color: "transparent"
|
||||
|
||||
// Make shadow area click-through, only notification content is clickable
|
||||
mask: Region {
|
||||
x: 0
|
||||
y: 0
|
||||
width: notifWindow.width
|
||||
height: notifWindow.height
|
||||
intersection: Intersection.Xor
|
||||
|
||||
Region {
|
||||
// The clickable content area is inset by shadowPadding from all edges
|
||||
x: notifWindow.shadowPadding
|
||||
y: notifWindow.shadowPadding
|
||||
width: notifWindow.notifWidth
|
||||
height: Math.max(0, notifWindow.height - notifWindow.shadowPadding * 2)
|
||||
intersection: Intersection.Subtract
|
||||
}
|
||||
}
|
||||
|
||||
// Parse location setting
|
||||
readonly property string location: Settings.data.notifications.location || "top_right"
|
||||
readonly property bool isTop: location.startsWith("top")
|
||||
readonly property bool isBottom: location.startsWith("bottom")
|
||||
readonly property bool isLeft: location.endsWith("_left")
|
||||
readonly property bool isRight: location.endsWith("_right")
|
||||
readonly property bool isCentered: location === "top" || location === "bottom"
|
||||
|
||||
readonly property string barPos: Settings.getBarPositionForScreen(notifWindow.screen?.name)
|
||||
readonly property bool isFloating: Settings.data.bar.barType === "floating"
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(notifWindow.screen?.name)
|
||||
|
||||
readonly property bool isFramed: Settings.data.bar.barType === "framed"
|
||||
readonly property real frameThickness: Settings.data.bar.frameThickness ?? 8
|
||||
|
||||
readonly property bool isCompact: Settings.data.notifications.density === "compact"
|
||||
readonly property int notifWidth: Math.round((isCompact ? 320 : 440) * Style.uiScaleRatio)
|
||||
readonly property int shadowPadding: Style.shadowBlurMax + Style.marginL
|
||||
|
||||
// Calculate bar and frame offsets for each edge separately
|
||||
readonly property int barOffsetTop: {
|
||||
if (barPos !== "top")
|
||||
return isFramed ? frameThickness : 0;
|
||||
const floatMarginV = isFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
|
||||
return barHeight + floatMarginV;
|
||||
}
|
||||
|
||||
readonly property int barOffsetBottom: {
|
||||
if (barPos !== "bottom")
|
||||
return isFramed ? frameThickness : 0;
|
||||
const floatMarginV = isFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0;
|
||||
return barHeight + floatMarginV;
|
||||
}
|
||||
|
||||
readonly property int barOffsetLeft: {
|
||||
if (barPos !== "left")
|
||||
return isFramed ? frameThickness : 0;
|
||||
const floatMarginH = isFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
|
||||
return barHeight + floatMarginH;
|
||||
}
|
||||
|
||||
readonly property int barOffsetRight: {
|
||||
if (barPos !== "right")
|
||||
return isFramed ? frameThickness : 0;
|
||||
const floatMarginH = isFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0;
|
||||
return barHeight + floatMarginH;
|
||||
}
|
||||
|
||||
// Anchoring
|
||||
anchors.top: isTop
|
||||
anchors.bottom: isBottom
|
||||
anchors.left: isLeft
|
||||
anchors.right: isRight
|
||||
|
||||
// Margins for PanelWindow - only apply bar offset for the specific edge where the bar is
|
||||
margins.top: isTop ? barOffsetTop - shadowPadding + Style.marginM : 0
|
||||
margins.bottom: isBottom ? barOffsetBottom - shadowPadding : 0
|
||||
margins.left: isLeft ? barOffsetLeft - shadowPadding + Style.marginM : 0
|
||||
margins.right: isRight ? barOffsetRight - shadowPadding + Style.marginM : 0
|
||||
|
||||
implicitWidth: notifWidth + shadowPadding * 2
|
||||
implicitHeight: notificationStack.implicitHeight + Style.marginL
|
||||
|
||||
property var animateConnection: null
|
||||
|
||||
Component.onCompleted: {
|
||||
animateConnection = function (notificationId) {
|
||||
var delegate = null;
|
||||
if (notificationRepeater) {
|
||||
for (var i = 0; i < notificationRepeater.count; i++) {
|
||||
var item = notificationRepeater.itemAt(i);
|
||||
if (item?.notificationId === notificationId) {
|
||||
delegate = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (delegate && typeof delegate.animateOut === "function" && !delegate.isRemoving) {
|
||||
delegate.animateOut();
|
||||
}
|
||||
} catch (e) {
|
||||
// Service fallback if delegate is already invalid
|
||||
NotificationService.dismissPopup(notificationId);
|
||||
}
|
||||
};
|
||||
|
||||
NotificationService.animateAndRemove.connect(animateConnection);
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (animateConnection) {
|
||||
NotificationService.animateAndRemove.disconnect(animateConnection);
|
||||
animateConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: notificationStack
|
||||
|
||||
anchors {
|
||||
top: parent.isTop ? parent.top : undefined
|
||||
bottom: parent.isBottom ? parent.bottom : undefined
|
||||
left: parent.isLeft ? parent.left : undefined
|
||||
right: parent.isRight ? parent.right : undefined
|
||||
horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined
|
||||
}
|
||||
|
||||
spacing: -notifWindow.shadowPadding * 2 + Style.marginM
|
||||
|
||||
Behavior on implicitHeight {
|
||||
enabled: !Settings.data.general.animationDisabled
|
||||
SpringAnimation {
|
||||
spring: 2.0
|
||||
damping: 0.4
|
||||
epsilon: 0.01
|
||||
mass: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: notificationRepeater
|
||||
model: notificationModel
|
||||
|
||||
delegate: Item {
|
||||
id: card
|
||||
|
||||
property string notificationId: model.id
|
||||
property var notificationData: model
|
||||
property bool isHovered: false
|
||||
property bool isRemoving: false
|
||||
|
||||
readonly property int animationDelay: index * 100
|
||||
readonly property int slideDistance: 300
|
||||
|
||||
Layout.preferredWidth: notifWidth + notifWindow.shadowPadding * 2
|
||||
Layout.preferredHeight: (notifWindow.isCompact ? compactContent.implicitHeight : notificationContent.implicitHeight) + Style.margin2M + notifWindow.shadowPadding * 2
|
||||
Layout.maximumHeight: Layout.preferredHeight
|
||||
|
||||
// Animation properties
|
||||
property real scaleValue: 0.8
|
||||
property real opacityValue: 0.0
|
||||
property real slideOffset: 0
|
||||
property real swipeOffset: 0
|
||||
property real swipeOffsetY: 0
|
||||
property real pressGlobalX: 0
|
||||
property real pressGlobalY: 0
|
||||
property bool isSwiping: false
|
||||
property bool suppressClick: false
|
||||
readonly property bool useVerticalSwipe: notifWindow.location === "bottom" || notifWindow.location === "top"
|
||||
readonly property real swipeStartThreshold: Math.round(18 * Style.uiScaleRatio)
|
||||
readonly property real swipeDismissThreshold: Math.max(110, cardBackground.width * 0.32)
|
||||
readonly property real verticalSwipeDismissThreshold: Math.max(70, cardBackground.height * 0.35)
|
||||
|
||||
scale: scaleValue
|
||||
opacity: opacityValue
|
||||
transform: Translate {
|
||||
x: card.swipeOffset
|
||||
y: card.slideOffset + card.swipeOffsetY
|
||||
}
|
||||
|
||||
readonly property real slideInOffset: notifWindow.isTop ? -slideDistance : slideDistance
|
||||
readonly property real slideOutOffset: slideInOffset
|
||||
|
||||
function clampSwipeDelta(deltaX) {
|
||||
if (notifWindow.isRight)
|
||||
return Math.max(0, deltaX);
|
||||
if (notifWindow.isLeft)
|
||||
return Math.min(0, deltaX);
|
||||
return deltaX;
|
||||
}
|
||||
|
||||
function clampVerticalSwipeDelta(deltaY) {
|
||||
if (notifWindow.isBottom)
|
||||
return Math.max(0, deltaY);
|
||||
if (notifWindow.isTop)
|
||||
return Math.min(0, deltaY);
|
||||
return deltaY;
|
||||
}
|
||||
|
||||
// Animation setup
|
||||
function triggerEntryAnimation() {
|
||||
animInDelayTimer.stop();
|
||||
removalTimer.stop();
|
||||
resumeTimer.stop();
|
||||
isRemoving = false;
|
||||
isHovered = false;
|
||||
isSwiping = false;
|
||||
swipeOffset = 0;
|
||||
swipeOffsetY = 0;
|
||||
if (Settings.data.general.animationDisabled) {
|
||||
slideOffset = 0;
|
||||
scaleValue = 1.0;
|
||||
opacityValue = 1.0;
|
||||
return;
|
||||
}
|
||||
|
||||
slideOffset = slideInOffset;
|
||||
scaleValue = 0.8;
|
||||
opacityValue = 0.0;
|
||||
animInDelayTimer.interval = animationDelay;
|
||||
animInDelayTimer.start();
|
||||
}
|
||||
|
||||
Component.onCompleted: triggerEntryAnimation()
|
||||
|
||||
onNotificationIdChanged: triggerEntryAnimation()
|
||||
|
||||
Timer {
|
||||
id: animInDelayTimer
|
||||
interval: 0
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (card.isRemoving)
|
||||
return;
|
||||
slideOffset = 0;
|
||||
scaleValue = 1.0;
|
||||
opacityValue = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
function animateOut() {
|
||||
if (isRemoving)
|
||||
return;
|
||||
animInDelayTimer.stop();
|
||||
resumeTimer.stop();
|
||||
isRemoving = true;
|
||||
isSwiping = false;
|
||||
swipeOffset = 0;
|
||||
swipeOffsetY = 0;
|
||||
if (!Settings.data.general.animationDisabled) {
|
||||
slideOffset = slideOutOffset;
|
||||
scaleValue = 0.8;
|
||||
opacityValue = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
function dismissBySwipe() {
|
||||
if (isRemoving)
|
||||
return;
|
||||
animInDelayTimer.stop();
|
||||
resumeTimer.stop();
|
||||
isRemoving = true;
|
||||
isSwiping = false;
|
||||
if (!Settings.data.general.animationDisabled) {
|
||||
if (useVerticalSwipe) {
|
||||
swipeOffset = 0;
|
||||
swipeOffsetY = swipeOffsetY >= 0 ? cardBackground.height + Style.marginXL : -cardBackground.height - Style.marginXL;
|
||||
} else {
|
||||
swipeOffset = swipeOffset >= 0 ? cardBackground.width + Style.marginXL : -cardBackground.width - Style.marginXL;
|
||||
swipeOffsetY = 0;
|
||||
}
|
||||
scaleValue = 0.8;
|
||||
opacityValue = 0.0;
|
||||
} else {
|
||||
swipeOffset = 0;
|
||||
swipeOffsetY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function runAction(actionId, isDismissed) {
|
||||
if (!isDismissed) {
|
||||
if (NotificationService.invokeActionAndSuppressClose(notificationId, actionId))
|
||||
card.animateOut();
|
||||
} else {
|
||||
if (Settings.data.notifications.clearDismissed)
|
||||
NotificationService.removeFromHistory(notificationId);
|
||||
card.animateOut();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: removalTimer
|
||||
interval: Style.animationSlow
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
NotificationService.dismissPopup(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
onIsRemovingChanged: {
|
||||
if (isRemoving) {
|
||||
removalTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
enabled: !Settings.data.general.animationDisabled
|
||||
SpringAnimation {
|
||||
spring: 3
|
||||
damping: 0.4
|
||||
epsilon: 0.01
|
||||
mass: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
enabled: !Settings.data.general.animationDisabled
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on slideOffset {
|
||||
enabled: !Settings.data.general.animationDisabled
|
||||
SpringAnimation {
|
||||
spring: 2.5
|
||||
damping: 0.3
|
||||
epsilon: 0.01
|
||||
mass: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on swipeOffset {
|
||||
enabled: !Settings.data.general.animationDisabled && !card.isSwiping
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on swipeOffsetY {
|
||||
enabled: !Settings.data.general.animationDisabled && !card.isSwiping
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Sub item with the right dimensions, really usefull for the
|
||||
// HoverHandler: card items are overlapping because of the
|
||||
// negative spacing of notificationStack.
|
||||
Item {
|
||||
id: displayedCard
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: notifWindow.shadowPadding
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: {
|
||||
isHovered = hovered;
|
||||
if (isHovered) {
|
||||
resumeTimer.stop();
|
||||
NotificationService.pauseTimeout(notificationId);
|
||||
} else {
|
||||
resumeTimer.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: resumeTimer
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (!isHovered) {
|
||||
NotificationService.resumeTimeout(notificationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right-click to dismiss
|
||||
MouseArea {
|
||||
id: cardDragArea
|
||||
anchors.fill: cardBackground
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
hoverEnabled: true
|
||||
onPressed: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y);
|
||||
card.pressGlobalX = globalPoint.x;
|
||||
card.pressGlobalY = globalPoint.y;
|
||||
card.isSwiping = false;
|
||||
card.suppressClick = false;
|
||||
}
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
if (!(mouse.buttons & Qt.LeftButton) || card.isRemoving)
|
||||
return;
|
||||
const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y);
|
||||
const rawDeltaX = globalPoint.x - card.pressGlobalX;
|
||||
const rawDeltaY = globalPoint.y - card.pressGlobalY;
|
||||
const deltaX = card.clampSwipeDelta(rawDeltaX);
|
||||
const deltaY = card.clampVerticalSwipeDelta(rawDeltaY);
|
||||
if (!card.isSwiping) {
|
||||
if (card.useVerticalSwipe) {
|
||||
if (Math.abs(deltaY) < card.swipeStartThreshold)
|
||||
return;
|
||||
card.isSwiping = true;
|
||||
} else {
|
||||
if (Math.abs(deltaX) < card.swipeStartThreshold)
|
||||
return;
|
||||
card.isSwiping = true;
|
||||
}
|
||||
}
|
||||
if (card.useVerticalSwipe) {
|
||||
card.swipeOffset = 0;
|
||||
card.swipeOffsetY = deltaY;
|
||||
} else {
|
||||
card.swipeOffset = deltaX;
|
||||
card.swipeOffsetY = 0;
|
||||
}
|
||||
}
|
||||
onReleased: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
card.animateOut();
|
||||
if (Settings.data.notifications.clearDismissed) {
|
||||
NotificationService.removeFromHistory(notificationId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mouse.button !== Qt.LeftButton)
|
||||
return;
|
||||
|
||||
if (card.isSwiping) {
|
||||
const dismissDistance = card.useVerticalSwipe ? Math.abs(card.swipeOffsetY) : Math.abs(card.swipeOffset);
|
||||
const threshold = card.useVerticalSwipe ? card.verticalSwipeDismissThreshold : card.swipeDismissThreshold;
|
||||
if (dismissDistance >= threshold) {
|
||||
card.dismissBySwipe();
|
||||
if (Settings.data.notifications.clearDismissed) {
|
||||
NotificationService.removeFromHistory(notificationId);
|
||||
}
|
||||
} else {
|
||||
card.swipeOffset = 0;
|
||||
card.swipeOffsetY = 0;
|
||||
}
|
||||
card.suppressClick = true;
|
||||
card.isSwiping = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.suppressClick)
|
||||
return;
|
||||
|
||||
var actions = model.actionsJson ? JSON.parse(model.actionsJson) : [];
|
||||
var hasDefault = actions.some(function (a) {
|
||||
return a.identifier === "default";
|
||||
});
|
||||
if (hasDefault && NotificationService.invokeActionAndSuppressClose(notificationId, "default")) {
|
||||
card.animateOut();
|
||||
} else {
|
||||
// Without a default action, or if invoking it fails,
|
||||
// the best fallback is focusing the sender window by app identity.
|
||||
NotificationService.focusSenderWindow(model.appName);
|
||||
card.animateOut();
|
||||
}
|
||||
}
|
||||
onCanceled: {
|
||||
card.isSwiping = false;
|
||||
card.swipeOffset = 0;
|
||||
card.swipeOffsetY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Background with border
|
||||
Rectangle {
|
||||
id: cardBackground
|
||||
anchors.fill: parent
|
||||
radius: Style.radiusL
|
||||
border.color: Qt.alpha(Color.mOutline, Color.adaptiveOpacity(Settings.data.notifications.backgroundOpacity) || 1.0)
|
||||
border.width: Style.borderS
|
||||
color: Qt.alpha(Color.mSurface, Color.adaptiveOpacity(Settings.data.notifications.backgroundOpacity) || 1.0)
|
||||
|
||||
// Progress bar
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 2
|
||||
color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
id: progressBar
|
||||
readonly property real progressWidth: cardBackground.width - (2 * cardBackground.radius)
|
||||
height: parent.height
|
||||
x: cardBackground.radius + (progressWidth * (1 - model.progress)) / 2
|
||||
width: progressWidth * model.progress
|
||||
|
||||
color: {
|
||||
var baseColor = model.urgency === 2 ? Color.mError : model.urgency === 0 ? Color.mOnSurface : Color.mPrimary;
|
||||
return Qt.alpha(baseColor, Color.adaptiveOpacity(Settings.data.notifications.backgroundOpacity) || 1.0);
|
||||
}
|
||||
|
||||
antialiasing: true
|
||||
|
||||
Behavior on width {
|
||||
enabled: !card.isRemoving
|
||||
NumberAnimation {
|
||||
duration: 100
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on x {
|
||||
enabled: !card.isRemoving
|
||||
NumberAnimation {
|
||||
duration: 100
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NDropShadow {
|
||||
anchors.fill: cardBackground
|
||||
source: cardBackground
|
||||
autoPaddingEnabled: true
|
||||
}
|
||||
|
||||
// Content
|
||||
ColumnLayout {
|
||||
id: notificationContent
|
||||
visible: !notifWindow.isCompact
|
||||
anchors.fill: cardBackground
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginL
|
||||
Layout.leftMargin: Style.marginM
|
||||
Layout.rightMargin: Style.marginM
|
||||
Layout.topMargin: Style.marginM
|
||||
Layout.bottomMargin: Style.marginM
|
||||
|
||||
NImageRounded {
|
||||
Layout.preferredWidth: Math.round(40 * Style.uiScaleRatio)
|
||||
Layout.preferredHeight: Math.round(40 * Style.uiScaleRatio)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: Math.min(Style.radiusL, Layout.preferredWidth / 2)
|
||||
imagePath: model.originalImage || ""
|
||||
borderColor: "transparent"
|
||||
borderWidth: 0
|
||||
fallbackIcon: "bell"
|
||||
fallbackIconSize: 24
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
// Header with urgency indicator
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 6
|
||||
Layout.preferredHeight: 6
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: Style.radiusXS
|
||||
color: model.urgency === 2 ? Color.mError : model.urgency === 0 ? Color.mOnSurface : Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.appName || "Unknown App"
|
||||
pointSize: Style.fontSizeXS
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mSecondary
|
||||
}
|
||||
|
||||
NText {
|
||||
textFormat: Text.PlainText
|
||||
text: " " + Time.formatRelativeTime(model.timestamp)
|
||||
pointSize: Style.fontSizeXXS
|
||||
color: Color.mOnSurfaceVariant
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.summary || I18n.tr("common.no-summary")
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: Color.mOnSurface
|
||||
textFormat: Text.StyledText
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
maximumLineCount: 3
|
||||
elide: Text.ElideRight
|
||||
visible: text.length > 0
|
||||
Layout.fillWidth: true
|
||||
Layout.rightMargin: Style.marginM
|
||||
}
|
||||
|
||||
NText {
|
||||
text: model.body || ""
|
||||
pointSize: Style.fontSizeM
|
||||
color: Color.mOnSurface
|
||||
textFormat: Text.StyledText
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
|
||||
maximumLineCount: 5
|
||||
elide: Text.ElideRight
|
||||
visible: text.length > 0
|
||||
Layout.fillWidth: true
|
||||
Layout.rightMargin: Style.marginXL
|
||||
}
|
||||
|
||||
// Actions
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
Layout.topMargin: Style.marginM
|
||||
flow: Flow.LeftToRight
|
||||
|
||||
property string parentNotificationId: notificationId
|
||||
property var parsedActions: {
|
||||
try {
|
||||
return model.actionsJson ? JSON.parse(model.actionsJson) : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
visible: parsedActions.length > 0
|
||||
|
||||
Repeater {
|
||||
model: parent.parsedActions
|
||||
|
||||
delegate: NButton {
|
||||
property var actionData: modelData
|
||||
|
||||
text: {
|
||||
var actionText = actionData.text || "Open";
|
||||
if (actionText.includes(",")) {
|
||||
return actionText.split(",")[1] || actionText;
|
||||
}
|
||||
return actionText;
|
||||
}
|
||||
fontSize: Style.fontSizeS
|
||||
backgroundColor: Color.mPrimary
|
||||
textColor: hovered ? Color.mOnHover : Color.mOnPrimary
|
||||
hoverColor: Color.mHover
|
||||
outlined: false
|
||||
implicitHeight: 24
|
||||
onClicked: {
|
||||
card.runAction(actionData.identifier, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close button
|
||||
NIconButton {
|
||||
visible: !notifWindow.isCompact
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("tooltips.dismiss-notification")
|
||||
baseSize: Style.baseWidgetSize * 0.6
|
||||
anchors.top: cardBackground.top
|
||||
anchors.topMargin: Style.marginXL
|
||||
anchors.right: cardBackground.right
|
||||
anchors.rightMargin: Style.marginXL
|
||||
|
||||
onClicked: {
|
||||
card.runAction("", true);
|
||||
}
|
||||
}
|
||||
|
||||
// Compact content
|
||||
RowLayout {
|
||||
id: compactContent
|
||||
visible: notifWindow.isCompact
|
||||
anchors.fill: cardBackground
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NImageRounded {
|
||||
Layout.preferredWidth: Math.round(24 * Style.uiScaleRatio)
|
||||
Layout.preferredHeight: Math.round(24 * Style.uiScaleRatio)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: Style.radiusXS
|
||||
imagePath: model.originalImage || ""
|
||||
borderColor: "transparent"
|
||||
borderWidth: 0
|
||||
fallbackIcon: "bell"
|
||||
fallbackIconSize: 16
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
|
||||
NText {
|
||||
text: model.summary || I18n.tr("common.no-summary")
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightMedium
|
||||
color: Color.mOnSurface
|
||||
textFormat: Text.StyledText
|
||||
maximumLineCount: 1
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: model.body && model.body.length > 0
|
||||
Layout.fillWidth: true
|
||||
text: model.body || ""
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
textFormat: Text.StyledText
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Services.Hardware
|
||||
import qs.Services.Keyboard
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
// Unified OSD component that displays volume, input volume, and brightness changes
|
||||
Variants {
|
||||
id: osd
|
||||
|
||||
// Do not change the order or it will break settings.
|
||||
enum Type {
|
||||
Volume,
|
||||
InputVolume,
|
||||
Brightness,
|
||||
LockKey
|
||||
}
|
||||
|
||||
model: Quickshell.screens.filter(screen => (Settings.data.osd.monitors.includes(screen.name) || Settings.data.osd.monitors.length === 0) && Settings.data.osd.enabled)
|
||||
|
||||
delegate: Loader {
|
||||
id: root
|
||||
|
||||
required property ShellScreen modelData
|
||||
|
||||
active: false
|
||||
|
||||
// OSD State
|
||||
property int currentOSDType: -1 // OSD.Type enum value, -1 means none
|
||||
property bool startupComplete: false
|
||||
property real currentBrightness: 0
|
||||
property bool suppressInputOSD: false
|
||||
|
||||
// Lock Key States
|
||||
property string lastLockKeyChanged: "" // "caps", "num", "scroll", or ""
|
||||
|
||||
// Current values (computed properties)
|
||||
readonly property real currentVolume: AudioService.volume
|
||||
readonly property bool isMuted: AudioService.muted
|
||||
readonly property real currentInputVolume: AudioService.inputVolume
|
||||
readonly property bool isInputMuted: AudioService.inputMuted
|
||||
readonly property real epsilon: 0.005
|
||||
|
||||
// LockKey OSD enabled state (reactive to settings)
|
||||
readonly property bool lockKeyOSDEnabled: {
|
||||
const enabledTypes = Settings.data.osd.enabledTypes || [];
|
||||
if (enabledTypes.length === 0)
|
||||
return false;
|
||||
return enabledTypes.includes(OSD.Type.LockKey);
|
||||
}
|
||||
|
||||
readonly property var validIcons: {
|
||||
const iconKeys = Object.keys(Icons.icons);
|
||||
const aliasKeys = Object.keys(Icons.aliases);
|
||||
return new Set([...iconKeys, ...aliasKeys]);
|
||||
}
|
||||
|
||||
function getIcon() {
|
||||
switch (currentOSDType) {
|
||||
case OSD.Type.Volume:
|
||||
if (isMuted)
|
||||
return "volume-mute";
|
||||
// Show volume-x icon when volume is effectively 0% (within rounding threshold)
|
||||
if (currentVolume < root.epsilon)
|
||||
return "volume-x";
|
||||
return currentVolume <= 0.5 ? "volume-low" : "volume-high";
|
||||
case OSD.Type.InputVolume:
|
||||
return isInputMuted ? "microphone-off" : "microphone";
|
||||
case OSD.Type.Brightness:
|
||||
// Show sun-off icon when brightness is effectively 0% (within rounding threshold)
|
||||
if (currentBrightness < root.epsilon)
|
||||
return "sun-off";
|
||||
return currentBrightness <= 0.5 ? "brightness-low" : "brightness-high";
|
||||
case OSD.Type.LockKey:
|
||||
return "keyboard";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentValue() {
|
||||
switch (currentOSDType) {
|
||||
case OSD.Type.Volume:
|
||||
return isMuted ? 0 : currentVolume;
|
||||
case OSD.Type.InputVolume:
|
||||
return isInputMuted ? 0 : currentInputVolume;
|
||||
case OSD.Type.Brightness:
|
||||
return currentBrightness;
|
||||
case OSD.Type.LockKey:
|
||||
return 1.0; // Always show 100% when showing lock key status
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxValue() {
|
||||
if (currentOSDType === OSD.Type.Volume || currentOSDType === OSD.Type.InputVolume) {
|
||||
return Settings.data.audio.volumeOverdrive ? 1.5 : 1.0;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
function getDisplayPercentage() {
|
||||
if (currentOSDType === OSD.Type.LockKey) {
|
||||
// For lock keys, return the pre-determined status text
|
||||
return lastLockKeyChanged;
|
||||
}
|
||||
|
||||
const value = getCurrentValue();
|
||||
const max = getMaxValue();
|
||||
if ((currentOSDType === OSD.Type.Volume || currentOSDType === OSD.Type.InputVolume) && Settings.data.audio.volumeOverdrive) {
|
||||
const pct = Math.round(value * 100);
|
||||
return pct + "%";
|
||||
}
|
||||
const pct = Math.round(Math.min(max, value) * 100);
|
||||
return pct + "%";
|
||||
}
|
||||
|
||||
function getProgressColor() {
|
||||
const isMutedState = (currentOSDType === OSD.Type.Volume && isMuted) || (currentOSDType === OSD.Type.InputVolume && isInputMuted);
|
||||
if (isMutedState) {
|
||||
return Color.mError;
|
||||
}
|
||||
// When volumeOverdrive is enabled, show error color if volume is above 100%
|
||||
if ((currentOSDType === OSD.Type.Volume || currentOSDType === OSD.Type.InputVolume) && Settings.data.audio.volumeOverdrive) {
|
||||
const value = getCurrentValue();
|
||||
if (value > 1.0) {
|
||||
return Color.mError;
|
||||
}
|
||||
}
|
||||
// For lock keys, use a different color to indicate the lock state
|
||||
if (currentOSDType === OSD.Type.LockKey) {
|
||||
// Check the specific lock key that was changed
|
||||
if (lastLockKeyChanged.startsWith("CAPS")) {
|
||||
return LockKeysService.capsLockOn ? Color.mPrimary : Color.mOnSurfaceVariant;
|
||||
} else if (lastLockKeyChanged.startsWith("NUM")) {
|
||||
return LockKeysService.numLockOn ? Color.mPrimary : Color.mOnSurfaceVariant;
|
||||
} else if (lastLockKeyChanged.startsWith("SCROLL")) {
|
||||
return LockKeysService.scrollLockOn ? Color.mPrimary : Color.mOnSurfaceVariant;
|
||||
}
|
||||
}
|
||||
return Color.mPrimary;
|
||||
}
|
||||
|
||||
function getIconColor() {
|
||||
const isMutedState = (currentOSDType === OSD.Type.Volume && isMuted) || (currentOSDType === OSD.Type.InputVolume && isInputMuted);
|
||||
if (isMutedState)
|
||||
return Color.mError;
|
||||
|
||||
if (currentOSDType === OSD.Type.LockKey) {
|
||||
// Check the specific lock key that was changed
|
||||
if (lastLockKeyChanged.startsWith("CAPS")) {
|
||||
return LockKeysService.capsLockOn ? Color.mPrimary : Color.mOnSurfaceVariant;
|
||||
} else if (lastLockKeyChanged.startsWith("NUM")) {
|
||||
return LockKeysService.numLockOn ? Color.mPrimary : Color.mOnSurfaceVariant;
|
||||
} else if (lastLockKeyChanged.startsWith("SCROLL")) {
|
||||
return LockKeysService.scrollLockOn ? Color.mPrimary : Color.mOnSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
return Color.mOnSurface;
|
||||
}
|
||||
|
||||
// Brightness Handling
|
||||
function connectBrightnessMonitors() {
|
||||
for (var i = 0; i < BrightnessService.monitors.length; i++) {
|
||||
const monitor = BrightnessService.monitors[i];
|
||||
monitor.brightnessUpdated.disconnect(onBrightnessChanged);
|
||||
monitor.brightnessUpdated.connect(onBrightnessChanged);
|
||||
}
|
||||
}
|
||||
|
||||
function onBrightnessChanged(newBrightness) {
|
||||
if (!root)
|
||||
return;
|
||||
|
||||
root.currentBrightness = newBrightness;
|
||||
// Don't show OSD if brightness panel is open
|
||||
var brightnessPanel = PanelService.getPanel("brightnessPanel", root.modelData);
|
||||
var controlCenterPanel = PanelService.getPanel("controlCenterPanel", root.modelData);
|
||||
|
||||
if (brightnessPanel && brightnessPanel.isPanelOpen)
|
||||
return;
|
||||
if (controlCenterPanel && controlCenterPanel.isPanelOpen) {
|
||||
var cards = Settings.data.controlCenter.cards || [];
|
||||
if (cards.some(c => c.enabled && c.id === "brightness-card"))
|
||||
return;
|
||||
}
|
||||
showOSD(OSD.Type.Brightness);
|
||||
}
|
||||
|
||||
// Check if a specific OSD type is enabled
|
||||
function isTypeEnabled(type) {
|
||||
const enabledTypes = Settings.data.osd.enabledTypes || [];
|
||||
// If enabledTypes is empty, no types are enabled (no OSD will be shown)
|
||||
if (enabledTypes.length === 0)
|
||||
return false;
|
||||
return enabledTypes.includes(type);
|
||||
}
|
||||
|
||||
// OSD Display Control
|
||||
function showOSD(type) {
|
||||
// Ignore all OSD requests during startup period
|
||||
if (!startupComplete)
|
||||
return;
|
||||
|
||||
// Check if this OSD type is enabled
|
||||
if (!isTypeEnabled(type))
|
||||
return;
|
||||
|
||||
// Suppress Audio OSD if Audio Panel or Control Center (with audio card) is open
|
||||
if (type === OSD.Type.Volume || type === OSD.Type.InputVolume) {
|
||||
var audioPanel = PanelService.getPanel("audioPanel", root.modelData);
|
||||
if (audioPanel && audioPanel.isPanelOpen)
|
||||
return;
|
||||
var controlCenterPanel = PanelService.getPanel("controlCenterPanel", root.modelData);
|
||||
if (controlCenterPanel && controlCenterPanel.isPanelOpen) {
|
||||
var cards = Settings.data.controlCenter.cards || [];
|
||||
if (cards.some(c => c.enabled && c.id === "audio-card"))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
currentOSDType = type;
|
||||
|
||||
if (!root.active) {
|
||||
root.active = true;
|
||||
}
|
||||
|
||||
if (root.item) {
|
||||
root.item.showOSD();
|
||||
} else {
|
||||
Qt.callLater(() => {
|
||||
if (root.item)
|
||||
root.item.showOSD();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hideOSD() {
|
||||
if (root.item?.osdItem) {
|
||||
root.item.osdItem.hideImmediately();
|
||||
} else if (root.active) {
|
||||
root.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
// AudioService monitoring
|
||||
Connections {
|
||||
target: AudioService
|
||||
|
||||
function onVolumeChanged() {
|
||||
showOSD(OSD.Type.Volume);
|
||||
}
|
||||
|
||||
function onVolumeAtMaximum() {
|
||||
showOSD(OSD.Type.Volume);
|
||||
}
|
||||
|
||||
function onVolumeAtMinimum() {
|
||||
showOSD(OSD.Type.Volume);
|
||||
}
|
||||
|
||||
function onMutedChanged() {
|
||||
if (AudioService.consumeOutputOSDSuppression())
|
||||
return;
|
||||
showOSD(OSD.Type.Volume);
|
||||
}
|
||||
|
||||
function onInputVolumeChanged() {
|
||||
if (suppressInputOSD)
|
||||
return;
|
||||
if (AudioService.hasInput)
|
||||
showOSD(OSD.Type.InputVolume);
|
||||
}
|
||||
|
||||
function onInputMutedChanged() {
|
||||
if (suppressInputOSD)
|
||||
return;
|
||||
if (!AudioService.hasInput)
|
||||
return;
|
||||
if (AudioService.consumeInputOSDSuppression())
|
||||
return;
|
||||
showOSD(OSD.Type.InputVolume);
|
||||
}
|
||||
|
||||
// Refresh OSD when device changes to ensure correct volume is displayed
|
||||
function onSinkChanged() {
|
||||
suppressInputOSD = true;
|
||||
inputSuppressionTimer.restart();
|
||||
// If volume OSD is currently showing, refresh it to show new device's volume
|
||||
if (root.currentOSDType === OSD.Type.Volume) {
|
||||
Qt.callLater(() => {
|
||||
showOSD(OSD.Type.Volume);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onSourceChanged() {
|
||||
// If input volume OSD is currently showing, refresh it to show new device's volume
|
||||
if (root.currentOSDType === OSD.Type.InputVolume) {
|
||||
Qt.callLater(() => {
|
||||
showOSD(OSD.Type.InputVolume);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness monitoring
|
||||
Connections {
|
||||
target: BrightnessService
|
||||
function onMonitorsChanged() {
|
||||
connectBrightnessMonitors();
|
||||
}
|
||||
}
|
||||
|
||||
// Register/unregister LockKeysService polling based on whether LockKey OSD is enabled
|
||||
onLockKeyOSDEnabledChanged: {
|
||||
if (lockKeyOSDEnabled) {
|
||||
LockKeysService.registerComponent("osd:" + (modelData?.name || "unknown"));
|
||||
} else {
|
||||
LockKeysService.unregisterComponent("osd:" + (modelData?.name || "unknown"));
|
||||
}
|
||||
}
|
||||
Component.onCompleted: {
|
||||
if (lockKeyOSDEnabled) {
|
||||
LockKeysService.registerComponent("osd:" + (modelData?.name || "unknown"));
|
||||
}
|
||||
}
|
||||
|
||||
// LockKeys monitoring with a cleaner approach
|
||||
// Only connect when LockKey OSD is enabled to avoid starting the service unnecessarily
|
||||
Connections {
|
||||
target: root.lockKeyOSDEnabled ? LockKeysService : null
|
||||
|
||||
function onCapsLockChanged(active) {
|
||||
root.lastLockKeyChanged = active ? "CAPS ON" : "CAPS OFF";
|
||||
root.showOSD(OSD.Type.LockKey);
|
||||
}
|
||||
|
||||
function onNumLockChanged(active) {
|
||||
root.lastLockKeyChanged = active ? "NUM ON" : "NUM OFF";
|
||||
root.showOSD(OSD.Type.LockKey);
|
||||
}
|
||||
|
||||
function onScrollLockChanged(active) {
|
||||
root.lastLockKeyChanged = active ? "SCROLL ON" : "SCROLL OFF";
|
||||
root.showOSD(OSD.Type.LockKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Startup timer - connect brightness monitors and enable OSD after 2 seconds
|
||||
Timer {
|
||||
id: startupTimer
|
||||
interval: 2000
|
||||
running: true
|
||||
onTriggered: {
|
||||
connectBrightnessMonitors();
|
||||
root.startupComplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to reset the input volume OSD suppression
|
||||
Timer {
|
||||
id: inputSuppressionTimer
|
||||
interval: 300
|
||||
repeat: false
|
||||
onTriggered: root.suppressInputOSD = false
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
LockKeysService.unregisterComponent("osd:" + (modelData?.name || "unknown"));
|
||||
if (typeof BrightnessService !== "undefined" && BrightnessService.monitors) {
|
||||
for (var i = 0; i < BrightnessService.monitors.length; i++) {
|
||||
try {
|
||||
BrightnessService.monitors[i].brightnessUpdated.disconnect(onBrightnessChanged);
|
||||
} catch (e) {
|
||||
// Ignore errors if already disconnected or not connected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Visual Component
|
||||
sourceComponent: PanelWindow {
|
||||
id: panel
|
||||
screen: modelData
|
||||
|
||||
// Position configuration
|
||||
readonly property string location: Settings.data.osd?.location || "top_right"
|
||||
readonly property bool isTop: location === "top" || location.startsWith("top")
|
||||
readonly property bool isBottom: location === "bottom" || location.startsWith("bottom")
|
||||
readonly property bool isLeft: location.includes("_left") || location === "left"
|
||||
readonly property bool isRight: location.includes("_right") || location === "right"
|
||||
readonly property bool verticalMode: location === "left" || location === "right"
|
||||
|
||||
// Hidden text element for measuring lock key text width
|
||||
NText {
|
||||
id: lockKeyTextMetrics
|
||||
visible: false
|
||||
text: root.getDisplayPercentage()
|
||||
pointSize: Style.fontSizeS
|
||||
family: Settings.data.ui.fontFixed
|
||||
elide: Text.ElideNone
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
|
||||
// Dimensions
|
||||
readonly property bool isShortMode: root.currentOSDType === OSD.Type.LockKey
|
||||
readonly property int longHWidth: Math.round(320 * Style.uiScaleRatio)
|
||||
readonly property int longHHeight: Math.round(72 * Style.uiScaleRatio)
|
||||
readonly property int shortHWidth: Math.round(180 * Style.uiScaleRatio)
|
||||
readonly property int longVWidth: Math.round(80 * Style.uiScaleRatio)
|
||||
readonly property int longVHeight: Math.round(280 * Style.uiScaleRatio)
|
||||
readonly property int shortVHeight: Math.round(180 * Style.uiScaleRatio)
|
||||
|
||||
// Dynamic width for horizontal lock keys based on text length
|
||||
// Explicitly bind to contentWidth to ensure reactivity
|
||||
readonly property int lockKeyHWidth: {
|
||||
if (root.currentOSDType !== OSD.Type.LockKey || verticalMode) {
|
||||
return shortHWidth;
|
||||
}
|
||||
const text = root.getDisplayPercentage();
|
||||
if (!text) {
|
||||
return shortHWidth;
|
||||
}
|
||||
// Access contentWidth to create binding dependency
|
||||
const textWidth = Math.ceil(lockKeyTextMetrics.contentWidth || 0);
|
||||
if (textWidth === 0) {
|
||||
// Fallback: estimate based on text length if measurement not ready
|
||||
const fontSize = Style.fontSizeS * Settings.data.ui.fontFixedScale * Style.uiScaleRatio;
|
||||
const estimatedWidth = text.length * fontSize * 0.6;
|
||||
const iconWidth = Style.fontSizeXL * Style.uiScaleRatio;
|
||||
const margins = Style.margin2L;
|
||||
const spacing = Style.marginM;
|
||||
const bgMargins = Style.margin2M * 1.5;
|
||||
return Math.max(shortHWidth, Math.round((estimatedWidth + iconWidth + margins + spacing + bgMargins) * 1.1));
|
||||
}
|
||||
const iconWidth = Style.fontSizeXL * Style.uiScaleRatio;
|
||||
const margins = Style.margin2L; // Left and right content margins
|
||||
const spacing = Style.marginM; // Spacing between icon and text
|
||||
const bgMargins = Style.margin2M * 1.5; // Background margins
|
||||
const totalWidth = textWidth + iconWidth + margins + spacing + bgMargins;
|
||||
// Ensure minimum width and add some buffer
|
||||
return Math.max(shortHWidth, Math.round(totalWidth * 1.1));
|
||||
}
|
||||
|
||||
// Dynamic height for vertical lock keys based on text length
|
||||
readonly property int lockKeyVHeight: {
|
||||
if (root.currentOSDType !== OSD.Type.LockKey || !verticalMode) {
|
||||
return shortVHeight;
|
||||
}
|
||||
const text = root.getDisplayPercentage();
|
||||
const charCount = text ? text.length : 0;
|
||||
if (charCount === 0) {
|
||||
return shortVHeight;
|
||||
}
|
||||
// Calculate height: font size * char count + margins + icon space
|
||||
// Font size M (11pt) scaled, plus some spacing between chars
|
||||
const fontSize = Style.fontSizeS * Settings.data.ui.fontFixedScale * Style.uiScaleRatio;
|
||||
const charHeight = fontSize * 1.3; // Add 30% for line height (matches Layout.preferredHeight)
|
||||
const textHeight = charCount * charHeight;
|
||||
// Background margins (Style.marginM * 1.5 * 2 for top and bottom)
|
||||
const bgMargins = Style.marginM * 1.5 * 2;
|
||||
// Content margins (Style.margin2L for top and bottom)
|
||||
const contentMargins = Style.margin2L;
|
||||
// Icon size: fontSizeXL scaled, with extra space for icon rendering and padding
|
||||
const iconSize = Style.fontSizeXL * Style.uiScaleRatio * 1.8; // Add 80% for icon rendering and padding
|
||||
// Spacing between text and icon (Style.marginM for lock keys)
|
||||
const textIconSpacing = Style.marginM;
|
||||
// Add extra buffer to ensure everything fits comfortably
|
||||
const buffer = Style.marginL;
|
||||
const totalHeight = textHeight + bgMargins + contentMargins + iconSize + textIconSpacing + buffer;
|
||||
// Ensure minimum height and add extra padding for safety
|
||||
return Math.max(shortVHeight, Math.round(totalHeight * 1.1));
|
||||
}
|
||||
|
||||
readonly property int barThickness: {
|
||||
const base = Math.max(8, Math.round(8 * Style.uiScaleRatio));
|
||||
return base % 2 === 0 ? base : base + 1;
|
||||
}
|
||||
|
||||
anchors.top: isTop
|
||||
anchors.bottom: isBottom
|
||||
anchors.left: isLeft
|
||||
anchors.right: isRight
|
||||
|
||||
readonly property string screenBarPosition: Settings.getBarPositionForScreen(root.modelData?.name)
|
||||
readonly property real barHeight: Style.getBarHeightForScreen(root.modelData?.name)
|
||||
readonly property bool isFramed: Settings.data.bar.barType === "framed"
|
||||
readonly property real frameThickness: Settings.data.bar.frameThickness ?? 8
|
||||
|
||||
function calculateMargin(isAnchored, position) {
|
||||
if (!isAnchored)
|
||||
return 0;
|
||||
|
||||
let base = Style.marginM;
|
||||
if (screenBarPosition === position) {
|
||||
const isVertical = position === "top" || position === "bottom";
|
||||
const floatExtra = Math.ceil(Settings.data.bar.barType === "floating" ? (isVertical ? Settings.data.bar.marginVertical : Settings.data.bar.marginHorizontal) : 0);
|
||||
return barHeight + base + floatExtra;
|
||||
}
|
||||
|
||||
if (isFramed) {
|
||||
return base + frameThickness;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
margins.top: calculateMargin(anchors.top, "top")
|
||||
margins.bottom: calculateMargin(anchors.bottom, "bottom")
|
||||
margins.left: calculateMargin(anchors.left, "left")
|
||||
margins.right: calculateMargin(anchors.right, "right")
|
||||
|
||||
implicitWidth: verticalMode ? longVWidth : (isShortMode ? lockKeyHWidth : longHWidth)
|
||||
implicitHeight: verticalMode ? (isShortMode ? lockKeyVHeight : longVHeight) : longHHeight
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.namespace: "noctalia-osd-" + (screen?.name || "unknown")
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
WlrLayershell.layer: Settings.data.osd?.overlayLayer ? WlrLayer.Overlay : WlrLayer.Top
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Click-through — OSD is display-only, no input needed
|
||||
mask: Region {}
|
||||
|
||||
Item {
|
||||
id: osdItem
|
||||
anchors.fill: parent
|
||||
visible: false
|
||||
opacity: 0
|
||||
scale: 0.85
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: Settings.data.osd.autoHideMs
|
||||
onTriggered: osdItem.hide()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: visibilityTimer
|
||||
interval: Style.animationNormal + 50
|
||||
onTriggered: {
|
||||
osdItem.visible = false;
|
||||
root.currentOSDType = -1;
|
||||
root.lastLockKeyChanged = "";
|
||||
root.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: background
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM * 1.5
|
||||
radius: Style.radiusL
|
||||
color: Qt.alpha(Color.mSurface, Color.adaptiveOpacity(Settings.data.osd.backgroundOpacity) || 1.0)
|
||||
border.color: Qt.alpha(Color.mOutline, Color.adaptiveOpacity(Settings.data.osd.backgroundOpacity) || 1.0)
|
||||
border.width: {
|
||||
const bw = Math.max(2, Style.borderM);
|
||||
return bw % 2 === 0 ? bw : bw + 1;
|
||||
}
|
||||
}
|
||||
|
||||
NDropShadow {
|
||||
anchors.fill: background
|
||||
source: background
|
||||
autoPaddingEnabled: true
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: contentLoader
|
||||
anchors.fill: background
|
||||
anchors.margins: Style.marginM
|
||||
active: true
|
||||
sourceComponent: panel.verticalMode ? verticalContent : horizontalContent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: horizontalContent
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.marginL
|
||||
anchors.rightMargin: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
TextMetrics {
|
||||
id: percentageMetrics
|
||||
font.family: Settings.data.ui.fontFixed
|
||||
font.pointSize: Style.fontSizeS * (Settings.data.ui.fontFixedScale * Style.uiScaleRatio)
|
||||
text: "150%"
|
||||
}
|
||||
|
||||
// Common Icon for all types
|
||||
NIcon {
|
||||
icon: root.getIcon()
|
||||
color: root.getIconColor()
|
||||
pointSize: Style.fontSizeXL
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lock Key Status Text (replaces progress bar)
|
||||
NText {
|
||||
visible: root.currentOSDType === OSD.Type.LockKey
|
||||
text: root.getDisplayPercentage()
|
||||
color: root.getProgressColor()
|
||||
pointSize: Style.fontSizeS
|
||||
elide: Text.ElideNone
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
// Progress Bar for Volume/Brightness
|
||||
Rectangle {
|
||||
visible: root.currentOSDType !== OSD.Type.LockKey
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
height: panel.barThickness
|
||||
radius: Math.min(Style.iRadiusL, panel.barThickness / 2)
|
||||
color: Color.mSurfaceVariant
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.min(1.0, root.getCurrentValue() / root.getMaxValue())
|
||||
radius: parent.radius
|
||||
color: root.getProgressColor()
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Percentage Text for Volume/Brightness
|
||||
NText {
|
||||
visible: root.currentOSDType !== OSD.Type.LockKey
|
||||
text: root.getDisplayPercentage()
|
||||
color: Color.mOnSurface
|
||||
pointSize: Style.fontSizeS
|
||||
family: Settings.data.ui.fontFixed
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignRight
|
||||
horizontalAlignment: Text.AlignRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Layout.fillWidth: false
|
||||
Layout.preferredWidth: Math.ceil(percentageMetrics.width) + Math.round(8 * Style.uiScaleRatio)
|
||||
Layout.maximumWidth: Math.ceil(percentageMetrics.width) + Math.round(8 * Style.uiScaleRatio)
|
||||
Layout.minimumWidth: Math.ceil(percentageMetrics.width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: verticalContent
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: Style.marginL
|
||||
anchors.bottomMargin: Style.marginL
|
||||
spacing: root.currentOSDType === OSD.Type.LockKey ? Style.marginM : Style.marginS
|
||||
clip: root.currentOSDType !== OSD.Type.LockKey
|
||||
|
||||
ColumnLayout {
|
||||
id: textVerticalLayout
|
||||
visible: root.currentOSDType === OSD.Type.LockKey
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: false
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: 0
|
||||
|
||||
property var verticalTextChars: []
|
||||
|
||||
function updateVerticalTextChars() {
|
||||
const text = root.getDisplayPercentage();
|
||||
const chars = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
chars.push(text[i]);
|
||||
}
|
||||
verticalTextChars = chars;
|
||||
}
|
||||
|
||||
Component.onCompleted: updateVerticalTextChars()
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onLastLockKeyChangedChanged() {
|
||||
if (root.currentOSDType === OSD.Type.LockKey) {
|
||||
textVerticalLayout.updateVerticalTextChars();
|
||||
}
|
||||
}
|
||||
function onCurrentOSDTypeChanged() {
|
||||
if (root.currentOSDType === OSD.Type.LockKey) {
|
||||
textVerticalLayout.updateVerticalTextChars();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: textVerticalLayout.verticalTextChars
|
||||
|
||||
NText {
|
||||
text: modelData || ""
|
||||
color: root.getProgressColor()
|
||||
pointSize: Style.fontSizeS
|
||||
family: Settings.data.ui.fontFixed
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: {
|
||||
const fontSize = Style.fontSizeS * Settings.data.ui.fontFixedScale * Style.uiScaleRatio;
|
||||
return Math.round(fontSize * 1.3);
|
||||
}
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: root.currentOSDType !== OSD.Type.LockKey
|
||||
text: root.getDisplayPercentage()
|
||||
color: Color.mOnSurface
|
||||
pointSize: Style.fontSizeS
|
||||
family: Settings.data.ui.fontFixed
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Layout.preferredHeight: Math.round(20 * Style.uiScaleRatio)
|
||||
}
|
||||
|
||||
Item {
|
||||
visible: root.currentOSDType !== OSD.Type.LockKey
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: root.currentOSDType !== OSD.Type.LockKey
|
||||
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: panel.barThickness
|
||||
radius: Math.min(Style.iRadiusL, panel.barThickness / 2)
|
||||
color: Color.mSurfaceVariant
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: parent.height * Math.min(1.0, root.getCurrentValue() / root.getMaxValue())
|
||||
radius: parent.radius
|
||||
color: root.getProgressColor()
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: root.getIcon()
|
||||
color: root.getIconColor()
|
||||
pointSize: root.currentOSDType === OSD.Type.LockKey ? Style.fontSizeXL : Style.fontSizeL
|
||||
Layout.alignment: root.currentOSDType === OSD.Type.LockKey ? Qt.AlignHCenter : (Qt.AlignHCenter | Qt.AlignBottom)
|
||||
Layout.preferredHeight: root.currentOSDType === OSD.Type.LockKey ? (Style.fontSizeXL * Style.uiScaleRatio * 1.5) : -1
|
||||
Layout.minimumHeight: root.currentOSDType === OSD.Type.LockKey ? (Style.fontSizeXL * Style.uiScaleRatio) : 0
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationNormal
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delay showing the OSD to allow the layout to settle after activation.
|
||||
// Without this, the percentage text renders outside the box on first
|
||||
// show.
|
||||
Timer {
|
||||
id: showDelayTimer
|
||||
interval: 30
|
||||
onTriggered: {
|
||||
osdItem.visible = true;
|
||||
osdItem.opacity = 1;
|
||||
osdItem.scale = 1.0;
|
||||
hideTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
hideTimer.stop();
|
||||
visibilityTimer.stop();
|
||||
showDelayTimer.start();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
hideTimer.stop();
|
||||
visibilityTimer.stop();
|
||||
osdItem.opacity = 0;
|
||||
osdItem.scale = 0.85;
|
||||
visibilityTimer.start();
|
||||
}
|
||||
|
||||
function hideImmediately() {
|
||||
hideTimer.stop();
|
||||
visibilityTimer.stop();
|
||||
osdItem.opacity = 0;
|
||||
osdItem.scale = 0.85;
|
||||
osdItem.visible = false;
|
||||
root.currentOSDType = -1;
|
||||
root.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showOSD() {
|
||||
osdItem.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Modules.MainScreen
|
||||
import qs.Services.Media
|
||||
import qs.Widgets
|
||||
|
||||
SmartPanel {
|
||||
id: root
|
||||
|
||||
preferredWidth: Math.round(440 * Style.uiScaleRatio)
|
||||
preferredHeight: Math.round(420 * Style.uiScaleRatio)
|
||||
|
||||
panelContent: Item {
|
||||
id: panelContent
|
||||
|
||||
// Volume state (lazy-loaded with panelContent)
|
||||
property real localOutputVolume: AudioService.volume || 0
|
||||
property bool localOutputVolumeChanging: false
|
||||
property int lastSinkId: -1
|
||||
|
||||
property real localInputVolume: AudioService.inputVolume || 0
|
||||
property bool localInputVolumeChanging: false
|
||||
property int lastSourceId: -1
|
||||
|
||||
readonly property bool outputVolumeGuard: outputVolumeSlider.sliderActive || localOutputVolumeChanging
|
||||
readonly property bool inputVolumeGuard: inputVolumeSlider.sliderActive || localInputVolumeChanging
|
||||
|
||||
// UI state (lazy-loaded with panelContent)
|
||||
property int currentTabIndex: 0
|
||||
|
||||
Component.onCompleted: {
|
||||
var vol = AudioService.volume;
|
||||
localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
var inputVol = AudioService.inputVolume;
|
||||
localInputVolume = (inputVol !== undefined && !isNaN(inputVol)) ? inputVol : 0;
|
||||
if (AudioService.sink) {
|
||||
lastSinkId = AudioService.sink.id;
|
||||
}
|
||||
if (AudioService.source) {
|
||||
lastSourceId = AudioService.source.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset local volume when device changes - use current device's volume
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onSinkChanged() {
|
||||
if (AudioService.sink) {
|
||||
const newSinkId = AudioService.sink.id;
|
||||
if (newSinkId !== panelContent.lastSinkId) {
|
||||
panelContent.lastSinkId = newSinkId;
|
||||
// Immediately set local volume to current device's volume
|
||||
var vol = AudioService.volume;
|
||||
panelContent.localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
} else {
|
||||
panelContent.lastSinkId = -1;
|
||||
panelContent.localOutputVolume = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onSourceChanged() {
|
||||
if (AudioService.source) {
|
||||
const newSourceId = AudioService.source.id;
|
||||
if (newSourceId !== panelContent.lastSourceId) {
|
||||
panelContent.lastSourceId = newSourceId;
|
||||
// Immediately set local volume to current device's volume
|
||||
var vol = AudioService.inputVolume;
|
||||
panelContent.localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
} else {
|
||||
panelContent.lastSourceId = -1;
|
||||
panelContent.localInputVolume = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connections to update local volumes when AudioService changes
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onVolumeChanged() {
|
||||
if (!panelContent.outputVolumeGuard && !AudioService.isSettingOutputVolume && AudioService.sink && AudioService.sink.id === panelContent.lastSinkId) {
|
||||
var vol = AudioService.volume;
|
||||
panelContent.localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: AudioService
|
||||
function onInputVolumeChanged() {
|
||||
if (!panelContent.inputVolumeGuard && !AudioService.isSettingInputVolume && AudioService.source && AudioService.source.id === panelContent.lastSourceId) {
|
||||
var vol = AudioService.inputVolume;
|
||||
panelContent.localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: outputVolumeSlider
|
||||
function onSliderActiveChanged() {
|
||||
if (!outputVolumeSlider.sliderActive && AudioService.sink && AudioService.sink.id === panelContent.lastSinkId) {
|
||||
var vol = AudioService.volume;
|
||||
panelContent.localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: inputVolumeSlider
|
||||
function onSliderActiveChanged() {
|
||||
if (!inputVolumeSlider.sliderActive && AudioService.source && AudioService.source.id === panelContent.lastSourceId) {
|
||||
var vol = AudioService.inputVolume;
|
||||
panelContent.localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to debounce volume changes
|
||||
// Only sync if the device hasn't changed (check by comparing IDs)
|
||||
Timer {
|
||||
interval: 100
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
// Only sync if sink hasn't changed
|
||||
if (AudioService.sink && AudioService.sink.id === panelContent.lastSinkId) {
|
||||
if (Math.abs(panelContent.localOutputVolume - AudioService.volume) >= 0.01) {
|
||||
AudioService.setVolume(panelContent.localOutputVolume);
|
||||
}
|
||||
}
|
||||
// Only sync if source hasn't changed
|
||||
if (AudioService.source && AudioService.source.id === panelContent.lastSourceId) {
|
||||
if (Math.abs(panelContent.localInputVolume - AudioService.inputVolume) >= 0.01) {
|
||||
AudioService.setInputVolume(panelContent.localInputVolume);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find application streams that are actually playing audio (connected to default sink)
|
||||
// Use linkGroups to find nodes connected to the default audio sink
|
||||
// Note: We need to use link IDs since source/target properties require binding
|
||||
readonly property var appStreams: AudioService.appStreams
|
||||
|
||||
// Use implicitHeight from content + margins to avoid binding loops
|
||||
property real contentPreferredHeight: mainColumn.implicitHeight + Style.margin2L
|
||||
|
||||
ColumnLayout {
|
||||
id: mainColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
// HEADER
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: header.implicitHeight + Style.margin2M
|
||||
|
||||
ColumnLayout {
|
||||
id: header
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
RowLayout {
|
||||
NIcon {
|
||||
icon: "settings-audio"
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: I18n.tr("panels.audio.title")
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: {
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NTabBar {
|
||||
id: tabBar
|
||||
Layout.fillWidth: true
|
||||
margins: Style.marginS
|
||||
currentIndex: panelContent.currentTabIndex
|
||||
distributeEvenly: true
|
||||
onCurrentIndexChanged: panelContent.currentTabIndex = currentIndex
|
||||
|
||||
NTabButton {
|
||||
text: I18n.tr("common.volumes")
|
||||
tabIndex: 0
|
||||
checked: tabBar.currentIndex === 0
|
||||
}
|
||||
|
||||
NTabButton {
|
||||
text: I18n.tr("common.devices")
|
||||
tabIndex: 1
|
||||
checked: tabBar.currentIndex === 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content Stack
|
||||
StackLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
currentIndex: panelContent.currentTabIndex
|
||||
|
||||
// Applications Tab (Volume)
|
||||
NScrollView {
|
||||
id: volumeScrollView
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
verticalPolicy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
reserveScrollbarSpace: false
|
||||
gradientColor: Color.mSurface
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Style.marginM
|
||||
width: volumeScrollView.availableWidth
|
||||
|
||||
// Output Volume
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: outputVolumeColumn.implicitHeight + Style.margin2M
|
||||
|
||||
ColumnLayout {
|
||||
id: outputVolumeColumn
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
|
||||
NText {
|
||||
text: I18n.tr("common.output")
|
||||
pointSize: Style.fontSizeM
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: AudioService.sink ? (" - " + (AudioService.sink.description || AudioService.sink.name || "")) : ""
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NValueSlider {
|
||||
id: outputVolumeSlider
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
|
||||
value: localOutputVolume
|
||||
stepSize: 0.01
|
||||
heightRatio: 0.5
|
||||
onMoved: function (value) {
|
||||
localOutputVolume = value;
|
||||
}
|
||||
onPressedChanged: function (pressed) {
|
||||
localOutputVolumeChanging = pressed;
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: Math.round((panelContent.outputVolumeGuard ? localOutputVolume : AudioService.volume) * 100) + "%"
|
||||
pointSize: Style.fontSizeM
|
||||
family: Settings.data.ui.fontFixed
|
||||
color: Color.mOnSurface
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.preferredWidth: 45 * Style.uiScaleRatio
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: AudioService.getOutputIcon()
|
||||
tooltipText: I18n.tr("tooltips.output-muted")
|
||||
baseSize: Style.baseWidgetSize * 0.7
|
||||
onClicked: {
|
||||
AudioService.suppressOutputOSD();
|
||||
AudioService.setOutputMuted(!AudioService.muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input Volume
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: inputVolumeColumn.implicitHeight + Style.margin2M
|
||||
|
||||
ColumnLayout {
|
||||
id: inputVolumeColumn
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
|
||||
NText {
|
||||
text: I18n.tr("common.input")
|
||||
pointSize: Style.fontSizeM
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: AudioService.source ? (" - " + (AudioService.source.description || AudioService.source.name || "")) : ""
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NValueSlider {
|
||||
id: inputVolumeSlider
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
|
||||
value: localInputVolume
|
||||
stepSize: 0.01
|
||||
heightRatio: 0.5
|
||||
onMoved: function (value) {
|
||||
localInputVolume = value;
|
||||
}
|
||||
onPressedChanged: function (pressed) {
|
||||
localInputVolumeChanging = pressed;
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: Math.round((panelContent.inputVolumeGuard ? localInputVolume : AudioService.inputVolume) * 100) + "%"
|
||||
pointSize: Style.fontSizeM
|
||||
family: Settings.data.ui.fontFixed
|
||||
color: Color.mOnSurface
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.preferredWidth: 45 * Style.uiScaleRatio
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: AudioService.getInputIcon()
|
||||
tooltipText: I18n.tr("tooltips.input-muted")
|
||||
baseSize: Style.baseWidgetSize * 0.7
|
||||
onClicked: {
|
||||
AudioService.suppressInputOSD();
|
||||
AudioService.setInputMuted(!AudioService.inputMuted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind all app stream nodes to access their audio properties
|
||||
PwObjectTracker {
|
||||
id: appStreamsTracker
|
||||
objects: panelContent.appStreams
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: panelContent.appStreams
|
||||
|
||||
NBox {
|
||||
id: appBox
|
||||
required property PwNode modelData
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: appRow.implicitHeight + Style.margin2M
|
||||
visible: !isCaptureStream
|
||||
|
||||
// Track individual node to ensure properties are bound
|
||||
PwObjectTracker {
|
||||
objects: modelData ? [modelData] : []
|
||||
}
|
||||
|
||||
property PwNodeAudio nodeAudio: (modelData && modelData.audio) ? modelData.audio : null
|
||||
property real appVolume: (nodeAudio && nodeAudio.volume !== undefined) ? nodeAudio.volume : 0.0
|
||||
property bool appMuted: (nodeAudio && nodeAudio.muted !== undefined) ? nodeAudio.muted : false
|
||||
|
||||
// Check if this is a capture stream (after node is bound)
|
||||
readonly property bool isCaptureStream: {
|
||||
if (!modelData || !modelData.properties)
|
||||
return false;
|
||||
const props = modelData.properties;
|
||||
// Exclude capture streams - check for stream.capture.sink property
|
||||
if (props["stream.capture.sink"] !== undefined) {
|
||||
return true;
|
||||
}
|
||||
const mediaClass = props["media.class"] || "";
|
||||
// Exclude Stream/Input (capture) but allow Stream/Output (playback)
|
||||
if (mediaClass.includes("Capture") || mediaClass === "Stream/Input" || mediaClass === "Stream/Input/Audio") {
|
||||
return true;
|
||||
}
|
||||
const mediaRole = props["media.role"] || "";
|
||||
if (mediaRole === "Capture") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper function to validate if a fuzzy match is actually related
|
||||
function isValidMatch(searchTerm, entry) {
|
||||
if (!entry)
|
||||
return false;
|
||||
var search = searchTerm.toLowerCase();
|
||||
var id = (entry.id || "").toLowerCase();
|
||||
var name = (entry.name || "").toLowerCase();
|
||||
var icon = (entry.icon || "").toLowerCase();
|
||||
// Match is valid if search term appears in entry or entry appears in search
|
||||
return id.includes(search) || name.includes(search) || icon.includes(search) || search.includes(id.split('.').pop()) || search.includes(name.replace(/\s+/g, ''));
|
||||
}
|
||||
|
||||
readonly property string appName: {
|
||||
if (!modelData)
|
||||
return "Unknown App";
|
||||
|
||||
var props = modelData.properties;
|
||||
var desc = modelData.description || "";
|
||||
var name = modelData.name || "";
|
||||
|
||||
if (!props) {
|
||||
if (desc)
|
||||
return desc;
|
||||
if (name) {
|
||||
var nameParts = name.split(/[-_]/);
|
||||
if (nameParts.length > 0 && nameParts[0])
|
||||
return nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1);
|
||||
return name;
|
||||
}
|
||||
return "Unknown App";
|
||||
}
|
||||
|
||||
var binaryName = props["application.process.binary"] || "";
|
||||
|
||||
// Try binary name first (fixes Electron apps like vesktop)
|
||||
if (binaryName) {
|
||||
var binParts = binaryName.split("/");
|
||||
if (binParts.length > 0) {
|
||||
var binName = binParts[binParts.length - 1].toLowerCase();
|
||||
var entry = ThemeIcons.findAppEntry(binName);
|
||||
// Only use entry if it's actually related to binary name
|
||||
if (entry && entry.name && isValidMatch(binName, entry))
|
||||
return entry.name;
|
||||
}
|
||||
}
|
||||
|
||||
var computedAppName = props["application.name"] || "";
|
||||
var mediaName = props["media.name"] || "";
|
||||
var mediaTitle = props["media.title"] || "";
|
||||
var appId = props["application.id"] || "";
|
||||
|
||||
if (appId) {
|
||||
var entry = ThemeIcons.findAppEntry(appId);
|
||||
if (entry && entry.name && isValidMatch(appId, entry))
|
||||
return entry.name;
|
||||
if (!computedAppName) {
|
||||
var parts = appId.split(".");
|
||||
if (parts.length > 0 && parts[0])
|
||||
computedAppName = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!computedAppName && binaryName) {
|
||||
var binParts = binaryName.split("/");
|
||||
if (binParts.length > 0 && binParts[binParts.length - 1])
|
||||
computedAppName = binParts[binParts.length - 1].charAt(0).toUpperCase() + binParts[binParts.length - 1].slice(1);
|
||||
}
|
||||
|
||||
var result = computedAppName || mediaTitle || mediaName || binaryName || desc || name;
|
||||
|
||||
if (!result || result === "" || result === "Unknown App") {
|
||||
if (name) {
|
||||
var nameParts = name.split(/[-_]/);
|
||||
if (nameParts.length > 0 && nameParts[0])
|
||||
result = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return result || "Unknown App";
|
||||
}
|
||||
|
||||
readonly property string appIcon: {
|
||||
if (!modelData)
|
||||
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
|
||||
|
||||
var props = modelData.properties;
|
||||
|
||||
if (!props) {
|
||||
var name = modelData.name || "";
|
||||
if (name) {
|
||||
var nameParts = name.split(/[-_]/);
|
||||
if (nameParts.length > 0) {
|
||||
var entry = ThemeIcons.findAppEntry(nameParts[0].toLowerCase());
|
||||
if (entry && entry.icon && isValidMatch(nameParts[0].toLowerCase(), entry))
|
||||
return ThemeIcons.iconFromName(entry.icon, "application-x-executable");
|
||||
}
|
||||
}
|
||||
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
|
||||
}
|
||||
|
||||
var binaryName = props["application.process.binary"] || "";
|
||||
if (binaryName) {
|
||||
var binParts = binaryName.split("/");
|
||||
if (binParts.length > 0) {
|
||||
var binName = binParts[binParts.length - 1].toLowerCase();
|
||||
var entry = ThemeIcons.findAppEntry(binName);
|
||||
if (entry && entry.icon && isValidMatch(binName, entry))
|
||||
return ThemeIcons.iconFromName(entry.icon, "");
|
||||
}
|
||||
}
|
||||
|
||||
var iconName = props["application.icon-name"] || "";
|
||||
if (iconName && ThemeIcons.iconExists(iconName)) {
|
||||
var iconPath = ThemeIcons.iconFromName(iconName, "");
|
||||
if (iconPath && iconPath !== "")
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
var appId = props["application.id"] || "";
|
||||
if (appId) {
|
||||
var entry = ThemeIcons.findAppEntry(appId);
|
||||
if (entry && entry.icon && isValidMatch(appId, entry))
|
||||
return ThemeIcons.iconFromName(entry.icon, "");
|
||||
}
|
||||
|
||||
var appName = props["application.name"] || "";
|
||||
if (appName) {
|
||||
var entry = ThemeIcons.findAppEntry(appName.toLowerCase());
|
||||
if (entry && entry.icon && isValidMatch(appName.toLowerCase(), entry))
|
||||
return ThemeIcons.iconFromName(entry.icon, "");
|
||||
}
|
||||
|
||||
var name = modelData.name || "";
|
||||
if (name) {
|
||||
var nameParts = name.split(/[-_]/);
|
||||
if (nameParts.length > 0) {
|
||||
var entry = ThemeIcons.findAppEntry(nameParts[0].toLowerCase());
|
||||
if (entry && entry.icon && isValidMatch(nameParts[0].toLowerCase(), entry))
|
||||
return ThemeIcons.iconFromName(entry.icon, "");
|
||||
}
|
||||
}
|
||||
|
||||
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: appRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
// App Icon
|
||||
IconImage {
|
||||
id: appIconImage
|
||||
Layout.preferredWidth: Style.baseWidgetSize
|
||||
Layout.preferredHeight: Style.baseWidgetSize
|
||||
source: appBox.appIcon
|
||||
smooth: true
|
||||
asynchronous: true
|
||||
|
||||
// Fallback icon if image fails to load
|
||||
NIcon {
|
||||
anchors.fill: parent
|
||||
icon: "apps"
|
||||
pointSize: Style.fontSizeXL
|
||||
color: Color.mPrimary
|
||||
visible: appIconImage.status === Image.Error || appIconImage.status === Image.Null || appBox.appIcon === ""
|
||||
}
|
||||
}
|
||||
|
||||
// App Name and Volume Slider
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
|
||||
NText {
|
||||
text: appBox.appName || "Unknown App"
|
||||
pointSize: Style.fontSizeM
|
||||
color: Color.mOnSurface
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NValueSlider {
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
|
||||
value: (appBox.appVolume !== undefined) ? appBox.appVolume : 0.0
|
||||
stepSize: 0.01
|
||||
heightRatio: 0.5
|
||||
enabled: !!(appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true)
|
||||
onMoved: function (value) {
|
||||
if (appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true) {
|
||||
appBox.nodeAudio.volume = value;
|
||||
var key = AudioService.getAppKey(appBox.modelData);
|
||||
if (key)
|
||||
AudioService.setAppStreamVolume(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
text: Math.round((appBox.appVolume !== undefined ? appBox.appVolume : 0.0) * 100) + "%"
|
||||
pointSize: Style.fontSizeM
|
||||
family: Settings.data.ui.fontFixed
|
||||
color: Color.mOnSurface
|
||||
opacity: enabled ? 1.0 : 0.6
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
Layout.preferredWidth: 45 * Style.uiScaleRatio
|
||||
horizontalAlignment: Text.AlignRight
|
||||
enabled: !!(appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true)
|
||||
}
|
||||
|
||||
// Mute Button
|
||||
NIconButton {
|
||||
icon: (appBox.appMuted === true) ? "volume-mute" : "volume-high"
|
||||
tooltipText: (appBox.appMuted === true) ? I18n.tr("tooltips.unmute") : I18n.tr("tooltips.mute")
|
||||
baseSize: Style.baseWidgetSize * 0.7
|
||||
enabled: !!(appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true)
|
||||
onClicked: {
|
||||
if (appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true) {
|
||||
var newMuted = !appBox.appMuted;
|
||||
appBox.nodeAudio.muted = newMuted;
|
||||
var key = AudioService.getAppKey(appBox.modelData);
|
||||
if (key)
|
||||
AudioService.setAppStreamMuted(key, newMuted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
NText {
|
||||
visible: panelContent.appStreams.length === 0
|
||||
text: I18n.tr("panels.audio.panel-applications-empty")
|
||||
pointSize: Style.fontSizeM
|
||||
color: Color.mOnSurfaceVariant
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Style.marginXL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Devices Tab
|
||||
NScrollView {
|
||||
id: devicesScrollView
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
verticalPolicy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
reserveScrollbarSpace: false
|
||||
gradientColor: Color.mSurface
|
||||
|
||||
// AudioService Devices
|
||||
ColumnLayout {
|
||||
spacing: Style.marginM
|
||||
width: devicesScrollView.availableWidth
|
||||
|
||||
// -------------------------------
|
||||
// Output Devices
|
||||
ButtonGroup {
|
||||
id: sinks
|
||||
}
|
||||
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: outputColumn.implicitHeight + Style.margin2M
|
||||
|
||||
ColumnLayout {
|
||||
id: outputColumn
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NText {
|
||||
text: I18n.tr("panels.audio.devices-output-device-label")
|
||||
pointSize: Style.fontSizeL
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: AudioService.sinks
|
||||
NRadioButton {
|
||||
ButtonGroup.group: sinks
|
||||
required property PwNode modelData
|
||||
pointSize: Style.fontSizeS
|
||||
text: modelData.description
|
||||
checked: AudioService.sink?.id === modelData.id
|
||||
onClicked: {
|
||||
AudioService.setAudioSink(modelData);
|
||||
localOutputVolume = AudioService.volume;
|
||||
}
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// Input Devices
|
||||
ButtonGroup {
|
||||
id: sources
|
||||
}
|
||||
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: inputColumn.implicitHeight + Style.margin2M
|
||||
|
||||
ColumnLayout {
|
||||
id: inputColumn
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NText {
|
||||
text: I18n.tr("panels.audio.devices-input-device-label")
|
||||
pointSize: Style.fontSizeL
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: AudioService.sources
|
||||
NRadioButton {
|
||||
ButtonGroup.group: sources
|
||||
required property PwNode modelData
|
||||
pointSize: Style.fontSizeS
|
||||
text: modelData.description
|
||||
checked: AudioService.source?.id === modelData.id
|
||||
onClicked: AudioService.setAudioSource(modelData)
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import qs.Commons
|
||||
import qs.Modules.MainScreen
|
||||
import qs.Services.Hardware
|
||||
import qs.Services.Networking
|
||||
import qs.Services.Power
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
SmartPanel {
|
||||
id: root
|
||||
|
||||
preferredWidth: Math.round(440 * Style.uiScaleRatio)
|
||||
preferredHeight: Math.round(460 * Style.uiScaleRatio)
|
||||
|
||||
panelContent: Item {
|
||||
id: panelContent
|
||||
|
||||
property real contentPreferredHeight: mainLayout.implicitHeight + Style.margin2L
|
||||
|
||||
property var batteryWidgetInstance: BarService.lookupWidget("Battery", screen ? screen.name : null)
|
||||
readonly property var batteryWidgetSettings: batteryWidgetInstance ? batteryWidgetInstance.widgetSettings : null
|
||||
readonly property var batteryWidgetMetadata: BarWidgetRegistry.widgetMetadata["Battery"]
|
||||
readonly property bool powerProfileAvailable: PowerProfileService.available
|
||||
readonly property var powerProfiles: [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
|
||||
readonly property bool profilesAvailable: PowerProfileService.available
|
||||
property int profileIndex: profileToIndex(PowerProfileService.profile)
|
||||
readonly property bool showPowerProfiles: panelID ? panelID.showPowerProfiles : resolveWidgetSetting("showPowerProfiles", false)
|
||||
readonly property bool showNoctaliaPerformance: panelID ? panelID.showNoctaliaPerformance : resolveWidgetSetting("showNoctaliaPerformance", false)
|
||||
readonly property bool isLowBattery: BatteryService.isLowBattery
|
||||
readonly property bool isCriticalBattery: BatteryService.isCriticalBattery
|
||||
readonly property var primaryDevice: BatteryService.primaryDevice
|
||||
|
||||
function profileToIndex(p) {
|
||||
return powerProfiles.indexOf(p) ?? 1;
|
||||
}
|
||||
|
||||
function indexToProfile(idx) {
|
||||
return powerProfiles[idx] ?? PowerProfile.Balanced;
|
||||
}
|
||||
|
||||
function setProfileByIndex(idx) {
|
||||
var prof = indexToProfile(idx);
|
||||
profileIndex = idx;
|
||||
PowerProfileService.setProfile(prof);
|
||||
}
|
||||
|
||||
function resolveWidgetSetting(key, defaultValue) {
|
||||
if (batteryWidgetSettings && batteryWidgetSettings[key] !== undefined)
|
||||
return batteryWidgetSettings[key];
|
||||
if (batteryWidgetMetadata && batteryWidgetMetadata[key] !== undefined)
|
||||
return batteryWidgetMetadata[key];
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: PowerProfileService
|
||||
function onProfileChanged() {
|
||||
panelContent.profileIndex = panelContent.profileToIndex(PowerProfileService.profile);
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: BarService
|
||||
function onActiveWidgetsChanged() {
|
||||
panelContent.batteryWidgetInstance = BarService.lookupWidget("Battery", screen ? screen.name : null);
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: mainLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
// HEADER
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: headerRow.implicitHeight + Style.margin2M
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: (BatteryService.isCharging(primaryDevice) || BatteryService.isPluggedIn(primaryDevice)) ? Color.mPrimary : (BatteryService.isCriticalBattery(primaryDevice) || BatteryService.isLowBattery(primaryDevice)) ? Color.mError : Color.mOnSurface
|
||||
icon: BatteryService.getIcon(BatteryService.getPercentage(primaryDevice), BatteryService.isCharging(primaryDevice), BatteryService.isPluggedIn(primaryDevice), BatteryService.isDeviceReady(primaryDevice))
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Style.marginXXS
|
||||
Layout.fillWidth: true
|
||||
|
||||
NText {
|
||||
text: I18n.tr("common.battery")
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Charge level + health/time
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: chargeLayout.implicitHeight + Style.margin2L
|
||||
visible: BatteryService.laptopBatteries.length > 0 || BatteryService.bluetoothBatteries.length > 0
|
||||
|
||||
ColumnLayout {
|
||||
id: chargeLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginL
|
||||
|
||||
// Laptop batteries section
|
||||
Repeater {
|
||||
model: BatteryService.laptopBatteries
|
||||
delegate: ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
RowLayout {
|
||||
Item {
|
||||
id: batteryInfoItem
|
||||
implicitWidth: batteryInfoRow.implicitWidth
|
||||
implicitHeight: batteryInfoRow.implicitHeight
|
||||
|
||||
RowLayout {
|
||||
id: batteryInfoRow
|
||||
anchors.fill: parent
|
||||
|
||||
NIcon {
|
||||
icon: BatteryService.getIcon(BatteryService.getPercentage(modelData), BatteryService.isCharging(modelData), BatteryService.isPluggedIn(modelData), BatteryService.isDeviceReady(modelData))
|
||||
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
|
||||
}
|
||||
|
||||
NText {
|
||||
readonly property string dName: BatteryService.getDeviceName(modelData)
|
||||
text: dName ? dName : I18n.tr("common.battery")
|
||||
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
|
||||
pointSize: Style.fontSizeS
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onEntered: {
|
||||
if (modelData.healthSupported) {
|
||||
TooltipService.show(batteryInfoItem, `${I18n.tr("battery.battery-health")}: ${Math.round(modelData.healthPercentage)}%`);
|
||||
}
|
||||
}
|
||||
onExited: TooltipService.hide(batteryInfoItem)
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NText {
|
||||
text: BatteryService.getTimeRemainingText(modelData)
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: Math.round(8 * Style.uiScaleRatio)
|
||||
radius: Math.min(Style.radiusL, height / 2)
|
||||
color: Color.mSurface
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
width: {
|
||||
var p = BatteryService.getPercentage(modelData);
|
||||
var ratio = Math.max(0, Math.min(1, p / 100));
|
||||
return parent.width * ratio;
|
||||
}
|
||||
color: Color.mPrimary
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.preferredWidth: 40 * Style.uiScaleRatio
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: `${BatteryService.getPercentage(modelData)}%`
|
||||
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
|
||||
pointSize: Style.fontSizeS
|
||||
font.weight: Style.fontWeightBold
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
visible: BatteryService.laptopBatteries.length > 0 && BatteryService.bluetoothBatteries.length > 0
|
||||
}
|
||||
|
||||
// Other devices (Bluetooth) section
|
||||
Repeater {
|
||||
model: BatteryService.bluetoothBatteries
|
||||
delegate: ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
NIcon {
|
||||
icon: BluetoothService.getDeviceIcon(modelData)
|
||||
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
|
||||
}
|
||||
|
||||
NText {
|
||||
readonly property string dName: BatteryService.getDeviceName(modelData)
|
||||
text: dName ? dName : I18n.tr("common.bluetooth")
|
||||
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
|
||||
pointSize: Style.fontSizeS
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: Math.round(8 * Style.uiScaleRatio)
|
||||
radius: Math.min(Style.radiusL, height / 2)
|
||||
color: Color.mSurface
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
width: {
|
||||
var p = BatteryService.getPercentage(modelData);
|
||||
var ratio = Math.max(0, Math.min(1, p / 100));
|
||||
return parent.width * ratio;
|
||||
}
|
||||
color: Color.mPrimary
|
||||
}
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.preferredWidth: 40 * Style.uiScaleRatio
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: `${BatteryService.getPercentage(modelData)}%`
|
||||
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
|
||||
pointSize: Style.fontSizeS
|
||||
font.weight: Style.fontWeightBold
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
height: controlsLayout.implicitHeight + Style.margin2L
|
||||
visible: showPowerProfiles || showNoctaliaPerformance
|
||||
|
||||
ColumnLayout {
|
||||
id: controlsLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
ColumnLayout {
|
||||
visible: powerProfileAvailable && showPowerProfiles
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
NText {
|
||||
text: I18n.tr("battery.power-profile")
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NText {
|
||||
text: PowerProfileService.getName(profileIndex)
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
NValueSlider {
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: 2
|
||||
stepSize: 1
|
||||
snapAlways: true
|
||||
heightRatio: 0.5
|
||||
value: profileIndex
|
||||
enabled: profilesAvailable
|
||||
onPressedChanged: (pressed, v) => {
|
||||
if (!pressed) {
|
||||
setProfileByIndex(v);
|
||||
}
|
||||
}
|
||||
onMoved: v => {
|
||||
profileIndex = v;
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
NIcon {
|
||||
icon: "powersaver"
|
||||
pointSize: Style.fontSizeS
|
||||
color: PowerProfileService.getIcon() === "powersaver" ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: "balanced"
|
||||
pointSize: Style.fontSizeS
|
||||
color: PowerProfileService.getIcon() === "balanced" ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: "performance"
|
||||
pointSize: Style.fontSizeS
|
||||
color: PowerProfileService.getIcon() === "performance" ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
visible: showPowerProfiles && PowerProfileService.available && showNoctaliaPerformance
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
visible: showNoctaliaPerformance
|
||||
|
||||
NText {
|
||||
text: I18n.tr("toast.noctalia-performance.label")
|
||||
pointSize: Style.fontSizeM
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: PowerProfileService.noctaliaPerformanceMode ? "rocket" : "rocket-off"
|
||||
pointSize: Style.fontSizeL
|
||||
color: PowerProfileService.noctaliaPerformanceMode ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
}
|
||||
|
||||
NToggle {
|
||||
checked: PowerProfileService.noctaliaPerformanceMode
|
||||
onToggled: checked => PowerProfileService.noctaliaPerformanceMode = checked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Bluetooth
|
||||
import "../Settings/Tabs/Connections" as BluetoothPrefs
|
||||
import qs.Commons
|
||||
import qs.Modules.MainScreen
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.Hardware
|
||||
import qs.Services.Networking
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
SmartPanel {
|
||||
id: root
|
||||
|
||||
preferredWidth: Math.round(440 * Style.uiScaleRatio)
|
||||
preferredHeight: Math.round(500 * Style.uiScaleRatio)
|
||||
|
||||
panelContent: Rectangle {
|
||||
id: panelContent
|
||||
color: "transparent"
|
||||
|
||||
property real contentPreferredHeight: Math.min(root.preferredHeight, mainColumn.implicitHeight + Style.margin2L)
|
||||
|
||||
ColumnLayout {
|
||||
id: mainColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
// Header
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: headerRow.implicitHeight + Style.margin2M
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: BluetoothService.enabled ? "bluetooth" : "bluetooth-off"
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: BluetoothService.enabled ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
}
|
||||
|
||||
NLabel {
|
||||
label: I18n.tr("common.bluetooth")
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NToggle {
|
||||
id: bluetoothSwitch
|
||||
checked: BluetoothService.enabled
|
||||
enabled: !NetworkService.airplaneModeEnabled && BluetoothService.bluetoothAvailable
|
||||
onToggled: checked => BluetoothService.setBluetoothEnabled(checked)
|
||||
baseSize: Style.baseWidgetSize * 0.65
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: Settings.data.network.bluetoothAutoConnect ? "bluetooth-connected" : "bluetooth"
|
||||
tooltipText: Settings.data.network.bluetoothAutoConnect ? I18n.tr("tooltips.bluetooth-auto-connect-on") : I18n.tr("tooltips.bluetooth-auto-connect-off")
|
||||
colorFg: Settings.data.network.bluetoothAutoConnect ? Color.mPrimary : Color.mOnSurfaceVariant
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: Settings.data.network.bluetoothAutoConnect = !Settings.data.network.bluetoothAutoConnect
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "settings"
|
||||
tooltipText: I18n.tr("tooltips.open-settings")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: SettingsPanelService.openToTab(SettingsPanel.Tab.Connections, 1, screen)
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: {
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NScrollView {
|
||||
id: bluetoothScrollView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
verticalPolicy: ScrollBar.AsNeeded
|
||||
reserveScrollbarSpace: false
|
||||
gradientColor: Color.mSurface
|
||||
|
||||
ColumnLayout {
|
||||
id: devicesList
|
||||
width: bluetoothScrollView.availableWidth
|
||||
spacing: Style.marginM
|
||||
|
||||
// Adapter not available of disabled
|
||||
NBox {
|
||||
id: disabledBox
|
||||
visible: !BluetoothService.enabled
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: disabledColumn.implicitHeight + Style.margin2M
|
||||
|
||||
ColumnLayout {
|
||||
id: disabledColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginL
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: "bluetooth-off"
|
||||
pointSize: 48
|
||||
color: Color.mOnSurfaceVariant
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: I18n.tr("bluetooth.panel.disabled")
|
||||
pointSize: Style.fontSizeL
|
||||
color: Color.mOnSurfaceVariant
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: I18n.tr("bluetooth.panel.enable-message")
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state when no paired devices
|
||||
NBox {
|
||||
id: emptyBox
|
||||
visible: {
|
||||
if (!BluetoothService.enabled || !BluetoothService.devices)
|
||||
return false;
|
||||
// Pulling pairedDevices count from the source component
|
||||
return (btSource.pairedDevices.length === 0 && btSource.connectedDevices.length === 0);
|
||||
}
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: emptyColumn.implicitHeight + Style.margin2M
|
||||
|
||||
ColumnLayout {
|
||||
id: emptyColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginL
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: "bluetooth"
|
||||
pointSize: 48
|
||||
color: Color.mOnSurfaceVariant
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NText {
|
||||
text: I18n.tr("bluetooth.panel.no-devices")
|
||||
pointSize: Style.fontSizeL
|
||||
color: Color.mOnSurfaceVariant
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
NButton {
|
||||
text: I18n.tr("common.settings")
|
||||
icon: "settings"
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
onClicked: SettingsPanelService.openToTab(SettingsPanel.Tab.Connections, 1, screen)
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pull connected/paired lists from BluetoothSubTab
|
||||
BluetoothPrefs.BluetoothSubTab {
|
||||
id: btSource
|
||||
Layout.fillWidth: true
|
||||
showOnlyLists: true
|
||||
visible: !disabledBox.visible && !emptyBox.visible
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Modules.MainScreen
|
||||
import qs.Services.Compositor
|
||||
import qs.Services.Hardware
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
SmartPanel {
|
||||
id: root
|
||||
|
||||
preferredWidth: Math.round(440 * Style.uiScaleRatio)
|
||||
preferredHeight: Math.round(420 * Style.uiScaleRatio)
|
||||
|
||||
panelContent: Item {
|
||||
id: panelContent
|
||||
property real contentPreferredHeight: mainColumn.implicitHeight + Style.margin2L
|
||||
|
||||
property var brightnessWidgetInstance: BarService.lookupWidget("Brightness", screen ? screen.name : null)
|
||||
readonly property var brightnessWidgetSettings: brightnessWidgetInstance ? brightnessWidgetInstance.widgetSettings : null
|
||||
readonly property var brightnessWidgetMetadata: BarWidgetRegistry.widgetMetadata["Brightness"]
|
||||
|
||||
function resolveWidgetSetting(key, defaultValue) {
|
||||
if (brightnessWidgetSettings && brightnessWidgetSettings[key] !== undefined)
|
||||
return brightnessWidgetSettings[key];
|
||||
if (brightnessWidgetMetadata && brightnessWidgetMetadata[key] !== undefined)
|
||||
return brightnessWidgetMetadata[key];
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: BarService
|
||||
function onActiveWidgetsChanged() {
|
||||
panelContent.brightnessWidgetInstance = BarService.lookupWidget("Brightness", screen ? screen.name : null);
|
||||
}
|
||||
}
|
||||
|
||||
property real globalBrightness: 0
|
||||
property bool globalBrightnessChanging: false
|
||||
property int globalBrightnessCapableMonitors: 0
|
||||
|
||||
function getIcon(brightness) {
|
||||
return brightness <= 0.5 ? "brightness-low" : "brightness-high";
|
||||
}
|
||||
|
||||
function getControllableMonitors() {
|
||||
var monitors = BrightnessService.monitors || [];
|
||||
return monitors.filter(m => m && m.brightnessControlAvailable);
|
||||
}
|
||||
|
||||
function updateGlobalBrightness() {
|
||||
var monitors = getControllableMonitors();
|
||||
panelContent.globalBrightnessCapableMonitors = monitors.length;
|
||||
|
||||
if (panelContent.globalBrightnessChanging)
|
||||
return;
|
||||
|
||||
if (monitors.length === 0) {
|
||||
panelContent.globalBrightness = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
monitors.forEach(m => {
|
||||
var brightnessValue = isNaN(m.brightness) ? 0 : m.brightness;
|
||||
total += brightnessValue;
|
||||
});
|
||||
panelContent.globalBrightness = total / monitors.length;
|
||||
}
|
||||
|
||||
function applyGlobalBrightness(value) {
|
||||
var monitors = BrightnessService.monitors || [];
|
||||
monitors.forEach(m => {
|
||||
if (m && m.brightnessControlAvailable) {
|
||||
m.setBrightness(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: updateGlobalBrightness()
|
||||
|
||||
Connections {
|
||||
target: BrightnessService
|
||||
function onMonitorBrightnessChanged(monitor, newBrightness) {
|
||||
panelContent.updateGlobalBrightness();
|
||||
}
|
||||
function onMonitorsChanged() {
|
||||
panelContent.updateGlobalBrightness();
|
||||
}
|
||||
function onDdcMonitorsChanged() {
|
||||
panelContent.updateGlobalBrightness();
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: mainColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
// HEADER
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: headerRow.implicitHeight + Style.margin2M
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: "settings-display"
|
||||
pointSize: Style.fontSizeXXL
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: I18n.tr("panels.display.title")
|
||||
pointSize: Style.fontSizeL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnSurface
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
baseSize: Style.baseWidgetSize * 0.8
|
||||
onClicked: {
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NScrollView {
|
||||
id: brightnessScrollView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
verticalPolicy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
reserveScrollbarSpace: false
|
||||
gradientColor: Color.mSurface
|
||||
|
||||
// AudioService Devices
|
||||
ColumnLayout {
|
||||
spacing: Style.marginM
|
||||
width: brightnessScrollView.availableWidth
|
||||
|
||||
NBox {
|
||||
Layout.fillWidth: true
|
||||
visible: panelContent.globalBrightnessCapableMonitors > 1 && panelContent.resolveWidgetSetting("applyToAllMonitors", false)
|
||||
implicitHeight: globalBrightnessContent.implicitHeight + (Style.marginXL)
|
||||
|
||||
ColumnLayout {
|
||||
id: globalBrightnessContent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NLabel {
|
||||
label: I18n.tr("panels.display.monitors-global-brightness-label")
|
||||
description: I18n.tr("panels.display.monitors-global-brightness-description")
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
NIcon {
|
||||
icon: panelContent.getIcon(panelContent.globalBrightness)
|
||||
pointSize: Style.fontSizeXL
|
||||
color: Color.mOnSurface
|
||||
}
|
||||
|
||||
NValueSlider {
|
||||
id: globalBrightnessSlider
|
||||
from: 0
|
||||
to: 1
|
||||
value: panelContent.globalBrightness
|
||||
stepSize: 0.01
|
||||
enabled: panelContent.globalBrightnessCapableMonitors > 0
|
||||
onMoved: value => {
|
||||
panelContent.globalBrightness = value;
|
||||
panelContent.applyGlobalBrightness(value);
|
||||
}
|
||||
onPressedChanged: (pressed, value) => {
|
||||
panelContent.globalBrightnessChanging = pressed;
|
||||
panelContent.globalBrightness = value;
|
||||
panelContent.applyGlobalBrightness(value);
|
||||
}
|
||||
Layout.fillWidth: true
|
||||
text: ""
|
||||
}
|
||||
|
||||
NText {
|
||||
text: panelContent.globalBrightnessCapableMonitors > 0 ? Math.round(panelContent.globalBrightness * 100) + "%" : "N/A"
|
||||
Layout.preferredWidth: 55
|
||||
horizontalAlignment: Text.AlignRight
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Quickshell.screens || []
|
||||
delegate: NBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: outputColumn.implicitHeight + Style.margin2M
|
||||
|
||||
property var brightnessMonitor: BrightnessService.getMonitorForScreen(modelData)
|
||||
readonly property real compositorScale: {
|
||||
const info = CompositorService.displayScales[modelData.name];
|
||||
return (info && info.scale) ? info.scale : 1.0;
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: outputColumn
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NLabel {
|
||||
label: modelData.name || "Unknown"
|
||||
labelColor: Color.mPrimary
|
||||
description: {
|
||||
I18n.tr("system.monitor-description", {
|
||||
"model": modelData.model,
|
||||
"width": modelData.width * compositorScale,
|
||||
"height": modelData.height * compositorScale,
|
||||
"scale": compositorScale
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
NIcon {
|
||||
icon: getIcon(brightnessMonitor ? brightnessMonitor.brightness : 0)
|
||||
pointSize: Style.fontSizeXL
|
||||
color: Color.mOnSurface
|
||||
}
|
||||
|
||||
NValueSlider {
|
||||
id: brightnessSlider
|
||||
from: 0
|
||||
to: 1
|
||||
value: brightnessMonitor ? brightnessMonitor.brightness : 0.5
|
||||
stepSize: 0.01
|
||||
enabled: brightnessMonitor ? brightnessMonitor.brightnessControlAvailable : false
|
||||
onMoved: value => {
|
||||
if (brightnessMonitor && brightnessMonitor.brightnessControlAvailable) {
|
||||
brightnessMonitor.setBrightness(value);
|
||||
}
|
||||
}
|
||||
onPressedChanged: (pressed, value) => {
|
||||
if (brightnessMonitor && brightnessMonitor.brightnessControlAvailable) {
|
||||
brightnessMonitor.setBrightness(value);
|
||||
}
|
||||
}
|
||||
Layout.fillWidth: true
|
||||
text: brightnessMonitor ? Math.round(brightnessSlider.value * 100) + "%" : "N/A"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.MainScreen
|
||||
import qs.Services.Noctalia
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
SmartPanel {
|
||||
id: root
|
||||
|
||||
preferredWidth: Math.round(820 * Style.uiScaleRatio)
|
||||
preferredHeight: Math.round(620 * Style.uiScaleRatio)
|
||||
panelAnchorHorizontalCenter: true
|
||||
panelAnchorVerticalCenter: true
|
||||
|
||||
panelContent: Rectangle {
|
||||
id: panelContent
|
||||
color: Color.mSurfaceVariant
|
||||
radius: Style.radiusM
|
||||
border.color: Color.mOutline
|
||||
border.width: Style.borderS
|
||||
|
||||
readonly property string currentVersion: UpdateService.currentVersion
|
||||
readonly property string previousVersion: UpdateService.previousVersion
|
||||
readonly property bool hasPreviousVersion: previousVersion && previousVersion.length > 0
|
||||
readonly property string releaseContent: UpdateService.releaseContent || ""
|
||||
|
||||
function colorizeHeaders(text) {
|
||||
if (!text)
|
||||
return "";
|
||||
const color = Color.mPrimary;
|
||||
return text.replace(/^# (.*)$/gm, `<br/><h1 style="color:${color}">$1</h1><br/>`).replace(/^## (.*)$/gm, `<br/><h2 style="color:${color}">$1</h2><br/>`);
|
||||
}
|
||||
readonly property string subtitleText: hasPreviousVersion ? I18n.tr("changelog.panel.subtitle-updated", {
|
||||
"previousVersion": previousVersion
|
||||
}) : I18n.tr("changelog.panel.subtitle-fresh")
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginM
|
||||
|
||||
NIcon {
|
||||
icon: "sparkles"
|
||||
color: Color.mPrimary
|
||||
pointSize: Style.fontSizeXXL
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXS
|
||||
|
||||
NText {
|
||||
text: I18n.tr("changelog.panel.title", {
|
||||
"version": currentVersion || UpdateService.currentVersion
|
||||
})
|
||||
pointSize: Style.fontSizeXL
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mPrimary
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
NText {
|
||||
text: subtitleText
|
||||
color: Color.mOnSurface
|
||||
opacity: Style.opacityMedium
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NIconButton {
|
||||
icon: "close"
|
||||
tooltipText: I18n.tr("common.close")
|
||||
onClicked: root.close()
|
||||
Layout.alignment: Qt.AlignTop | Qt.AlignRight
|
||||
Layout.preferredHeight: Style.baseWidgetSize
|
||||
Layout.preferredWidth: Style.baseWidgetSize
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
clip: true
|
||||
Layout.fillWidth: true
|
||||
color: Qt.alpha(Color.mPrimary, 0.08)
|
||||
radius: Style.radiusS
|
||||
border.color: Color.mPrimary
|
||||
border.width: Style.borderS
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginM
|
||||
spacing: Style.marginS
|
||||
|
||||
NText {
|
||||
text: hasPreviousVersion ? previousVersion : I18n.tr("changelog.panel.version-new-user")
|
||||
font.weight: Style.fontWeightSemiBold
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NIcon {
|
||||
icon: "arrow-right"
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
NText {
|
||||
text: currentVersion || UpdateService.currentVersion
|
||||
font.weight: Style.fontWeightSemiBold
|
||||
color: Color.mPrimary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NScrollView {
|
||||
id: changelogScrollView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
horizontalPolicy: ScrollBar.AlwaysOff
|
||||
verticalPolicy: ScrollBar.AsNeeded
|
||||
padding: 0
|
||||
|
||||
ColumnLayout {
|
||||
width: changelogScrollView.availableWidth
|
||||
spacing: Style.marginM
|
||||
|
||||
NText {
|
||||
visible: UpdateService.fetchError !== ""
|
||||
text: UpdateService.fetchError
|
||||
color: Color.mError
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
visible: releaseContent.length > 0
|
||||
text: panelContent.colorizeHeaders(releaseContent)
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideNone
|
||||
textFormat: Text.MarkdownText
|
||||
color: Color.mOnSurface
|
||||
}
|
||||
|
||||
NText {
|
||||
visible: releaseContent.length === 0
|
||||
text: I18n.tr("changelog.panel.empty")
|
||||
color: Color.mOnSurfaceVariant
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginS
|
||||
|
||||
NButton {
|
||||
Layout.fillWidth: true
|
||||
icon: "brand-discord"
|
||||
text: I18n.tr("changelog.panel.buttons-discord")
|
||||
outlined: true
|
||||
onClicked: UpdateService.openDiscord()
|
||||
}
|
||||
|
||||
NButton {
|
||||
Layout.fillWidth: true
|
||||
visible: UpdateService.feedbackUrl !== ""
|
||||
icon: "forms"
|
||||
text: I18n.tr("changelog.panel.buttons-feedback")
|
||||
outlined: true
|
||||
onClicked: UpdateService.openFeedbackForm()
|
||||
}
|
||||
|
||||
NButton {
|
||||
Layout.fillWidth: true
|
||||
icon: "check"
|
||||
text: I18n.tr("changelog.panel.buttons-dismiss")
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onClosed: {
|
||||
if (UpdateService && UpdateService.changelogCurrentVersion) {
|
||||
UpdateService.markChangelogSeen(UpdateService.changelogCurrentVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Cards
|
||||
import qs.Modules.MainScreen
|
||||
import qs.Services.Location
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
SmartPanel {
|
||||
id: root
|
||||
|
||||
panelContent: Item {
|
||||
id: panelContent
|
||||
anchors.fill: parent
|
||||
|
||||
readonly property real contentPreferredWidth: Math.round((Settings.data.location.showWeekNumberInCalendar ? 440 : 420) * Style.uiScaleRatio)
|
||||
readonly property real contentPreferredHeight: content.implicitHeight + Style.margin2L
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
x: Style.marginL
|
||||
y: Style.marginL
|
||||
width: parent.width - Style.margin2L
|
||||
spacing: Style.marginL
|
||||
|
||||
// All clock panel cards
|
||||
Repeater {
|
||||
model: Settings.data.calendar.cards
|
||||
Loader {
|
||||
active: modelData.enabled && (modelData.id !== "weather-card" || Settings.data.location.weatherEnabled)
|
||||
visible: active
|
||||
Layout.fillWidth: true
|
||||
sourceComponent: {
|
||||
switch (modelData.id) {
|
||||
case "calendar-header-card":
|
||||
return calendarHeaderCard;
|
||||
case "calendar-month-card":
|
||||
return calendarMonthCard;
|
||||
case "weather-card":
|
||||
return weatherCard;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: calendarHeaderCard
|
||||
CalendarHeaderCard {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: calendarMonthCard
|
||||
CalendarMonthCard {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: weatherCard
|
||||
WeatherCard {
|
||||
Layout.fillWidth: true
|
||||
forecastDays: 5
|
||||
showLocation: false
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Cards
|
||||
import qs.Modules.MainScreen
|
||||
import qs.Services.Media
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
SmartPanel {
|
||||
id: root
|
||||
|
||||
// Positioning
|
||||
readonly property string controlCenterPosition: Settings.data.controlCenter.position
|
||||
|
||||
// Check if there's a bar on this screen
|
||||
readonly property bool hasBarOnScreen: {
|
||||
var monitors = Settings.data.bar.monitors || [];
|
||||
return monitors.length === 0 || monitors.includes(screen?.name);
|
||||
}
|
||||
|
||||
// When position is "close_to_bar_button" but there's no bar, fall back to center
|
||||
readonly property bool shouldCenter: controlCenterPosition === "close_to_bar_button" && !hasBarOnScreen
|
||||
|
||||
panelAnchorHorizontalCenter: shouldCenter || (controlCenterPosition !== "close_to_bar_button" && (controlCenterPosition.endsWith("_center") || controlCenterPosition === "center"))
|
||||
panelAnchorVerticalCenter: shouldCenter || controlCenterPosition === "center"
|
||||
panelAnchorLeft: !shouldCenter && controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_left")
|
||||
panelAnchorRight: !shouldCenter && controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_right")
|
||||
panelAnchorBottom: !shouldCenter && controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("bottom_")
|
||||
panelAnchorTop: !shouldCenter && controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("top_")
|
||||
|
||||
preferredWidth: Math.round(440 * Style.uiScaleRatio)
|
||||
preferredHeight: {
|
||||
var height = 0;
|
||||
var count = 0;
|
||||
for (var i = 0; i < Settings.data.controlCenter.cards.length; i++) {
|
||||
const card = Settings.data.controlCenter.cards[i];
|
||||
if (!card.enabled)
|
||||
continue;
|
||||
const contributes = (card.id !== "weather-card" || Settings.data.location.weatherEnabled);
|
||||
if (!contributes)
|
||||
continue;
|
||||
count++;
|
||||
switch (card.id) {
|
||||
case "profile-card":
|
||||
height += profileHeight;
|
||||
break;
|
||||
case "shortcuts-card":
|
||||
height += shortcutsHeight;
|
||||
break;
|
||||
case "audio-card":
|
||||
height += audioHeight;
|
||||
break;
|
||||
case "brightness-card":
|
||||
height += brightnessHeight;
|
||||
break;
|
||||
case "weather-card":
|
||||
height += weatherHeight;
|
||||
break;
|
||||
case "media-sysmon-card":
|
||||
height += mediaSysMonHeight;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return height + (count + 1) * Style.marginL;
|
||||
}
|
||||
|
||||
readonly property int profileHeight: Math.round(64 * Style.uiScaleRatio)
|
||||
readonly property int shortcutsHeight: Math.round(52 * Style.uiScaleRatio)
|
||||
readonly property int audioHeight: Math.round(60 * Style.uiScaleRatio)
|
||||
readonly property int brightnessHeight: Math.round(60 * Style.uiScaleRatio)
|
||||
readonly property int mediaSysMonHeight: Math.round(260 * Style.uiScaleRatio)
|
||||
|
||||
// We keep a dynamic weather height due to a more complex layout and font scaling
|
||||
property int weatherHeight: Math.round(210 * Style.uiScaleRatio)
|
||||
|
||||
onOpened: {
|
||||
MediaService.autoSwitchingPaused = true;
|
||||
}
|
||||
|
||||
onClosed: {
|
||||
MediaService.autoSwitchingPaused = false;
|
||||
}
|
||||
|
||||
panelContent: Item {
|
||||
id: panelContent
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
x: Style.marginL
|
||||
y: Style.marginL
|
||||
width: parent.width - Style.margin2L
|
||||
spacing: Style.marginL
|
||||
|
||||
Repeater {
|
||||
model: Settings.data.controlCenter.cards
|
||||
Loader {
|
||||
active: modelData.enabled && (modelData.id !== "weather-card" || Settings.data.location.weatherEnabled)
|
||||
visible: active
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: {
|
||||
switch (modelData.id) {
|
||||
case "profile-card":
|
||||
return profileHeight;
|
||||
case "shortcuts-card":
|
||||
return shortcutsHeight;
|
||||
case "audio-card":
|
||||
return audioHeight;
|
||||
case "brightness-card":
|
||||
return brightnessHeight;
|
||||
case "weather-card":
|
||||
return weatherHeight;
|
||||
case "media-sysmon-card":
|
||||
return mediaSysMonHeight;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
sourceComponent: {
|
||||
switch (modelData.id) {
|
||||
case "profile-card":
|
||||
return profileCard;
|
||||
case "shortcuts-card":
|
||||
return shortcutsCard;
|
||||
case "audio-card":
|
||||
return audioCard;
|
||||
case "brightness-card":
|
||||
return brightnessCard;
|
||||
case "weather-card":
|
||||
return weatherCard;
|
||||
case "media-sysmon-card":
|
||||
return mediaSysMonCard;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: profileCard
|
||||
ProfileCard {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: shortcutsCard
|
||||
ShortcutsCard {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: audioCard
|
||||
AudioCard {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: brightnessCard
|
||||
BrightnessCard {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: weatherCard
|
||||
WeatherCard {
|
||||
Component.onCompleted: {
|
||||
root.weatherHeight = this.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: mediaSysMonCard
|
||||
RowLayout {
|
||||
spacing: Style.marginL
|
||||
|
||||
// Media card
|
||||
MediaCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
// System monitors combined in one card
|
||||
SystemMonitorCard {
|
||||
Layout.preferredWidth: Math.round(Style.baseWidgetSize * 2.625)
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Noctalia
|
||||
import qs.Services.UI
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string widgetId
|
||||
required property var widgetScreen
|
||||
required property var widgetProps
|
||||
|
||||
property string section: widgetProps && widgetProps.section || ""
|
||||
property int sectionIndex: widgetProps && widgetProps.sectionWidgetIndex || 0
|
||||
|
||||
// Don't reserve space unless the loaded widget is really visible
|
||||
implicitWidth: getImplicitSize(loader.item, "implicitWidth")
|
||||
implicitHeight: getImplicitSize(loader.item, "implicitHeight")
|
||||
|
||||
function getImplicitSize(item, prop) {
|
||||
return (item && item.visible) ? item[prop] : 0;
|
||||
}
|
||||
|
||||
readonly property bool _isPlugin: ControlCenterWidgetRegistry.isPluginWidget(widgetId)
|
||||
|
||||
function _loadPluginWidget() {
|
||||
var comp = ControlCenterWidgetRegistry.getWidget(widgetId);
|
||||
if (!comp)
|
||||
return;
|
||||
var pluginId = widgetId.substring(7); // Remove "plugin:" prefix
|
||||
var api = PluginService.getPluginAPI(pluginId);
|
||||
loader.setSource(comp.url, api ? {
|
||||
"pluginApi": api
|
||||
} : {});
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: loader
|
||||
anchors.fill: parent
|
||||
asynchronous: false
|
||||
|
||||
// Core widgets use sourceComponent; plugin widgets use setSource()
|
||||
// so pluginApi is available from the first binding evaluation.
|
||||
Component.onCompleted: {
|
||||
if (root._isPlugin) {
|
||||
root._loadPluginWidget();
|
||||
} else {
|
||||
sourceComponent = Qt.binding(function () {
|
||||
return ControlCenterWidgetRegistry.getWidget(widgetId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
// Apply properties to loaded widget
|
||||
for (var prop in widgetProps) {
|
||||
if (item.hasOwnProperty(prop)) {
|
||||
item[prop] = widgetProps[prop];
|
||||
}
|
||||
}
|
||||
|
||||
// Set screen property
|
||||
if (item.hasOwnProperty("screen")) {
|
||||
item.screen = widgetScreen;
|
||||
}
|
||||
|
||||
// Call custom onLoaded if it exists
|
||||
if (item.hasOwnProperty("onLoaded")) {
|
||||
item.onLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
// Explicitly clear references
|
||||
widgetProps = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling
|
||||
Component.onCompleted: {
|
||||
if (!ControlCenterWidgetRegistry.hasWidget(widgetId)) {
|
||||
Logger.w("ControlCenterWidgetLoader", "Widget not found in registry:", widgetId);
|
||||
// Retry briefly in case the registry initializes after this component
|
||||
retryTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Retry mechanism to cope with early evaluation before registry is ready
|
||||
Timer {
|
||||
id: retryTimer
|
||||
interval: 150
|
||||
repeat: true
|
||||
running: false
|
||||
property int attempts: 0
|
||||
onTriggered: {
|
||||
attempts += 1;
|
||||
if (ControlCenterWidgetRegistry.hasWidget(widgetId)) {
|
||||
loader.sourceComponent = ControlCenterWidgetRegistry.getWidget(widgetId);
|
||||
stop();
|
||||
attempts = 0;
|
||||
return;
|
||||
}
|
||||
if (attempts >= 20) { // ~3s max
|
||||
stop();
|
||||
attempts = 0;
|
||||
Logger.w("ControlCenterWidgetLoader", "Giving up waiting for widget:", widgetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Networking
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
icon: !NetworkService.airplaneModeEnabled ? "plane-off" : "plane"
|
||||
hot: NetworkService.airplaneModeEnabled
|
||||
tooltipText: I18n.tr("toast.airplane-mode.title")
|
||||
onClicked: {
|
||||
NetworkService.setAirplaneMode(!NetworkService.airplaneModeEnabled);
|
||||
}
|
||||
enabled: NetworkService.wifiAvailable && BluetoothService.bluetoothAvailable
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Networking
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
icon: !BluetoothService.enabled ? "bluetooth-off" : ((BluetoothService.connectedDevices && BluetoothService.connectedDevices.length > 0) ? "bluetooth-connected" : "bluetooth")
|
||||
tooltipText: I18n.tr("common.bluetooth")
|
||||
onClicked: {
|
||||
var p = PanelService.getPanel("bluetoothPanel", screen);
|
||||
if (p)
|
||||
p.toggle(this);
|
||||
}
|
||||
onRightClicked: {
|
||||
if (!NetworkService.airplaneModeEnabled) {
|
||||
BluetoothService.setBluetoothEnabled(!BluetoothService.enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string widgetId: "CustomButton"
|
||||
property var widgetSettings: null
|
||||
|
||||
property string onClickedCommand: ""
|
||||
property string onRightClickedCommand: ""
|
||||
property string onMiddleClickedCommand: ""
|
||||
property string stateChecksJson: "[]" // Store state checks as JSON string
|
||||
|
||||
property string generalTooltipText: ""
|
||||
property bool enableOnStateLogic: false
|
||||
property bool showExecTooltip: true
|
||||
property bool _hasCustomTooltip: false
|
||||
|
||||
property string _currentIcon: "heart" // Default icon
|
||||
property bool _isHot: false
|
||||
property var _parsedStateChecks: [] // Local array for parsed state checks
|
||||
|
||||
property int _currentStateCheckIndex: -1
|
||||
property string _activeStateIcon: ""
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
|
||||
function _updatePropertiesFromSettings() {
|
||||
if (!widgetSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClickedCommand = widgetSettings.onClicked || "";
|
||||
onRightClickedCommand = widgetSettings.onRightClicked || "";
|
||||
onMiddleClickedCommand = widgetSettings.onMiddleClicked || "";
|
||||
stateChecksJson = widgetSettings.stateChecksJson || "[]"; // Populate from widgetSettings
|
||||
try {
|
||||
_parsedStateChecks = JSON.parse(stateChecksJson);
|
||||
} catch (e) {
|
||||
Logger.e("CustomButton", "Failed to parse stateChecksJson:", e.message);
|
||||
_parsedStateChecks = [];
|
||||
}
|
||||
generalTooltipText = widgetSettings.generalTooltipText || "";
|
||||
_hasCustomTooltip = !!(widgetSettings.generalTooltipText && widgetSettings.generalTooltipText.trim() !== "");
|
||||
enableOnStateLogic = widgetSettings.enableOnStateLogic || false;
|
||||
showExecTooltip = widgetSettings.showExecTooltip !== undefined ? widgetSettings.showExecTooltip : true;
|
||||
|
||||
updateState();
|
||||
}
|
||||
|
||||
function onWidgetSettingsChanged() {
|
||||
if (widgetSettings) {
|
||||
_updatePropertiesFromSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: stateCheckProcessExecutor
|
||||
running: false
|
||||
command: _currentStateCheckIndex !== -1 && _parsedStateChecks.length > _currentStateCheckIndex ? ["sh", "-lc", _parsedStateChecks[_currentStateCheckIndex].command] : []
|
||||
onExited: function (exitCode, stdout, stderr) {
|
||||
var currentCheckItem = _parsedStateChecks[_currentStateCheckIndex];
|
||||
var currentCommand = currentCheckItem.command;
|
||||
if (exitCode === 0) {
|
||||
// Command succeeded, this is the active state
|
||||
_isHot = true;
|
||||
_activeStateIcon = currentCheckItem.icon || widgetSettings.icon || "heart";
|
||||
} else {
|
||||
// Command failed, try next one
|
||||
_currentStateCheckIndex++;
|
||||
_checkNextState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: stateUpdateTimer
|
||||
interval: 200
|
||||
running: false
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (enableOnStateLogic && _parsedStateChecks.length > 0) {
|
||||
_currentStateCheckIndex = 0;
|
||||
_checkNextState();
|
||||
} else {
|
||||
_isHot = false;
|
||||
_activeStateIcon = widgetSettings.icon || "heart";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _checkNextState() {
|
||||
if (_currentStateCheckIndex < _parsedStateChecks.length) {
|
||||
var currentCheckItem = _parsedStateChecks[_currentStateCheckIndex];
|
||||
if (currentCheckItem && currentCheckItem.command) {
|
||||
stateCheckProcessExecutor.running = true;
|
||||
} else {
|
||||
_currentStateCheckIndex++;
|
||||
_checkNextState();
|
||||
}
|
||||
} else {
|
||||
// All checks failed
|
||||
_isHot = false;
|
||||
_activeStateIcon = widgetSettings.icon || "heart";
|
||||
}
|
||||
}
|
||||
|
||||
function updateState() {
|
||||
if (!enableOnStateLogic || _parsedStateChecks.length === 0) {
|
||||
_isHot = false;
|
||||
_activeStateIcon = widgetSettings.icon || "heart";
|
||||
return;
|
||||
}
|
||||
stateUpdateTimer.restart();
|
||||
}
|
||||
|
||||
function _buildTooltipText() {
|
||||
// Build tooltip based on settings
|
||||
let tooltip = "";
|
||||
|
||||
// If user set custom text, always show it
|
||||
// If no custom text and showExecTooltip is off, show i18n default
|
||||
if (_hasCustomTooltip) {
|
||||
tooltip = generalTooltipText;
|
||||
} else if (!showExecTooltip) {
|
||||
tooltip = I18n.tr("bar.custom-button.default-tooltip");
|
||||
}
|
||||
|
||||
// Add command details if enabled
|
||||
if (showExecTooltip) {
|
||||
if (onClickedCommand) {
|
||||
tooltip += (tooltip ? "\n" : "") + I18n.tr("bar.custom-button.left-click-label") + `: ${onClickedCommand}`;
|
||||
}
|
||||
if (onRightClickedCommand) {
|
||||
tooltip += (tooltip ? "\n" : "") + I18n.tr("bar.custom-button.right-click-label") + `: ${onRightClickedCommand}`;
|
||||
}
|
||||
if (onMiddleClickedCommand) {
|
||||
tooltip += (tooltip ? "\n" : "") + I18n.tr("bar.custom-button.middle-click-label") + `: ${onMiddleClickedCommand}`;
|
||||
}
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
NIconButtonHot {
|
||||
id: button
|
||||
icon: _activeStateIcon
|
||||
hot: _isHot
|
||||
tooltipText: _buildTooltipText()
|
||||
onClicked: {
|
||||
if (onClickedCommand) {
|
||||
Quickshell.execDetached(["sh", "-lc", onClickedCommand]);
|
||||
updateState();
|
||||
}
|
||||
}
|
||||
onRightClicked: {
|
||||
if (onRightClickedCommand) {
|
||||
Quickshell.execDetached(["sh", "-lc", onRightClickedCommand]);
|
||||
updateState();
|
||||
}
|
||||
}
|
||||
onMiddleClicked: {
|
||||
if (onMiddleClickedCommand) {
|
||||
Quickshell.execDetached(["sh", "-lc", onMiddleClickedCommand]);
|
||||
updateState();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Power
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
icon: "dark-mode"
|
||||
tooltipText: Settings.data.colorSchemes.darkMode ? I18n.tr("tooltips.switch-to-light-mode") : I18n.tr("tooltips.switch-to-dark-mode")
|
||||
onClicked: Settings.data.colorSchemes.darkMode = !Settings.data.colorSchemes.darkMode
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Power
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
icon: IdleInhibitorService.isInhibited ? "keep-awake-on" : "keep-awake-off"
|
||||
hot: IdleInhibitorService.isInhibited
|
||||
tooltipText: I18n.tr("tooltips.keep-awake")
|
||||
onClicked: IdleInhibitorService.manualToggle()
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Networking
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
icon: NetworkService.getIcon()
|
||||
tooltipText: NetworkService.getStatusText(true)
|
||||
onClicked: {
|
||||
var panel = PanelService.getPanel("networkPanel", screen);
|
||||
panel?.toggle(this);
|
||||
}
|
||||
onRightClicked: {
|
||||
if (!NetworkService.airplaneModeEnabled) {
|
||||
NetworkService.setWifiEnabled(!NetworkService.wifiEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Modules.Panels.Settings
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
enabled: ProgramCheckerService.wlsunsetAvailable
|
||||
icon: Settings.data.nightLight.enabled ? (Settings.data.nightLight.forced ? "nightlight-forced" : "nightlight-on") : "nightlight-off"
|
||||
hot: Settings.data.nightLight.enabled
|
||||
tooltipText: I18n.tr("common.night-light")
|
||||
|
||||
onClicked: {
|
||||
if (!Settings.data.nightLight.enabled) {
|
||||
Settings.data.nightLight.enabled = true;
|
||||
Settings.data.nightLight.forced = false;
|
||||
} else if (Settings.data.nightLight.enabled && !Settings.data.nightLight.forced) {
|
||||
Settings.data.nightLight.forced = true;
|
||||
} else {
|
||||
Settings.data.nightLight.enabled = false;
|
||||
Settings.data.nightLight.forced = false;
|
||||
}
|
||||
}
|
||||
|
||||
onRightClicked: {
|
||||
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
|
||||
settingsPanel.requestedTab = SettingsPanel.Tab.Display;
|
||||
settingsPanel.open();
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.Power
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
icon: PowerProfileService.noctaliaPerformanceMode ? "rocket" : "rocket-off"
|
||||
tooltipText: I18n.tr("tooltips.noctalia-performance-enabled")
|
||||
hot: PowerProfileService.noctaliaPerformanceMode
|
||||
onClicked: PowerProfileService.toggleNoctaliaPerformance()
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.System
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
icon: NotificationService.doNotDisturb ? "bell-off" : "bell"
|
||||
hot: NotificationService.doNotDisturb
|
||||
tooltipText: I18n.tr("common.notifications")
|
||||
onClicked: PanelService.getPanel("notificationHistoryPanel", screen)?.toggle(this)
|
||||
onRightClicked: NotificationService.doNotDisturb = !NotificationService.doNotDisturb
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import qs.Commons
|
||||
import qs.Services.Power
|
||||
import qs.Widgets
|
||||
|
||||
// Performance
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
readonly property bool hasPP: PowerProfileService.available
|
||||
|
||||
enabled: hasPP
|
||||
icon: PowerProfileService.getIcon()
|
||||
hot: !PowerProfileService.isDefault()
|
||||
tooltipText: I18n.tr("control-center.power-profile.tooltip-action")
|
||||
onClicked: PowerProfileService.cycleProfile()
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Services.UI
|
||||
import qs.Widgets
|
||||
|
||||
NIconButtonHot {
|
||||
property ShellScreen screen
|
||||
|
||||
enabled: Settings.data.wallpaper.enabled
|
||||
icon: "wallpaper-selector"
|
||||
tooltipText: I18n.tr("wallpaper.panel.title")
|
||||
onClicked: PanelService.getPanel("wallpaperPanel", screen)?.toggle()
|
||||
onRightClicked: WallpaperService.setRandomWallpaper()
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Modules.Dock
|
||||
import qs.Modules.MainScreen
|
||||
|
||||
SmartPanel {
|
||||
id: root
|
||||
panelBackgroundColor: Qt.alpha(Color.mSurface, Settings.data.dock.backgroundOpacity)
|
||||
|
||||
readonly property string dockPosition: Settings.data.dock.position
|
||||
readonly property bool isVertical: dockPosition === "left" || dockPosition === "right"
|
||||
readonly property bool hasBar: modelData && modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) || (Settings.data.bar.monitors.length === 0)) : false
|
||||
readonly property bool barAtSameEdge: hasBar && Settings.getBarPositionForScreen(modelData?.name) === dockPosition
|
||||
readonly property bool isFramed: Settings.data.bar.barType === "framed" && hasBar
|
||||
property bool isDockHovered: false
|
||||
property bool panelHovered: false
|
||||
readonly property int iconSize: Math.round(12 + 24 * (Settings.data.dock.size ?? 1))
|
||||
readonly property int maxWidth: screen ? screen.width * 0.8 : 1000
|
||||
readonly property int maxHeight: screen ? screen.height * 0.8 : 1000
|
||||
readonly property bool autoHide: false
|
||||
readonly property int hideDelay: 1000
|
||||
readonly property int showDelay: 100
|
||||
|
||||
// Shared state with dock content
|
||||
property bool dockHovered: false
|
||||
property bool anyAppHovered: false
|
||||
property bool menuHovered: false
|
||||
property bool hidden: false
|
||||
property bool peekHovered: false
|
||||
|
||||
// Track the currently open context menu
|
||||
property var currentContextMenu: null
|
||||
|
||||
// Combined model of running apps and pinned apps
|
||||
property var dockApps: []
|
||||
property var groupCycleIndices: ({})
|
||||
|
||||
// Track the session order of apps (transient reordering)
|
||||
property var sessionAppOrder: []
|
||||
|
||||
// Drag and Drop state for visual feedback
|
||||
property int dragSourceIndex: -1
|
||||
property int dragTargetIndex: -1
|
||||
|
||||
// Revision counter to force icon re-evaluation
|
||||
property int iconRevision: 0
|
||||
|
||||
property alias hideTimer: hideTimer
|
||||
property alias showTimer: showTimer
|
||||
|
||||
onMenuHoveredChanged: {
|
||||
if (!menuHovered && !panelHovered) {
|
||||
hoverCloseTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
onAnyAppHoveredChanged: {
|
||||
if (anyAppHovered) {
|
||||
hoverCloseTimer.stop();
|
||||
} else if (!panelHovered && !menuHovered && !dockHovered && !isDockHovered) {
|
||||
hoverCloseTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
onClosed: {
|
||||
hoverCloseTimer.stop();
|
||||
isDockHovered = false;
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
// Don't auto-start close timer here — the peek zone's onExited in Dock.qml
|
||||
// starts hideTimer which checks panel.isDockHovered, giving the user time
|
||||
// to move into the panel without it closing prematurely.
|
||||
}
|
||||
|
||||
panelAnchorTop: dockPosition === "top"
|
||||
panelAnchorBottom: dockPosition === "bottom"
|
||||
panelAnchorLeft: dockPosition === "left"
|
||||
panelAnchorRight: dockPosition === "right"
|
||||
panelAnchorHorizontalCenter: !isVertical
|
||||
panelAnchorVerticalCenter: isVertical
|
||||
|
||||
forceAttachToBar: hasBar
|
||||
exclusiveKeyboard: false
|
||||
|
||||
// when dragging ended but the cursor is outside the dock area, restart the timer
|
||||
onDragSourceIndexChanged: {
|
||||
if (dragSourceIndex === -1) {
|
||||
if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) {
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to close any open context menu
|
||||
function closeAllContextMenus() {
|
||||
if (currentContextMenu && currentContextMenu.visible) {
|
||||
currentContextMenu.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function getAppKey(appData) {
|
||||
if (!appData)
|
||||
return null;
|
||||
|
||||
if (Settings.data.dock.groupApps) {
|
||||
return appData.appId;
|
||||
}
|
||||
|
||||
// Use stable appId for pinned apps to maintain their slot regardless of running state
|
||||
if (appData.type === "pinned" || appData.type === "pinned-running") {
|
||||
return appData.appId;
|
||||
}
|
||||
|
||||
// prefer toplevel object identity for unpinned running apps to distinguish instances
|
||||
if (appData.toplevel)
|
||||
return appData.toplevel;
|
||||
|
||||
// fallback to appId
|
||||
return appData.appId;
|
||||
}
|
||||
|
||||
function sortDockApps(apps) {
|
||||
if (!sessionAppOrder || sessionAppOrder.length === 0) {
|
||||
return apps;
|
||||
}
|
||||
|
||||
const sorted = [];
|
||||
const remaining = [...apps];
|
||||
|
||||
// Pick apps that are in the session order
|
||||
for (let i = 0; i < sessionAppOrder.length; i++) {
|
||||
const key = sessionAppOrder[i];
|
||||
|
||||
// Pick ALL matching apps (e.g. all instances of a pinned app)
|
||||
while (true) {
|
||||
const idx = remaining.findIndex(app => getAppKey(app) === key);
|
||||
if (idx !== -1) {
|
||||
sorted.push(remaining[idx]);
|
||||
remaining.splice(idx, 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append any new/remaining apps
|
||||
remaining.forEach(app => sorted.push(app));
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function reorderApps(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= dockApps.length || toIndex >= dockApps.length)
|
||||
return;
|
||||
|
||||
const list = [...dockApps];
|
||||
const item = list.splice(fromIndex, 1)[0];
|
||||
list.splice(toIndex, 0, item);
|
||||
|
||||
dockApps = list;
|
||||
sessionAppOrder = dockApps.map(getAppKey);
|
||||
savePinnedOrder();
|
||||
}
|
||||
|
||||
function savePinnedOrder() {
|
||||
const currentPinned = Settings.data.dock.pinnedApps || [];
|
||||
const newPinned = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Extract pinned apps in their current visual order
|
||||
dockApps.forEach(app => {
|
||||
if (app.appId && !seen.has(app.appId)) {
|
||||
const isPinned = currentPinned.some(p => normalizeAppId(p) === normalizeAppId(app.appId));
|
||||
|
||||
if (isPinned) {
|
||||
newPinned.push(app.appId);
|
||||
seen.add(app.appId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check if any pinned apps were missed (unlikely if dockApps is correct)
|
||||
currentPinned.forEach(p => {
|
||||
if (!seen.has(p)) {
|
||||
newPinned.push(p);
|
||||
seen.add(p);
|
||||
}
|
||||
});
|
||||
|
||||
if (JSON.stringify(currentPinned) !== JSON.stringify(newPinned)) {
|
||||
Settings.data.dock.pinnedApps = newPinned;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to normalize app IDs for case-insensitive matching
|
||||
function normalizeAppId(appId) {
|
||||
if (!appId || typeof appId !== 'string')
|
||||
return "";
|
||||
let id = appId.toLowerCase().trim();
|
||||
if (id.endsWith(".desktop"))
|
||||
id = id.substring(0, id.length - 8);
|
||||
return id;
|
||||
}
|
||||
|
||||
// Helper function to check if an app ID matches a pinned app (case-insensitive)
|
||||
function isAppIdPinned(appId, pinnedApps) {
|
||||
if (!appId || !pinnedApps || pinnedApps.length === 0)
|
||||
return false;
|
||||
const normalizedId = normalizeAppId(appId);
|
||||
// Direct match
|
||||
if (pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId))
|
||||
return true;
|
||||
// Resolve via desktop entry lookup (handles StartupWMClass != .desktop filename)
|
||||
const resolved = resolveToDesktopEntryId(appId);
|
||||
if (resolved !== appId) {
|
||||
const normalizedResolved = normalizeAppId(resolved);
|
||||
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedResolved);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Desktop entry ID resolution cache (cleared when DesktopEntries change)
|
||||
property var _desktopEntryIdCache: ({})
|
||||
|
||||
// Resolve a toplevel appId to its canonical .desktop entry ID via heuristic lookup.
|
||||
function resolveToDesktopEntryId(appId) {
|
||||
if (!appId)
|
||||
return appId;
|
||||
if (_desktopEntryIdCache.hasOwnProperty(appId))
|
||||
return _desktopEntryIdCache[appId];
|
||||
try {
|
||||
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
|
||||
const entry = DesktopEntries.heuristicLookup(appId);
|
||||
if (entry && entry.id) {
|
||||
_desktopEntryIdCache[appId] = entry.id;
|
||||
return entry.id;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
_desktopEntryIdCache[appId] = appId;
|
||||
return appId;
|
||||
}
|
||||
|
||||
// Helper function to get app name from desktop entry
|
||||
function getAppNameFromDesktopEntry(appId) {
|
||||
if (!appId)
|
||||
return appId;
|
||||
|
||||
try {
|
||||
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
|
||||
const entry = DesktopEntries.heuristicLookup(appId);
|
||||
if (entry && entry.name) {
|
||||
return entry.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) {
|
||||
const entry = DesktopEntries.byId(appId);
|
||||
if (entry && entry.name) {
|
||||
return entry.name;
|
||||
}
|
||||
}
|
||||
} catch (e)
|
||||
// Fall through to return original appId
|
||||
{}
|
||||
|
||||
// Return original appId if we can't find a desktop entry
|
||||
return appId;
|
||||
}
|
||||
|
||||
function getToplevelsForEntry(appData) {
|
||||
if (!appData)
|
||||
return [];
|
||||
|
||||
if (appData.toplevels && appData.toplevels.length > 0) {
|
||||
return appData.toplevels.filter(toplevel => toplevel && (!Settings.data.dock.onlySameOutput || !toplevel.screens || toplevel.screens.includes(screen)));
|
||||
}
|
||||
|
||||
if (!appData.toplevel)
|
||||
return [];
|
||||
|
||||
if (Settings.data.dock.onlySameOutput && appData.toplevel.screens && !appData.toplevel.screens.includes(screen))
|
||||
return [];
|
||||
|
||||
return [appData.toplevel];
|
||||
}
|
||||
|
||||
function getPrimaryToplevelForEntry(appData) {
|
||||
const toplevels = getToplevelsForEntry(appData);
|
||||
if (toplevels.length === 0)
|
||||
return null;
|
||||
|
||||
if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel))
|
||||
return ToplevelManager.activeToplevel;
|
||||
|
||||
return toplevels[0];
|
||||
}
|
||||
|
||||
// Build grouped render model without mutating the raw toplevel list.
|
||||
function buildGroupedDockApps(apps) {
|
||||
if (!Settings.data.dock.groupApps) {
|
||||
return apps.map(app => {
|
||||
const entry = Object.assign({}, app);
|
||||
entry.toplevels = getToplevelsForEntry(app);
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
const grouped = [];
|
||||
const groupedById = new Map();
|
||||
|
||||
apps.forEach(app => {
|
||||
const appId = app.appId;
|
||||
const toplevels = getToplevelsForEntry(app);
|
||||
const existing = groupedById.get(appId);
|
||||
|
||||
if (existing) {
|
||||
toplevels.forEach(toplevel => {
|
||||
if (!existing.toplevels.includes(toplevel)) {
|
||||
existing.toplevels.push(toplevel);
|
||||
}
|
||||
});
|
||||
if (app.type === "pinned" || app.type === "pinned-running") {
|
||||
existing.isPinned = true;
|
||||
}
|
||||
} else {
|
||||
const entry = {
|
||||
"type": app.type,
|
||||
"appId": appId,
|
||||
"title": app.title,
|
||||
"toplevels": toplevels.slice(),
|
||||
"isPinned": app.type === "pinned" || app.type === "pinned-running"
|
||||
};
|
||||
grouped.push(entry);
|
||||
groupedById.set(appId, entry);
|
||||
}
|
||||
});
|
||||
|
||||
grouped.forEach(entry => {
|
||||
entry.toplevel = getPrimaryToplevelForEntry(entry);
|
||||
if (entry.toplevels.length > 0 && entry.isPinned) {
|
||||
entry.type = "pinned-running";
|
||||
} else if (entry.toplevels.length > 0) {
|
||||
entry.type = "running";
|
||||
} else {
|
||||
entry.type = "pinned";
|
||||
}
|
||||
if (entry.toplevel && entry.toplevel.title && entry.toplevel.title.trim() !== "") {
|
||||
entry.title = entry.toplevel.title;
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// Function to update the combined dock apps model
|
||||
function updateDockApps() {
|
||||
const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : [];
|
||||
const pinnedApps = Settings.data.dock.pinnedApps || [];
|
||||
const combined = [];
|
||||
const processedToplevels = new Set();
|
||||
const processedPinnedAppIds = new Set();
|
||||
|
||||
// push an app onto combined with the given appType
|
||||
function pushApp(appType, toplevel, appId, title) {
|
||||
// Use canonical ID for pinned apps to ensure key stability
|
||||
const canonicalId = isAppIdPinned(appId, pinnedApps) ? (pinnedApps.find(p => normalizeAppId(p) === normalizeAppId(appId)) || appId) : appId;
|
||||
|
||||
// For running apps, track by toplevel object to allow multiple instances
|
||||
if (toplevel) {
|
||||
if (processedToplevels.has(toplevel)) {
|
||||
return;
|
||||
}
|
||||
if (Settings.data.dock.onlySameOutput && toplevel.screens && !toplevel.screens.includes(screen)) {
|
||||
return;
|
||||
}
|
||||
combined.push({
|
||||
"type": appType,
|
||||
"toplevel": toplevel,
|
||||
"toplevels": toplevel ? [toplevel] : [],
|
||||
"appId": canonicalId,
|
||||
"title": title
|
||||
});
|
||||
processedToplevels.add(toplevel);
|
||||
} else {
|
||||
// For pinned apps that aren't running, track by appId to avoid duplicates
|
||||
if (processedPinnedAppIds.has(canonicalId)) {
|
||||
return;
|
||||
}
|
||||
combined.push({
|
||||
"type": appType,
|
||||
"toplevel": toplevel,
|
||||
"toplevels": [],
|
||||
"appId": canonicalId,
|
||||
"title": title
|
||||
});
|
||||
processedPinnedAppIds.add(canonicalId);
|
||||
}
|
||||
}
|
||||
|
||||
function pushRunning(first) {
|
||||
runningApps.forEach(toplevel => {
|
||||
if (toplevel) {
|
||||
// Use robust matching to check if pinned
|
||||
const isPinned = isAppIdPinned(toplevel.appId, pinnedApps);
|
||||
if (!first && isPinned && processedToplevels.has(toplevel)) {
|
||||
return; // Already added by pushPinned()
|
||||
}
|
||||
pushApp((first && isPinned) ? "pinned-running" : "running", toplevel, toplevel.appId, toplevel.title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushPinned() {
|
||||
pinnedApps.forEach(pinnedAppId => {
|
||||
// Find all running instances of this pinned app using robust matching
|
||||
// Also resolve toplevel appId via desktop entry lookup to handle
|
||||
// StartupWMClass != .desktop filename (e.g. zen -> zen-browser-bin)
|
||||
const normalizedPinned = normalizeAppId(pinnedAppId);
|
||||
const matchingToplevels = runningApps.filter(app => {
|
||||
if (!app)
|
||||
return false;
|
||||
if (normalizeAppId(app.appId) === normalizedPinned)
|
||||
return true;
|
||||
const resolved = resolveToDesktopEntryId(app.appId);
|
||||
return resolved !== app.appId && normalizeAppId(resolved) === normalizedPinned;
|
||||
});
|
||||
|
||||
if (matchingToplevels.length > 0) {
|
||||
// Add all running instances as pinned-running
|
||||
matchingToplevels.forEach(toplevel => {
|
||||
pushApp("pinned-running", toplevel, pinnedAppId, toplevel.title);
|
||||
});
|
||||
} else {
|
||||
// App is pinned but not running - add once
|
||||
pushApp("pinned", null, pinnedAppId, getAppNameFromDesktopEntry(pinnedAppId) || pinnedAppId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// if pinnedStatic then push all pinned and then all remaining running apps
|
||||
if (Settings.data.dock.pinnedStatic) {
|
||||
pushPinned();
|
||||
pushRunning(false);
|
||||
|
||||
// else add all running apps and then remaining pinned apps
|
||||
} else {
|
||||
pushRunning(true);
|
||||
pushPinned();
|
||||
}
|
||||
|
||||
const sortedApps = sortDockApps(combined);
|
||||
dockApps = buildGroupedDockApps(sortedApps);
|
||||
const cycleState = root.groupCycleIndices || {};
|
||||
const nextCycleState = {};
|
||||
dockApps.forEach(app => {
|
||||
if (app && app.appId && cycleState[app.appId] !== undefined) {
|
||||
nextCycleState[app.appId] = cycleState[app.appId];
|
||||
}
|
||||
});
|
||||
root.groupCycleIndices = nextCycleState;
|
||||
|
||||
// Sync session order if needed
|
||||
if (!sessionAppOrder || sessionAppOrder.length === 0) {
|
||||
sessionAppOrder = dockApps.map(getAppKey);
|
||||
} else {
|
||||
const currentKeys = new Set(dockApps.map(getAppKey));
|
||||
const existingKeys = new Set();
|
||||
const newOrder = [];
|
||||
|
||||
// Keep existing keys that are still present
|
||||
sessionAppOrder.forEach(key => {
|
||||
if (currentKeys.has(key)) {
|
||||
newOrder.push(key);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new keys at the end
|
||||
dockApps.forEach(app => {
|
||||
const key = getAppKey(app);
|
||||
if (!existingKeys.has(key)) {
|
||||
newOrder.push(key);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (JSON.stringify(newOrder) !== JSON.stringify(sessionAppOrder)) {
|
||||
sessionAppOrder = newOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update dock apps when toplevels change
|
||||
Connections {
|
||||
target: ToplevelManager ? ToplevelManager.toplevels : null
|
||||
function onValuesChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
}
|
||||
|
||||
// Update dock apps when pinned apps change
|
||||
Connections {
|
||||
target: Settings.data.dock
|
||||
function onPinnedAppsChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
function onOnlySameOutputChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
function onGroupAppsChanged() {
|
||||
updateDockApps();
|
||||
}
|
||||
}
|
||||
|
||||
// Initial update when component is ready
|
||||
Component.onCompleted: {
|
||||
if (ToplevelManager) {
|
||||
updateDockApps();
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh icons when DesktopEntries becomes available
|
||||
Connections {
|
||||
target: DesktopEntries.applications
|
||||
function onValuesChanged() {
|
||||
root.iconRevision++;
|
||||
root._desktopEntryIdCache = {};
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: hideDelay
|
||||
onTriggered: {}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: showTimer
|
||||
interval: showDelay
|
||||
onTriggered: {}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hoverCloseTimer
|
||||
interval: hideDelay
|
||||
onTriggered: {
|
||||
if (root.dockHovered || root.isDockHovered || root.anyAppHovered || root.menuHovered || (root.currentContextMenu && root.currentContextMenu.visible)) {
|
||||
restart();
|
||||
return;
|
||||
}
|
||||
root.isDockHovered = false;
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
|
||||
panelContent: Item {
|
||||
id: panelContent
|
||||
|
||||
property bool allowAttach: true
|
||||
property real frameThickness: isFramed && !barAtSameEdge && !Settings.data.dock.sitOnFrame ? Settings.data.bar.frameThickness : 0
|
||||
property real contentPreferredWidth: Math.round(dockContainerWrapper.width) - (isVertical ? frameThickness : 0)
|
||||
property real contentPreferredHeight: Math.round(dockContainerWrapper.height) - (!isVertical ? frameThickness : 0)
|
||||
|
||||
Item {
|
||||
id: hoverArea
|
||||
anchors.fill: dockContainerWrapper
|
||||
anchors.margins: -frameThickness
|
||||
|
||||
// Detect hover over dock area including frame thickness
|
||||
HoverHandler {
|
||||
id: dockHoverArea
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onHoveredChanged: {
|
||||
root.panelHovered = hovered;
|
||||
if (hovered) {
|
||||
root.isDockHovered = true;
|
||||
hoverCloseTimer.stop();
|
||||
} else {
|
||||
root.isDockHovered = false;
|
||||
if (root.menuHovered || (root.currentContextMenu && root.currentContextMenu.visible)) {
|
||||
hoverCloseTimer.stop();
|
||||
} else {
|
||||
hoverCloseTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: dockContainerWrapper
|
||||
readonly property real frameThickness: isFramed ? Settings.data.bar.frameThickness : 0
|
||||
width: dockContent.dockContainer.width
|
||||
height: dockContent.dockContainer.height
|
||||
anchors.top: root.dockPosition === "bottom" ? parent.top : undefined
|
||||
anchors.bottom: root.dockPosition === "top" ? parent.bottom : undefined
|
||||
anchors.left: root.dockPosition === "right" ? parent.left : undefined
|
||||
anchors.right: root.dockPosition === "left" ? parent.right : undefined
|
||||
|
||||
DockContent {
|
||||
id: dockContent
|
||||
anchors.fill: parent
|
||||
dockRoot: root
|
||||
extraTop: 0
|
||||
extraBottom: 0
|
||||
extraLeft: 0
|
||||
extraRight: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Services.Keyboard
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: previewPanel
|
||||
|
||||
property var currentItem: null
|
||||
property string fullContent: ""
|
||||
property string imageDataUrl: ""
|
||||
property bool loadingFullContent: false
|
||||
property bool isImageContent: false
|
||||
|
||||
implicitHeight: contentArea.implicitHeight + Style.margin2L
|
||||
|
||||
function loadContent() {
|
||||
if (!currentItem || !currentItem.clipboardId)
|
||||
return;
|
||||
|
||||
if (isImageContent) {
|
||||
// For images, check cache first then decode
|
||||
imageDataUrl = ClipboardService.getImageData(currentItem.clipboardId) || "";
|
||||
loadingFullContent = !imageDataUrl;
|
||||
if (!imageDataUrl && currentItem.mime) {
|
||||
ClipboardService.decodeToDataUrl(currentItem.clipboardId, currentItem.mime, null);
|
||||
}
|
||||
} else {
|
||||
// For text, check sync cache first
|
||||
const cached = ClipboardService.getContent(currentItem.clipboardId);
|
||||
if (cached) {
|
||||
fullContent = cached;
|
||||
loadingFullContent = false;
|
||||
} else {
|
||||
// Show preview while loading full content
|
||||
fullContent = currentItem.preview || "";
|
||||
loadingFullContent = true;
|
||||
// Async decode as fallback
|
||||
var requestedId = currentItem.clipboardId;
|
||||
ClipboardService.decode(requestedId, function (content) {
|
||||
if (!previewPanel || !previewPanel.currentItem) {
|
||||
return;
|
||||
}
|
||||
if (previewPanel.currentItem.clipboardId === requestedId) {
|
||||
var trimmed = content ? content.trim() : "";
|
||||
if (trimmed !== "") {
|
||||
previewPanel.fullContent = trimmed;
|
||||
}
|
||||
previewPanel.loadingFullContent = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: previewPanel
|
||||
function onCurrentItemChanged() {
|
||||
fullContent = "";
|
||||
imageDataUrl = "";
|
||||
loadingFullContent = false;
|
||||
isImageContent = currentItem && currentItem.isImage;
|
||||
|
||||
if (currentItem && currentItem.clipboardId) {
|
||||
loadContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property int _rev: ClipboardService.revision
|
||||
on_RevChanged: {
|
||||
// When cache updates, try to load content if we're still showing loading or preview
|
||||
if (currentItem && currentItem.clipboardId && !isImageContent && loadingFullContent) {
|
||||
const cached = ClipboardService.getContent(currentItem.clipboardId);
|
||||
if (cached) {
|
||||
fullContent = cached;
|
||||
loadingFullContent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: imageUpdateTimer
|
||||
interval: 200
|
||||
running: currentItem && currentItem.isImage && imageDataUrl === ""
|
||||
repeat: currentItem && currentItem.isImage && imageDataUrl === ""
|
||||
|
||||
onTriggered: {
|
||||
if (currentItem && currentItem.clipboardId) {
|
||||
const newData = ClipboardService.getImageData(currentItem.clipboardId) || "";
|
||||
if (newData !== imageDataUrl) {
|
||||
imageDataUrl = newData;
|
||||
if (newData) {
|
||||
loadingFullContent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: contentArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: loadingFullContent
|
||||
visible: loadingFullContent
|
||||
width: Style.baseWidgetSize
|
||||
height: width
|
||||
}
|
||||
|
||||
// Preview for image entry
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
spacing: Style.marginS
|
||||
visible: isImageContent && !loadingFullContent && imageDataUrl !== ""
|
||||
|
||||
NImageRounded {
|
||||
id: previewImage
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
radius: Style.marginS
|
||||
imagePath: imageDataUrl
|
||||
imageFillMode: Image.PreserveAspectFit
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
Layout.bottomMargin: Style.marginS
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
const meta = ClipboardService.parseImageMeta(currentItem?.preview);
|
||||
if (meta)
|
||||
return `${meta.fmt} • ${meta.w}×${meta.h} • ${meta.size}`;
|
||||
// Fallback to basic info
|
||||
const format = (currentItem?.mime || "image").split("/")[1]?.toUpperCase() || "Image";
|
||||
return `${format} • ${previewImage.implicitWidth}×${previewImage.implicitHeight}`;
|
||||
}
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
// Preview for text entry
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginS
|
||||
spacing: Style.marginS
|
||||
visible: !isImageContent && !loadingFullContent
|
||||
|
||||
NScrollView {
|
||||
id: clipboardScrollView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
horizontalPolicy: Settings.data.appLauncher.clipboardWrapText ? ScrollBar.AlwaysOff : ScrollBar.AsNeeded
|
||||
|
||||
NText {
|
||||
text: fullContent
|
||||
width: Settings.data.appLauncher.clipboardWrapText ? clipboardScrollView.availableWidth : implicitWidth
|
||||
wrapMode: Settings.data.appLauncher.clipboardWrapText ? Text.Wrap : Text.NoWrap
|
||||
textFormat: Text.PlainText
|
||||
font.pointSize: Style.fontSizeM
|
||||
font.family: Settings.data.ui.fontFixed
|
||||
color: Color.mOnSurface
|
||||
}
|
||||
}
|
||||
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
Layout.bottomMargin: Style.marginS
|
||||
}
|
||||
|
||||
NText {
|
||||
Layout.fillWidth: true
|
||||
visible: fullContent.length > 0
|
||||
text: {
|
||||
const chars = fullContent.length;
|
||||
const words = fullContent.split(/\s+/).filter(w => w.length > 0).length;
|
||||
const lines = fullContent.split('\n').length;
|
||||
return `${chars} chars, ${words} words, ${lines} lines`;
|
||||
}
|
||||
pointSize: Style.fontSizeS
|
||||
color: Color.mOnSurfaceVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user