diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 03a63235e..e9bea0d39 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -973,7 +973,7 @@ Singleton { function islandStripThickness(bc) { return LayoutResolver.islandThickness(islandSettings(bc), islandDefaultsFor(bc)); } - readonly property var _islandHomeGroupIds: ["media", "clock", "weather", "status", "volume", "brightness", "notifications"] + readonly property var _islandHomeGroupIds: ["media", "clock", "weather", "status", "volume", "brightness", "notifications", "privacy"] readonly property var _islandHomeLayoutDefault: [ { "id": "media", @@ -1002,6 +1002,10 @@ Singleton { { "id": "notifications", "enabled": true + }, + { + "id": "privacy", + "enabled": true } ] function getIslandHomeLayout(bc) { diff --git a/quickshell/Common/settings/BarWidgetDefaults.js b/quickshell/Common/settings/BarWidgetDefaults.js index 2550c2c3b..442f64bba 100644 --- a/quickshell/Common/settings/BarWidgetDefaults.js +++ b/quickshell/Common/settings/BarWidgetDefaults.js @@ -36,6 +36,7 @@ var ISLAND_DEFAULTS = { islandNotificationExpand: false, islandNotificationPopups: false, islandSystemOsd: true, + islandChargingPulse: true, islandHomeCompactTight: false, islandHomeClockDisplay: "both", islandHomeVolumeDisplay: "both", diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 340bc95cf..863ecbf63 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -1280,7 +1280,7 @@ Item { } Variants { - model: SettingsData.getFilteredScreens("osd") + model: root.legacySystemLevelOsdScreens delegate: MicVolumeOSD {} } @@ -1292,19 +1292,19 @@ Item { } Variants { - model: SettingsData.getFilteredScreens("osd") + model: root.legacySystemLevelOsdScreens delegate: IdleInhibitorOSD {} } Variants { - model: SettingsData.osdPowerProfileEnabled ? SettingsData.getFilteredScreens("osd") : [] + model: SettingsData.osdPowerProfileEnabled ? root.legacySystemLevelOsdScreens : [] delegate: PowerProfileOSD {} } Variants { - model: SettingsData.getFilteredScreens("osd") + model: root.legacySystemLevelOsdScreens delegate: CapsLockOSD {} } diff --git a/quickshell/Modules/ControlCenter/Widgets/AudioDeviceTile.qml b/quickshell/Modules/ControlCenter/Widgets/AudioDeviceTile.qml index 04142956d..df2fe9d2b 100644 --- a/quickshell/Modules/ControlCenter/Widgets/AudioDeviceTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/AudioDeviceTile.qml @@ -31,7 +31,7 @@ CcTile { } active: !!audio && !audio.muted showExpand: true - enabled: widgetDef?.enabled ?? true + available: widgetDef?.enabled ?? true tallContent: Component { Item { DankRingGauge { diff --git a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml index e51951881..2e7b169ab 100644 --- a/quickshell/Modules/ControlCenter/Widgets/CcTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/CcTile.qml @@ -49,6 +49,8 @@ Item { property bool opensPage: false property color restIconColor: CcMetrics.tileInactiveIcon property Component tallContent: null + // The tile's own state. The island disables a face while it fades in, and that must not restyle the tile. + property bool available: true property bool interactive: true property bool iconBlinking: false property real iconRotation: 0 @@ -68,29 +70,29 @@ Item { property bool acceptsInput: interactive && enabled readonly property bool bodyActive: showsActive && !hasIconBox readonly property color bodyColor: { - if (!enabled) + if (!available) return Theme.onSurface_12; return bodyActive ? CcMetrics.tileActiveColor : CcMetrics.tileInactiveColor; } readonly property color contentColor: { - if (!enabled) + if (!available) return Theme.onSurface_38; return bodyActive ? CcMetrics.tileActiveContent : CcMetrics.tileInactiveContent; } readonly property color subtitleColor: { - if (!enabled) + if (!available) return Theme.onSurface_38; return bodyActive ? CcMetrics.tileActiveContent : CcMetrics.tileInactiveSubtitle; } readonly property color iconColor: { - if (!enabled) + if (!available) return Theme.onSurface_38; if (hasIconBox) return showsActive ? CcMetrics.tileActiveContent : CcMetrics.tileInactiveContent; return bodyActive ? CcMetrics.tileActiveContent : root.restIconColor; } readonly property color iconBoxColor: { - if (!enabled) + if (!available) return Theme.onSurface_12; return showsActive ? CcMetrics.tileActiveColor : CcMetrics.iconBoxInactiveColor; } @@ -106,6 +108,7 @@ Item { width: parent?.width ?? 0 height: CcMetrics.tileHeight + enabled: available activeFocusOnTab: acceptsInput Accessible.role: toggle && (!showExpand || compact) && !opensPage ? Accessible.CheckBox : Accessible.Button Accessible.checkable: toggle && (!showExpand || compact) && !opensPage diff --git a/quickshell/Modules/ControlCenter/Widgets/DiskUsageTile.qml b/quickshell/Modules/ControlCenter/Widgets/DiskUsageTile.qml index 462746d81..257f6bdcd 100644 --- a/quickshell/Modules/ControlCenter/Widgets/DiskUsageTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/DiskUsageTile.qml @@ -39,7 +39,7 @@ CcTile { } active: false opensPage: true - enabled: DgopService.dgopAvailable + available: DgopService.dgopAvailable tallContent: Component { Item { DankRingGauge { diff --git a/quickshell/Modules/ControlCenter/Widgets/NetworkTile.qml b/quickshell/Modules/ControlCenter/Widgets/NetworkTile.qml index b9558924c..9f3f3d73d 100644 --- a/quickshell/Modules/ControlCenter/Widgets/NetworkTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/NetworkTile.qml @@ -95,7 +95,7 @@ CcTile { } } showExpand: true - enabled: widgetDef?.enabled ?? true + available: widgetDef?.enabled ?? true onClicked: { if (status === "ethernet" || status === "cellular" || NetworkService.wifiToggling) diff --git a/quickshell/Modules/ControlCenter/Widgets/NightModeTile.qml b/quickshell/Modules/ControlCenter/Widgets/NightModeTile.qml index 8d372b2d4..63f51f856 100644 --- a/quickshell/Modules/ControlCenter/Widgets/NightModeTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/NightModeTile.qml @@ -9,7 +9,7 @@ CcTile { iconName: NightModeService.nightModeEnabled ? "nightlight" : "dark_mode" title: I18n.tr("Night mode") active: NightModeService.nightModeEnabled || false - enabled: NightModeService.automationAvailable + available: NightModeService.automationAvailable onClicked: NightModeService.toggleNightMode() expandedContent: Component { diff --git a/quickshell/Modules/ControlCenter/Widgets/PluginTile.qml b/quickshell/Modules/ControlCenter/Widgets/PluginTile.qml index 6348c0675..53a1076ea 100644 --- a/quickshell/Modules/ControlCenter/Widgets/PluginTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/PluginTile.qml @@ -21,7 +21,7 @@ CcTile { expandedContent: instance?.ccExpandedContent ?? null bodyContent: docked ? (instance?.ccFooterContent ?? null) : null expandedMinimumHeight: instance?.ccExpandedMinimumHeight ?? Theme.listItemHeight - enabled: instance !== null + available: instance !== null onClicked: { if ((compact || !toggle) && hasDetail) { diff --git a/quickshell/Modules/DankBar/Widgets/Battery.qml b/quickshell/Modules/DankBar/Widgets/Battery.qml index d52ba80eb..c8c8bbf91 100644 --- a/quickshell/Modules/DankBar/Widgets/Battery.qml +++ b/quickshell/Modules/DankBar/Widgets/Battery.qml @@ -109,12 +109,15 @@ BasePill { return parts.join(" "); } - // Percent always stays inside the pill; time and wattage show beside it. + // Percent stays inside the pill unless the duo glyph occupies it; time and wattage show beside it. readonly property string horizontalSideText: { if (!pillStyle) { return horizontalDisplayText; } const parts = []; + if (showPercent && batteryStyle === "duo") { + parts.push(`${BatteryService.batteryLevel}%`); + } if (showTime && batteryTimeText) { parts.push(batteryTimeText); } diff --git a/quickshell/Modules/DankIsland/Activities/DestinationCompact.qml b/quickshell/Modules/DankIsland/Activities/DestinationCompact.qml index ef109fbab..405e293dc 100644 --- a/quickshell/Modules/DankIsland/Activities/DestinationCompact.qml +++ b/quickshell/Modules/DankIsland/Activities/DestinationCompact.qml @@ -10,6 +10,7 @@ Item { required property var controller required property string activityId required property string label + property string value: "" property string iconName: "" property color iconColor: Theme.primary property Component leading: null @@ -54,6 +55,14 @@ Item { font.pixelSize: Theme.fontSizeMedium font.weight: Theme.fontWeightMedium } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + visible: root.value !== "" + text: root.value + color: Theme.surfaceTextSecondary + font.pixelSize: Theme.fontSizeMedium + } } // The pill is only as wide as the strip on a side edge, so the label is dropped. @@ -80,5 +89,13 @@ Item { size: Theme.iconSizeSmall color: root.iconColor } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + visible: root.value !== "" + text: root.value + color: Theme.surfaceTextSecondary + font.pixelSize: Theme.fontSizeSmall + } } } diff --git a/quickshell/Modules/DankIsland/Activities/HomeCompact.qml b/quickshell/Modules/DankIsland/Activities/HomeCompact.qml index 3dac4a64d..b7a754731 100644 --- a/quickshell/Modules/DankIsland/Activities/HomeCompact.qml +++ b/quickshell/Modules/DankIsland/Activities/HomeCompact.qml @@ -46,6 +46,8 @@ Item { return !!AudioService.sink?.audio; case "brightness": return BrightnessService.brightnessAvailable && !!root.brightnessDevice; + case "privacy": + return PrivacyService.anyPrivacyActive; } return true; } @@ -81,6 +83,9 @@ Item { case "brightness": root.systemModel.open(groupId); return; + case "privacy": + root.controller.requestControlCenter("audioInput", false); + return; } root.controller.requestControlCenter("", false); } @@ -226,6 +231,15 @@ Item { } } + component PrivacyIcon: DankIcon { + required property bool active + + visible: active + size: root.statusIconSize + color: Theme.error + filled: true + } + component GroupHoverArea: IslandSlotHoverArea { required property var group @@ -243,6 +257,10 @@ Item { if (event.button === Qt.MiddleButton) { if (group.usesConnectivity) root.toggleConnectivity(connectivityTypeAt(event)); + if (group.isPrivacy && PrivacyService.microphoneActive && AudioService.source?.audio) { + SessionData.suppressOSDTemporarily(); + AudioService.toggleMicMute(); + } if (group.isMedia && root.controller.mediaAvailable && MprisController.activePlayer?.canTogglePlaying) MprisController.activePlayer.togglePlaying(); if (group.isVolume && AudioService.sink?.audio) { @@ -277,6 +295,7 @@ Item { readonly property bool isNotifications: group.groupId === "notifications" readonly property bool isVolume: group.groupId === "volume" readonly property bool isBrightness: group.groupId === "brightness" + readonly property bool isPrivacy: group.groupId === "privacy" readonly property bool isSystemLevel: group.isVolume || group.isBrightness readonly property bool usesConnectivity: group.isStatus && root.controller.homeStatusContent === "connectivity" readonly property bool usesBattery: group.isStatus && !group.usesConnectivity && BatteryService.batteryAvailable @@ -394,6 +413,27 @@ Item { } } + Grid { + visible: group.isPrivacy + columns: group.vertical ? 1 : 3 + spacing: Theme.spacingXXS + + PrivacyIcon { + name: "mic" + active: PrivacyService.microphoneActive + } + + PrivacyIcon { + name: "camera_video" + active: PrivacyService.cameraActive + } + + PrivacyIcon { + name: "screen_share" + active: PrivacyService.screensharingActive + } + } + DankIcon { visible: group.isStatus && !group.usesBattery && !group.usesConnectivity name: "tune" diff --git a/quickshell/Modules/DankIsland/Activities/SystemLevelCompact.qml b/quickshell/Modules/DankIsland/Activities/SystemLevelCompact.qml index 7e1822e4e..946ebdc64 100644 --- a/quickshell/Modules/DankIsland/Activities/SystemLevelCompact.qml +++ b/quickshell/Modules/DankIsland/Activities/SystemLevelCompact.qml @@ -37,7 +37,7 @@ Item { visible: !root.isVertical iconName: root.systemModel.iconName tonalIcon: false - iconInteractive: root.systemModel.volumeActivity + iconInteractive: root.systemModel.mutable iconLabel: root.systemModel.muted ? I18n.tr("Unmute") : I18n.tr("Mute") value: Math.round(root.systemModel.value) minimum: root.systemModel.minimum @@ -65,11 +65,11 @@ Item { anchors.top: parent.top name: root.systemModel.iconName size: Math.min(Theme.iconSize, root.iconSize) - color: root.systemModel.volumeActivity ? Theme.surfaceText : Theme.primary + color: root.systemModel.mutable ? Theme.surfaceText : Theme.primary MouseArea { anchors.fill: parent - enabled: root.systemModel.volumeActivity + enabled: root.systemModel.mutable cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: root.systemModel.toggleMute() } diff --git a/quickshell/Modules/DankIsland/Activities/SystemLevelExpanded.qml b/quickshell/Modules/DankIsland/Activities/SystemLevelExpanded.qml index 0fb51a454..6ffb14d92 100644 --- a/quickshell/Modules/DankIsland/Activities/SystemLevelExpanded.qml +++ b/quickshell/Modules/DankIsland/Activities/SystemLevelExpanded.qml @@ -33,7 +33,7 @@ Item { } StyledText { - text: root.systemModel.volumeActivity ? I18n.tr("Click the icon to mute", "island volume face: hint under the title") : I18n.tr("Display brightness control", "island brightness face: hint under the title") + text: root.systemModel.mutable ? I18n.tr("Click the icon to mute", "island volume face: hint under the title") : I18n.tr("Display brightness control", "island brightness face: hint under the title") color: Theme.surfaceTextSecondary font.pixelSize: Theme.fontSizeSmall } @@ -56,12 +56,13 @@ Item { width: parent.width size: "m" insetIcon: root.systemModel.iconName - insetIconClickable: root.systemModel.volumeActivity + insetIconClickable: root.systemModel.mutable insetIconLabel: root.systemModel.muted ? I18n.tr("Unmute") : I18n.tr("Mute") Accessible.name: root.systemModel.title onInsetIconClicked: root.systemModel.toggleMute() minimum: root.systemModel.minimum maximum: Math.max(root.systemModel.minimum + 1, Math.round(root.systemModel.maximum)) + visible: root.systemModel.levelActivity enabled: root.systemModel.available showValue: false unit: root.systemModel.unit diff --git a/quickshell/Modules/DankIsland/Activities/SystemStateCompact.qml b/quickshell/Modules/DankIsland/Activities/SystemStateCompact.qml new file mode 100644 index 000000000..a552c5b5b --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/SystemStateCompact.qml @@ -0,0 +1,30 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common + +DestinationCompact { + id: root + + required property var systemModel + + readonly property bool linksControlCenter: root.systemModel.controlCenterSection !== "" + + activityId: root.systemModel.kind + iconName: root.systemModel.iconName + label: root.systemModel.title + value: root.systemModel.displayValue + + IslandSlotHoverArea { + anchors.fill: parent + controller: root.controller + cursorShape: root.linksControlCenter ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (root.linksControlCenter) { + root.controller.requestControlCenter(root.systemModel.controlCenterSection, false); + return; + } + root.controller.requestCollapse(); + } + } +} diff --git a/quickshell/Modules/DankIsland/DankIslandSurface.qml b/quickshell/Modules/DankIsland/DankIslandSurface.qml index d7ba437c2..73958ebfd 100644 --- a/quickshell/Modules/DankIsland/DankIslandSurface.qml +++ b/quickshell/Modules/DankIsland/DankIslandSurface.qml @@ -680,6 +680,14 @@ Item { Component { id: compactSystemComponent + Loader { + sourceComponent: root.systemModel.levelActivity ? compactSystemLevelComponent : compactSystemStateComponent + } + } + + Component { + id: compactSystemLevelComponent + SystemLevelCompact { systemModel: root.systemModel isVertical: root.isVertical @@ -687,6 +695,15 @@ Item { } } + Component { + id: compactSystemStateComponent + + SystemStateCompact { + systemModel: root.systemModel + controller: root.controller + } + } + Component { id: expandedSystemComponent diff --git a/quickshell/Modules/DankIsland/IslandBarHost.qml b/quickshell/Modules/DankIsland/IslandBarHost.qml index c0d58abac..7900150f9 100644 --- a/quickshell/Modules/DankIsland/IslandBarHost.qml +++ b/quickshell/Modules/DankIsland/IslandBarHost.qml @@ -424,6 +424,7 @@ Item { controller: controller enabled: root.setting("islandSystemOsd") && root.barConfig?.visible !== false + chargingPulseEnabled: root.setting("islandChargingPulse") } IslandNotificationSource { diff --git a/quickshell/Modules/DankIsland/IslandContentHost.qml b/quickshell/Modules/DankIsland/IslandContentHost.qml index ed6960b26..181e47f6c 100644 --- a/quickshell/Modules/DankIsland/IslandContentHost.qml +++ b/quickshell/Modules/DankIsland/IslandContentHost.qml @@ -54,7 +54,15 @@ Item { readonly property real outgoingCompactFade: root.fadeCompact(root.outgoingMorph) readonly property real outgoingExpandedFade: root.fadeExpanded(root.outgoingMorph) readonly property bool mediaSurfaceActive: root.surfaceActive("media") - readonly property bool systemSurfaceActive: root.surfaceActive("volume") || root.surfaceActive("brightness") + readonly property bool systemSurfaceActive: root.controller.systemActivities.some(id => root.surfaceActive(id)) + + function systemCompactOpacity() { + return Math.max(0, ...root.controller.systemActivities.map(id => root.compactOpacity(id))); + } + + function systemExpandedOpacity() { + return Math.max(0, ...root.controller.systemActivities.map(id => root.expandedOpacity(id))); + } property string renderedActivity: "home" property string outgoingActivity: "" @@ -342,7 +350,7 @@ Item { active: root.systemSurfaceActive activity: "volume" face: root.systemCompactComponent - opacity: Math.max(root.compactOpacity("volume"), root.compactOpacity("brightness")) + opacity: root.systemCompactOpacity() } ExpandedFace { @@ -350,7 +358,7 @@ Item { active: root.systemSurfaceActive && (root.expanded || root.expandedFade > 0) asynchronous: false sourceComponent: root.systemExpandedComponent - opacity: Math.max(root.expandedOpacity("volume"), root.expandedOpacity("brightness")) + opacity: root.systemExpandedOpacity() } CompactFace { diff --git a/quickshell/Modules/DankIsland/IslandController.qml b/quickshell/Modules/DankIsland/IslandController.qml index 1c8e3b22d..a635ecfb4 100644 --- a/quickshell/Modules/DankIsland/IslandController.qml +++ b/quickshell/Modules/DankIsland/IslandController.qml @@ -236,6 +236,10 @@ QtObject { } function setDestinationContentLength(activityId, length) { + if (isSystemActivity(activityId)) { + setSystemStateContentLength(length); + return; + } const entry = destinationState[activityId]; const next = Math.ceil(length); if (!entry || !isFinite(next) || next <= 0 || Math.abs(next - entry.contentLength) < 2) @@ -429,7 +433,13 @@ QtObject { "bottomLeftRadius": root.dotSize / 2, "bottomRightRadius": root.dotSize / 2 }) - readonly property var dotTransientActivities: ["notification", "volume", "brightness"] + readonly property var systemLevelActivities: ["volume", "brightness", "mic"] + readonly property var systemStateActivities: ["capslock", "powerprofile", "idleinhibitor", "charging"] + readonly property var systemActivities: systemLevelActivities.concat(systemStateActivities) + readonly property var dotTransientActivities: ["notification"].concat(systemActivities) + function isSystemActivity(activityId) { + return root.systemActivities.indexOf(activityId) !== -1; + } // Transients keep their island pill so the dot can still show a notification or a level. function usesDotFace(activityId) { return root.dotMode && root.dotTransientActivities.indexOf(activityId) === -1; @@ -441,13 +451,15 @@ QtObject { readonly property var launcherExpandedTarget: sheetTarget(Math.min(dashboardAvailableWidth, LauncherMetrics.sizeWidth(SettingsData.dankLauncherV2Size)), Math.min(dashboardAvailableHeight, LauncherMetrics.sizeHeight(SettingsData.dankLauncherV2Size))) readonly property var controlCenterExpandedTarget: sheetTarget(controlCenterSheetWidth + controlCenterSheetInset, controlCenterHeight) readonly property var systemCompactTarget: pillTarget(root.isVertical ? 240 : (SettingsData.osdAlwaysShowValue ? 330 : 282), compactFaceThickness) + property real systemStateContentLength: 0 + readonly property var systemStateCompactTarget: pillTarget(Math.ceil(Math.max(compactFaceThickness, systemStateContentLength + destinationCompactEndPad * 2)), compactFaceThickness) readonly property var systemExpandedTarget: sheetTarget(460, 176) readonly property var notificationCompactTarget: pillTarget(Math.ceil(Math.max(notificationCompactMinLength, Math.min(notificationCompactMaxLength, notificationContentLength))), compactFaceThickness) readonly property var notificationExpandedTarget: sheetTarget(520, 220) readonly property var notificationCenterExpandedTarget: dashboardTargetFor("notificationcenter") readonly property var clipboardExpandedTarget: sheetTarget(Math.min(dashboardAvailableWidth, ClipboardConstants.sizeWidth(SettingsData.clipboardSize)), Math.min(dashboardAvailableHeight, ClipboardConstants.sizeHeight(SettingsData.clipboardSize))) - readonly property bool systemActivityActive: activeActivity === "volume" || activeActivity === "brightness" + readonly property bool systemActivityActive: isSystemActivity(activeActivity) readonly property bool notificationActive: activeActivity === "notification" readonly property bool notificationHeldForSystem: root.systemActivityActive && root.transientReturnActivity === "notification" readonly property bool transientActive: systemActivityActive || notificationActive @@ -460,14 +472,15 @@ QtObject { switch (activityId) { case "notification": return notificationCompactTarget; - case "volume": - case "brightness": - return systemCompactTarget; case "media": return mediaCompactTarget; case "home": return homeCompactTarget; } + if (systemLevelActivities.indexOf(activityId) !== -1) + return systemCompactTarget; + if (systemStateActivities.indexOf(activityId) !== -1) + return systemStateCompactTarget; return isDestination(activityId) ? pillTarget(destinationCompactLength(activityId), compactFaceThickness) : homeCompactTarget; } @@ -478,9 +491,6 @@ QtObject { return homeExpandedTarget; case "notification": return notificationExpandedTarget; - case "volume": - case "brightness": - return systemExpandedTarget; case "launcher": return launcherExpandedTarget; case "controlcenter": @@ -492,7 +502,7 @@ QtObject { case "media": return mediaExpandedTarget; } - return dashboardTargetFor(activityId); + return isSystemActivity(activityId) ? systemExpandedTarget : dashboardTargetFor(activityId); } readonly property var expandedTarget: expandedTargetFor(activeActivity) @@ -758,8 +768,15 @@ QtObject { activeActivity = returnActivity; } + function setSystemStateContentLength(length) { + const next = Math.ceil(length); + if (!isFinite(next) || next <= 0 || Math.abs(next - systemStateContentLength) < 2) + return; + systemStateContentLength = next; + } + function requestSystemActivity(activityId) { - if (activityId !== "volume" && activityId !== "brightness") + if (!isSystemActivity(activityId)) return false; launcherSessionActive = false; diff --git a/quickshell/Modules/DankIsland/IslandSystemSource.qml b/quickshell/Modules/DankIsland/IslandSystemSource.qml index 67b2aa768..38ad0c1ae 100644 --- a/quickshell/Modules/DankIsland/IslandSystemSource.qml +++ b/quickshell/Modules/DankIsland/IslandSystemSource.qml @@ -10,21 +10,114 @@ QtObject { required property IslandController controller property string kind: "volume" - // Off: volume and brightness keep their OSDs and only the face's own controls open this activity. + // Off: system levels and state changes keep their OSDs and only the face's own controls open this activity. property bool enabled: true + property bool chargingPulseEnabled: true readonly property bool volumeActivity: kind === "volume" - readonly property bool available: volumeActivity ? !!AudioService.sink?.audio : BrightnessService.brightnessAvailable - readonly property bool muted: volumeActivity && (AudioService.sink?.audio?.muted ?? false) - readonly property real value: volumeActivity ? AudioService.sinkVolumePercent : BrightnessService.brightnessLevel + readonly property bool micActivity: kind === "mic" + readonly property bool brightnessActivity: kind === "brightness" + readonly property bool levelActivity: volumeActivity || micActivity || brightnessActivity + readonly property var audio: volumeActivity ? AudioService.sink?.audio : (micActivity ? AudioService.source?.audio : null) + readonly property bool available: levelActivity ? (brightnessActivity ? BrightnessService.brightnessAvailable : !!audio) : true + readonly property bool muted: !!audio && (audio.muted ?? false) + readonly property bool mutable: volumeActivity || micActivity + readonly property real value: { + switch (kind) { + case "volume": + return AudioService.sinkVolumePercent; + case "mic": + return AudioService.sourceVolumePercent; + case "brightness": + return BrightnessService.brightnessLevel; + } + return 0; + } readonly property var brightnessDevice: BrightnessService.getCurrentDeviceInfo() - readonly property real maximum: volumeActivity ? AudioService.sinkMaxVolume : BrightnessService.brightnessMaximum(brightnessDevice) - readonly property int minimum: volumeActivity ? 0 : BrightnessService.brightnessMinimum(brightnessDevice) + readonly property real maximum: { + switch (kind) { + case "volume": + return AudioService.sinkMaxVolume; + case "brightness": + return BrightnessService.brightnessMaximum(brightnessDevice); + } + return 100; + } + readonly property int minimum: brightnessActivity ? BrightnessService.brightnessMinimum(brightnessDevice) : 0 readonly property real ratio: maximum > 0 ? Math.max(0, Math.min(1, value / maximum)) : 0 - readonly property string title: volumeActivity ? I18n.tr("Volume", "island system face: volume title") : I18n.tr("Brightness", "island system face: brightness title") - readonly property string unit: volumeActivity ? "%" : BrightnessService.brightnessUnit(brightnessDevice) - readonly property string displayValue: muted ? I18n.tr("Muted", "island system face: muted value label") : Math.round(value) + unit - readonly property string iconName: volumeActivity ? AudioService.sinkVolumeIconName : BrightnessService.brightnessIconName(brightnessDevice, value) + readonly property string title: { + switch (kind) { + case "volume": + return I18n.tr("Volume", "island system face: volume title"); + case "mic": + return I18n.tr("Microphone", "island system face: microphone title"); + case "brightness": + return I18n.tr("Brightness", "island system face: brightness title"); + case "capslock": + return I18n.tr("Caps lock", "island system face: caps lock title"); + case "powerprofile": + return I18n.tr("Power profile", "island system face: power profile title"); + case "idleinhibitor": + return I18n.tr("Idle inhibitor", "island system face: idle inhibitor title"); + case "charging": + return BatteryService.isPluggedIn ? I18n.tr("Plugged in", "island system face: power connected title") : I18n.tr("Discharging", "island system face: power disconnected title"); + } + return ""; + } + readonly property string unit: brightnessActivity ? BrightnessService.brightnessUnit(brightnessDevice) : "%" + readonly property string displayValue: { + switch (kind) { + case "capslock": + return DMSService.capsLockState ? I18n.tr("On") : I18n.tr("Off"); + case "powerprofile": + return Theme.getPowerProfileLabel(PowerProfileWatcher.currentProfile); + case "idleinhibitor": + return SessionService.idleInhibited ? I18n.tr("On") : I18n.tr("Off"); + case "charging": + return root.chargingValue(); + } + return muted ? I18n.tr("Muted", "island system face: muted value label") : Math.round(value) + unit; + } + readonly property string iconName: { + switch (kind) { + case "volume": + return AudioService.sinkVolumeIconName; + case "mic": + return muted ? "mic_off" : "mic"; + case "brightness": + return BrightnessService.brightnessIconName(brightnessDevice, value); + case "capslock": + return DMSService.capsLockState ? "shift_lock" : "shift_lock_off"; + case "powerprofile": + return Theme.getPowerProfileIcon(PowerProfileWatcher.currentProfile); + case "idleinhibitor": + return SessionService.idleInhibited ? "motion_sensor_active" : "motion_sensor_idle"; + case "charging": + return BatteryService.getBatteryIcon(); + } + return ""; + } + // Level pulses carry their own controls, so only state pulses link out. + readonly property string controlCenterSection: { + switch (kind) { + case "mic": + return "audioInput"; + case "powerprofile": + case "charging": + return "battery"; + case "idleinhibitor": + return "idleInhibitor"; + } + return ""; + } + + function chargingValue() { + const level = Math.round(BatteryService.batteryLevel) + "%"; + const seconds = BatteryService.estimatedSeconds(); + if (!seconds) + return level; + return level + " ยท " + BatteryService.formatTimeRemaining(); + } function show(activityKind) { if (!enabled || SessionData.suppressOSD) @@ -40,17 +133,27 @@ QtObject { function setRatio(nextRatio) { const clampedRatio = Math.max(0, Math.min(1, nextRatio)); SessionData.suppressOSDTemporarily(); - if (volumeActivity) { + switch (kind) { + case "volume": AudioService.setVolume(Math.round(clampedRatio * maximum)); return; + case "mic": + AudioService.setMicVolume(Math.round(clampedRatio * maximum)); + return; + case "brightness": + BrightnessService.setBrightness(Math.round(clampedRatio * maximum), BrightnessService.lastIpcDevice, true); + return; } - BrightnessService.setBrightness(Math.round(clampedRatio * maximum), BrightnessService.lastIpcDevice, true); } function toggleMute() { - if (!volumeActivity || !available) + if (!mutable || !available) return; SessionData.suppressOSDTemporarily(); + if (micActivity) { + AudioService.toggleMicMute(); + return; + } AudioService.toggleMute(); } @@ -68,6 +171,24 @@ QtObject { } } + property Connections micConnection: Connections { + target: AudioService.source?.audio ?? null + + function onMutedChanged() { + if (SettingsData.osdMicMuteEnabled) + root.show("mic"); + } + } + + property Connections micServiceConnection: Connections { + target: AudioService + + function onMicVolumeChanged() { + if (SettingsData.osdMicVolumeEnabled) + root.show("mic"); + } + } + property Connections brightnessConnection: Connections { target: BrightnessService @@ -76,4 +197,40 @@ QtObject { root.show("brightness"); } } + + property Connections capsLockConnection: Connections { + target: DMSService + + function onCapsLockStateChanged() { + if (SettingsData.osdCapsLockEnabled) + root.show("capslock"); + } + } + + property Connections powerProfileConnection: Connections { + target: PowerProfileWatcher + + function onProfileChanged() { + if (SettingsData.osdPowerProfileEnabled) + root.show("powerprofile"); + } + } + + property Connections idleInhibitorConnection: Connections { + target: SessionService + + function onInhibitorChanged() { + if (SettingsData.osdIdleInhibitorEnabled) + root.show("idleinhibitor"); + } + } + + property Connections batteryConnection: Connections { + target: BatteryService + + function onIsPluggedInChanged() { + if (root.chargingPulseEnabled && BatteryService.batteryAvailable) + root.show("charging"); + } + } } diff --git a/quickshell/Modules/Settings/BarWidgetOptions/BatteryOptions.qml b/quickshell/Modules/Settings/BarWidgetOptions/BatteryOptions.qml index dbd926a3d..e98281695 100644 --- a/quickshell/Modules/Settings/BarWidgetOptions/BatteryOptions.qml +++ b/quickshell/Modules/Settings/BarWidgetOptions/BatteryOptions.qml @@ -8,7 +8,7 @@ Column { property var page: null - readonly property var styleValues: ["icon", "solid", "outline", "ring"] + readonly property var styleValues: ["icon", "solid", "outline", "ring", "duo"] readonly property var barConfig: { SettingsData.barConfigs; return SettingsData.getBarConfig(page?.barId ?? ""); @@ -84,7 +84,7 @@ Column { resetStore: root.page resetKeys: ["batteryStyle"] text: I18n.tr("Battery style") - model: [I18n.tr("Icon", "battery widget: system battery glyph"), I18n.tr("Solid", "island settings: filled battery meter style"), I18n.tr("Outline", "island settings: outlined battery meter style"), I18n.tr("Circle", "island settings: circular battery meter style")] + model: [I18n.tr("Icon", "battery widget: system battery glyph"), I18n.tr("Solid", "island settings: filled battery meter style"), I18n.tr("Outline", "island settings: outlined battery meter style"), I18n.tr("Circle", "island settings: circular battery meter style"), I18n.tr("Duo", "battery meter style: open battery arc around the network glyph")] currentIndex: Math.max(0, root.styleValues.indexOf(root.page.value("batteryStyle"))) onSelectionChanged: (index, selected) => { if (selected) diff --git a/quickshell/Modules/Settings/Widgets/IslandHomeCard.qml b/quickshell/Modules/Settings/Widgets/IslandHomeCard.qml index 35571e4a8..06482f8d1 100644 --- a/quickshell/Modules/Settings/Widgets/IslandHomeCard.qml +++ b/quickshell/Modules/Settings/Widgets/IslandHomeCard.qml @@ -14,7 +14,7 @@ SettingsCard { readonly property var clockDisplayValues: ["time", "date", "both"] readonly property var systemLevelDisplayValues: ["icon", "percentage", "both"] readonly property var statusContentValues: ["battery", "connectivity"] - readonly property var batteryStyleValues: ["solid", "outline", "ring"] + readonly property var batteryStyleValues: ["solid", "outline", "ring", "duo"] readonly property bool batteryShown: SettingsData.islandHomeGroupEnabled(root.store.config, "status") && BatteryService.batteryAvailable && SettingsData.islandHomeStatusContent(root.store.config) === "battery" @@ -110,12 +110,12 @@ SettingsCard { SettingsButtonGroupRow { settingKey: root.keyPrefix + "BatteryStyle" - tags: ["island", "battery", "gauge", "solid", "outline", "ring", "circle", "appearance"] + tags: ["island", "battery", "gauge", "solid", "outline", "ring", "circle", "duo", "wifi", "appearance"] resetStore: root.store resetKeys: ["islandBatteryStyle"] text: I18n.tr("Battery style", "island settings: battery meter style row") visible: root.batteryShown - model: [I18n.tr("Solid", "island settings: filled battery meter style"), I18n.tr("Outline", "island settings: outlined battery meter style"), I18n.tr("Circle", "island settings: circular battery meter style")] + model: [I18n.tr("Solid", "island settings: filled battery meter style"), I18n.tr("Outline", "island settings: outlined battery meter style"), I18n.tr("Circle", "island settings: circular battery meter style"), I18n.tr("Duo", "battery meter style: open battery arc around the network glyph")] values: root.batteryStyleValues value: root.store.setting("islandBatteryStyle") fallbackValue: "solid" diff --git a/quickshell/Modules/Settings/Widgets/IslandHomeLayoutEditor.qml b/quickshell/Modules/Settings/Widgets/IslandHomeLayoutEditor.qml index ac198b012..3dca033b0 100644 --- a/quickshell/Modules/Settings/Widgets/IslandHomeLayoutEditor.qml +++ b/quickshell/Modules/Settings/Widgets/IslandHomeLayoutEditor.qml @@ -55,6 +55,11 @@ Column { "icon": "notifications", "text": I18n.tr("Notifications", "island settings: notification badge slot row"), "description": "" + }, + "privacy": { + "icon": "privacy_tip", + "text": I18n.tr("Privacy", "island settings: microphone, camera and screen share indicator slot row"), + "description": I18n.tr("Shown only while the microphone, camera or screen sharing is in use", "island settings: privacy slot hint") } }) diff --git a/quickshell/Modules/Settings/Widgets/IslandNotificationsCard.qml b/quickshell/Modules/Settings/Widgets/IslandNotificationsCard.qml index 1b83aceeb..5637b4690 100644 --- a/quickshell/Modules/Settings/Widgets/IslandNotificationsCard.qml +++ b/quickshell/Modules/Settings/Widgets/IslandNotificationsCard.qml @@ -1,5 +1,6 @@ import QtQuick import qs.Common +import qs.Services SettingsCard { id: root @@ -27,7 +28,7 @@ SettingsCard { SettingsToggleRow { settingKey: root.keyPrefix + "SystemOsd" - tags: ["island", "volume", "brightness", "osd", "level", "standard"] + tags: ["island", "volume", "brightness", "microphone", "caps lock", "power profile", "idle inhibitor", "osd", "level", "standard"] resetStore: root.store resetKeys: ["islandSystemOsd"] text: I18n.tr("Use standard OSDs", "island settings: show volume and brightness changes as the regular on-screen displays instead of in the island") @@ -35,6 +36,18 @@ SettingsCard { onToggled: checked => root.store.apply("islandSystemOsd", !checked) } + SettingsToggleRow { + settingKey: root.keyPrefix + "ChargingPulse" + tags: ["island", "battery", "charging", "plugged", "power", "pulse"] + resetStore: root.store + resetKeys: ["islandChargingPulse"] + text: I18n.tr("Charging", "island settings: pulse the island when power is connected or removed") + description: I18n.tr("Show the battery briefly when power is connected or removed", "island settings: charging pulse toggle description") + visible: root.store.setting("islandSystemOsd") && BatteryService.batteryAvailable + checked: root.store.setting("islandChargingPulse") + onToggled: checked => root.store.apply("islandChargingPulse", checked) + } + SettingsToggleRow { settingKey: root.keyPrefix + "NotificationExpand" tags: ["island", "notifications", "expand", "arrival", "size"] diff --git a/quickshell/Widgets/BatteryMeter.qml b/quickshell/Widgets/BatteryMeter.qml index 0b35e3669..6bb680de3 100644 --- a/quickshell/Widgets/BatteryMeter.qml +++ b/quickshell/Widgets/BatteryMeter.qml @@ -22,6 +22,12 @@ Item { readonly property bool outlined: root.meterStyle === "outline" readonly property bool ring: root.meterStyle === "ring" + // Duo: an open arc around the network glyph, signal dots in the gap. + readonly property bool duo: root.meterStyle === "duo" + readonly property bool circular: root.ring || root.duo + readonly property real arcStart: root.duo ? 150 : -90 + readonly property real arcSpan: root.duo ? 240 : 360 + readonly property real chargeTarget: Math.max(root.level, Math.min(100, SettingsData.batteryChargeLimit)) readonly property real unit: root.thickness / 14 readonly property real level: Math.max(0, Math.min(100, BatteryService.batteryLevel)) readonly property bool charging: BatteryService.batteryAvailable && BatteryService.isCharging @@ -39,6 +45,8 @@ Item { } readonly property color dimColor: Theme.withAlpha(root.fillColor, root.hovered ? 0.6 : 0.48) readonly property color trackColor: { + if (root.duo) + return Theme.withAlpha(root.fillColor, root.hovered ? 0.3 : 0.16); if (root.ring) return Theme.withAlpha(root.fillColor, root.hovered ? 0.4 : 0.26); if (root.outlined) @@ -52,7 +60,7 @@ Item { } readonly property int glyphWeight: Theme.fontWeight readonly property string numberText: Math.round(root.level).toString() - readonly property bool boltVisible: root.charging && root.showBolt + readonly property bool boltVisible: root.charging && root.showBolt && !root.duo readonly property bool numberInside: !root.vertical && root.showNumber && BatteryService.batteryAvailable readonly property bool ringNumberVisible: root.ring && root.showNumber && !root.boltVisible && BatteryService.batteryAvailable readonly property real strokeWidth: root.outlined ? 1.5 * root.unit : 0 @@ -72,9 +80,9 @@ Item { readonly property real ringInnerRadius: Math.max(1, (root.ringDiameter - 2 * root.ringStroke) / 2) readonly property real ringChordWidth: 2 * Math.sqrt(Math.max(1, Math.pow(root.ringInnerRadius, 2) - Math.pow(fitMetrics.tightBoundingRect.height / 2, 2))) readonly property real ringTextSize: root.textNeed > 0 ? Math.min(root.baseTextSize, root.baseTextSize * root.ringChordWidth / root.textNeed) : root.baseTextSize - readonly property real textSize: root.ring ? root.ringTextSize : root.baseTextSize + readonly property real textSize: root.circular ? root.ringTextSize : root.baseTextSize readonly property real textBaseline: root.height / 2 - digitInk.tightBoundingRect.y - digitInk.tightBoundingRect.height / 2 - readonly property real boltBadgeSize: Math.round((root.ring ? 12 : 9) * root.unit) + readonly property real boltBadgeSize: Math.round((root.circular ? 12 : 9) * root.unit) readonly property real boltBadgeWidth: Math.round(root.boltBadgeSize * (8 / 13)) readonly property real bodyLength: Math.max(Math.round(25 * root.unit), Math.ceil(root.numberInside ? root.textNeed + root.textCanvasLeft + 1.5 * root.unit : 0)) readonly property real capGap: Math.max(1, Math.round(root.unit)) @@ -82,8 +90,8 @@ Item { readonly property real capBreadth: Math.max(1, Math.round(1.25 * root.unit)) readonly property real capSpan: Math.round(6 * root.unit) - implicitWidth: root.ring ? root.ringDiameter : root.vertical ? Math.round(14 * root.unit) : root.capOffset + (root.boltVisible ? root.boltBadgeWidth : root.capBreadth) - implicitHeight: root.ring ? root.ringDiameter : root.vertical ? root.capOffset + root.capBreadth : Math.round(14 * root.unit) + implicitWidth: root.circular ? root.ringDiameter : root.vertical ? Math.round(14 * root.unit) : root.capOffset + (root.boltVisible ? root.boltBadgeWidth : root.capBreadth) + implicitHeight: root.circular ? root.ringDiameter : root.vertical ? root.capOffset + root.capBreadth : Math.round(14 * root.unit) StyledTextMetrics { id: fitMetrics @@ -151,7 +159,7 @@ Item { Shape { id: gauge - property real sweep: root.level * 3.6 + property real sweep: root.level / 100 * root.arcSpan anchors.fill: parent preferredRendererType: Shape.CurveRenderer @@ -174,8 +182,24 @@ Item { centerY: root.ringDiameter / 2 radiusX: root.ringRadius radiusY: root.ringRadius - startAngle: -90 - sweepAngle: 360 + startAngle: root.arcStart + sweepAngle: root.arcSpan + } + } + + ShapePath { + fillColor: "transparent" + strokeColor: root.duo && root.charging ? Theme.withAlpha(root.fillColor, 0.45) : "transparent" + strokeWidth: root.ringStroke + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: root.ringDiameter / 2 + centerY: root.ringDiameter / 2 + radiusX: root.ringRadius + radiusY: root.ringRadius + startAngle: root.arcStart + gauge.sweep + sweepAngle: Math.max(0, (root.chargeTarget - root.level) / 100 * root.arcSpan) } } @@ -190,7 +214,7 @@ Item { centerY: root.ringDiameter / 2 radiusX: root.ringRadius radiusY: root.ringRadius - startAngle: -90 + startAngle: root.arcStart sweepAngle: gauge.sweep } } @@ -198,13 +222,81 @@ Item { } Loader { - active: root.ring + active: root.circular anchors.centerIn: parent width: root.ringDiameter height: root.ringDiameter sourceComponent: ringGauge } + Loader { + active: root.duo + anchors.centerIn: parent + width: root.ringDiameter + height: root.ringDiameter + sourceComponent: duoFace + } + + Component { + id: duoFace + + Item { + id: face + + readonly property string networkIcon: { + if (NetworkService.wifiToggling) + return "sync"; + switch (NetworkService.networkStatus) { + case "ethernet": + return "lan"; + case "cellular": + return "network_cell"; + case "vpn": + return NetworkService.ethernetConnected ? "lan" : (NetworkService.cellularConnected ? "network_cell" : NetworkService.wifiSignalIcon); + } + return NetworkService.wifiEnabled && NetworkService.wifiAvailable ? NetworkService.wifiSignalIcon : "wifi_off"; + } + // Inner pair first so a partial signal stays symmetric. + readonly property int litDots: NetworkService.wifiConnected ? (NetworkService.wifiSignalStrength > 50 ? 4 : 2) : 0 + readonly property real dotSize: Math.max(2, Math.round(root.ringStroke * 1.25)) + readonly property int glyphInset: Math.round(root.ringDiameter * 0.2) + + DankIcon { + id: glyph + + x: face.glyphInset + y: face.glyphInset + name: face.networkIcon + size: root.ringDiameter - 2 * face.glyphInset + color: NetworkService.networkStatus !== "disconnected" ? Theme.surfaceText : Theme.surfaceTextMedium + + DankBlink { + target: glyph + running: face.visible && (NetworkService.wifiToggling || NetworkService.isWifiConnecting) + } + } + + Repeater { + model: 4 + + Rectangle { + id: dot + + required property int index + readonly property real angle: (54 + dot.index * 24) * Math.PI / 180 + readonly property bool lit: dot.index === 1 || dot.index === 2 ? face.litDots >= 2 : face.litDots >= 4 + + x: root.ringDiameter / 2 + Math.cos(dot.angle) * root.ringRadius - width / 2 + y: root.ringDiameter / 2 + Math.sin(dot.angle) * root.ringRadius - height / 2 + width: face.dotSize + height: face.dotSize + radius: width / 2 + color: dot.lit ? Theme.surfaceText : Theme.withAlpha(Theme.surfaceText, 0.16) + } + } + } + } + NumericText { isMonospace: false visible: root.ringNumberVisible @@ -224,7 +316,7 @@ Item { width: root.vertical ? root.capSpan : root.capBreadth height: root.vertical ? root.capBreadth : root.capSpan radius: root.capBreadth / 2 - visible: !root.ring && (root.vertical || !root.boltVisible) + visible: !root.circular && (root.vertical || !root.boltVisible) color: root.outlined ? root.fillColor : root.dimColor } @@ -236,7 +328,7 @@ Item { width: root.vertical ? root.width : root.bodyLength height: root.vertical ? root.bodyLength : root.height radius: 4 * root.unit - visible: !root.ring + visible: !root.circular color: root.trackColor border.width: root.strokeWidth border.color: root.fillColor @@ -250,7 +342,7 @@ Item { width: frame.width - root.strokeWidth * 2 height: frame.height - root.strokeWidth * 2 radius: Math.max(0, frame.radius - root.strokeWidth) - visible: !root.ring + visible: !root.circular color: "transparent" Rectangle { @@ -301,7 +393,7 @@ Item { } Glyphs { - visible: root.numberInside && !root.ring + visible: root.numberInside && !root.circular ink: Theme.surfaceText } @@ -311,7 +403,7 @@ Item { width: fill.width height: fill.height clip: true - visible: root.numberInside && !root.outlined && !root.ring + visible: root.numberInside && !root.outlined && !root.circular Glyphs { x: -parent.x @@ -322,8 +414,8 @@ Item { Bolt { visible: root.boltVisible - x: root.ring || root.vertical ? (root.width - width) / 2 : root.capOffset - y: !root.ring && root.vertical ? frame.y + (frame.height - height) / 2 : (root.height - height) / 2 + x: root.circular || root.vertical ? (root.width - width) / 2 : root.capOffset + y: !root.circular && root.vertical ? frame.y + (frame.height - height) / 2 : (root.height - height) / 2 fillColor: Theme.surfaceText } } diff --git a/quickshell/tests/island-routing.test.mjs b/quickshell/tests/island-routing.test.mjs index 2c1b89b31..4dc5638a4 100644 --- a/quickshell/tests/island-routing.test.mjs +++ b/quickshell/tests/island-routing.test.mjs @@ -593,3 +593,57 @@ test("an expanding hosted island closes its own bar's popout only for deliberate host.closeSameEdgeSurfaces(); assert.deepEqual(closed.splice(0), ["modal:launcher"], "connected chrome closes only the same-edge slot, and only this screen's modal"); }); + +const controllerSource = readFileSync(new URL("../Modules/DankIsland/IslandController.qml", import.meta.url), "utf8"); + +function controllerContext() { + const context = vm.createContext({ + systemLevelActivities: ["volume", "brightness", "mic"], + systemStateActivities: ["capslock", "powerprofile", "idleinhibitor", "charging"], + activeActivity: "home", + expanded: false, + notificationActive: false, + launcherSessionActive: false, + transientReturnActivity: "", + hoverExpanded: false, + keyboardDismissRequested: false, + transientTimer: { stop() {}, restart() {} }, + notificationTimer: { stop() {} }, + hoverOpenTimer: { stop() {} }, + hoverCloseTimer: { stop() {} }, + destinations: [], + destinationState: {}, + destinationRevision: 0, + timeoutSuspended: false + }); + context.root = context; + context.systemActivities = context.systemLevelActivities.concat(context.systemStateActivities); + Object.defineProperty(context, "systemActivityActive", { get: () => context.isSystemActivity(context.activeActivity) }); + return functions(controllerSource, " ", context); +} + +test("state pulses are system transients that return to the interrupted activity", () => { + const controller = controllerContext(); + controller.activeActivity = "media"; + assert.equal(controller.requestSystemActivity("capslock"), true); + assert.equal(controller.activeActivity, "capslock"); + assert.equal(controller.transientReturnActivity, "media"); + assert.equal(controller.requestSystemActivity("charging"), true, "a second pulse replaces the first"); + assert.equal(controller.transientReturnActivity, "media", "the return point is kept across chained pulses"); + assert.equal(controller.requestSystemActivity("bogus"), false); + assert.equal(controller.activeActivity, "charging"); + controller.expanded = true; + assert.equal(controller.requestSystemActivity("mic"), false, "an open sheet is not interrupted"); +}); + +test("the privacy slot is a known home layout entry appended after stored ones", () => { + const groupIds = JSON.parse(settingsSource.match(/_islandHomeGroupIds: (\[.*\])/)[1]); + const layoutDefault = JSON.parse(settingsSource.match(/_islandHomeLayoutDefault: (\[[\s\S]*?\n \])/)[1]); + const settings = functions(settingsSource, " ", vm.createContext({ _islandHomeGroupIds: groupIds, _islandHomeLayoutDefault: layoutDefault })); + settings.islandSetting = (config, key) => config[key]; + settings.islandSettings = config => config; + const legacy = { islandHomeLayout: [{ id: "clock" }, { id: "media", enabled: false }] }; + const ids = settings.getIslandHomeLayout(legacy).map(entry => entry.id); + assert.ok(ids.includes("privacy"), "a stored layout gains the privacy slot"); + assert.equal(ids.indexOf("privacy"), ids.length - 1, "new slots append after the stored order"); +}); diff --git a/quickshell/tests/qml/island-pulses.qml b/quickshell/tests/qml/island-pulses.qml new file mode 100644 index 000000000..86fffc21f --- /dev/null +++ b/quickshell/tests/qml/island-pulses.qml @@ -0,0 +1,149 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Modules.DankBar +import qs.Modules.DankIsland +import qs.Modules.Frame +import qs.DankCommon.Common as DC + +// State pulses (caps lock, charging) and the mic level share the system transient with volume, and the duo status glyph loads in the home face. +ShellRoot { + id: root + + readonly property var screen: Quickshell.screens[0] + readonly property string barId: "main" + + Item { + Repeater { + model: ScriptModel { + values: SettingsData.barConfigs + objectProp: "id" + } + delegate: DankBar { + required property var modelData + barConfig: modelData + } + } + } + + Frame {} + + DankIsland {} + + Component.onCompleted: { + Quickshell.watchFiles = false; + DC.Style.theme = Theme; + DC.Style.settings = SettingsData; + DC.I18n.backend = I18n; + } + + Timer { + interval: 0 + running: SettingsData._hasLoaded && SessionData._hasLoaded + onTriggered: { + SettingsData.reduceMotion = true; + SettingsData.frameMode = "connected"; + SettingsData.frameEnabled = true; + FrameTransitionState.acknowledge(FrameTransitionState.revision); + SettingsData.barConfigs = [ + { + id: root.barId, + enabled: true, + visible: true, + position: 0, + screenPreferences: ["all"], + leftWidgets: ["clock"], + centerWidgets: [ + { + id: "island", + enabled: true, + islandBatteryStyle: "duo", + islandHomeLayout: [ + { + id: "status", + enabled: true + }, + { + id: "clock", + enabled: true + } + ] + } + ], + rightWidgets: ["clock"] + } + ]; + } + } + + Timer { + id: steps + + property int step: 0 + property int waited: 0 + readonly property var body: IslandHostRegistry.hostFor(root.screen.name, root.barId) + + interval: 25 + repeat: true + running: !!body && !!body.islandController && SettingsData.frameEnabled + + function finish(message) { + if (message) + console.error("FIXTURE_FAIL", message); + else + console.log("FIXTURE_PASS"); + stop(); + Qt.quit(); + } + + function expectActivity(activityId) { + if (body.islandController.activeActivity === activityId) + return true; + finish("expected " + activityId + " but the island shows " + body.islandController.activeActivity); + return false; + } + + onTriggered: { + if (++waited > 800) { + finish("timed out at step " + step); + return; + } + if (body.motionRunning || body.bandThickness <= 0) + return; + const controller = body.islandController; + waited = 0; + switch (step++) { + case 0: + if (!controller.requestSystemActivity("capslock")) { + finish("caps lock is not a system transient"); + return; + } + expectActivity("capslock"); + return; + case 1: + if (!controller.requestSystemActivity("charging")) + finish("charging is not a system transient"); + return; + case 2: + if (!expectActivity("charging")) + return; + if (controller.compactTargetFor("charging").width === controller.compactTargetFor("volume").width) + finish("a state pulse borrows the level pill width"); + return; + case 3: + if (!controller.requestSystemActivity("mic")) + finish("mic is not a system level"); + return; + case 4: + if (!expectActivity("mic")) + return; + controller.requestCollapse(); + return; + default: + if (expectActivity("home")) + finish(""); + } + } + } +} diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index e53a8ebce..c2d88755e 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -3181,6 +3181,40 @@ "description": "Routes launcher, dash, control center and notification shortcuts", "conditionKey": "islandEnabled" }, + { + "section": "islandChargingPulse", + "label": "Charging", + "tabIndex": 3, + "category": "General", + "keywords": [ + "bar", + "battery", + "briefly", + "charge", + "charging", + "connected", + "general", + "hibernate", + "island", + "panel", + "plugged", + "power", + "pulse", + "reboot", + "removed", + "restart", + "show", + "shutdown", + "sleep", + "statusbar", + "suspend", + "taskbar", + "topbar" + ], + "parentLabel": "Bar", + "description": "Show the battery briefly when power is connected or removed", + "conditionKey": "islandEnabled" + }, { "section": "islandNotificationBadgeClearOnOpen", "label": "Clear badge on open", @@ -4257,12 +4291,16 @@ "keywords": [ "bar", "brightness", + "caps lock", "general", + "idle inhibitor", "island", "level", + "microphone", "osd", "osds", "panel", + "power profile", "standard", "statusbar", "taskbar", @@ -4802,6 +4840,7 @@ "charge", "charging", "circle", + "duo", "gauge", "island", "outline", @@ -4812,7 +4851,8 @@ "statusbar", "style", "taskbar", - "topbar" + "topbar", + "wifi" ], "parentLabel": "Bar", "conditionKey": "islandEnabled" @@ -9814,7 +9854,15 @@ "optionFile": "PrivacyOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:26", + "section": "barWidgetOption:IslandOptions.qml:25", + "label": "Charging", + "tabIndex": 22, + "keywords": [], + "runtimeType": "barWidgetOption", + "optionFile": "IslandOptions.qml" + }, + { + "section": "barWidgetOption:IslandOptions.qml:27", "label": "Clear badge on open", "tabIndex": 22, "keywords": [], @@ -10030,7 +10078,7 @@ "optionFile": "AppsDockOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:25", + "section": "barWidgetOption:IslandOptions.qml:26", "label": "Expand by default", "tabIndex": 22, "keywords": [], @@ -10326,7 +10374,7 @@ "optionFile": "WorkspaceSwitcherOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:27", + "section": "barWidgetOption:IslandOptions.qml:28", "label": "Motion", "tabIndex": 22, "keywords": [], @@ -10422,7 +10470,7 @@ "optionFile": "AppsDockOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:28", + "section": "barWidgetOption:IslandOptions.qml:29", "label": "Reduce motion", "tabIndex": 22, "keywords": [], @@ -10638,7 +10686,7 @@ "optionFile": "AppsDockOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:30", + "section": "barWidgetOption:IslandOptions.qml:31", "label": "Spring damping", "tabIndex": 22, "keywords": [], @@ -10646,7 +10694,7 @@ "optionFile": "IslandOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:31", + "section": "barWidgetOption:IslandOptions.qml:32", "label": "Spring mass", "tabIndex": 22, "keywords": [], @@ -10654,7 +10702,7 @@ "optionFile": "IslandOptions.qml" }, { - "section": "barWidgetOption:IslandOptions.qml:29", + "section": "barWidgetOption:IslandOptions.qml:30", "label": "Spring stiffness", "tabIndex": 22, "keywords": [], @@ -16854,6 +16902,40 @@ "description": "Routes launcher, dash, control center and notification shortcuts", "conditionKey": "dotEnabled" }, + { + "section": "dotChargingPulse", + "label": "Charging", + "tabIndex": 65, + "category": "Dot", + "keywords": [ + "bar", + "battery", + "briefly", + "charge", + "charging", + "connected", + "dot", + "hibernate", + "island", + "panel", + "plugged", + "power", + "pulse", + "reboot", + "removed", + "restart", + "show", + "shutdown", + "sleep", + "statusbar", + "suspend", + "taskbar", + "topbar" + ], + "parentLabel": "Bar", + "description": "Show the battery briefly when power is connected or removed", + "conditionKey": "dotEnabled" + }, { "section": "dotNotificationBadgeClearOnOpen", "label": "Clear badge on open", @@ -17491,12 +17573,16 @@ "keywords": [ "bar", "brightness", + "caps lock", "dot", + "idle inhibitor", "island", "level", + "microphone", "osd", "osds", "panel", + "power profile", "standard", "statusbar", "taskbar",