import QtQuick import QtQuick.Effects import QtQuick.Layouts import Quickshell import Quickshell.Wayland import qs.Commons import qs.Services.Hardware import qs.Services.Keyboard import qs.Services.Media import qs.Widgets // Unified OSD component that displays volume, input volume, and brightness changes Variants { id: osd // Do not change the order or it will break settings. enum Type { Volume, InputVolume, Brightness, LockKey } model: Quickshell.screens.filter(screen => (Settings.data.osd.monitors.includes(screen.name) || Settings.data.osd.monitors.length === 0) && Settings.data.osd.enabled) delegate: Loader { id: root required property ShellScreen modelData active: false // OSD State property int currentOSDType: -1 // OSD.Type enum value, -1 means none property bool startupComplete: false property real currentBrightness: 0 // Lock Key States property string lastLockKeyChanged: "" // "caps", "num", "scroll", or "" // Current values (computed properties) readonly property real currentVolume: AudioService.volume readonly property bool isMuted: AudioService.muted readonly property real currentInputVolume: AudioService.inputVolume readonly property bool isInputMuted: AudioService.inputMuted readonly property real epsilon: 0.005 // Helper Functions function getIcon() { switch (currentOSDType) { case OSD.Type.Volume: if (isMuted) return "volume-mute"; // Show volume-x icon when volume is effectively 0% (within rounding threshold) if (currentVolume < root.epsilon) return "volume-x"; return currentVolume <= 0.5 ? "volume-low" : "volume-high"; case OSD.Type.InputVolume: return isInputMuted ? "microphone-off" : "microphone"; case OSD.Type.Brightness: // Show sun-off icon when brightness is effectively 0% (within rounding threshold) if (currentBrightness < root.epsilon) return "sun-off"; return currentBrightness <= 0.5 ? "brightness-low" : "brightness-high"; case OSD.Type.LockKey: return "keyboard"; default: return ""; } } function getCurrentValue() { switch (currentOSDType) { case OSD.Type.Volume: return isMuted ? 0 : currentVolume; case OSD.Type.InputVolume: return isInputMuted ? 0 : currentInputVolume; case OSD.Type.Brightness: return currentBrightness; case OSD.Type.LockKey: return 1.0; // Always show 100% when showing lock key status default: return 0; } } function getMaxValue() { if (currentOSDType === OSD.Type.Volume || currentOSDType === OSD.Type.InputVolume) { return Settings.data.audio.volumeOverdrive ? 1.5 : 1.0; } return 1.0; } function getDisplayPercentage() { if (currentOSDType === OSD.Type.LockKey) { // For lock keys, return the pre-determined status text return lastLockKeyChanged; } const value = getCurrentValue(); const max = getMaxValue(); if ((currentOSDType === OSD.Type.Volume || currentOSDType === OSD.Type.InputVolume) && Settings.data.audio.volumeOverdrive) { const pct = Math.round(value * 100); return pct + "%"; } const pct = Math.round(Math.min(max, value) * 100); return pct + "%"; } function getProgressColor() { const isMutedState = (currentOSDType === OSD.Type.Volume && isMuted) || (currentOSDType === OSD.Type.InputVolume && isInputMuted); if (isMutedState) { return Color.mError; } // When volumeOverdrive is enabled, show error color if volume is above 100% if ((currentOSDType === OSD.Type.Volume || currentOSDType === OSD.Type.InputVolume) && Settings.data.audio.volumeOverdrive) { const value = getCurrentValue(); if (value > 1.0) { return Color.mError; } } // For lock keys, use a different color to indicate the lock state if (currentOSDType === OSD.Type.LockKey) { // Check the specific lock key that was changed if (lastLockKeyChanged.startsWith("CAPS")) { return LockKeysService.capsLockOn ? Color.mPrimary : Color.mOnSurfaceVariant; } else if (lastLockKeyChanged.startsWith("NUM")) { return LockKeysService.numLockOn ? Color.mPrimary : Color.mOnSurfaceVariant; } else if (lastLockKeyChanged.startsWith("SCROLL")) { return LockKeysService.scrollLockOn ? Color.mPrimary : Color.mOnSurfaceVariant; } } return Color.mPrimary; } function getIconColor() { const isMutedState = (currentOSDType === OSD.Type.Volume && isMuted) || (currentOSDType === OSD.Type.InputVolume && isInputMuted); if (isMutedState) return Color.mError; if (currentOSDType === OSD.Type.LockKey) { // Check the specific lock key that was changed if (lastLockKeyChanged.startsWith("CAPS")) { return LockKeysService.capsLockOn ? Color.mPrimary : Color.mOnSurfaceVariant; } else if (lastLockKeyChanged.startsWith("NUM")) { return LockKeysService.numLockOn ? Color.mPrimary : Color.mOnSurfaceVariant; } else if (lastLockKeyChanged.startsWith("SCROLL")) { return LockKeysService.scrollLockOn ? Color.mPrimary : Color.mOnSurfaceVariant; } } return Color.mOnSurface; } // Brightness Handling function connectBrightnessMonitors() { for (var i = 0; i < BrightnessService.monitors.length; i++) { const monitor = BrightnessService.monitors[i]; monitor.brightnessUpdated.disconnect(onBrightnessChanged); monitor.brightnessUpdated.connect(onBrightnessChanged); } } function onBrightnessChanged(newBrightness) { currentBrightness = newBrightness; showOSD(OSD.Type.Brightness); } // Check if a specific OSD type is enabled function isTypeEnabled(type) { const enabledTypes = Settings.data.osd.enabledTypes || []; // If enabledTypes is empty, all types are enabled (backwards compatibility) if (enabledTypes.length === 0) return true; return enabledTypes.includes(type); } // OSD Display Control function showOSD(type) { // Ignore all OSD requests during startup period if (!startupComplete) return; // Check if this OSD type is enabled if (!isTypeEnabled(type)) return; currentOSDType = type; if (!root.active) { root.active = true; } if (root.item) { root.item.showOSD(); } else { Qt.callLater(() => { if (root.item) root.item.showOSD(); }); } } function hideOSD() { if (root.item?.osdItem) { root.item.osdItem.hideImmediately(); } else if (root.active) { root.active = false; } } // Signal Connections // AudioService monitoring Connections { target: AudioService function onVolumeChanged() { showOSD(OSD.Type.Volume); } function onMutedChanged() { if (AudioService.consumeOutputOSDSuppression()) return; showOSD(OSD.Type.Volume); } function onInputVolumeChanged() { if (AudioService.hasInput) showOSD(OSD.Type.InputVolume); } function onInputMutedChanged() { if (!AudioService.hasInput) return; if (AudioService.consumeInputOSDSuppression()) return; showOSD(OSD.Type.InputVolume); } // Refresh OSD when device changes to ensure correct volume is displayed function onSinkChanged() { // If volume OSD is currently showing, refresh it to show new device's volume if (root.currentOSDType === OSD.Type.Volume) { Qt.callLater(() => { showOSD(OSD.Type.Volume); }); } } function onSourceChanged() { // If input volume OSD is currently showing, refresh it to show new device's volume if (root.currentOSDType === OSD.Type.InputVolume) { Qt.callLater(() => { showOSD(OSD.Type.InputVolume); }); } } } // Brightness monitoring Connections { target: BrightnessService function onMonitorsChanged() { connectBrightnessMonitors(); } } // LockKeys monitoring with a cleaner approach Connections { target: LockKeysService function onCapsLockChanged(active) { root.lastLockKeyChanged = active ? "CAPS ON" : "CAPS OFF"; root.showOSD(OSD.Type.LockKey); } function onNumLockChanged(active) { root.lastLockKeyChanged = active ? "NUM ON" : "NUM OFF"; root.showOSD(OSD.Type.LockKey); } function onScrollLockChanged(active) { root.lastLockKeyChanged = active ? "SCROLL ON" : "SCROLL OFF"; root.showOSD(OSD.Type.LockKey); } } // Startup timer - connect brightness monitors and enable OSD after 2 seconds Timer { id: startupTimer interval: 2000 running: true onTriggered: { connectBrightnessMonitors(); root.startupComplete = true; } } // Visual Component sourceComponent: PanelWindow { id: panel screen: modelData // Position configuration readonly property string location: Settings.data.osd?.location || "top_right" readonly property bool isTop: location === "top" || location.startsWith("top") readonly property bool isBottom: location === "bottom" || location.startsWith("bottom") readonly property bool isLeft: location.includes("_left") || location === "left" readonly property bool isRight: location.includes("_right") || location === "right" readonly property bool verticalMode: location === "left" || location === "right" // Dimensions readonly property bool isShortMode: root.currentOSDType === OSD.Type.LockKey readonly property int longHWidth: Math.round(320 * Style.uiScaleRatio) readonly property int longHHeight: Math.round(72 * Style.uiScaleRatio) readonly property int shortHWidth: Math.round(180 * Style.uiScaleRatio) readonly property int longVWidth: Math.round(80 * Style.uiScaleRatio) readonly property int longVHeight: Math.round(280 * Style.uiScaleRatio) readonly property int shortVHeight: Math.round(180 * Style.uiScaleRatio) readonly property int barThickness: { const base = Math.max(8, Math.round(8 * Style.uiScaleRatio)); return base % 2 === 0 ? base : base + 1; } anchors.top: isTop anchors.bottom: isBottom anchors.left: isLeft anchors.right: isRight function calculateMargin(isAnchored, position) { if (!isAnchored) return 0; let base = Style.marginM; if (Settings.data.bar.position === position) { const isVertical = position === "top" || position === "bottom"; const floatExtra = Settings.data.bar.floating ? (isVertical ? Settings.data.bar.marginVertical : Settings.data.bar.marginHorizontal) * Style.marginXL : 0; return Style.barHeight + base + floatExtra; } return base; } margins.top: calculateMargin(anchors.top, "top") margins.bottom: calculateMargin(anchors.bottom, "bottom") margins.left: calculateMargin(anchors.left, "left") margins.right: calculateMargin(anchors.right, "right") implicitWidth: verticalMode ? longVWidth : (isShortMode ? shortHWidth : longHWidth) implicitHeight: verticalMode ? (isShortMode ? shortVHeight : longVHeight) : longHHeight color: Color.transparent WlrLayershell.namespace: "noctalia-osd-" + (screen?.name || "unknown") WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.layer: Settings.data.osd?.overlayLayer ? WlrLayer.Overlay : WlrLayer.Top WlrLayershell.exclusionMode: ExclusionMode.Ignore Item { id: osdItem anchors.fill: parent visible: false opacity: 0 scale: 0.85 Behavior on opacity { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutQuad } } Behavior on scale { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutQuad } } Timer { id: hideTimer interval: Settings.data.osd.autoHideMs onTriggered: osdItem.hide() } Timer { id: visibilityTimer interval: Style.animationNormal + 50 onTriggered: { osdItem.visible = false; root.currentOSDType = -1; root.lastLockKeyChanged = ""; // Reset the lock key change indicator root.active = false; } } Rectangle { id: background anchors.fill: parent anchors.margins: Style.marginM * 1.5 radius: Style.radiusL color: Qt.alpha(Color.mSurface, Settings.data.osd.backgroundOpacity || 1.0) border.color: Qt.alpha(Color.mOutline, Settings.data.osd.backgroundOpacity || 1.0) border.width: { const bw = Math.max(2, Style.borderM); return bw % 2 === 0 ? bw : bw + 1; } } NDropShadow { anchors.fill: background source: background autoPaddingEnabled: true } Loader { id: contentLoader anchors.fill: background anchors.margins: Style.marginM active: true sourceComponent: panel.verticalMode ? verticalContent : horizontalContent } Component { id: horizontalContent RowLayout { anchors.fill: parent anchors.leftMargin: Style.marginL anchors.rightMargin: Style.marginL spacing: Style.marginM clip: true // TextMetrics to measure the maximum possible percentage width TextMetrics { id: percentageMetrics font.family: Settings.data.ui.fontFixed font.weight: Style.fontWeightMedium font.pointSize: Style.fontSizeS * (Settings.data.ui.fontFixedScale * Style.uiScaleRatio) text: "150%" // Maximum possible value with volumeOverdrive } // Common Icon for all types NIcon { icon: root.getIcon() color: root.getIconColor() pointSize: Style.fontSizeXL Layout.alignment: Qt.AlignVCenter Behavior on color { ColorAnimation { duration: Style.animationNormal easing.type: Easing.InOutQuad } } } // Lock Key Status Text (replaces progress bar) NText { visible: root.currentOSDType === OSD.Type.LockKey text: root.getDisplayPercentage() color: root.getProgressColor() pointSize: Style.fontSizeM family: Settings.data.ui.fontFixed font.weight: Style.fontWeightMedium Layout.fillWidth: true horizontalAlignment: Text.AlignHCenter Layout.alignment: Qt.AlignVCenter } // Progress Bar for Volume/Brightness Rectangle { visible: root.currentOSDType !== OSD.Type.LockKey Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter height: panel.barThickness radius: Math.round(panel.barThickness / 2) color: Color.mSurfaceVariant Rectangle { anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom width: parent.width * Math.min(1.0, root.getCurrentValue() / root.getMaxValue()) radius: parent.radius color: root.getProgressColor() Behavior on width { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutQuad } } Behavior on color { ColorAnimation { duration: Style.animationNormal easing.type: Easing.InOutQuad } } } } // Percentage Text for Volume/Brightness NText { visible: root.currentOSDType !== OSD.Type.LockKey text: root.getDisplayPercentage() color: Color.mOnSurface pointSize: Style.fontSizeS family: Settings.data.ui.fontFixed Layout.alignment: Qt.AlignVCenter | Qt.AlignRight horizontalAlignment: Text.AlignRight verticalAlignment: Text.AlignVCenter Layout.fillWidth: false Layout.preferredWidth: Math.ceil(percentageMetrics.width) + Math.round(8 * Style.uiScaleRatio) Layout.maximumWidth: Math.ceil(percentageMetrics.width) + Math.round(8 * Style.uiScaleRatio) Layout.minimumWidth: Math.ceil(percentageMetrics.width) } } } Component { id: verticalContent ColumnLayout { anchors.fill: parent anchors.topMargin: Style.marginL anchors.bottomMargin: Style.marginL spacing: root.currentOSDType === OSD.Type.LockKey ? Style.marginM : Style.marginS clip: true // Unified Text display for Percentage or Lock Status NText { text: root.getDisplayPercentage() color: root.currentOSDType === OSD.Type.LockKey ? root.getProgressColor() : Color.mOnSurface pointSize: root.currentOSDType === OSD.Type.LockKey ? Style.fontSizeM : Style.fontSizeS family: Settings.data.ui.fontFixed font.weight: root.currentOSDType === OSD.Type.LockKey ? Style.fontWeightMedium : Style.fontWeightRegular Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter // Only set preferredHeight for the standard case to maintain layout Layout.preferredHeight: root.currentOSDType === OSD.Type.LockKey ? -1 : Math.round(20 * Style.uiScaleRatio) } // Progress Bar for Volume/Brightness Item { visible: root.currentOSDType !== OSD.Type.LockKey Layout.fillWidth: true Layout.fillHeight: true Rectangle { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top anchors.bottom: parent.bottom width: panel.barThickness radius: Math.round(panel.barThickness / 2) color: Color.mSurfaceVariant Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: parent.height * Math.min(1.0, root.getCurrentValue() / root.getMaxValue()) radius: parent.radius color: root.getProgressColor() Behavior on height { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutQuad } } Behavior on color { ColorAnimation { duration: Style.animationNormal easing.type: Easing.InOutQuad } } } } } // Unified Icon display NIcon { icon: root.getIcon() color: root.getIconColor() pointSize: root.currentOSDType === OSD.Type.LockKey ? Style.fontSizeXL : Style.fontSizeL Layout.alignment: root.currentOSDType === OSD.Type.LockKey ? Qt.AlignHCenter : (Qt.AlignHCenter | Qt.AlignBottom) Behavior on color { ColorAnimation { duration: Style.animationNormal easing.type: Easing.InOutQuad } } } } } function show() { hideTimer.stop(); visibilityTimer.stop(); osdItem.visible = true; Qt.callLater(() => { osdItem.opacity = 1; osdItem.scale = 1.0; }); hideTimer.start(); } function hide() { hideTimer.stop(); visibilityTimer.stop(); osdItem.opacity = 0; osdItem.scale = 0.85; visibilityTimer.start(); } function hideImmediately() { hideTimer.stop(); visibilityTimer.stop(); osdItem.opacity = 0; osdItem.scale = 0.85; osdItem.visible = false; root.currentOSDType = -1; root.active = false; } } function showOSD() { osdItem.show(); } } } }