This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").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
}
}
}
}
}
}
}
@@ -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;
}
}
}
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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();
}
}
}
}
}
@@ -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
}
}
}
}
}
}
@@ -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);
}
}
}
}
@@ -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
}
}
}
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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
}
}
}
}
}
@@ -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);
}
}
}
}
@@ -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
}
@@ -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);
}
}
}
@@ -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();
}
}
}
}
@@ -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
}
@@ -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()
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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()
}
@@ -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
}
@@ -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()
}
@@ -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