Files
noctalia-shell/Modules/OSD/OSD.qml
T
2025-10-12 22:09:35 -04:00

578 lines
18 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
// Unified OSD component
// Loader activates only when showing OSD, deactivates when hidden to save resources
Variants {
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
// Access the notification model from the service
property ListModel notificationModel: NotificationService.activeList
// Loader is only active when actually showing something
active: false
// Current OSD display state
property string currentOSDType: "" // "volume", "inputVolume", "brightness", or ""
// Volume properties
readonly property real currentVolume: AudioService.volume
readonly property bool isMuted: AudioService.muted
property bool volumeInitialized: false
property bool muteInitialized: false
// Input volume properties
readonly property real currentInputVolume: AudioService.inputVolume
readonly property bool isInputMuted: AudioService.inputMuted
property bool inputVolumeInitialized: false
property bool inputMuteInitialized: false
// Brightness properties
property real lastUpdatedBrightness: 0
readonly property real currentBrightness: lastUpdatedBrightness
property bool brightnessInitialized: false
// Get appropriate icon based on current OSD type
function getIcon() {
if (currentOSDType === "volume") {
if (AudioService.muted) {
return "volume-mute"
}
return (AudioService.volume <= Number.EPSILON) ? "volume-zero" : (AudioService.volume <= 0.5) ? "volume-low" : "volume-high"
} else if (currentOSDType === "inputVolume") {
if (AudioService.inputMuted) {
return "microphone-off"
}
return "microphone"
} else if (currentOSDType === "brightness") {
return currentBrightness <= 0.5 ? "brightness-low" : "brightness-high"
}
return ""
}
// Get current value (0-1 range)
function getCurrentValue() {
if (currentOSDType === "volume") {
return isMuted ? 0 : currentVolume
} else if (currentOSDType === "inputVolume") {
return isInputMuted ? 0 : currentInputVolume
} else if (currentOSDType === "brightness") {
return currentBrightness
}
return 0
}
// Get display percentage
function getDisplayPercentage() {
if (currentOSDType === "volume") {
if (isMuted)
return "0%"
const pct = Math.round(Math.min(1.0, currentVolume) * 100)
return pct + "%"
} else if (currentOSDType === "inputVolume") {
if (isInputMuted)
return "0%"
const pct = Math.round(Math.min(1.0, currentInputVolume) * 100)
return pct + "%"
} else if (currentOSDType === "brightness") {
const pct = Math.round(Math.min(1.0, currentBrightness) * 100)
return pct + "%"
}
return ""
}
// Get progress bar color
function getProgressColor() {
if (currentOSDType === "volume") {
if (isMuted)
return Color.mError
return Color.mPrimary
} else if (currentOSDType === "inputVolume") {
if (isInputMuted)
return Color.mError
return Color.mPrimary
}
return Color.mPrimary
}
// Get icon color
function getIconColor() {
if ((currentOSDType === "volume" && isMuted) || (currentOSDType === "inputVolume" && isInputMuted)) {
return Color.mError
}
return Color.mOnSurface
}
sourceComponent: PanelWindow {
id: panel
screen: modelData
readonly property string location: (Settings.data.osd && Settings.data.osd.location) ? Settings.data.osd.location : "top_right"
readonly property bool isTop: (location === "top") || (location.length >= 3 && location.substring(0, 3) === "top")
readonly property bool isBottom: (location === "bottom") || (location.length >= 6 && location.substring(0, 6) === "bottom")
readonly property bool isLeft: (location.indexOf("_left") >= 0) || (location === "left")
readonly property bool isRight: (location.indexOf("_right") >= 0) || (location === "right")
readonly property bool isCentered: (location === "top" || location === "bottom")
readonly property bool verticalMode: (location === "left" || location === "right")
readonly property int hWidth: Math.round(320 * Style.uiScaleRatio)
readonly property int hHeight: Math.round(64 * Style.uiScaleRatio)
readonly property int vHeight: hWidth // Vertical OSD height (matches horizontal width)
// Ensure an even width to keep the vertical bar perfectly centered
readonly property int barThickness: {
const base = Math.max(8, Math.round(8 * Style.uiScaleRatio))
return (base % 2 === 0) ? base : base + 1
}
// Anchor selection based on location (window edges)
anchors.top: isTop
anchors.bottom: isBottom
anchors.left: isLeft
anchors.right: isRight
// Margins depending on bar position and chosen location
margins.top: {
if (!(anchors.top))
return 0
var base = Style.marginM
if (Settings.data.bar.position === "top") {
var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL : 0
return Style.barHeight + base + floatExtraV
}
return base
}
margins.bottom: {
if (!(anchors.bottom))
return 0
var base = Style.marginM
if (Settings.data.bar.position === "bottom") {
var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL : 0
return Style.barHeight + base + floatExtraV
}
return base
}
margins.left: {
if (!(anchors.left))
return 0
var base = Style.marginM
if (Settings.data.bar.position === "left") {
var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL : 0
return Style.barHeight + base + floatExtraH
}
return base
}
margins.right: {
if (!(anchors.right))
return 0
var base = Style.marginM
if (Settings.data.bar.position === "right") {
var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL : 0
return Style.barHeight + base + floatExtraH
}
return base
}
implicitWidth: verticalMode ? hHeight : hWidth
implicitHeight: osdItem.height
color: Color.transparent
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.layer: (Settings.data.osd && Settings.data.osd.alwaysOnTop) ? WlrLayer.Overlay : WlrLayer.Top
exclusionMode: PanelWindow.ExclusionMode.Ignore
Rectangle {
id: osdItem
width: parent.width
height: panel.verticalMode ? panel.vHeight : Math.round(64 * Style.uiScaleRatio)
radius: Style.radiusL
color: Color.mSurface
border.color: Color.mOutline
border.width: {
const bw = Math.max(2, Style.borderM)
return (bw % 2 === 0) ? bw : bw + 1
}
visible: false
opacity: 0
scale: 0.85 // initial scale for a little zoom effect
// Only horizontally center when the window itself is centered (top/bottom positions)
// For left/right vertical mode, fill the parent width
anchors.horizontalCenter: (!panel.verticalMode && panel.isCentered) ? parent.horizontalCenter : undefined
anchors.verticalCenter: panel.verticalMode ? parent.verticalCenter : undefined
Behavior on opacity {
NumberAnimation {
id: opacityAnimation
duration: Style.animationNormal
easing.type: Easing.InOutQuad
}
}
Behavior on scale {
NumberAnimation {
id: scaleAnimation
duration: Style.animationNormal
easing.type: Easing.InOutQuad
}
}
Timer {
id: hideTimer
interval: Settings.data.osd.autoHideMs
onTriggered: osdItem.hide()
}
// Timer to handle visibility after animations complete
Timer {
id: visibilityTimer
interval: Style.animationNormal + 50 // Add small buffer
onTriggered: {
osdItem.visible = false
root.currentOSDType = ""
// Deactivate the loader when done
root.active = false
}
}
Loader {
id: contentLoader
anchors.fill: parent
active: true
sourceComponent: verticalMode ? verticalContent : horizontalContent
}
Component {
id: horizontalContent
Item {
anchors.fill: parent
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.marginL
spacing: Style.marginM
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
}
}
}
// Progress bar with calculated width
Rectangle {
Layout.fillWidth: true
height: panel.barThickness
radius: Math.round(panel.barThickness / 2)
color: Color.mSurfaceVariant
Layout.alignment: Qt.AlignVCenter
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.min(1.0, root.getCurrentValue())
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
NText {
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.preferredWidth: Math.round(50 * Style.uiScaleRatio)
}
}
}
}
Component {
id: verticalContent
Item {
anchors.fill: parent
ColumnLayout {
// Ensure inner padding respects the rounded corners; avoid clipping the icon/text
property int vMargin: {
const styleMargin = Style.marginL
const cornerGuard = Math.round(osdItem.radius)
return Math.max(styleMargin, cornerGuard)
}
property int vMarginTop: Math.round(Math.max(osdItem.radius, Style.marginS))
property int balanceDelta: Style.marginS
anchors.fill: parent
anchors.topMargin: vMargin
anchors.bottomMargin: vMargin
spacing: Style.marginS
// Percentage text at top
Item {
Layout.fillWidth: true
Layout.preferredHeight: Math.round(20 * Style.uiScaleRatio)
NText {
id: percentText
text: root.getDisplayPercentage()
color: Color.mOnSurface
pointSize: Style.fontSizeS
family: Settings.data.ui.fontFixed
width: Math.round(50 * Style.uiScaleRatio)
height: parent.height
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// Progress bar
Item {
Layout.fillWidth: true
Layout.fillHeight: true // Fill remaining space between text and icon
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())
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
}
}
}
}
}
// Icon at bottom
NIcon {
icon: root.getIcon()
color: root.getIconColor()
pointSize: Style.fontSizeL
Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom
Behavior on color {
ColorAnimation {
duration: Style.animationNormal
easing.type: Easing.InOutQuad
}
}
}
}
}
}
function show() {
// Cancel any pending hide operations
hideTimer.stop()
visibilityTimer.stop()
// Make visible and animate in
osdItem.visible = true
// Use Qt.callLater to ensure the visible change is processed before animation
Qt.callLater(() => {
osdItem.opacity = 1
osdItem.scale = 1.0
})
// Start the auto-hide timer
hideTimer.start()
}
function hide() {
hideTimer.stop()
visibilityTimer.stop()
// Start fade out animation
osdItem.opacity = 0
osdItem.scale = 0.85 // Less dramatic scale change for smoother effect
// Delay hiding the element until after animation completes
visibilityTimer.start()
}
function hideImmediately() {
hideTimer.stop()
visibilityTimer.stop()
osdItem.opacity = 0
osdItem.scale = 0.85
osdItem.visible = false
root.currentOSDType = ""
root.active = false
}
}
function showOSD() {
osdItem.show()
}
}
// Volume change monitoring
Connections {
target: AudioService
function onVolumeChanged() {
if (volumeInitialized) {
showOSD("volume")
}
}
function onMutedChanged() {
if (muteInitialized) {
showOSD("volume")
}
}
function onInputVolumeChanged() {
if (inputVolumeInitialized) {
showOSD("inputVolume")
}
}
function onInputMutedChanged() {
if (inputMuteInitialized) {
showOSD("inputVolume")
}
}
}
// Timer to initialize volume/mute flags after services are ready
Timer {
id: initTimer
interval: 500
running: true
onTriggered: {
volumeInitialized = true
muteInitialized = true
inputVolumeInitialized = true
inputMuteInitialized = true
// Don't initialize brightness here - let it initialize on first change like volume
connectBrightnessMonitors()
}
}
// Brightness change monitoring
Connections {
target: BrightnessService
function onMonitorsChanged() {
connectBrightnessMonitors()
}
}
function disconnectBrightnessMonitors() {
for (var i = 0; i < BrightnessService.monitors.length; i++) {
let monitor = BrightnessService.monitors[i]
monitor.brightnessUpdated.disconnect(onBrightnessChanged)
}
}
function connectBrightnessMonitors() {
for (var i = 0; i < BrightnessService.monitors.length; i++) {
let monitor = BrightnessService.monitors[i]
// Disconnect first to avoid duplicate connections
monitor.brightnessUpdated.disconnect(onBrightnessChanged)
monitor.brightnessUpdated.connect(onBrightnessChanged)
}
}
function onBrightnessChanged(newBrightness) {
root.lastUpdatedBrightness = newBrightness
if (!brightnessInitialized) {
brightnessInitialized = true
return
}
showOSD("brightness")
}
function showOSD(type) {
// Update the current OSD type
currentOSDType = type
// Activate the loader if not already active
if (!root.active) {
root.active = true
}
// Show the OSD (may need to wait for loader to create the item)
if (root.item) {
root.item.showOSD()
} else {
// If item not ready yet, wait for it
Qt.callLater(() => {
if (root.item) {
root.item.showOSD()
}
})
}
}
function hideOSD() {
if (root.item && root.item.osdItem) {
root.item.osdItem.hideImmediately()
} else if (root.active) {
// If loader is active but item isn't ready, just deactivate
root.active = false
}
}
}
}