From 2bafbcd2bee007e82b11876cf55ad6cd9b0ff51d Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 9 Oct 2026 17:28:41 +0200 Subject: [PATCH] Add transient settings OSD for Sway --- .../quickshell-osd-synthetic-brightness.png | Bin 0 -> 7001 bytes .../.config/quickshell/labfy-sway/Bar.qml | 23 ++++ .../labfy-sway/OsdVisualFixture.qml | 30 +++++ .../.config/quickshell/labfy-sway/README.md | 4 + .../controlcenter/ControlCenter.qml | 4 + .../quickshell/labfy-sway/osd/Observation.js | 36 ++++++ .../quickshell/labfy-sway/osd/OsdService.qml | 108 ++++++++++++++++++ .../quickshell/labfy-sway/osd/OsdWindow.qml | 93 +++++++++++++++ .../quickshell/labfy-sway/osd/README.md | 43 +++++++ .../quickshell/labfy-sway/overview/README.md | 3 + .../quickshell/labfy-sway/overview/backend.py | 3 +- .../.config/quickshell/labfy-sway/shell.qml | 3 + .../labfy-sway/status/RightStatusArea.qml | 11 +- tests/quickshell/test_osd_observation.js | 54 +++++++++ 14 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 docs/assets/screenshots/quickshell-osd-synthetic-brightness.png create mode 100644 quickshell/.config/quickshell/labfy-sway/OsdVisualFixture.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/osd/Observation.js create mode 100644 quickshell/.config/quickshell/labfy-sway/osd/OsdService.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/osd/OsdWindow.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/osd/README.md create mode 100644 tests/quickshell/test_osd_observation.js diff --git a/docs/assets/screenshots/quickshell-osd-synthetic-brightness.png b/docs/assets/screenshots/quickshell-osd-synthetic-brightness.png new file mode 100644 index 0000000000000000000000000000000000000000..616d57b6464929250162b5fec2160c49a50e48f2 GIT binary patch literal 7001 zcmc&(^;gtiwEfU62uMjQ-CsaDq1N2GL{du8tm(2HE}M5%y(c{hwL0xr?C^Uq>-r=nqp$p5jzQG{k&m6Zgd!4sxo-H z#?(7d&Eyw$5aL`(% ztb_<$7}_gHoV=4_U|720%x#4)1Tw$uA63c#Yn0YRv^^2QISx{{TZ~AW&}2^sb6+to zcGQkDX$rqX>&yL;b~EX^(IEMSeSZ( zC(t3=WZ?S7N^Xrjq|V@|qNDkaT(7s}&9F%vr2StBZJY;I1fDC16vvJ)UvORm)b&p* zTt2+g=Qx}%*Z;@JMkwQk^7*hbk+C5@U_gsLyOzPrIr3AMhc*spR5|5hl>cXe_!er- z004s9d8R)pNB~NCAJIHlpVhxxFVLf$yQ{i>L&@WpVnSM z%!;ZX1v(y*ycoLZVf`k}{}v(0tuHT4MpdvTo*F*6>8Ke;-yORn7AR>Jv+E};@6t$w z4^kmnu_Ft_3+{o)h!U)lm|rg``PZ5&tJfno-u#0nDOyZ_dgxuemkE z6oHW@W@#n){MrlAr}@b?>dXcSoVb3Qka&}CsEUTP3;Bwbwj@dNp;2HM_W7*M2@5Vf?Qoo6cv|v}WU`rM|4gLU| z#n`~ORPEWb(A6qN1B|ezzA)+f&w}s%_#5Un__FO9*Y?!Lu6h!20SG?2Kyg!q6!BHj1qj&rCyUqI2AbU$9JzZV{ z_n7$hQ;n^ojpN&yo=&SWw;`r<%HG}my|#{90eHbHui{f{8a zoy=_hhww~Q>pXBug?$++| zuEnX|Q{-p-t2R;JVm68JhYPp4h7KM7cTXxXc(V9dRq=tzPhJ554q6cud`{sB_ZEzP z!F>Rrc3 zyXQi97=Z4FniS`CY`5ZWOhA`y$M?oT}Ba}4vc?Pp#WDB!#4{MhSdkYS_+oE@4W-Ge$B zKX!oLNKp3KIf`}SyRp9*$sok%KP%yl$FCw`KNf9Ds?~^ZoF7B3AP>>u?Fv1b$f55- zH;Z}K-)+Q`6*=$5$>pcWhjDij9p7IbZZHBsFZ;OZ%}CkVIbgsjeS2K%;gt@2VhKH+ z2o+iw01P9>mSv@L>Nba!Tl~`CrC^)#s{;s=hs=&v;K?b3kq@Lvc}|>x)p<{3-SsfA zsv>{`FvpuTJItK1v0_v-YUSx zm7CPqtDV-B~Z*5MnA>_%d7u_ItHUR z{6@XXKZ%xY!=5dLSH}AP4%(_fsng;(MyY7beQMETp29R0Vymt8W>u`9`wzW9sm(vu zK){PpnC?_o#{MQH{U~)VPIG!x8Vg7V4~?qXStdT*9F^|86NV)l=^8m>RB}h;Z;!l) zitl+<@!7i$4mNy+W2~(uz^7KPr22}hG~0C8uCQ#Qv`^;g<47z!-R(p{U{sDTbI%Wk zmOp|CMZ^SzU$`msjyzl&2GfM?Oe61pPB2xCYEp_9QW_{1McSHTrdMJh7I)Gxd(FTc zuxIQThR?3TqSyetAWuCPjdTA;67qqsY!+9(qEfO=n-?H^gW8yQ*jn0cq4x`mDSCxk$&lHRH?)`1jBG|_EWRr|o84W- z2Y>{qW%Z$zX$#cWWek>(+~MsK^S$Xyqoxx%A?$1ZaKTdJ*=87JlmEW>-_(FOD>xuTs?j8hf)K)BlXO7C<2mt9y1SX)7O_7!Zg58X*k?z|`dj z%O|uUd+DrRz89B=G8U(In#eL*dB|`M>SQJg7xCvbsmr}YBF<=RoH9(54aN_byH3*- z?W{6yMsWbiDNZfvb+Iq~&wBW%^X)vhP}#I9nCQqnoQxScadea-MlbtVpgY1|-}kFz zp=&ep;K*Yz=N4TZNGb!)cA=!G|H5x_W}(^Vb?L8?)sK=8K1(Bx=e~m9Y>rtrgs@~x z5oGqqTt{s=+U=k@aO43_Lqx;JMm%0xU#6^#VSSB^p17MXR{{ViUH5}wD)9z--?d&V zyWmE1DywE9+&}h7nhL)+i;JI;E}h33hgQ0}<=d%KLd6RF zS%7Y$R}@%E1)^U!*RR>xXOS!Z?oBVY$vyE(>ALLj0Y{6nrgqf&Fq{nbY@9!|rdJF9 zgsSSef8VR)#;~m5J89|m-5jeFQ8#F4xIsv?(@R>84LdAtNjC8MtIUOG4Cr7J{BUER zEloC`3Wu?W^{I+4&u*QezDg=o(Cx{d+{k%K&2Pkgv-1#WcxWoqQ2x^&-$I0hFs&|C z?5aont)uy*};hT8A+mXx%IAy&{&ug;4al!!O03L=Z>ZA#Z7HQ{dI}hlZ<_LTlX4;fh>~e}A!t<5|9YpB7JhP*)zUkcpsI zS)N*Z} zs{KYR?>p}r)iIgec?TgA7ZQJPxoC>rZpN?*9l)T)-&*Y-w*9-DKLtt)UN(PrZNBa< z3U!&M;J&gVtdN&hWVxQ_(|BD5Dvdp!MczDI)8J{8m!9eHWzFVf%7=4DlqE9O9-oy* z7I`3K8+u1|3-@$4QX(!85rl$8Uou%7Q}S_`C8t=ufi-DTor=8*ncKPH#P_se|MvIL zy-AKJ?69faF-bt7KYp5(2Twj+CCKf;Vs85!4B-1)i2KZ*I=iF*-C5IdMC5AqGix*Z zdSCE~*WDf6?(dJONCH>*jSKpwEP~fpevapYY^!W1k}1g)pg*{Wkx&WM?NQpk&&8-1 zTwG2ZI$I8YlTN=^IDmpl91g4eIdB_!n@X&hAq7oSWQMx+Ckuy;<@oqKE}_J?c;U7* z%)EWN0FY@ry8__ zi;a!CuHV-O%=HU98#DLd__{kaUzuzObSqcBi%!@tJWnwfi2aPxh17y@079QhCZiJA zJkQaQCkXl>fg3fX!Kpx_&6CZk`*UVhsE#>LW9s{!vji~)V5jvXW{L~SO>%jTR(YZA z<9Ckd{D}2r5p$kSyJ5$-_Y-q2l9k`7b8L+-{!9nd;kmtX+Lv@XvRB-l;dQr=0l=V6F}sk2Mr# z9kgEZf1~ykshyiiD|_?tg($T&;X>k5 z!#Ckk=yUQ*fnKz@d?7DV>9g9oEoMBNNvfu2H_#I*C!v5r30e*xtMz+tX<((7Y@M7T zF$K?JHp8CEb12BNwGZB-3-v#`ESud?I|G1Ux(lV65eprnT!xBIm~<`*-{>OvI3`Yw zRfg~lH7UX2Rw#nh;l`@6Q?-3=UtUIkX%&0m zo4lQz{7J;T>)iYe>i<5d$OG$q4jy1H!s#>SBI9E)BP$k(MGw(r9i7bd%r z{3fZdLZQCgSF`V0s;2x6pDPV@cwN+q3?ge8JW0wQHAquYV~?0U8kHSvR)srS8h12d zkmEluVSiNrb1PA4rGN42E{TbnEl&VF zcSeMDp5gZ`o>6}lsIrhfIK#obPbRKjO;1np`V?JRRn+pZ9kG~>UJ~%bLz-g)Dx=l! z-rJip7jyQB=2{1xMb;w!Af;`A`P`b^;g|Fkmq-XtiHUoL$r)GBE)L;d=iqng#g# zB*shB+HBay?4Upg-<|VVoeLMP@2}6(=_w=z!*f16NlYHg%iH=T%Kzam_Y-hYV3d>V zags$lnqnGc;JRIQHeKWeZ??pX$U3oQ(D@zQK?i5Gle9rXg6(X4eF_zo#BXV70dA7{ z;&r3XP)M`+b)iD)Tdtp`=5n@(IJk ziS@&F-1D)K=^@>PIZI6&oO#2Bv%~u(%^IN|)?m6T_mBoQd%M^e0^+uywq>(H-zt*S zH2tSHt9rx*VBHhl6zKMTrsTrbA|W$6)lk!l^9j>Z&`$k!3j9eGh=>3EXI`iSNI##4 z+!vOWMYL_CquuH1dm+Wpb5Y|9#z@O*t^oY&o)*MtMeuDtpk5m(IE)5u+$z)(0-!ohTQpjFYXp(tA~#K z@gpAECfeiv-`*!mC@HjQuIYz}|BnW#h{q)^Leru{9nUdJqJ9hDU_@ehR@`$(h zG>D-@gJl<;qTai6E?GQOCp~VyG4O&PBWx-gTl9~yN*hDuQ|epPU@dQ^?0tdP!^o~s z;P{DA+8%A93{Ob{eiMBjmLZ{Kc9c_K+MFE~l#Jk7y05cPA)u#Q_4pe`iZ`>QItUsR z4k2if7L~1&>WiDI;nuBGyC0!IS6fZ`zHB{pCbF*r_x9*QQz%D>aQRz*fBLoFJp{%q z7RHyR@OYh~@`^F!gJgvB(*#`j3xkx>unFXuwSO>C^cGGJ=T0EQ;2YxdSb9Qq$9;De z3GJ%QAAwqED|j0Jk(gTz{s*%rR3=QQd6bGaZ&lLNMNqBI8|&VjWW?B|r>k0oMaPu^ zh!nL;OEF^&{61jP4XmB9veD&ak@4aEp{1)bN&;`t4^cr$Y|$i@(HH(a{;DGN0qyac z01s;+A#A0YduyNpECDU)OAulE*giffX5n1Uow~+x9n!R1JXL-Y z5RC~prw*SaRm}^6iFLE<{Nin>kV!~%BE3{4Q&IGu@S4OV6)!di(D8T z8&@MwXiu~soS|zSaK7|3rPmzA_3&}@&5&RfMG|Gy?xZelaaCY|I4z^37`AaN2;BA&z3PNB? zwnm1>{B(Hje&!YpX?$lJXOv_f^7Y#5bg)K6v69_#i1ZtRvWcdU&MQpzZPM7v=@8W% zoxHazhYDn^GU;F-`Lp-vn_;uB-$8Gp`%{x1W2$B9m20@-<8|`-NQMiDEQX zsvPG9Dca!K40#A_awJ>bMT>{O 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 { diff --git a/quickshell/.config/quickshell/labfy-sway/OsdVisualFixture.qml b/quickshell/.config/quickshell/labfy-sway/OsdVisualFixture.qml new file mode 100644 index 000000000..925bc5670 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/OsdVisualFixture.qml @@ -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 + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index de8c4273a..f09c2e79b 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index 857b57455..3fe33624a 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/osd/Observation.js b/quickshell/.config/quickshell/labfy-sway/osd/Observation.js new file mode 100644 index 000000000..2607f67a7 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/osd/Observation.js @@ -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; +} diff --git a/quickshell/.config/quickshell/labfy-sway/osd/OsdService.qml b/quickshell/.config/quickshell/labfy-sway/osd/OsdService.qml new file mode 100644 index 000000000..5b3a68ffb --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/osd/OsdService.qml @@ -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 + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/osd/OsdWindow.qml b/quickshell/.config/quickshell/labfy-sway/osd/OsdWindow.qml new file mode 100644 index 000000000..27c37f345 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/osd/OsdWindow.qml @@ -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 + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/osd/README.md b/quickshell/.config/quickshell/labfy-sway/osd/README.md new file mode 100644 index 000000000..c17f24868 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/osd/README.md @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/overview/README.md b/quickshell/.config/quickshell/labfy-sway/overview/README.md index dae36920e..2b8382924 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/README.md +++ b/quickshell/.config/quickshell/labfy-sway/overview/README.md @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/overview/backend.py b/quickshell/.config/quickshell/labfy-sway/overview/backend.py index 91b6d161a..259075010 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/backend.py +++ b/quickshell/.config/quickshell/labfy-sway/overview/backend.py @@ -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 = {} diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index 59f374de2..6c1887b69 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml index ac342a83a..b508cbcaf 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml @@ -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 { diff --git a/tests/quickshell/test_osd_observation.js b/tests/quickshell/test_osd_observation.js new file mode 100644 index 000000000..9391bc34e --- /dev/null +++ b/tests/quickshell/test_osd_observation.js @@ -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");