add noctalia and fuzzel

This commit is contained in:
2026-05-23 21:20:58 +02:00
parent 364801f1a3
commit 9a3eceb3ab
599 changed files with 204318 additions and 0 deletions
@@ -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
}
}