Add transient settings OSD for Sway

This commit is contained in:
fy59 2026-10-09 17:28:41 +02:00
parent 195b777b84
commit 2bafbcd2be
14 changed files with 412 additions and 3 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -27,6 +27,7 @@ PanelWindow {
required property var applicationCoordinator
required property bool authenticationActive
required property var keepAwakeController
required property var osdService
screen: modelData
// INVARIANT: une seule surface de barre porte l'inhibiteur, même avec
@ -454,6 +455,7 @@ PanelWindow {
adapter: controlCenter.adapter
controlCenterOpen: controlCenter.visible
audioBrightnessController: controlCenter
onAdjustedFromBar: kind => bar.osdService.barAction(kind, bar.screen)
keepAwakeController: bar.keepAwakeController
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
onOpenPageRequested: page => {
@ -496,6 +498,27 @@ PanelWindow {
barWindow: bar
keepAwakeController: bar.keepAwakeController
onThresholdApplied: rightStatusArea.refreshBatteryThreshold()
onVisibleChanged: bar.osdService.setPanel(bar.screen, visible, currentPage)
onCurrentPageChanged: bar.osdService.setPanel(bar.screen, visible, currentPage)
}
Connections {
target: controlCenter
function snapshot() {
if (!bar.startupHost) return;
bar.osdService.sampleSink(controlCenter.volumeIdentity,
controlCenter.volumeAvailable, controlCenter.volumeRaw,
controlCenter.volumePercent, controlCenter.volumeMuted);
bar.osdService.sampleBrightness(controlCenter.brightnessIdentity,
controlCenter.brightnessAvailable, controlCenter.brightnessRaw,
controlCenter.brightnessPercent);
}
function onVolumeIdentityChanged() { snapshot(); }
function onVolumeAvailableChanged() { snapshot(); }
function onVolumeRawChanged() { snapshot(); }
function onVolumeMutedChanged() { snapshot(); }
function onBrightnessAvailableChanged() { snapshot(); }
function onBrightnessRawChanged() { snapshot(); }
Component.onCompleted: snapshot()
}
DateCenter {

View file

@ -0,0 +1,30 @@
import Quickshell
import Quickshell.Wayland
import "osd"
import "theme"
// Données de démonstration uniquement : aucun backend audio ni rétroéclairage.
ShellRoot {
// Fond de démonstration neutre sous la carte translucide : la capture ne
// révèle aucun pixel du bureau ni d'une application réelle.
PanelWindow {
screen: Quickshell.screens[0]
anchors { left: true; bottom: true }
margins.left: Math.max(0, Math.round((screen.width - implicitWidth) / 2))
margins.bottom: Math.min(40, Math.max(8, Math.round(screen.height / 20)))
implicitWidth: Math.min(320, Math.max(1, screen.width - 16))
implicitHeight: 80
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "labfy-osd-visual-backdrop"
mask: Region { width: 0; height: 0 }
color: Theme.surface0
}
OsdWindow {
hostScreen: Quickshell.screens[0]
kind: "brightness"
percent: 42
muted: false
}
}

View file

@ -211,6 +211,10 @@ Control Center, avec un minimum sûr de 10 % et un maximum de 100 %. Ses
écriture, y compris si le pilote arrondit. L'absence de sortie audio ou de
rétroéclairage désactive l'icône concernée.
L'OSD des réglages est décrit dans [osd/README.md](osd/README.md). Il observe
ces mêmes valeurs confirmées, ainsi que la sourdine de la source audio par
défaut, sans modifier les commandes des touches matérielles.
Le libellé `RESIZE` en Catppuccin Peach apparaît à gauche des workspaces
quand l'événement IPC Sway `mode` annonce `resize`. Un seul abonnement
`I3IpcListener` partage cet état entre toutes les sorties. Une requête

View file

@ -27,8 +27,12 @@ PopupWindow {
readonly property bool volumeAvailable: volumeSlider.sinkAudio !== null
readonly property bool volumeMuted: volumeSlider.muted
readonly property int volumePercent: volumeSlider.percent
readonly property var volumeIdentity: volumeSlider.sink
readonly property real volumeRaw: volumeSlider.sinkAudio ? volumeSlider.sinkAudio.volume : NaN
readonly property bool brightnessAvailable: brightnessSlider.available
readonly property int brightnessPercent: brightnessSlider.percent
readonly property string brightnessIdentity: brightnessSlider.backlightPath
readonly property real brightnessRaw: brightnessSlider.brightness
function adjustVolume(steps) { volumeSlider.adjustBy(steps); }
function adjustBrightness(steps) { brightnessSlider.adjustBy(steps); }
readonly property var pendingAction: sessionExitGate.pendingAction

View file

@ -0,0 +1,36 @@
.pragma library
// CONTRACT: chaque identité de périphérique a une première valeur de référence,
// qui ne représente jamais une action. Les valeurs brutes détectent aussi les
// changements arrondis vers le même pourcentage affiché.
function observe(previous, identity, available, raw, muted) {
if (!identity || !available || !Number.isFinite(raw))
return { state: null, changed: false, volumeChanged: false, muteChanged: false };
const state = { identity: identity, raw: raw, muted: !!muted };
if (!previous || previous.identity !== identity)
return { state: state, changed: false, volumeChanged: false, muteChanged: false };
const volumeChanged = previous.raw !== raw;
const muteChanged = previous.muted !== !!muted;
return { state: state, changed: volumeChanged || muteChanged,
volumeChanged: volumeChanged, muteChanged: muteChanged };
}
function nextCard(card, kind, value, muted, screen, now) {
return { kind: kind, value: value, muted: muted, screen: screen, deadline: now + 1500,
generation: card ? card.generation + 1 : 1 };
}
function expire(card, generation, now) {
return !!card && card.generation === generation && now >= card.deadline;
}
function screenForEvent(screens, focusedName, origin, kind, now) {
if (origin && origin.kind === kind && origin.until >= now
&& screens.indexOf(origin.screen) >= 0) return origin.screen;
return screens.find(screen => screen.name === focusedName) || screens[0] || null;
}
function pageShows(kind, page) {
return kind === "brightness" ? page === 0
: kind === "microphone" ? page === 15 : page === 0 || page === 15;
}

View file

@ -0,0 +1,108 @@
import QtQuick
import Quickshell
import Quickshell.I3
import Quickshell.Services.Pipewire
import "Observation.js" as Observation
Item {
id: service
required property bool authenticationActive
property var sinkState: null
property var brightnessState: null
property var sourceState: null
property var card: null
property var panels: ({})
property var origin: null
readonly property var source: Pipewire.defaultAudioSource
readonly property var sourceAudio: source && source.ready && source.audio ? source.audio : null
// WHY: AudioMixerPage ne suit les nœuds que pendant son ouverture. Ce
// tracker léger permet de lire la sourdine du défaut sans ouvrir de flux.
PwObjectTracker { objects: [service.source] }
onSourceAudioChanged: sampleSource()
Connections {
target: service.sourceAudio
function onMutedChanged() { service.sampleSource(); }
}
function sampleSource() {
const audio = sourceAudio;
const result = Observation.observe(sourceState, source, !!audio,
audio ? audio.volume : NaN, audio ? audio.muted : false);
sourceState = result.state;
if (result.muteChanged) publish("microphone", 0, audio.muted);
}
function sampleSink(identity, available, raw, percent, muted) {
const result = Observation.observe(sinkState, identity, available, raw, muted);
sinkState = result.state;
if (result.muteChanged || result.volumeChanged)
publish("volume", percent, muted);
}
function sampleBrightness(identity, available, raw, percent) {
const result = Observation.observe(brightnessState, identity, available, raw, false);
brightnessState = result.state;
if (result.changed) publish("brightness", percent, false);
}
function barAction(kind, screen) {
// L'indice de provenance n'est consommé que par une valeur confirmée ;
// il expire afin qu'une modification externe ultérieure suive le focus.
origin = { kind: kind, screen: screen, until: Date.now() + 700 };
}
function setPanel(screen, visible, page) {
const next = Object.assign({}, panels);
if (screen) next[screen.name] = visible ? page : -1;
panels = next;
}
function panelShows(kind) {
// Tous les Control Centers montrent les mêmes réglages globaux ; une
// page visible sur une autre sortie suffit donc à rendre l'OSD redondant.
return Object.keys(panels).some(name => Observation.pageShows(kind, panels[name]));
}
function focusedScreen() {
const monitor = I3.focusedMonitor;
const screens = Quickshell.screens;
return Observation.screenForEvent(screens, monitor ? monitor.name : "",
null, "", Date.now());
}
function publish(kind, percent, muted) {
if (authenticationActive) return;
const now = Date.now();
const monitor = I3.focusedMonitor;
const screen = Observation.screenForEvent(Quickshell.screens,
monitor ? monitor.name : "", origin, kind, now);
if (origin && (origin.kind === kind || origin.until < now)) origin = null;
if (!screen || panelShows(kind)) return;
// INVARIANT: une seule carte et une seule échéance courante ; un ancien
// callback ne peut fermer une génération plus récente.
card = Observation.nextCard(card, kind, percent, muted, screen, now);
expiry.restart();
}
onAuthenticationActiveChanged: if (authenticationActive) {
card = null;
origin = null;
expiry.stop();
}
Timer {
id: expiry
interval: 1500
repeat: false
property int generation: 0
onTriggered: {
if (Observation.expire(service.card, generation, Date.now())) service.card = null;
}
function restart() {
generation = service.card.generation;
stop();
start();
}
}
LazyLoader {
active: !!service.card && !service.authenticationActive
OsdWindow {
// LazyLoader peut garder l'objet pendant une frame de destruction.
hostScreen: service.card ? service.card.screen : service.focusedScreen()
kind: service.card ? service.card.kind : "volume"
percent: service.card ? service.card.value : 0
muted: service.card ? service.card.muted : false
}
}
}

View file

@ -0,0 +1,93 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "../components"
import "../theme"
PanelWindow {
id: window
required property var hostScreen
required property string kind
required property int percent
required property bool muted
screen: hostScreen
anchors { left: true; bottom: true }
margins.left: Math.max(0, Math.round((hostScreen.width - implicitWidth) / 2))
margins.bottom: Math.min(40, Math.max(8, Math.round(hostScreen.height / 20)))
implicitWidth: Math.min(320, Math.max(1, hostScreen.width - 16))
implicitHeight: 80
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "labfy-setting-osd"
color: "transparent"
// CONTRACT: Region vide au niveau de la fenêtre Wayland : ni clic, ni
// défilement, ni glisser-déposer ne sont dirigés vers la carte.
mask: Region { width: 0; height: 0 }
readonly property color emphasis: kind === "brightness" ? Theme.yellow
: muted ? Theme.danger : Theme.mauve
readonly property string label: kind === "brightness" ? "Luminosité"
: kind === "microphone" ? "Microphone" : "Volume"
readonly property string glyph: kind === "brightness" ? "󰖨"
: kind === "microphone" ? (muted ? "󰍭" : "󰍬")
: (muted ? "󰝟" : "󰕾")
Rectangle {
anchors.fill: parent
radius: 4
// WHY: un léger aperçu du contenu reste visible sans changer de palette.
color: Qt.rgba(Theme.popupBackground.r, Theme.popupBackground.g,
Theme.popupBackground.b, 0.92)
border.color: Theme.outline
border.width: 1
NerdIcon {
id: icon
anchors.left: parent.left
anchors.leftMargin: 16
anchors.top: parent.top
anchors.topMargin: 13
text: window.glyph
color: window.emphasis
font.pixelSize: 26
}
Text {
anchors.left: icon.right
anchors.leftMargin: 12
anchors.verticalCenter: icon.verticalCenter
text: window.label
color: Theme.foreground
font.pixelSize: 15
font.bold: true
}
Text {
anchors.right: parent.right
anchors.rightMargin: 16
anchors.verticalCenter: icon.verticalCenter
text: window.kind === "microphone" ? (window.muted ? "Muet" : "Non muet")
: window.muted ? "Muet · " + window.percent + "%" : window.percent + "%"
color: window.muted ? Theme.danger : Theme.foreground
font.pixelSize: 14
}
Rectangle {
visible: window.kind !== "microphone"
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 16
anchors.rightMargin: 16
anchors.bottom: parent.bottom
anchors.bottomMargin: 15
height: 7
radius: 3
color: Theme.buttonBackground
Rectangle {
width: Math.max(0, Math.min(1, window.percent / 100)) * parent.width
height: parent.height
radius: parent.radius
color: window.emphasis
}
}
}
}

View file

@ -0,0 +1,43 @@
# OSD volume, luminosité et microphone
`OsdService` observe le sink PipeWire par défaut via le `VolumeSlider` déjà
partagé entre la barre et le Control Center. Il observe le rétroéclairage via
le `BrightnessSlider` existant, et suit en plus le nœud source PipeWire par
défaut avec `PwObjectTracker`, même si la page Audio n'a jamais été ouverte.
Ce dernier suivi ne crée aucun flux de capture. Les touches Sway gardent leurs
commandes `pactl` et `brightnessctl`, leurs pas et leurs bornes. Le défilement
de la barre utilise toujours les mêmes curseurs. Un changement externe des
mêmes valeurs est également détecté. Les flux d'applications du mixeur ne
déclenchent rien.
Chaque nouvelle identité de périphérique et chaque reconnexion établissent
une référence sans affichage. Une valeur brute identique est ignorée ; la
carte présente la valeur observée après confirmation, y compris un volume
externe supérieur à 100 %. Seule la jauge est plafonnée. Une valeur
indisponible ne devient pas `0 %`. Le microphone indique `Muet` ou `Non muet`,
sans prétendre détecter une capture active. Lorsqu'un contrôle correspondant
est visible dans un Control Center, l'OSD est supprimé. Toute carte active est
fermée dès le début d'une authentification Polkit, sans rejeu ultérieur.
La surface `labfy-setting-osd` est une seule `PanelWindow` de 320 × 80 pixels
logiques au maximum, centrée en bas à environ 40 pixels de la sortie choisie.
Son fond reprend `Theme.popupBackground` à 92 % d'opacité.
Une action de barre privilégie sa sortie ; les autres changements suivent le
moniteur focalisé de `I3`, avec repli sur la première sortie disponible.
Elle est sur la couche `Overlay`, au-dessus d'un contenu plein écran, avec
`WlrKeyboardFocus.None`, zone d'exclusion ignorée et région d'entrée Wayland
vide. Elle ne prend donc ni focus ni entrée pointeur. Sa création n'ouvre ni
panneau, ni notification, ni `IdleInhibitor` ; la tasse et le pont Firefox ne
sont pas liés à cette surface.
Chaque événement pertinent remplace le contenu de la carte et relance son
délai de 1500 ms. Une génération de minuteur empêche une ancienne échéance
de fermer une carte plus récente. La surface est détruite à l'expiration.
L'Overview évite de capturer cette surface sans supprimer ses miniatures
existantes.
`node tests/quickshell/test_osd_observation.js` vérifie les références, les
changements, les rafales, les échéances, la sourdine synthétique et le choix de
sortie. `OsdVisualFixture.qml` montre une luminosité fictive de 42 % ; sa
[capture recadrée](../../../../../docs/assets/screenshots/quickshell-osd-synthetic-brightness.png)
ne contient aucune donnée personnelle.

View file

@ -9,6 +9,9 @@ utiles sur `eDP-1`, sans défilement. Sur les petits écrans, le défilement res
disponible si la grille dépasse réellement la hauteur utile. La surface
layer-shell `Overlay` prend le focus
clavier exclusivement pendant l'ouverture et libère ce focus en se fermant.
La surface `labfy-setting-osd` suspend seulement une nouvelle capture
automatique tant qu'elle est visible ; les miniatures déjà validées restent
disponibles.
## Provenance des miniatures

View file

@ -173,7 +173,8 @@ def state():
output_info = {item["name"]: item for item in outputs if item.get("active")}
overlay_outputs = [item["name"] for item in outputs if item.get("active")
and any(surface.get("namespace") in {
"labfy-workspace-overview", "labfy-applications-menu"}
"labfy-workspace-overview", "labfy-applications-menu",
"labfy-setting-osd"}
for surface in item.get("layer_shell_surfaces") or [])]
workspace_nodes = {}

View file

@ -8,12 +8,14 @@ import "notifications"
import "idlebridge"
import "theme"
import "polkit"
import "osd"
ShellRoot {
id: shell
// CONTRACT: one PolkitAgent owns the native request queue for the entire
// user session. A dialog is constructed only for its current flow.
PolkitAgent { id: polkitAgent }
OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive }
property var polkitScreen: null
readonly property bool authenticationActive: polkitAgent.flow !== null
signal authenticationOpening()
@ -200,6 +202,7 @@ ShellRoot {
model: Quickshell.screens
Bar {
osdService: shellOsdService
applicationCoordinator: shell
authenticationActive: shell.authenticationActive
keepAwakeController: shell

View file

@ -16,6 +16,7 @@ Row {
signal toggleControlCenterRequested()
signal toggleRemovableMediaRequested()
signal toggleClipboardRequested()
signal adjustedFromBar(string kind)
function refreshBatteryThreshold() { batteryIndicator.refreshThreshold() }
function closeRemovableMedia() { removableMediaIndicator.popupOpen = false }
function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open }
@ -42,7 +43,10 @@ Row {
available: area.audioBrightnessController.volumeAvailable
clickable: true
onActivated: area.openPageRequested(15)
onAdjusted: steps => area.audioBrightnessController.adjustVolume(steps)
onAdjusted: steps => {
area.adjustedFromBar("volume");
area.audioBrightnessController.adjustVolume(steps);
}
}
ScrollIcon {
glyph: "󰖨"
@ -51,7 +55,10 @@ Row {
label: "Luminosité"
percent: area.audioBrightnessController.brightnessPercent
available: area.audioBrightnessController.brightnessAvailable
onAdjusted: steps => area.audioBrightnessController.adjustBrightness(steps)
onAdjusted: steps => {
area.adjustedFromBar("brightness");
area.audioBrightnessController.adjustBrightness(steps);
}
}
}
Rectangle {

View file

@ -0,0 +1,54 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.resolve(__dirname,
"../../quickshell/.config/quickshell/labfy-sway/osd/Observation.js"), "utf8")
.replace(/^\.pragma library\s*/, "");
const model = {};
vm.createContext(model);
vm.runInContext(source, model);
const sink = {};
let result = model.observe(null, sink, true, 0.57, false);
assert.equal(result.changed, false); // Première lecture : référence seulement.
result = model.observe(result.state, sink, true, 0.57, false);
assert.equal(result.changed, false);
result = model.observe(result.state, sink, true, 0.571, false);
assert.equal(result.volumeChanged, true); // Même pourcentage arrondi, autre valeur brute.
result = model.observe(result.state, sink, true, 0.571, true);
assert.equal(result.muteChanged, true);
let microphone = model.observe(null, "source A", true, 0.36, false);
assert.equal(microphone.changed, false); // Page Audio jamais ouverte.
microphone = model.observe(microphone.state, "source A", true, 0.36, true);
assert.equal(microphone.muteChanged, true);
microphone = model.observe(microphone.state, "source B", true, 0.2, false);
assert.equal(microphone.changed, false);
result = model.observe(result.state, {}, true, 0.25, false);
assert.equal(result.changed, false); // Nouveau défaut : état initial ignoré.
result = model.observe(result.state, null, false, NaN, false);
assert.equal(result.state, null);
result = model.observe(result.state, sink, true, 0.57, false);
assert.equal(result.changed, false); // Reconnexion : nouvelle référence.
const screen = { name: "sortie-test" };
let card = model.nextCard(null, "volume", 57, false, screen, 0);
card = model.nextCard(card, "volume", 58, false, screen, 500);
assert.equal(card.deadline, 2000);
assert.equal(model.expire(card, 1, 1500), false);
card = model.nextCard(card, "brightness", 42, false, screen, 800);
assert.equal(card.kind, "brightness");
assert.equal(model.expire(card, 2, 2000), false); // Ancien timer.
assert.equal(model.expire(card, 3, 2299), false);
assert.equal(model.expire(card, 3, 2300), true);
const other = { name: "autre-sortie" };
assert.equal(model.screenForEvent([screen, other], other.name, null, "volume", 0), other);
assert.equal(model.screenForEvent([screen, other], other.name,
{ kind: "volume", screen, until: 100 }, "volume", 50), screen);
assert.equal(model.screenForEvent([screen, other], other.name,
{ kind: "volume", screen, until: 100 }, "volume", 101), other);
assert.equal(model.pageShows("brightness", 0), true);
assert.equal(model.pageShows("microphone", 0), false);
assert.equal(model.pageShows("microphone", 15), true);
console.log("OSD observation et minuteur: PASS");