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,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