feat(quickshell): publish clipboard, overview, bar and local fixes
This commit is contained in:
parent
dd72ba36b9
commit
25a2d7b555
46 changed files with 3776 additions and 117 deletions
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# These upstream unified diffs preserve source whitespace needed by patch.
|
||||||
|
tools/slurp-keymap-guard.patch whitespace=-blank-at-eol,-space-before-tab
|
||||||
|
tools/quickshell-qt-compat-v1/qt612-metatypes-v031.patch whitespace=-blank-at-eol,-space-before-tab
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.I3
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import "modules"
|
import "modules"
|
||||||
import "controlcenter"
|
import "controlcenter"
|
||||||
|
|
@ -8,6 +10,8 @@ import "datecenter"
|
||||||
import "notifications"
|
import "notifications"
|
||||||
import "status"
|
import "status"
|
||||||
import "theme"
|
import "theme"
|
||||||
|
import "clipboard"
|
||||||
|
import "overview"
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: bar
|
id: bar
|
||||||
|
|
@ -17,6 +21,7 @@ PanelWindow {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property var notificationService
|
required property var notificationService
|
||||||
required property bool startupHost
|
required property bool startupHost
|
||||||
|
required property bool resizeMode
|
||||||
screen: modelData
|
screen: modelData
|
||||||
|
|
||||||
anchors {
|
anchors {
|
||||||
|
|
@ -40,9 +45,160 @@ PanelWindow {
|
||||||
focusable: true
|
focusable: true
|
||||||
color: Theme.panelBackground
|
color: Theme.panelBackground
|
||||||
|
|
||||||
|
function closeClipboard() {
|
||||||
|
if (clipboardLoader.item && !clipboardLoader.item.closing)
|
||||||
|
clipboardLoader.item.dismiss(true);
|
||||||
|
}
|
||||||
|
// CONTRACT: clic et IPC suivent la même exclusion que les panneaux de barre.
|
||||||
|
function toggleClipboard() {
|
||||||
|
if (clipboardLoader.active) {
|
||||||
|
closeClipboard();
|
||||||
|
} else {
|
||||||
|
closeOverview();
|
||||||
|
controlCenter.visible = false;
|
||||||
|
dateCenter.requestClose();
|
||||||
|
windowStrip.closeMenu();
|
||||||
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
clipboardLoader.active = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONTRACT: un seul écran reçoit l'Overview ; une capture sans overlay
|
||||||
|
// précède l'ouverture afin de montrer le workspace actif tel qu'affiché.
|
||||||
|
function closeOverview() {
|
||||||
|
overviewRequested = false;
|
||||||
|
if (overviewLoader.item) overviewLoader.item.dismiss();
|
||||||
|
}
|
||||||
|
function toggleOverview() {
|
||||||
|
if (overviewRequested || overviewLoader.active) { closeOverview(); return true; }
|
||||||
|
closeClipboard();
|
||||||
|
controlCenter.visible = false;
|
||||||
|
dateCenter.requestClose();
|
||||||
|
windowStrip.closeMenu();
|
||||||
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
overviewRequested = true;
|
||||||
|
if (!overviewCapture.running) overviewCapture.running = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
property bool overviewRequested: false
|
||||||
|
readonly property string overviewBackend: (Quickshell.env("XDG_CONFIG_HOME") ||
|
||||||
|
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/overview/backend.py"
|
||||||
|
IpcHandler {
|
||||||
|
target: "overviewUi-" + bar.screen.name
|
||||||
|
function toggle(): bool { return bar.toggleOverview(); }
|
||||||
|
function state(): string {
|
||||||
|
return JSON.stringify({ loaded: overviewLoader.active,
|
||||||
|
visible: !!overviewLoader.item && overviewLoader.item.visible,
|
||||||
|
requested: bar.overviewRequested,
|
||||||
|
cards: overviewLoader.item ? overviewLoader.item.cards.map(item => ({
|
||||||
|
number: item.number, windows: item.windows.length,
|
||||||
|
snapshot: item.snapshot !== "", output: item.output
|
||||||
|
})) : [],
|
||||||
|
bounds: overviewLoader.item ? {
|
||||||
|
x: overviewLoader.item.cardX, y: overviewLoader.item.cardY,
|
||||||
|
width: overviewLoader.item.cardWidth, height: overviewLoader.item.cardHeight
|
||||||
|
} : null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: overviewCapture
|
||||||
|
command: ["python3", "-B", bar.overviewBackend, "capture", "--output", bar.screen.name]
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (bar.overviewRequested) overviewLoader.active = true;
|
||||||
|
else if (code !== 0) console.warn("Capture du workspace impossible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CONTRACT: un workspace nouvellement visible reçoit une capture après
|
||||||
|
// que Sway a présenté sa première frame, sans attendre le cycle de 15 s.
|
||||||
|
Connections {
|
||||||
|
target: I3
|
||||||
|
function onRawEvent(event) {
|
||||||
|
if (event.type === "workspace") overviewCaptureRefresh.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Timer {
|
||||||
|
id: overviewCaptureRefresh
|
||||||
|
interval: 350; repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
|
||||||
|
&& !controlCenter.visible && !dateCenter.visible)
|
||||||
|
overviewCapture.running = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Un seul snapshot toutes les 15 secondes pour le workspace actuellement
|
||||||
|
// visible ; aucune boucle de capture pendant l'Overview ou un autre panneau.
|
||||||
|
Timer {
|
||||||
|
interval: 15000; repeat: true; running: true; triggeredOnStart: true
|
||||||
|
onTriggered: {
|
||||||
|
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
|
||||||
|
&& !controlCenter.visible && !dateCenter.visible)
|
||||||
|
overviewCapture.running = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyLoader {
|
||||||
|
id: overviewLoader
|
||||||
|
active: false
|
||||||
|
WorkspaceOverview {
|
||||||
|
barWindow: bar
|
||||||
|
onOverviewDismissed: { bar.overviewRequested = false; overviewLoader.active = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONTRACT: contrôle événementiel par écran pour ouvrir les pages depuis
|
||||||
|
// les raccourcis et les vérifications visuelles ; aucun état n'est dupliqué.
|
||||||
|
IpcHandler {
|
||||||
|
target: "appearanceUi-" + bar.screen.name
|
||||||
|
function openPage(page: string): bool {
|
||||||
|
if (controlCenter.pages[page] === undefined) return false;
|
||||||
|
bar.closeClipboard();
|
||||||
|
dateCenter.requestClose();
|
||||||
|
windowStrip.closeMenu();
|
||||||
|
controlCenter.openPage(page);
|
||||||
|
controlCenter.visible = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function close(): bool { controlCenter.visible = false; return true; }
|
||||||
|
function openDateCenter(): bool { bar.toggleDateCenter(); return true; }
|
||||||
|
function openWindowMenu(): bool {
|
||||||
|
bar.closeClipboard();
|
||||||
|
controlCenter.visible = false;
|
||||||
|
dateCenter.requestClose();
|
||||||
|
return windowStrip.openActiveMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONTRACT: un seul écran reçoit l'appel IPC ; le composant et ses données
|
||||||
|
// n'existent que pendant l'ouverture ou le nettoyage de fermeture.
|
||||||
|
IpcHandler {
|
||||||
|
target: "clipboardUi-" + bar.screen.name
|
||||||
|
function toggle(): bool { return bar.toggleClipboard(); }
|
||||||
|
function state(): string {
|
||||||
|
// Diagnostic sans nombre, identifiant ni aperçu de presse-papiers.
|
||||||
|
return JSON.stringify({ loaded: clipboardLoader.active,
|
||||||
|
visible: !!clipboardLoader.item && clipboardLoader.item.visible,
|
||||||
|
searchFocused: !!clipboardLoader.item && clipboardLoader.item.searchFocused,
|
||||||
|
listLoaded: !!clipboardLoader.item && clipboardLoader.item.listLoaded,
|
||||||
|
bounds: clipboardLoader.item ? {
|
||||||
|
x: clipboardLoader.item.cardX, y: clipboardLoader.item.cardY,
|
||||||
|
width: clipboardLoader.item.cardWidth, height: clipboardLoader.item.cardHeight
|
||||||
|
} : null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyLoader {
|
||||||
|
id: clipboardLoader
|
||||||
|
active: false
|
||||||
|
ClipboardPanel {
|
||||||
|
barWindow: bar
|
||||||
|
onCleanupFinished: clipboardLoader.active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
|
// CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
|
||||||
function toggleDateCenter() {
|
function toggleDateCenter() {
|
||||||
const opening = !dateCenter.visible && !dateCenter.opening;
|
const opening = !dateCenter.visible && !dateCenter.opening;
|
||||||
|
closeOverview();
|
||||||
|
closeClipboard();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
|
@ -59,11 +215,31 @@ PanelWindow {
|
||||||
height: bar.height
|
height: bar.height
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: resizeIndicator
|
||||||
|
visible: bar.resizeMode
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: visible ? resizeLabel.implicitWidth + 12 : 0
|
||||||
|
height: 24
|
||||||
|
radius: 4
|
||||||
|
color: Theme.buttonBackground
|
||||||
|
Text {
|
||||||
|
id: resizeLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "RESIZE"
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.peach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Workspaces {
|
Workspaces {
|
||||||
id: workspaces
|
id: workspaces
|
||||||
screen: bar.screen
|
screen: bar.screen
|
||||||
anchors.left: parent.left
|
anchors.left: resizeIndicator.right
|
||||||
anchors.leftMargin: 8
|
anchors.leftMargin: resizeIndicator.visible ? 6 : 0
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +251,8 @@ PanelWindow {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
maxWidth: Math.max(0, leftArea.width - x - 8)
|
maxWidth: Math.max(0, leftArea.width - x - 8)
|
||||||
onMenuOpened: {
|
onMenuOpened: {
|
||||||
|
bar.closeOverview();
|
||||||
|
bar.closeClipboard();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
|
@ -110,7 +288,11 @@ PanelWindow {
|
||||||
wifiDevice: controlCenter.wifiDevice
|
wifiDevice: controlCenter.wifiDevice
|
||||||
adapter: controlCenter.adapter
|
adapter: controlCenter.adapter
|
||||||
controlCenterOpen: controlCenter.visible
|
controlCenterOpen: controlCenter.visible
|
||||||
|
audioBrightnessController: controlCenter
|
||||||
|
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
|
||||||
onOpenPageRequested: page => {
|
onOpenPageRequested: page => {
|
||||||
|
bar.closeOverview();
|
||||||
|
bar.closeClipboard();
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
|
@ -118,7 +300,9 @@ PanelWindow {
|
||||||
controlCenter.visible = true;
|
controlCenter.visible = true;
|
||||||
}
|
}
|
||||||
onToggleControlCenterRequested: {
|
onToggleControlCenterRequested: {
|
||||||
|
bar.closeOverview();
|
||||||
const opening = !controlCenter.visible;
|
const opening = !controlCenter.visible;
|
||||||
|
bar.closeClipboard();
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
|
@ -126,12 +310,15 @@ PanelWindow {
|
||||||
controlCenter.visible = opening;
|
controlCenter.visible = opening;
|
||||||
}
|
}
|
||||||
onToggleRemovableMediaRequested: {
|
onToggleRemovableMediaRequested: {
|
||||||
|
bar.closeOverview();
|
||||||
const opening = !rightStatusArea.removableMediaOpen;
|
const opening = !rightStatusArea.removableMediaOpen;
|
||||||
|
bar.closeClipboard();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.setRemovableMediaOpen(opening);
|
rightStatusArea.setRemovableMediaOpen(opening);
|
||||||
}
|
}
|
||||||
|
onToggleClipboardRequested: bar.toggleClipboard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Barre principale et serveur de notifications de la session SwayFX.
|
||||||
```text
|
```text
|
||||||
Bar
|
Bar
|
||||||
├── Workspaces
|
├── Workspaces
|
||||||
|
├── Workspace Overview
|
||||||
├── Window/task switcher
|
├── Window/task switcher
|
||||||
├── Date Center
|
├── Date Center
|
||||||
│ ├── MPRIS
|
│ ├── MPRIS
|
||||||
|
|
@ -18,6 +19,7 @@ Bar
|
||||||
│ ├── Battery
|
│ ├── Battery
|
||||||
│ ├── Updates
|
│ ├── Updates
|
||||||
│ ├── Supports amovibles
|
│ ├── Supports amovibles
|
||||||
|
│ ├── Presse-papiers
|
||||||
│ ├── Tray
|
│ ├── Tray
|
||||||
│ └── Control Center
|
│ └── Control Center
|
||||||
└── Control Center
|
└── Control Center
|
||||||
|
|
@ -32,6 +34,14 @@ Bar
|
||||||
└── Session
|
└── Session
|
||||||
```
|
```
|
||||||
|
|
||||||
|
L'icône **Presse-papiers** ouvre le panneau d'historique sous la zone droite de
|
||||||
|
la barre ; `Mod+Alt+V` conserve le même basculement sur l'écran focalisé.
|
||||||
|
L'architecture, les raccourcis, les tests isolés et le retour vers Wofi sont
|
||||||
|
décrits dans [clipboard/README.md](clipboard/README.md).
|
||||||
|
|
||||||
|
`Super+W` ouvre la [vue d'ensemble visuelle](overview/README.md) sur la sortie
|
||||||
|
focalisée. `Super+Shift+W` conserve le layout Sway `tabbed`.
|
||||||
|
|
||||||
`quickshell-labfy-sway.service` est activé par
|
`quickshell-labfy-sway.service` est activé par
|
||||||
`wayland-session@sway.desktop.target`, sans autostart Sway parallèle.
|
`wayland-session@sway.desktop.target`, sans autostart Sway parallèle.
|
||||||
`NotificationService.qml` possède `org.freedesktop.Notifications` ; l'historique
|
`NotificationService.qml` possède `org.freedesktop.Notifications` ; l'historique
|
||||||
|
|
@ -58,41 +68,199 @@ service permanent ni changement automatique du thème.
|
||||||
## Supports amovibles
|
## Supports amovibles
|
||||||
|
|
||||||
`RemovableMediaIndicator.qml` affiche dans la zone d'état un indicateur
|
`RemovableMediaIndicator.qml` affiche dans la zone d'état un indicateur
|
||||||
lorsqu'au moins un système de fichiers éligible est présent. Son `PopupWindow`
|
lorsqu'au moins un support éligible est présent. Son `PopupWindow` natif liste
|
||||||
natif liste les volumes, leur état et les erreurs publiées, puis offre les
|
les volumes, leur état et les erreurs publiées, puis offre les actions
|
||||||
actions **Ouvrir**, **Monter** ou **Démonter**, et **Retirer en sécurité** si
|
**Ouvrir**, **Monter** ou **Démonter**, et **Retirer en sécurité** lorsque le
|
||||||
le lecteur le permet. Ouvrir lance Yazi dans Kitty via UWSM. Le popup participe
|
contrat le permet. Ouvrir lance Yazi dans Kitty via UWSM en utilisant le
|
||||||
à la même exclusion mutuelle que le Date Center, le menu des fenêtres et le
|
véritable point de montage UDisks pour USB/SD ou le chemin du `GMount` pour
|
||||||
Control Center.
|
MTP. Le popup participe à la même exclusion mutuelle que le Date Center, le
|
||||||
|
menu des fenêtres et le Control Center.
|
||||||
|
|
||||||
QuickShell lit exclusivement le snapshot JSON V1
|
QuickShell lit exclusivement le snapshot JSON V2
|
||||||
`$XDG_RUNTIME_DIR/labfy-removable-media.json`. Il refuse intégralement un
|
`$XDG_RUNTIME_DIR/labfy-removable-media.json`. Il refuse intégralement un
|
||||||
snapshot dont `schema` n'est pas `labfy.removable-media`, dont `version` n'est
|
snapshot dont `schema` n'est pas `labfy.removable-media`, dont `version` n'est
|
||||||
pas `1`, ou dont les appareils ne respectent pas le contrat attendu. Les
|
pas `2`, ou dont les appareils ne respectent pas le contrat attendu. Le schéma
|
||||||
commandes passent sans shell à `labfy-removable-mediactl`, qui est l'unique
|
publie `kind` et `actions`. Chaque appareil publie aussi
|
||||||
frontière de commande vers le daemon ; le socket Unix associé est privé (`0600`).
|
`safe_remove_runtime_id`, y compris lorsque `safe-remove` n'est pas disponible.
|
||||||
|
Les identifiants MTP restent opaques et aucune URI MTP n'est publiée. Le chemin
|
||||||
|
de montage exact fourni par GVFS peut néanmoins contenir un identifiant de
|
||||||
|
l'appareil ; le snapshot est privé (`0600`). Les commandes passent sans shell à `labfy-removable-mediactl`, qui est
|
||||||
|
l'unique frontière de commande vers le daemon ; le socket Unix associé est
|
||||||
|
privé (`0600`).
|
||||||
|
|
||||||
Le service utilisateur repose sur l'ObjectManager UDisks et ne présente que
|
Le service utilisateur est unique : il utilise l'ObjectManager UDisks pour les
|
||||||
les systèmes de fichiers USB ou SD/MMC. Les blocs marqués `HintIgnore` ou
|
systèmes de fichiers USB ou SD/MMC et `Gio.VolumeMonitor`/GVFS-MTP pour les
|
||||||
`HintSystem` sont exclus. Un volume `crypto_LUKS` n'est ni présenté ni
|
téléphones. Les blocs UDisks marqués `HintIgnore` ou `HintSystem` sont exclus.
|
||||||
déverrouillé automatiquement. Les périphériques MTP ne fournissent pas ce
|
Un volume `crypto_LUKS` n'est ni présenté ni déverrouillé automatiquement.
|
||||||
modèle UDisks de système de fichiers et ne sont donc pas pris en charge. Chaque
|
Pendant la vie du daemon, chaque support présent peut être monté
|
||||||
objet UDisks présent peut être monté automatiquement une seule fois ; après un
|
automatiquement une seule fois ; après un démontage manuel, il reste démonté
|
||||||
démontage manuel, il reste démonté jusqu'à sa disparition puis sa réinsertion.
|
jusqu'à sa disparition puis sa réinsertion. Au démarrage ou au redémarrage, le
|
||||||
Le retrait en sécurité agit au niveau du lecteur et vérifie que ses systèmes de
|
cold-start V1 effectue un nouveau scan : un téléphone déjà présent peut alors
|
||||||
fichiers sont démontés avant l'éjection ou la mise hors tension.
|
être monté. Pour USB/SD, le retrait en sécurité agit au niveau du lecteur et
|
||||||
|
vérifie que ses systèmes de fichiers sont démontés avant l'éjection ou la mise
|
||||||
|
hors tension. Pour MTP, il ferme logiquement le montage GVFS sans appeler
|
||||||
|
`PowerOff`.
|
||||||
|
|
||||||
Le contrôle visuel synthétique peut être activé avec
|
Le contrôle visuel synthétique peut être activé avec
|
||||||
`LABFY_REMOVABLE_MEDIA_TEST_STATE` (`0`, `mounted`, `unmounted` ou
|
`LABFY_REMOVABLE_MEDIA_TEST_STATE` (`0`, `USB`, `MTP`, `USB+MTP`, `busy` ou
|
||||||
`busy-error`) dans le processus QuickShell. Ces états ne lancent aucune
|
`error`) dans le processus QuickShell. Ces états ne lancent aucune commande.
|
||||||
commande. Les tests Python synthétiques du daemon et de son IPC sont décrits
|
Les tests Python synthétiques du daemon et de son IPC sont décrits dans
|
||||||
dans [systemd.md](../../../../docs/systemd.md#supports-amovibles-usbsd).
|
[systemd.md](../../../../docs/systemd.md#supports-amovibles-usbsd-et-mtp).
|
||||||
|
|
||||||
Cette fonction est indépendante de Session Restore : elle ne lit ni n'écrit de
|
Cette fonction est indépendante de Session Restore : elle ne lit ni n'écrit de
|
||||||
checkpoint de session et n'intervient pas dans son démarrage.
|
checkpoint de session et n'intervient pas dans son démarrage.
|
||||||
|
|
||||||
La validation sur matériel USB/SD réel n'a pas encore été effectuée ; l'état
|
Les 29 tests `unittest` synthétiques et `qmllint` sont passants. Un Samsung réel
|
||||||
actuel est couvert par les tests synthétiques seulement.
|
a été reconnu et les opérations ciblées de montage et de retrait en sécurité
|
||||||
|
ont été vérifiées dans le code isolé. La recette UI sur matériel MTP reste à
|
||||||
|
effectuer. La validation matérielle USB Ventoy V1 avait été effectuée avant le
|
||||||
|
commit précédent.
|
||||||
|
|
||||||
|
## Défilement sur les icônes et mode Resize
|
||||||
|
|
||||||
|
Les deux indicateurs du groupe système à droite de la barre règlent séparément le volume et la
|
||||||
|
luminosité par défilement vertical à deux doigts ou avec une molette. Un pas
|
||||||
|
vaut **un point de pourcentage**. Le geste physique vers le haut augmente la
|
||||||
|
valeur. Le clic sur le bouton Réglages rapides conserve son rôle.
|
||||||
|
|
||||||
|
`WheelHandler` accepte explicitement `Mouse` et `TouchPad`, et ne traite que
|
||||||
|
les 66 px de chaque zone « icône + pourcentage ». Pour un événement, `pixelDelta.y` est choisi s'il
|
||||||
|
est non nul, sinon `angleDelta.y` ; les deux ne sont jamais additionnés. Les
|
||||||
|
seuils initiaux sont **32 pixels** pour le touchpad et **120 unités angulaires**
|
||||||
|
pour un cran de molette. Le reliquat est conservé entre événements, remis à
|
||||||
|
zéro à la fin du geste si Qt fournit `ScrollEnd`, au changement de direction
|
||||||
|
ou au changement de source. Aucun délai ne jette les petits mouvements.
|
||||||
|
`LABFY_SCROLL_DEBUG=1` permet de diagnostiquer les deltas et phases reçus.
|
||||||
|
|
||||||
|
### Contrôles de barre
|
||||||
|
|
||||||
|
Le retour matériel a établi que le geste vers le haut agissait à l'envers sur
|
||||||
|
le volume. `ScrollSteps.js` applique la correction de signe **une seule fois**
|
||||||
|
aux événements de touchpad. La molette classique garde son sens et son seuil
|
||||||
|
de 120 unités. Les événements à `pixelDelta` non nul, ceux dont le périphérique
|
||||||
|
Qt est `TouchPad` et les petits deltas angulaires continus sont traités comme
|
||||||
|
gestes tactiles.
|
||||||
|
|
||||||
|
La luminosité pouvait rester bloquée après la première écriture : `onSaved`
|
||||||
|
plaçait la file en attente d'un nouveau `onLoaded` qui n'arrivait pas dans le
|
||||||
|
cas reproduit. Le watcher `FileView` peut charger la valeur avant `onSaved` ;
|
||||||
|
attendre ensuite un autre chargement n'est pas fiable. La variable
|
||||||
|
`awaitingReadback` restait alors vraie ; les demandes suivantes étaient
|
||||||
|
accumulées sans nouvelle écriture. Le diagnostic a reproduit ce cas avec le
|
||||||
|
Control Center fermé : le composant recevait `−1`, tandis que sysfs restait à
|
||||||
|
376000/400000. La correction libère la file depuis `saved`, relit la valeur matérielle
|
||||||
|
après le callback `FileView`, puis lance la dernière consigne en attente. La
|
||||||
|
relecture de ce petit fichier sysfs est bornée ; `saveFailed`, `loadFailed` et
|
||||||
|
les données invalides sont signalés. Aucun pourcentage optimiste n'est affiché.
|
||||||
|
|
||||||
|
La présentation reprend celle des indicateurs Wi-Fi, Bluetooth et batterie :
|
||||||
|
icône Nerd Font, pourcentage réel à largeur fixe, fond transparent hors survol
|
||||||
|
et infobulle. Le soleil `` remplace le glyphe ambigu ``. La disposition
|
||||||
|
finale, avec le tray tout à droite, est décrite ci-dessous.
|
||||||
|
|
||||||
|
Validation des contrôles : tests unitaires de `ScrollSteps`, `qmllint` et injection
|
||||||
|
synthétique dans `ScrollIcon.applyWheel` via un IPC temporaire retiré après
|
||||||
|
essai. Avec le Control Center fermé, le volume a fait 67 → 68 → 67 % ; la
|
||||||
|
luminosité a fait 372000 → 376000 → 372000 sur 400000, puis trois pas
|
||||||
|
rapprochés 93 → 96 % → 93 %. L'essai panneau ouvert puis refermé a conservé
|
||||||
|
la synchronisation à 93 %. L'appel direct au contrôle a également fonctionné
|
||||||
|
panneau fermé. L'injection était synthétique ; les essais tactiles et visuels
|
||||||
|
de la série ont été validés séparément et ne sont pas rejoués pour cette
|
||||||
|
publication.
|
||||||
|
Les bornes 0/100 % pour le volume et 10/100 % pour la luminosité sont testées
|
||||||
|
avec `node tests/quickshell/test_percent_math.js`, sans pousser le matériel
|
||||||
|
aux extrêmes.
|
||||||
|
|
||||||
|
Le volume utilise `Pipewire.defaultAudioSink` et son `PwObjectTracker`, comme
|
||||||
|
le curseur du Control Center. Il est borné entre 0 et 100 %, sans changer
|
||||||
|
`muted`. La luminosité utilise le `FileView` sysfs déjà employé par le
|
||||||
|
Control Center, avec un minimum sûr de 10 % et un maximum de 100 %. Ses
|
||||||
|
écritures sont sérialisées ; la valeur du matériel est relue après chaque
|
||||||
|
écriture, y compris si le pilote arrondit. L'absence de sortie audio ou de
|
||||||
|
rétroéclairage désactive l'icône concernée.
|
||||||
|
|
||||||
|
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
|
||||||
|
ponctuelle `get_binding_state` récupère le mode courant au démarrage et à la
|
||||||
|
reconnexion. L'indicateur n'intervient dans aucun raccourci Sway.
|
||||||
|
|
||||||
|
Validation : `qmllint`, `node tests/quickshell/test_scroll_steps.js`,
|
||||||
|
`sway -C`, `git diff --check`, transition IPC `default → resize → default`,
|
||||||
|
raccourci `Super+R` puis `Escape` injectés, rechargement QuickShell pendant
|
||||||
|
`resize`, et inspection visuelle de la barre. Les fonctions des contrôles ont été
|
||||||
|
appelées temporairement par IPC : PipeWire est passé de 60 à 61 %, et sysfs
|
||||||
|
de 374815/400000 à 380000/400000 (95 %) ; les niveaux initiaux ont ensuite
|
||||||
|
été restaurés et le point IPC de test retiré. Aucun geste multitouch physique
|
||||||
|
n'a été généré par l'automatisation.
|
||||||
|
|
||||||
|
### Présentation de la zone droite
|
||||||
|
|
||||||
|
La batterie, le volume et la luminosité partagent `PercentIndicatorContent` :
|
||||||
|
une zone d'icône de 22 px, un écart fixe de 4 px et une valeur alignée à gauche
|
||||||
|
dans une réserve de 38 px. Le composant mesure 66 × 26 px. La police mono à
|
||||||
|
12 px affiche partout un espace avant `%` (`9 %`, `10 %`, `99 %`, `100 %`) ;
|
||||||
|
une donnée indisponible affiche `—`. La réserve empêche le changement de
|
||||||
|
largeur sans éloigner le nombre de son icône. Les silhouettes des glyphes sont
|
||||||
|
ajustées individuellement : batterie 21 px, volume 22 px, soleil 23 px.
|
||||||
|
|
||||||
|
Le groupe système garde l'ordre Wi-Fi, Bluetooth, batterie, volume,
|
||||||
|
luminosité, avec 6 px entre indicateurs. Les deux séparateurs utilisent le
|
||||||
|
même token `Theme.separator`, mesurent 1 × 14 px et disposent chacun de 8 px
|
||||||
|
de marge latérale. Le tray garde les icônes et menus des applications, avec
|
||||||
|
des images centrées de 20 × 20 px dans les cibles existantes de 26 × 26 px et
|
||||||
|
4 px entre cibles. Les actions QuickShell gardent 5 px entre elles ; le bouton
|
||||||
|
Control Center mesure 30 × 26 px, sans fond au repos et avec un fond discret
|
||||||
|
au survol ou lorsque le panneau est ouvert.
|
||||||
|
|
||||||
|
La zone de défilement du volume et de la luminosité couvre les 66 × 26 px de
|
||||||
|
chaque indicateur, texte compris.
|
||||||
|
|
||||||
|
Validation de la présentation : `qmllint` sur les composants modifiés, tests Node de
|
||||||
|
`PercentPresentation`, `PercentMath` et `ScrollSteps`, `git diff --check`,
|
||||||
|
chargement de la configuration par QuickShell et inspection d'une capture de
|
||||||
|
la barre réelle à l'échelle de la sortie. Les formats 9/10/99/100 % ont été
|
||||||
|
testés sans modifier les niveaux matériels. Les essais tactiles et visuels de
|
||||||
|
la série ont également couvert le tray.
|
||||||
|
|
||||||
|
### Disposition finale de la zone droite
|
||||||
|
|
||||||
|
L'ordre visuel est désormais : Wi-Fi, Bluetooth, batterie, volume,
|
||||||
|
luminosité ; séparateur ; actions QuickShell, bouton Control Center compris ;
|
||||||
|
séparateur ; tray applicatif tout à droite. Le second séparateur disparaît
|
||||||
|
avec un tray vide. Les deux traits visibles utilisent `Theme.separator`,
|
||||||
|
mesurent 1 × 14 px et sont séparés des groupes par 8 px. Les cibles des
|
||||||
|
statuts, actions et icônes du tray gardent leur hauteur de 26 px ; les groupes
|
||||||
|
conservent respectivement 6, 5 et 4 px d'espacement interne.
|
||||||
|
|
||||||
|
La police commune des glyphes ne leur donne pas une hauteur de dessin commune :
|
||||||
|
les formes Wi-Fi, haut-parleur, soleil et réglages occupent moins de hauteur
|
||||||
|
que les formes Bluetooth et batterie. Les tailles de police sont donc ajustées
|
||||||
|
sur la barre réelle : Wi-Fi 30 px, Bluetooth 18 px, batterie 18 px, volume
|
||||||
|
28 px, luminosité 26 px, mises à jour 26 px, presse-papiers 24 px et bouton
|
||||||
|
Control Center 28 px. L'icône de support amovible conserve ses 18 px et les
|
||||||
|
images du tray leurs 20 × 20 px. Ces ajustements ne changent ni les zones
|
||||||
|
interactives, ni les callbacks, ni les contrôleurs du volume et de la
|
||||||
|
luminosité.
|
||||||
|
|
||||||
|
### Accents Catppuccin de la barre
|
||||||
|
|
||||||
|
Les pictogrammes utilisent directement les propriétés de `Theme`, alimentées
|
||||||
|
par l'unique `catppuccin.json` : Wi-Fi connecté `sky`, Bluetooth actif `blue`,
|
||||||
|
batterie normale `green`, volume non muet `mauve`, luminosité disponible
|
||||||
|
`yellow`, mises à jour disponibles `peach`, support amovible `teal`,
|
||||||
|
presse-papiers `pink` et menu système `lavender`. Les nombres et pourcentages
|
||||||
|
restent en `foreground`. `PercentIndicatorContent` sépare désormais la
|
||||||
|
couleur du glyphe de celle de la valeur, sans changer ses dimensions.
|
||||||
|
|
||||||
|
`StatusColorRoles.js` choisit les rôles selon les états réels, sans contenir
|
||||||
|
de valeur hexadécimale : Wi-Fi bloqué ou Bluetooth inactif atténués, volume
|
||||||
|
muet atténué, batterie sous 30 % en avertissement et à 15 % ou moins en
|
||||||
|
`danger`, erreur de mise à jour ou de support en `danger`, opération de
|
||||||
|
support en `warningForeground`. Une vérification de mises à jour encore en
|
||||||
|
attente reste neutre. Les couleurs du tray et les fonds existants ne changent
|
||||||
|
pas. Les états rares sont vérifiés avec des données isolées par
|
||||||
|
`tests/quickshell/test_status_color_roles.js`, sans agir sur le matériel.
|
||||||
|
|
||||||
## Parité avec i3status-rs
|
## Parité avec i3status-rs
|
||||||
|
|
||||||
|
|
@ -103,8 +271,8 @@ actuel est couvert par les tests synthétiques seulement.
|
||||||
| music | Date Center MPRIS |
|
| music | Date Center MPRIS |
|
||||||
| bluetooth | status + Control Center |
|
| bluetooth | status + Control Center |
|
||||||
| net | status + Control Center |
|
| net | status + Control Center |
|
||||||
| sound | Control Center |
|
| sound | icône à défilement + Control Center |
|
||||||
| backlight | Control Center |
|
| backlight | icône à défilement + Control Center |
|
||||||
| battery | status + page Batterie |
|
| battery | status + page Batterie |
|
||||||
| TLP | PowerProfile |
|
| TLP | PowerProfile |
|
||||||
| packages | Updates |
|
| packages | Updates |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import "../theme"
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: panel
|
||||||
|
required property var barWindow
|
||||||
|
signal cleanupFinished()
|
||||||
|
property var items: []
|
||||||
|
property var results: []
|
||||||
|
property int selected: 0
|
||||||
|
property string operation: ""
|
||||||
|
property string activeId: ""
|
||||||
|
property string thumbnail: ""
|
||||||
|
property bool confirming: false
|
||||||
|
property bool closing: false
|
||||||
|
property var queuedAction: null
|
||||||
|
property string message: ""
|
||||||
|
property bool listLoaded: false
|
||||||
|
readonly property bool searchFocused: search.activeFocus
|
||||||
|
readonly property real cardX: card.x
|
||||||
|
readonly property real cardY: card.y
|
||||||
|
readonly property real cardWidth: card.width
|
||||||
|
readonly property real cardHeight: card.height
|
||||||
|
// Le test QtTest cible la vraie hiérarchie d'items, jamais le backend réel.
|
||||||
|
readonly property Item interactionRoot: contentRoot
|
||||||
|
readonly property Item clearActionItem: clearLabel
|
||||||
|
readonly property Item confirmActionItem: confirmLabel
|
||||||
|
readonly property Item cancelActionItem: cancelLabel
|
||||||
|
readonly property Item removeActionItem: removeLabel
|
||||||
|
readonly property Item resultList: list
|
||||||
|
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
|
||||||
|
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/clipboard/backend.py"
|
||||||
|
readonly property string isolatedDb: Quickshell.env("LABFY_CLIPHIST_TEST_DB") || ""
|
||||||
|
readonly property string isolatedWlCopy: Quickshell.env("LABFY_CLIPHIST_TEST_WL_COPY") || ""
|
||||||
|
|
||||||
|
// WHY: l'appel IPC ne satisfait pas la contrainte de parent du grab PopupWindow.
|
||||||
|
// CONTRACT: le focus exclusif existe seulement durant cette surface éphémère.
|
||||||
|
screen: barWindow.screen
|
||||||
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
exclusiveZone: 0
|
||||||
|
aboveWindows: true
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||||
|
WlrLayershell.namespace: "labfy-clipboard-history"
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
function run(action, ident) {
|
||||||
|
if (helper.running) return false;
|
||||||
|
operation = action;
|
||||||
|
activeId = ident || "";
|
||||||
|
helper.command = ["python3", "-B", backend, action]
|
||||||
|
.concat(ident ? [ident] : [])
|
||||||
|
.concat(isolatedDb ? ["--db-path", isolatedDb] : [])
|
||||||
|
.concat(isolatedWlCopy ? ["--wl-copy-path", isolatedWlCopy] : []);
|
||||||
|
helper.running = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function refresh() { run("list", ""); }
|
||||||
|
function request(action, ident) {
|
||||||
|
// Une miniature en cours ne doit pas faire perdre une commande clavier.
|
||||||
|
if (helper.running) { queuedAction = { action: action, ident: ident || "" }; return; }
|
||||||
|
run(action, ident);
|
||||||
|
}
|
||||||
|
function filter() {
|
||||||
|
const query = search.text.toLocaleLowerCase();
|
||||||
|
results = items.filter(item => !query || (!item.image && item.preview.toLocaleLowerCase().includes(query)));
|
||||||
|
selected = 0;
|
||||||
|
updateThumbnail();
|
||||||
|
}
|
||||||
|
function currentItem() { return selected >= 0 && selected < results.length ? results[selected] : null; }
|
||||||
|
function updateThumbnail() {
|
||||||
|
thumbnail = "";
|
||||||
|
const item = currentItem();
|
||||||
|
if (item && item.image && !helper.running && !queuedAction) run("thumb", item.id);
|
||||||
|
}
|
||||||
|
function choose() {
|
||||||
|
const item = currentItem();
|
||||||
|
if (item && !confirming) request("select", item.id);
|
||||||
|
}
|
||||||
|
function remove() {
|
||||||
|
const item = currentItem();
|
||||||
|
if (item && !confirming) request("delete", item.id);
|
||||||
|
}
|
||||||
|
function dismiss(force) {
|
||||||
|
if (confirming && !force) { confirming = false; return; }
|
||||||
|
if (closing) return;
|
||||||
|
closing = true;
|
||||||
|
queuedAction = null;
|
||||||
|
visible = false;
|
||||||
|
items = []; results = []; thumbnail = "";
|
||||||
|
if (!helper.running) run("clean", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: helper
|
||||||
|
running: false
|
||||||
|
stdout: StdioCollector { id: output; waitForEnd: true }
|
||||||
|
stderr: StdioCollector { waitForEnd: true }
|
||||||
|
onExited: (code, status) => {
|
||||||
|
const action = panel.operation;
|
||||||
|
const requestedId = panel.activeId;
|
||||||
|
let data = {};
|
||||||
|
try { data = JSON.parse(output.text); } catch (_) {}
|
||||||
|
panel.operation = "";
|
||||||
|
panel.activeId = "";
|
||||||
|
if (panel.closing) {
|
||||||
|
if (action === "clean") panel.cleanupFinished();
|
||||||
|
else panel.run("clean", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code !== 0 || status !== 0 || data.error) {
|
||||||
|
panel.message = "Opération impossible. Réessayez.";
|
||||||
|
if (panel.queuedAction) {
|
||||||
|
const next = panel.queuedAction;
|
||||||
|
panel.queuedAction = null;
|
||||||
|
panel.run(next.action, next.ident);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "list") {
|
||||||
|
panel.items = data.items || [];
|
||||||
|
panel.listLoaded = true;
|
||||||
|
panel.filter();
|
||||||
|
} else if (action === "thumb") {
|
||||||
|
const current = panel.currentItem();
|
||||||
|
if (current && current.id === requestedId) panel.thumbnail = data.path || "";
|
||||||
|
else panel.updateThumbnail();
|
||||||
|
} else if (action === "select") panel.dismiss();
|
||||||
|
else if (action === "delete" || action === "wipe") {
|
||||||
|
panel.confirming = false;
|
||||||
|
panel.refresh();
|
||||||
|
}
|
||||||
|
if (panel.queuedAction && !helper.running) {
|
||||||
|
const next = panel.queuedAction;
|
||||||
|
panel.queuedAction = null;
|
||||||
|
panel.run(next.action, next.ident);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La surface Wayland est créée après Component.onCompleted : demander le
|
||||||
|
// focus au tour suivant, une fois le layer-shell visible et focusable.
|
||||||
|
Timer { interval: 120; running: true; onTriggered: search.forceActiveFocus() }
|
||||||
|
// WHY: TextField consomme Return avant Keys.onPressed sur cette version de Qt.
|
||||||
|
// CONTRACT: Entrée active l'entrée sélectionnée même pendant la recherche.
|
||||||
|
Shortcut { sequence: "Return"; context: Qt.WindowShortcut; enabled: panel.visible && !panel.confirming; onActivated: panel.choose() }
|
||||||
|
Shortcut { sequence: "Enter"; context: Qt.WindowShortcut; enabled: panel.visible && !panel.confirming; onActivated: panel.choose() }
|
||||||
|
Component.onCompleted: refresh()
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: contentRoot
|
||||||
|
anchors.fill: parent
|
||||||
|
Keys.onPressed: event => { if (event.key === Qt.Key_Escape) { panel.dismiss(); event.accepted = true; } }
|
||||||
|
MouseArea { anchors.fill: parent; onClicked: panel.dismiss(true) }
|
||||||
|
Rectangle {
|
||||||
|
id: card
|
||||||
|
// CONTRACT: même marge droite et même départ vertical que le
|
||||||
|
// Control Center ; la surface complète conserve son focus clavier.
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: panel.barWindow.height + 12
|
||||||
|
anchors.rightMargin: 16
|
||||||
|
width: Math.max(0, Math.min(540, parent.width - 32))
|
||||||
|
height: Math.max(0, Math.min(500, parent.height - anchors.topMargin - 16))
|
||||||
|
radius: 4
|
||||||
|
color: "#1e1e2e"
|
||||||
|
border.color: "#45475a"
|
||||||
|
border.width: 1
|
||||||
|
MouseArea { anchors.fill: parent; onClicked: {} }
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 12
|
||||||
|
Row {
|
||||||
|
width: parent.width; height: 30
|
||||||
|
Text { text: " Presse-papiers"; width: Math.max(0, parent.width - countLabel.implicitWidth - clearLabel.implicitWidth - 8); elide: Text.ElideRight
|
||||||
|
color: "#cdd6f4"; font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 18; font.bold: true }
|
||||||
|
Item { width: 8; height: 1 }
|
||||||
|
Text { id: countLabel; text: panel.items.length + " / 50"; color: "#a6adc8"; font.pixelSize: 12; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Text { id: clearLabel; text: " Tout effacer"; color: "#f38ba8"; font.pixelSize: 12; anchors.verticalCenter: parent.verticalCenter
|
||||||
|
MouseArea { anchors.fill: parent; onClicked: panel.confirming = true } }
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width; height: 40; radius: 4; color: "#313244"; border.color: search.activeFocus ? "#b4befe" : "#45475a"
|
||||||
|
TextField {
|
||||||
|
id: search
|
||||||
|
anchors.fill: parent; anchors.margins: 2
|
||||||
|
placeholderText: "Rechercher dans les textes…"
|
||||||
|
color: "#cdd6f4"; placeholderTextColor: "#a6adc8"
|
||||||
|
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13
|
||||||
|
background: Item {}
|
||||||
|
onTextChanged: panel.filter()
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
if (event.key === Qt.Key_Down) { panel.selected = Math.min(panel.results.length - 1, panel.selected + 1); list.positionViewAtIndex(panel.selected, ListView.Contain); panel.updateThumbnail(); event.accepted = true; }
|
||||||
|
else if (event.key === Qt.Key_Up) { panel.selected = Math.max(0, panel.selected - 1); list.positionViewAtIndex(panel.selected, ListView.Contain); panel.updateThumbnail(); event.accepted = true; }
|
||||||
|
else if (event.key === Qt.Key_Escape) { panel.dismiss(); event.accepted = true; }
|
||||||
|
else if (event.key === Qt.Key_Delete && (event.modifiers & Qt.ControlModifier)) { panel.remove(); event.accepted = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text { visible: panel.message !== ""; text: panel.message; color: "#f38ba8"; font.pixelSize: 12 }
|
||||||
|
Text { visible: panel.confirming; text: "Effacer tout l’historique ? Cette action est irréversible."; color: "#f9e2af"; font.pixelSize: 12 }
|
||||||
|
Row {
|
||||||
|
visible: panel.confirming; spacing: 16
|
||||||
|
Text { id: confirmLabel; text: "Confirmer la purge"; color: "#f38ba8"; font.bold: true
|
||||||
|
MouseArea { anchors.fill: parent; onClicked: panel.request("wipe", "") } }
|
||||||
|
Text { id: cancelLabel; text: "Annuler"; color: "#b4befe"; MouseArea { anchors.fill: parent; onClicked: panel.confirming = false } }
|
||||||
|
}
|
||||||
|
Item {
|
||||||
|
width: parent.width
|
||||||
|
height: Math.max(0, parent.height - y - 36)
|
||||||
|
Text { anchors.centerIn: parent; visible: panel.results.length === 0; text: panel.items.length === 0 ? "Historique vide" : "Aucun résultat textuel"; color: "#a6adc8" }
|
||||||
|
ListView {
|
||||||
|
id: list
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: panel.results.length > 0
|
||||||
|
clip: true; spacing: 6
|
||||||
|
model: panel.results
|
||||||
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
width: list.width - 12; height: modelData.image && panel.selected === index ? 122 : 66
|
||||||
|
radius: 4
|
||||||
|
color: panel.selected === index ? "#45475a" : "#313244"
|
||||||
|
border.color: panel.selected === index ? "#b4befe" : "transparent"
|
||||||
|
Row {
|
||||||
|
anchors.fill: parent; anchors.margins: 10; spacing: 10
|
||||||
|
Text { text: modelData.image ? "" : ""; color: "#b4befe"; font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 19 }
|
||||||
|
Column {
|
||||||
|
width: parent.width - 35; spacing: 5
|
||||||
|
Text { text: modelData.image ? "Image" : "Texte"; color: "#b4befe"; font.bold: true; font.pixelSize: 12 }
|
||||||
|
Text { width: parent.width; text: modelData.preview; color: "#cdd6f4"; font.pixelSize: 12; wrapMode: Text.Wrap; maximumLineCount: 2; elide: Text.ElideRight }
|
||||||
|
Image { visible: modelData.image && panel.selected === index && panel.thumbnail !== ""; source: visible ? panel.thumbnail : ""; width: 180; height: 75; fillMode: Image.PreserveAspectFit; asynchronous: true; cache: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseArea { anchors.fill: parent; acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onClicked: mouse => {
|
||||||
|
panel.selected = index;
|
||||||
|
if (mouse.button === Qt.RightButton) panel.remove();
|
||||||
|
else panel.choose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
width: parent.width; height: 20
|
||||||
|
Text { text: parent.width < 410 ? "↑ ↓ Entrée Échap" : "↑ ↓ parcourir Entrée copier Ctrl+Suppr retirer Échap fermer";
|
||||||
|
width: Math.max(0, parent.width - removeLabel.implicitWidth - 8); elide: Text.ElideRight
|
||||||
|
color: "#a6adc8"; font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 11 }
|
||||||
|
Item { width: 8; height: 1 }
|
||||||
|
Text { id: removeLabel; text: "Supprimer"; color: "#f38ba8"; font.pixelSize: 12
|
||||||
|
MouseArea { anchors.fill: parent; onClicked: panel.remove() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
quickshell/.config/quickshell/labfy-sway/clipboard/README.md
Normal file
42
quickshell/.config/quickshell/labfy-sway/clipboard/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Historique du presse-papiers
|
||||||
|
|
||||||
|
L'icône presse-papiers de la zone droite et `Mod+Alt+V` appellent la même fonction de la barre. Le raccourci passe par `~/.config/sway/scripts/clipboard-quickshell` et cible par IPC l'écran SwayFX focalisé. La barre crée `ClipboardPanel.qml` avec `LazyLoader` et le détruit à la fermeture. Le panneau reste une surface layer-shell à focus clavier exclusif, sans zone réservée ; sa carte est alignée sur la marge droite du Control Center, juste sous la barre. Elle mesure au plus 540 × 500 px et se réduit sur les petits écrans. Le focus revient à la fenêtre précédente quand la surface disparaît. Aucun démon supplémentaire ni interrogation permanente de `cliphist`.
|
||||||
|
|
||||||
|
Les collecteurs `cliphist-text.service` et `cliphist-image.service`, la base `cliphist` et la limite de **50 entrées affichées** restent inchangés. `backend.py` exécute directement `cliphist` et `wl-copy` sans shell. QML reçoit uniquement les aperçus de `cliphist list`, les identifiants et le type MIME. Avant une sélection ou une suppression, le helper vérifie que l'identifiant est toujours dans les 50 entrées courantes. `decode` doit terminer avec succès avant que `wl-copy` ne reçoive son flux binaire ; les images gardent leur type MIME.
|
||||||
|
|
||||||
|
La version installée de `cliphist` (0.7.0) ne fournit ni `list -fields` ni `max-store-size` : son code ignore sans erreur les entrées de plus de 5 000 000 octets. Un PNG plus grand peut donc être proposé par Wayland sans entrer dans l'historique. La limite n'est pas relevée implicitement. Une version ultérieure de `cliphist` expose ces options, mais une migration et son budget de stockage doivent être décidés séparément.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
- `↑` / `↓` : changer la sélection ; `Entrée` : restaurer l'entrée sélectionnée.
|
||||||
|
- Saisie directe : filtrer les textes ; les images restent visibles lorsque la recherche est vide.
|
||||||
|
- Clic gauche sur une entrée : restaurer et fermer ; défilement : parcourir.
|
||||||
|
- `Ctrl+Suppr` ou « Supprimer » : retirer l'entrée sélectionnée.
|
||||||
|
- « Tout effacer » demande une confirmation explicite ; « Annuler » et `Échap` l'annulent.
|
||||||
|
- `Échap`, un clic extérieur ou un second clic sur l'icône ferment le panneau. `Mod+Alt+V` le referme aussi.
|
||||||
|
|
||||||
|
L'ouverture du presse-papiers ferme Date Center, Control Center, le menu des fenêtres et le panneau des supports amovibles. L'ouverture de ces panneaux ferme réciproquement le presse-papiers. Les notifications ne sont pas modifiées.
|
||||||
|
|
||||||
|
## Images et confidentialité
|
||||||
|
|
||||||
|
Une seule image sélectionnée peut recevoir une miniature. Elle est produite par ImageMagick dans un répertoire `0700` sous `XDG_RUNTIME_DIR`, avec limites locales de mémoire et de surface. Le panneau nettoie ses miniatures à la fermeture. Les anciens fichiers de `cliphist/thumbs` ne sont pas réutilisés : leurs permissions et leur provenance ne sont pas garanties. Le décodage utilise un fichier temporaire privé, sans placer les octets binaires dans une propriété QML ou dans les arguments d'une commande.
|
||||||
|
|
||||||
|
Les journaux ne contiennent ni aperçu ni contenu décodé. L'interface affiche les aperçus seulement quand l'utilisateur l'ouvre. Elle ne crée pas de base supplémentaire ni de nouvelle politique de rétention. Les deux collecteurs actuels ne distinguent pas de manière fiable les secrets des autres contenus ; l'exclusion automatique des mots de passe n'est donc pas annoncée. Pour éviter la collecte d'un secret, il faut agir à la source ou définir ultérieurement une politique explicite.
|
||||||
|
|
||||||
|
## Dépendances et retour arrière
|
||||||
|
|
||||||
|
Dépendances : QuickShell 0.3.1, Qt 6, SwayFX, `jq`, Python 3, `cliphist`, `wl-clipboard`, ImageMagick et une Nerd Font. Le script Wofi `copypast` reste présent. Pour revenir à Wofi, remplacer uniquement la ligne `Mod+Alt+V` de `sway/.config/sway/bind` par `bindsym $mod+Alt+v exec ~/.config/sway/scripts/copypast`, puis recharger la configuration Sway. Les autres usages de Wofi, dont le lanceur `Mod+Alt+L`, restent indépendants.
|
||||||
|
|
||||||
|
## Vérification isolée
|
||||||
|
|
||||||
|
`python3 -B -m unittest discover -s tests/clipboard -v` utilise une base `cliphist` temporaire et le chemin absolu d'un faux `wl-copy`. Le test couvre texte, image PNG, octets, type MIME, suppression, purge, cache et historique vide. Il ne touche jamais l'historique personnel. Le panneau live est inspectable sans contenu via `quickshell ipc -c labfy-sway call clipboardUi-eDP-1 state` (adapter le nom de sortie).
|
||||||
|
|
||||||
|
`python3 -B tests/clipboard/ui_quickshell_smoke.py` exige une session Wayland et exerce les vrais `MouseArea` du panneau avec QtTest et la touche Entrée avec `ydotool` : icône, liste, texte, PNG, miniature, défilement, suppression, annulation et confirmation de purge, clic extérieur et focus. La base `cliphist`, le cache de miniatures et le faux `wl-copy` sont créés dans un répertoire temporaire privé. `CLIPBOARD_UI_CAPTURE=/run/user/$UID/clipboard-card.png` ajoute une capture intérieure de la carte factice pour vérifier le rendu.
|
||||||
|
|
||||||
|
`Print` passe par `screenshot-region` : il ferme d'abord le panneau exclusif s'il est ouvert, lance le `slurp` corrigé, puis confie la géométrie à `grim`. Échap arrête la commande avant toute écriture dans le presse-papiers ; seul un fichier PNG vérifié est remis à `wl-copy --type image/png`. Le binaire utilisateur `$HOME/.local/libexec/labfy-slurp` est construit par `tools/build-slurp-keymap-guard` depuis `slurp` 1.5.0, avec une garde sur les événements clavier reçus avant la keymap. La construction ne remplace pas `/usr/bin/slurp` et ne nécessite pas de privilèges administrateur.
|
||||||
|
Lorsque le paquet officiel inclura cette garde, remplacer le chemin utilisateur
|
||||||
|
dans `screenshot-region` par le binaire officiel et retirer la construction locale.
|
||||||
|
|
||||||
|
Pour une instance graphique de test, `LABFY_CLIPHIST_TEST_DB` désigne la base isolée et `LABFY_CLIPHIST_TEST_WL_COPY` le chemin absolu du faux `wl-copy`. Les deux variables doivent être fournies ensemble afin de ne toucher ni la base ni le presse-papiers de la session.
|
||||||
|
|
||||||
|
Limites : le filtre porte sur les aperçus textuels, pas sur le texte intégral ; les images ne sont pas recherchées. Une miniature ImageMagick peut être refusée par les limites de ressources sans empêcher la sélection de l'image. La surface prend le focus clavier seulement après sa création Wayland, environ 120 ms après ouverture.
|
||||||
164
quickshell/.config/quickshell/labfy-sway/clipboard/backend.py
Executable file
164
quickshell/.config/quickshell/labfy-sway/clipboard/backend.py
Executable file
|
|
@ -0,0 +1,164 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pont éphémère cliphist : seules les métadonnées et miniatures passent à QML."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import selectors
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
IMAGE = re.compile(r"^\[\[ binary data .*?\b(png|jpeg|jpg|bmp|webp)\b", re.I)
|
||||||
|
MIMES = {"png": "image/png", "jpeg": "image/jpeg", "jpg": "image/jpeg", "bmp": "image/bmp", "webp": "image/webp"}
|
||||||
|
MAX_DECODE_BYTES = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def command(db, action):
|
||||||
|
return ["cliphist"] + (["-db-path", db] if db else []) + [action]
|
||||||
|
|
||||||
|
|
||||||
|
def entries(db):
|
||||||
|
# CONTRACT: les 50 premières lignes sont les plus récentes ; le champ avant TAB est l'identité stable.
|
||||||
|
result = subprocess.run(command(db, "list"), capture_output=True, timeout=10)
|
||||||
|
if result.returncode:
|
||||||
|
if result.returncode == 1 and b"please store something first" in result.stderr:
|
||||||
|
return []
|
||||||
|
raise RuntimeError("Historique indisponible")
|
||||||
|
items = []
|
||||||
|
for raw in result.stdout.splitlines()[:50]:
|
||||||
|
ident, separator, preview = raw.partition(b"\t")
|
||||||
|
if not separator or not ident.isdigit():
|
||||||
|
continue
|
||||||
|
label = preview.decode("utf-8", "replace")
|
||||||
|
image = IMAGE.match(label)
|
||||||
|
items.append({"id": ident.decode("ascii"), "image": bool(image),
|
||||||
|
"mime": MIMES.get(image.group(1).lower()) if image else "text/plain;charset=utf-8",
|
||||||
|
"preview": "Image · " + label[3:-3].strip() if image else label[:400]})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def exact(db, ident):
|
||||||
|
if not re.fullmatch(r"[0-9]+", ident):
|
||||||
|
raise ValueError("Identifiant invalide")
|
||||||
|
for item in entries(db):
|
||||||
|
if item["id"] == ident:
|
||||||
|
return item
|
||||||
|
raise ValueError("Entrée introuvable parmi les 50 éléments affichés")
|
||||||
|
|
||||||
|
|
||||||
|
def decode(db, ident, output):
|
||||||
|
# WHY: le flux binaire privé est limité en espace et en temps ; aucune capture mémoire QML.
|
||||||
|
process = subprocess.Popen(command(db, "decode"), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL)
|
||||||
|
try:
|
||||||
|
process.stdin.write((ident + "\t").encode("ascii"))
|
||||||
|
process.stdin.close()
|
||||||
|
size = 0
|
||||||
|
deadline = time.monotonic() + 20
|
||||||
|
with selectors.DefaultSelector() as selector:
|
||||||
|
selector.register(process.stdout, selectors.EVENT_READ)
|
||||||
|
while True:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise RuntimeError("Décodage trop lent")
|
||||||
|
ready = selector.select(max(0, deadline - time.monotonic()))
|
||||||
|
if not ready:
|
||||||
|
raise RuntimeError("Décodage trop lent")
|
||||||
|
chunk = os.read(process.stdout.fileno(), 65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_DECODE_BYTES:
|
||||||
|
raise RuntimeError("Entrée trop volumineuse")
|
||||||
|
output.write(chunk)
|
||||||
|
if process.wait(timeout=2):
|
||||||
|
raise RuntimeError("Décodage impossible")
|
||||||
|
finally:
|
||||||
|
if not process.stdin.closed:
|
||||||
|
process.stdin.close()
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
process.stdout.close()
|
||||||
|
output.flush()
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir():
|
||||||
|
# CONTRACT: le test graphique peut isoler les miniatures sans changer le
|
||||||
|
# socket Wayland fourni par XDG_RUNTIME_DIR.
|
||||||
|
runtime = Path(os.environ.get("LABFY_CLIPHIST_TEST_CACHE_DIR") or
|
||||||
|
os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
|
||||||
|
path = runtime / ("labfy-cliphist-panel-" + str(os.getuid()))
|
||||||
|
path.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
if path.is_symlink() or path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077:
|
||||||
|
raise RuntimeError("Répertoire temporaire non privé")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("action", choices=("list", "select", "delete", "wipe", "thumb", "clean"))
|
||||||
|
parser.add_argument("id", nargs="?")
|
||||||
|
parser.add_argument("--db-path", default=None, help=argparse.SUPPRESS)
|
||||||
|
parser.add_argument("--wl-copy-path", default="wl-copy", help=argparse.SUPPRESS)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.action == "list":
|
||||||
|
print(json.dumps({"items": entries(args.db_path)}, ensure_ascii=False))
|
||||||
|
elif args.action == "clean":
|
||||||
|
path = cache_dir()
|
||||||
|
for file in path.glob("*.png"):
|
||||||
|
if file.is_file() and not file.is_symlink():
|
||||||
|
file.unlink()
|
||||||
|
print('{"ok":true}')
|
||||||
|
elif args.action == "wipe":
|
||||||
|
# CONTRACT: la confirmation est dans QML ; cette commande n'est appelée qu'après confirmation explicite.
|
||||||
|
subprocess.run(command(args.db_path, "wipe"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=10)
|
||||||
|
print('{"ok":true}')
|
||||||
|
else:
|
||||||
|
item = exact(args.db_path, args.id or "")
|
||||||
|
if args.action == "delete":
|
||||||
|
result = subprocess.run(command(args.db_path, "delete"), input=(item["id"] + "\t").encode("ascii"),
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError("Suppression impossible")
|
||||||
|
print('{"ok":true}')
|
||||||
|
return
|
||||||
|
if args.action == "thumb" and not item["image"]:
|
||||||
|
raise ValueError("Miniature réservée aux images")
|
||||||
|
with tempfile.TemporaryFile() as decoded:
|
||||||
|
decode(args.db_path, item["id"], decoded)
|
||||||
|
if args.action == "select":
|
||||||
|
# INVARIANT: wl-copy reçoit les octets décodés par stdin avec le MIME exact.
|
||||||
|
result = subprocess.run([args.wl_copy_path, "--type", item["mime"]], stdin=decoded,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError("Restauration du presse-papiers impossible")
|
||||||
|
print('{"ok":true}')
|
||||||
|
else:
|
||||||
|
path = cache_dir() / (item["id"] + ".png")
|
||||||
|
if not path.exists():
|
||||||
|
with tempfile.NamedTemporaryFile(dir=path.parent, prefix="thumb-", suffix=".png", delete=False) as out:
|
||||||
|
temporary = Path(out.name)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["magick", "-limit", "memory", "64MiB", "-limit", "map", "128MiB",
|
||||||
|
"-limit", "disk", "256MiB", "-limit", "time", "8",
|
||||||
|
"-limit", "area", "40MP", "-", "-thumbnail", "180x110>", "png:-"],
|
||||||
|
stdin=decoded, stdout=out, stderr=subprocess.DEVNULL, timeout=10)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError("Miniature indisponible")
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
print(json.dumps({"path": path.as_uri()}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired, RuntimeError, ValueError) as error:
|
||||||
|
# CONTRACT: aucun aperçu ni contenu décodé n'est écrit dans les journaux.
|
||||||
|
print(json.dumps({"error": str(error)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
@ -3,6 +3,7 @@ import QtQuick.Controls
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
|
import "PercentMath.js" as PercentMath
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: brightnessControl
|
id: brightnessControl
|
||||||
|
|
@ -15,6 +16,46 @@ Item {
|
||||||
readonly property real fraction: available ? brightness / maximum : 0
|
readonly property real fraction: available ? brightness / maximum : 0
|
||||||
readonly property int percent: Math.round(fraction * 100)
|
readonly property int percent: Math.round(fraction * 100)
|
||||||
|
|
||||||
|
// INVARIANT: une seule écriture sysfs à la fois ; les gestes rapides
|
||||||
|
// mettent à jour la consigne, puis chaque résultat est relu du matériel.
|
||||||
|
property int requestedPercent: -1
|
||||||
|
property int writtenPercent: -1
|
||||||
|
property bool writeInFlight: false
|
||||||
|
property bool readbackFailed: false
|
||||||
|
function requestPercent(value) {
|
||||||
|
if (!available) return;
|
||||||
|
requestedPercent = PercentMath.bounded(value, 10);
|
||||||
|
if (!writeInFlight) writeRequested();
|
||||||
|
}
|
||||||
|
function adjustBy(steps) {
|
||||||
|
if (!available || !steps) return;
|
||||||
|
requestPercent((requestedPercent >= 0 ? requestedPercent : percent) + steps);
|
||||||
|
}
|
||||||
|
function writeRequested() {
|
||||||
|
if (!available || requestedPercent < 0) return;
|
||||||
|
const raw = Math.max(1, Math.round(maximum * requestedPercent / 100));
|
||||||
|
writtenPercent = requestedPercent;
|
||||||
|
writeInFlight = true;
|
||||||
|
brightnessFile.setText(String(raw));
|
||||||
|
}
|
||||||
|
function finishWrite() {
|
||||||
|
// Exécuter après le signal saved évite de réentrer dans les callbacks
|
||||||
|
// FileView pendant l'attente bornée de lecture des quelques octets sysfs.
|
||||||
|
readbackFailed = false;
|
||||||
|
brightnessFile.reload();
|
||||||
|
brightnessFile.waitForJob();
|
||||||
|
const raw = brightnessFile.text().trim();
|
||||||
|
const actual = /^[0-9]+$/.test(raw) ? Number(raw) : NaN;
|
||||||
|
writeInFlight = false;
|
||||||
|
if (readbackFailed || !Number.isSafeInteger(actual) || actual < 0 || actual > maximum) {
|
||||||
|
requestedPercent = -1;
|
||||||
|
console.warn("Relecture du rétroéclairage impossible après écriture");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestedPercent !== writtenPercent) writeRequested();
|
||||||
|
else requestedPercent = -1;
|
||||||
|
}
|
||||||
|
|
||||||
implicitHeight: content.implicitHeight
|
implicitHeight: content.implicitHeight
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
|
|
@ -28,6 +69,25 @@ Item {
|
||||||
watchChanges: true
|
watchChanges: true
|
||||||
atomicWrites: false
|
atomicWrites: false
|
||||||
onFileChanged: reload()
|
onFileChanged: reload()
|
||||||
|
onLoadFailed: error => {
|
||||||
|
brightnessControl.readbackFailed = true;
|
||||||
|
console.warn("Lecture du rétroéclairage impossible", error);
|
||||||
|
}
|
||||||
|
onSaved: {
|
||||||
|
// WHY: FileView peut déjà avoir émis loaded() depuis le watcher
|
||||||
|
// avant saved(). Attendre un second loaded() bloquait la file pour
|
||||||
|
// toujours. Relire ici les quelques octets de sysfs, puis libérer
|
||||||
|
// l'écriture avant de lancer la dernière consigne en attente.
|
||||||
|
// Le pilote peut arrondir ; une nouvelle consigne est la seule
|
||||||
|
// raison de poursuivre, jamais un écart entre cible et matériel.
|
||||||
|
Qt.callLater(brightnessControl.finishWrite);
|
||||||
|
}
|
||||||
|
onSaveFailed: error => {
|
||||||
|
console.warn("Écriture du rétroéclairage impossible", error);
|
||||||
|
brightnessControl.writeInFlight = false;
|
||||||
|
brightnessControl.requestedPercent = -1;
|
||||||
|
reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|
@ -79,7 +139,7 @@ Item {
|
||||||
onMoved: {
|
onMoved: {
|
||||||
if (brightnessControl.available) {
|
if (brightnessControl.available) {
|
||||||
const fraction = Math.max(from, Math.min(to, value));
|
const fraction = Math.max(from, Math.min(to, value));
|
||||||
brightnessFile.setText(String(Math.round(brightnessControl.maximum * fraction)));
|
brightnessControl.requestPercent(fraction * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ PopupWindow {
|
||||||
property int currentPage: pages.main
|
property int currentPage: pages.main
|
||||||
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
||||||
signal thresholdApplied()
|
signal thresholdApplied()
|
||||||
|
// CONTRACT: la barre appelle les mêmes instances que les curseurs du
|
||||||
|
// panneau ; les valeurs affichées restent celles des backends observés.
|
||||||
|
readonly property bool volumeAvailable: volumeSlider.sinkAudio !== null
|
||||||
|
readonly property bool volumeMuted: volumeSlider.muted
|
||||||
|
readonly property int volumePercent: volumeSlider.percent
|
||||||
|
readonly property bool brightnessAvailable: brightnessSlider.available
|
||||||
|
readonly property int brightnessPercent: brightnessSlider.percent
|
||||||
|
function adjustVolume(steps) { volumeSlider.adjustBy(steps); }
|
||||||
|
function adjustBrightness(steps) { brightnessSlider.adjustBy(steps); }
|
||||||
readonly property var pendingAction: sessionExitGate.pendingAction
|
readonly property var pendingAction: sessionExitGate.pendingAction
|
||||||
readonly property string checkpointError: sessionExitGate.errorMessage
|
readonly property string checkpointError: sessionExitGate.errorMessage
|
||||||
readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME")
|
readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME")
|
||||||
|
|
@ -162,8 +171,8 @@ PopupWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VolumeSlider { width: parent.width }
|
VolumeSlider { id: volumeSlider; width: parent.width }
|
||||||
BrightnessSlider { width: parent.width }
|
BrightnessSlider { id: brightnessSlider; width: parent.width }
|
||||||
PowerProfile { width: parent.width }
|
PowerProfile { width: parent.width }
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,19 @@ Rectangle {
|
||||||
required property bool open
|
required property bool open
|
||||||
signal toggled()
|
signal toggled()
|
||||||
|
|
||||||
width: 48
|
// Même hauteur que les actions voisines, avec une cible légèrement plus
|
||||||
|
// large pour l'accès au panneau sans capsule permanente.
|
||||||
|
width: 30
|
||||||
height: 26
|
height: 26
|
||||||
radius: 4
|
radius: 4
|
||||||
color: open ? Theme.accent : pointer.containsMouse ? Theme.border : Theme.buttonBackground
|
color: open ? Theme.emphasisBackground : pointer.containsMouse ? Theme.border : "transparent"
|
||||||
|
|
||||||
NerdIcon {
|
NerdIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: ""
|
text: ""
|
||||||
// WHY: garder le même poids visuel que les statuts voisins dans la capsule fixe.
|
// WHY: garder le même poids visuel que les actions voisines sans capsule permanente.
|
||||||
font.pixelSize: 20
|
font.pixelSize: 28
|
||||||
color: controlButton.open ? Theme.onAccent : Theme.foreground
|
color: controlButton.open ? Theme.accentForeground : Theme.lavender
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
// CONTRACT: les deux contrôles appliquent des points de pourcentage absolus ;
|
||||||
|
// leur plancher diffère, mais aucun ne peut dépasser 100 %.
|
||||||
|
function bounded(value, minimum) {
|
||||||
|
return Math.max(minimum, Math.min(100, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import QtQuick.Controls
|
||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
|
import "PercentMath.js" as PercentMath
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: volumeControl
|
id: volumeControl
|
||||||
|
|
@ -13,6 +14,28 @@ Item {
|
||||||
readonly property bool muted: sinkAudio ? sinkAudio.muted : false
|
readonly property bool muted: sinkAudio ? sinkAudio.muted : false
|
||||||
readonly property int percent: sinkAudio ? Math.round(sinkAudio.volume * 100) : 0
|
readonly property int percent: sinkAudio ? Math.round(sinkAudio.volume * 100) : 0
|
||||||
|
|
||||||
|
// CONTRACT: un pas est un point absolu, calculé depuis la dernière
|
||||||
|
// consigne locale tant que PipeWire n'a pas encore publié son écho.
|
||||||
|
property int requestedPercent: -1
|
||||||
|
property var sentPercents: []
|
||||||
|
function adjustBy(steps) {
|
||||||
|
if (!sinkAudio || !steps) return;
|
||||||
|
const base = requestedPercent >= 0 ? requestedPercent : percent;
|
||||||
|
const next = PercentMath.bounded(base + steps, 0);
|
||||||
|
requestedPercent = next;
|
||||||
|
sentPercents = sentPercents.concat([next]).slice(-100);
|
||||||
|
sinkAudio.volume = next / 100;
|
||||||
|
}
|
||||||
|
onSinkAudioChanged: { requestedPercent = -1; sentPercents = []; }
|
||||||
|
onPercentChanged: {
|
||||||
|
// Un changement extérieur annule la consigne locale ; les échos de
|
||||||
|
// commandes rapprochées déjà envoyées ne doivent pas l'annuler.
|
||||||
|
if (requestedPercent === percent || sentPercents.indexOf(percent) < 0) {
|
||||||
|
requestedPercent = -1;
|
||||||
|
sentPercents = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PwObjectTracker {
|
PwObjectTracker {
|
||||||
objects: [volumeControl.sink]
|
objects: [volumeControl.sink]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
quickshell/.config/quickshell/labfy-sway/overview/README.md
Normal file
98
quickshell/.config/quickshell/labfy-sway/overview/README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Vue d'ensemble des workspaces SwayFX
|
||||||
|
|
||||||
|
`Super+W` ouvre et ferme la surface `PanelWindow` centrée sur la sortie
|
||||||
|
focalisée. Elle affiche toujours les workspaces numérotés 1 à 10 ; un workspace
|
||||||
|
absent de l'arbre Sway est indiqué vide. Le panneau atteint au plus 1140 × 730
|
||||||
|
pixels logiques, avec 16 pixels de marge minimale. Avec cinq colonnes, sa
|
||||||
|
hauteur est de 566 pixels : la grille de 430 pixels tient dans 438 pixels
|
||||||
|
utiles sur `eDP-1`, sans défilement. Sur les petits écrans, le défilement reste
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Provenance des miniatures
|
||||||
|
|
||||||
|
`grim -o <sortie> -s 0.25 -t jpeg -q 72` prend un **snapshot réel de la sortie
|
||||||
|
visible**. Une capture a lieu à l'ouverture, puis lors du changement de
|
||||||
|
workspace après 350 ms et au plus toutes les 15 s pendant que l'Overview est
|
||||||
|
fermée. Les images sont remplacées atomiquement dans
|
||||||
|
`$XDG_RUNTIME_DIR/labfy-workspace-overview/`. Le cache est éphémère et privé.
|
||||||
|
Chaque JPEG possède un manifeste JSON avec l'identité du workspace, sa sortie,
|
||||||
|
une empreinte de la hiérarchie et de la géométrie Sway, l'horodatage et le
|
||||||
|
`mtime` du fichier. `state()` ne fournit l'image que si ces données correspondent
|
||||||
|
à l'arbre courant. Les titres et le focus ne font pas partie de l'empreinte.
|
||||||
|
Une modification de taille, disposition, flottement ou composition de fenêtres
|
||||||
|
masque immédiatement l'ancien snapshot complet. Les images individuelles
|
||||||
|
disponibles restent affichées dans les nouveaux rectangles des fenêtres.
|
||||||
|
Un workspace devenu vide efface son ancienne image et son manifeste. Les
|
||||||
|
workspaces inchangés conservent leur capture.
|
||||||
|
|
||||||
|
La sortie qui porte l'Overview ne peut pas être capturée tant que sa surface
|
||||||
|
overlay est visible. Après un changement structurel, elle montre donc la
|
||||||
|
reconstruction jusqu'à la fermeture du panneau ; une capture différée de
|
||||||
|
300 ms est alors tentée. Le helper vérifie avant et après `grim` la présence
|
||||||
|
de l'overlay, l'identité et l'empreinte du workspace. Une sortie visible
|
||||||
|
différente de celle du panneau peut être recapturée pendant l'ouverture,
|
||||||
|
après 350 ms, si elle ne porte pas l'overlay. Ces captures multi-écrans n'ont
|
||||||
|
pas été vérifiées sur matériel. Une capture peut contenir une autre application
|
||||||
|
superposée à la sortie entre deux vérifications ; elle reste désignée comme
|
||||||
|
dernière capture.
|
||||||
|
|
||||||
|
`grim -T <identifiant>` capture réellement une fenêtre de cette session
|
||||||
|
SwayFX 0.6, même lorsque son workspace est caché. Le backend lit
|
||||||
|
`foreign_toplevel_identifier` sur la fenêtre dans `get_tree`, puis associe
|
||||||
|
l'image obtenue au vrai `con_id`. Passer le `con_id` à `grim -T` échoue : ce
|
||||||
|
sont deux identités distinctes. Chaque image individuelle a un manifeste
|
||||||
|
contenant les deux identifiants, son horodatage et le `mtime` du JPEG. Elle
|
||||||
|
reste utilisable après un déplacement ou un redimensionnement, et disparaît
|
||||||
|
du cache à la fermeture de la fenêtre. L'ouverture de l'Overview rafraîchit
|
||||||
|
la sortie visible et les fenêtres sans capture ; un changement structurel
|
||||||
|
recapture les fenêtres des workspaces concernés, par lots de 16 au plus.
|
||||||
|
Cette capture par toplevel n'inclut pas l'overlay de l'Overview.
|
||||||
|
|
||||||
|
La capture d'une fenêtre cachée peut refléter sa dernière frame si
|
||||||
|
l'application cesse de dessiner hors écran. `grim -T` ne fournit pas le rendu
|
||||||
|
composé d'un workspace caché. **Aucune carte n'est présentée comme live.**
|
||||||
|
Un workspace dont la capture complète est encore compatible montre ce
|
||||||
|
snapshot. Après un changement, la carte place les dernières images des
|
||||||
|
fenêtres encore présentes selon `get_tree`. Si une fenêtre n'a pas de capture
|
||||||
|
individuelle valide, son rectangle reste une reconstruction géométrique.
|
||||||
|
Les rectangles reprennent la géométrie réelle,
|
||||||
|
le focus, la position flottante et le titre de l'application. Les onglets et
|
||||||
|
les piles sont représentés par les rectangles et titres des feuilles ; leur
|
||||||
|
ornement Sway exact n'est pas reproduit.
|
||||||
|
|
||||||
|
Les sorties sont obtenues par `get_outputs`, les workspaces par
|
||||||
|
`get_workspaces`, les fenêtres par `get_tree`. Les changements `window`,
|
||||||
|
`workspace` et `output` déclenchent une relecture regroupée à 220 ms lorsque
|
||||||
|
la vue est ouverte. Chaque barre capture uniquement sa propre sortie. La
|
||||||
|
sélection du panneau passe par la sortie focalisée de Sway. Une seule sortie a
|
||||||
|
été vérifiée réellement ; les déplacements et captures entre plusieurs
|
||||||
|
sorties demandent un contrôle sur matériel multi-écrans.
|
||||||
|
|
||||||
|
La commande de déplacement valide le `con_id`, attend la réponse de Sway, puis
|
||||||
|
compare les arbres avant et après. Les numéros source et destination, ainsi que
|
||||||
|
les workspaces dont l'empreinte a changé, sont renvoyés à l'interface. Les
|
||||||
|
événements IPC provoquent aussi une relecture regroupée pour les changements
|
||||||
|
externes. La sélection clavier initiale suit le workspace réellement focalisé.
|
||||||
|
Le cache éphémère garde au plus une paire JPEG/manifeste par fenêtre encore
|
||||||
|
présente dans les workspaces 1 à 10, plus les captures complètes compatibles
|
||||||
|
des sorties ; les fichiers temporaires interrompus sont retirés après une
|
||||||
|
période de grâce. Aucun service supplémentaire n'est lancé.
|
||||||
|
|
||||||
|
## Interactions
|
||||||
|
|
||||||
|
- Clic sur une carte : activer le workspace et fermer.
|
||||||
|
- Clic sur une fenêtre : activer son workspace, focaliser son `con_id`, fermer.
|
||||||
|
- Glisser une fenêtre sur une carte : la déplacer et garder l'Overview ouverte.
|
||||||
|
- Clic droit sur une fenêtre : sélectionner « déplacer vers ».
|
||||||
|
- Flèches : choisir la carte ; `Tab` : choisir une fenêtre ; `M` : choisir une
|
||||||
|
destination ; chiffres `1` à `9`, `0` pour `10`, puis `Entrée` : déplacer.
|
||||||
|
- `Entrée` sans mode déplacement : activer la carte ou la fenêtre choisie.
|
||||||
|
- `Échap` ou clic hors du panneau : fermer.
|
||||||
|
|
||||||
|
Le helper `backend.py` vérifie les bornes de workspace et l'existence du vrai
|
||||||
|
`con_id` dans l'arbre juste avant toute commande. Les arguments sont passés
|
||||||
|
directement à `swaymsg`, sans shell ni titre de fenêtre. Le déplacement utilise
|
||||||
|
`[con_id=N] move container to workspace number W` et ne change pas le workspace
|
||||||
|
actif. Une réponse Sway en échec laisse le panneau ouvert et relit l'arbre.
|
||||||
|
|
@ -0,0 +1,681 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.I3
|
||||||
|
import Quickshell.I3._Ipc
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import "../theme"
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: overview
|
||||||
|
required property var barWindow
|
||||||
|
signal overviewDismissed()
|
||||||
|
property var cards: []
|
||||||
|
property int selectedWorkspace: 1
|
||||||
|
property bool initialSelectionSet: false
|
||||||
|
property int selectedWindow: 0
|
||||||
|
property int moveTarget: 0
|
||||||
|
property int dropTarget: 0
|
||||||
|
property string message: ""
|
||||||
|
property bool refreshPending: false
|
||||||
|
property string actionName: ""
|
||||||
|
property var dirtyCaptures: []
|
||||||
|
property var pendingWindowNumbers: []
|
||||||
|
property var activeWindowNumbers: []
|
||||||
|
property int windowCaptureCursor: 0
|
||||||
|
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
|
||||||
|
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/overview/backend.py"
|
||||||
|
readonly property real cardX: card.x
|
||||||
|
readonly property real cardY: card.y
|
||||||
|
readonly property real cardWidth: card.width
|
||||||
|
readonly property real cardHeight: card.height
|
||||||
|
readonly property Item interactionRoot: keyboardRoot
|
||||||
|
readonly property real gridContentHeight: grid.height
|
||||||
|
readonly property real gridViewportHeight: scroll.height
|
||||||
|
|
||||||
|
// WHY: la surface layer-shell prend le clavier, comme ClipboardPanel.
|
||||||
|
// INVARIANT: sa disparition rend le focus au client Sway précédent sans touche injectée.
|
||||||
|
screen: barWindow.screen
|
||||||
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
|
exclusionMode: ExclusionMode.Ignore
|
||||||
|
exclusiveZone: 0
|
||||||
|
aboveWindows: true
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||||
|
WlrLayershell.namespace: "labfy-workspace-overview"
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
visible = false;
|
||||||
|
// WHY: grim inclurait cette surface overlay. Après sa disparition,
|
||||||
|
// seules les sorties visibles dont l'arbre a changé sont recapturées.
|
||||||
|
for (const card of dirtyCaptures) {
|
||||||
|
Quickshell.execDetached(["python3", "-B", backend, "capture", "--output", card.output,
|
||||||
|
"--number", String(card.number), "--workspace-id", String(card.workspaceId),
|
||||||
|
"--revision", card.revision,
|
||||||
|
"--settle-ms", "300"]);
|
||||||
|
}
|
||||||
|
overviewDismissed();
|
||||||
|
}
|
||||||
|
function applyState(data) {
|
||||||
|
const next = data.cards || [];
|
||||||
|
const changedWindows = [];
|
||||||
|
if (!cards.length) {
|
||||||
|
for (const current of next)
|
||||||
|
if (current.windows.length && (current.visible
|
||||||
|
|| current.windows.some(window => !window.preview)))
|
||||||
|
changedWindows.push(current.number);
|
||||||
|
}
|
||||||
|
if (cards.length) {
|
||||||
|
for (const current of next) {
|
||||||
|
const old = cards.find(value => value.number === current.number);
|
||||||
|
if (old && current.windows.length && (old.revision !== current.revision
|
||||||
|
|| (!old.visible && current.visible))) changedWindows.push(current.number);
|
||||||
|
if (!old || old.revision === current.revision || !current.visible
|
||||||
|
|| !current.output) continue;
|
||||||
|
if (current.windows.length === 0) {
|
||||||
|
dirtyCaptures = dirtyCaptures.filter(value => value.output !== current.output);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// CONTRACT: comparer les arbres avant/après repère aussi bien
|
||||||
|
// la source que la destination d'un déplacement confirmé IPC.
|
||||||
|
dirtyCaptures = dirtyCaptures.filter(value => value.output !== current.output)
|
||||||
|
.concat([{ output: current.output, number: current.number,
|
||||||
|
workspaceId: current.workspaceId, revision: current.revision }]);
|
||||||
|
if (current.output !== screen.name && !captureDebounce.running)
|
||||||
|
captureDebounce.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changedWindows.length) queueWindowCaptures(changedWindows);
|
||||||
|
if (JSON.stringify(cards) !== JSON.stringify(next)) cards = next;
|
||||||
|
if (!initialSelectionSet || !cards.some(value => value.number === selectedWorkspace && value.exists)) {
|
||||||
|
const focused = cards.find(value => value.focused);
|
||||||
|
if (focused) selectedWorkspace = focused.number;
|
||||||
|
initialSelectionSet = true;
|
||||||
|
}
|
||||||
|
selectedWindow = Math.min(selectedWindow, currentWindows().length);
|
||||||
|
if (selectedWindow === 0) moveTarget = 0;
|
||||||
|
}
|
||||||
|
function queueWindowCaptures(numbers) {
|
||||||
|
for (const number of numbers)
|
||||||
|
if (!pendingWindowNumbers.includes(number)) pendingWindowNumbers.push(number);
|
||||||
|
if (!windowCapture.running && !windowCaptureDebounce.running) windowCaptureDebounce.start();
|
||||||
|
}
|
||||||
|
function startWindowCapture() {
|
||||||
|
if (windowCapture.running) return;
|
||||||
|
if (windowCaptureCursor === 0) {
|
||||||
|
if (!pendingWindowNumbers.length) return;
|
||||||
|
activeWindowNumbers = pendingWindowNumbers;
|
||||||
|
pendingWindowNumbers = [];
|
||||||
|
}
|
||||||
|
windowCapture.command = ["python3", "-B", backend, "capture-windows", "--numbers",
|
||||||
|
activeWindowNumbers.join(","), "--cursor", String(windowCaptureCursor)];
|
||||||
|
windowCapture.running = true;
|
||||||
|
}
|
||||||
|
function refresh() {
|
||||||
|
if (reader.running) { refreshPending = true; return; }
|
||||||
|
reader.running = true;
|
||||||
|
}
|
||||||
|
function scheduleRefresh() {
|
||||||
|
// INVARIANT: un flux de titres animés ne repousse jamais indéfiniment
|
||||||
|
// la lecture d'une fermeture ou d'un déplacement de fenêtre.
|
||||||
|
if (!debounce.running) debounce.start();
|
||||||
|
}
|
||||||
|
function applyTitleEvent(event) {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(event.data); } catch (_) { return false; }
|
||||||
|
if (data.change !== "title" || !data.container) return false;
|
||||||
|
let found = false;
|
||||||
|
const updated = cards.map(card => Object.assign({}, card, { windows: card.windows.map(window => {
|
||||||
|
if (window.id !== data.container.id) return window;
|
||||||
|
found = true;
|
||||||
|
return Object.assign({}, window, { title: data.container.name || window.app });
|
||||||
|
}) }));
|
||||||
|
if (found) cards = updated;
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
function execute(action, number, ident) {
|
||||||
|
if (actor.running || number < 1 || number > 10) return;
|
||||||
|
actionName = action;
|
||||||
|
const args = ["python3", "-B", backend, action, "--number", String(number)];
|
||||||
|
if (ident > 0) args.push("--con-id", String(ident));
|
||||||
|
actor.command = args;
|
||||||
|
actor.running = true;
|
||||||
|
}
|
||||||
|
function activateWorkspace(number) { execute("workspace", number, 0); }
|
||||||
|
function focusWindow(number, ident) { execute("focus", number, ident); }
|
||||||
|
function moveWindow(ident, number) {
|
||||||
|
if (!ident || number < 1 || number > 10) return;
|
||||||
|
execute("move", number, ident);
|
||||||
|
}
|
||||||
|
function currentWindows() {
|
||||||
|
const item = cards.find(value => value.number === selectedWorkspace);
|
||||||
|
return item ? item.windows : [];
|
||||||
|
}
|
||||||
|
function navigate(delta) {
|
||||||
|
selectedWorkspace = Math.max(1, Math.min(10, selectedWorkspace + delta));
|
||||||
|
selectedWindow = 0;
|
||||||
|
}
|
||||||
|
function workspaceAt(x, y) {
|
||||||
|
// CONTRACT: le dépôt exige un point dans une carte réelle, même si
|
||||||
|
// Qt rogne l'item glissé à la limite de sa miniature.
|
||||||
|
for (let index = 0; index < workspaceRepeater.count; index++) {
|
||||||
|
const item = workspaceRepeater.itemAt(index);
|
||||||
|
if (!item) continue;
|
||||||
|
const point = item.mapFromItem(keyboardRoot, x, y);
|
||||||
|
if (point.x >= 0 && point.y >= 0 && point.x < item.width && point.y < item.height)
|
||||||
|
return item.number;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: refresh()
|
||||||
|
Timer { id: debounce; interval: 220; repeat: false; onTriggered: overview.refresh() }
|
||||||
|
Timer {
|
||||||
|
id: captureDebounce
|
||||||
|
interval: 350; repeat: false
|
||||||
|
onTriggered: overview.captureNextOtherOutput()
|
||||||
|
}
|
||||||
|
Timer {
|
||||||
|
id: windowCaptureDebounce
|
||||||
|
interval: 250; repeat: false
|
||||||
|
onTriggered: overview.startWindowCapture()
|
||||||
|
}
|
||||||
|
function captureNextOtherOutput() {
|
||||||
|
if (otherCapture.running) return;
|
||||||
|
const card = dirtyCaptures.find(value => value.output !== screen.name);
|
||||||
|
if (!card) return;
|
||||||
|
otherCapture.command = ["python3", "-B", backend, "capture", "--output", card.output,
|
||||||
|
"--number", String(card.number), "--workspace-id", String(card.workspaceId),
|
||||||
|
"--revision", card.revision];
|
||||||
|
otherCapture.running = true;
|
||||||
|
}
|
||||||
|
Timer { interval: 120; running: true; onTriggered: keyboardRoot.forceActiveFocus() }
|
||||||
|
I3IpcListener {
|
||||||
|
subscriptions: ["window"]
|
||||||
|
onIpcEvent: event => {
|
||||||
|
if (event.type === "subscribe") {
|
||||||
|
overview.scheduleRefresh();
|
||||||
|
} else if (!overview.applyTitleEvent(event)) overview.scheduleRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: I3
|
||||||
|
function onRawEvent(event) {
|
||||||
|
if (event.type === "workspace" || event.type === "output") overview.scheduleRefresh();
|
||||||
|
}
|
||||||
|
function onConnected() { overview.scheduleRefresh(); }
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: reader
|
||||||
|
command: ["python3", "-B", overview.backend, "state"]
|
||||||
|
stdout: StdioCollector { id: readerOutput; waitForEnd: true }
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code === 0) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(readerOutput.text);
|
||||||
|
overview.applyState(data);
|
||||||
|
} catch (error) { overview.message = "Lecture des workspaces impossible."; }
|
||||||
|
} else overview.message = "Lecture des workspaces impossible.";
|
||||||
|
if (overview.refreshPending) {
|
||||||
|
overview.refreshPending = false;
|
||||||
|
Qt.callLater(overview.refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: otherCapture
|
||||||
|
stdout: StdioCollector { id: otherCaptureOutput; waitForEnd: true }
|
||||||
|
onExited: (code, status) => {
|
||||||
|
let result = {};
|
||||||
|
try { result = JSON.parse(otherCaptureOutput.text); } catch (_) {}
|
||||||
|
const args = otherCapture.command;
|
||||||
|
const output = args[args.indexOf("--output") + 1];
|
||||||
|
if (code === 0 && result.captured)
|
||||||
|
overview.dirtyCaptures = overview.dirtyCaptures.filter(value =>
|
||||||
|
value.output !== output || value.revision !== result.revision);
|
||||||
|
overview.refresh();
|
||||||
|
if (result.captured && overview.dirtyCaptures.some(value => value.output !== overview.screen.name))
|
||||||
|
captureDebounce.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: windowCapture
|
||||||
|
stdout: StdioCollector { id: windowCaptureOutput; waitForEnd: true }
|
||||||
|
onExited: (code, status) => {
|
||||||
|
let result = {};
|
||||||
|
try { result = JSON.parse(windowCaptureOutput.text); } catch (_) {}
|
||||||
|
overview.refresh();
|
||||||
|
if (code === 0 && result.nextCursor > 0) {
|
||||||
|
overview.windowCaptureCursor = result.nextCursor;
|
||||||
|
} else {
|
||||||
|
overview.windowCaptureCursor = 0;
|
||||||
|
overview.activeWindowNumbers = [];
|
||||||
|
}
|
||||||
|
if (overview.windowCaptureCursor > 0 || overview.pendingWindowNumbers.length)
|
||||||
|
windowCaptureDebounce.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: actor
|
||||||
|
stdout: StdioCollector { id: actorOutput; waitForEnd: true }
|
||||||
|
onExited: (code, status) => {
|
||||||
|
let result = {};
|
||||||
|
try { result = JSON.parse(actorOutput.text); } catch (_) {}
|
||||||
|
if (code !== 0 || !result.ok) {
|
||||||
|
overview.message = "Action Sway impossible ; la vue a été actualisée.";
|
||||||
|
overview.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overview.applyState(result.state);
|
||||||
|
overview.message = "";
|
||||||
|
if (overview.actionName === "move") {
|
||||||
|
overview.selectedWindow = 0;
|
||||||
|
overview.moveTarget = 0;
|
||||||
|
overview.refresh();
|
||||||
|
} else overview.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: keyboardRoot
|
||||||
|
anchors.fill: parent
|
||||||
|
focus: true
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
if (event.key === Qt.Key_Escape) { overview.dismiss(); event.accepted = true; }
|
||||||
|
else if (event.key === Qt.Key_Left) { overview.navigate(-1); event.accepted = true; }
|
||||||
|
else if (event.key === Qt.Key_Right) { overview.navigate(1); event.accepted = true; }
|
||||||
|
else if (event.key === Qt.Key_Up) { overview.navigate(-columns); event.accepted = true; }
|
||||||
|
else if (event.key === Qt.Key_Down) { overview.navigate(columns); event.accepted = true; }
|
||||||
|
else if (event.key === Qt.Key_Tab) {
|
||||||
|
const windows = overview.currentWindows();
|
||||||
|
if (windows.length) overview.selectedWindow = (overview.selectedWindow + 1) % (windows.length + 1);
|
||||||
|
event.accepted = true;
|
||||||
|
} else if (event.key === Qt.Key_M && overview.selectedWindow > 0) {
|
||||||
|
overview.moveTarget = overview.selectedWorkspace === 10 ? 1 : overview.selectedWorkspace + 1;
|
||||||
|
event.accepted = true;
|
||||||
|
} else if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9 && overview.moveTarget) {
|
||||||
|
overview.moveTarget = event.key === Qt.Key_0 ? 10 : event.key - Qt.Key_0;
|
||||||
|
event.accepted = true;
|
||||||
|
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||||
|
const windows = overview.currentWindows();
|
||||||
|
if (overview.moveTarget && overview.selectedWindow > 0)
|
||||||
|
overview.moveWindow(windows[overview.selectedWindow - 1].id, overview.moveTarget);
|
||||||
|
else if (overview.selectedWindow > 0)
|
||||||
|
overview.focusWindow(overview.selectedWorkspace, windows[overview.selectedWindow - 1].id);
|
||||||
|
else overview.activateWorkspace(overview.selectedWorkspace);
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseArea { anchors.fill: parent; onClicked: overview.dismiss() }
|
||||||
|
|
||||||
|
readonly property int columns: card.width >= 1030 ? 5 : card.width >= 750 ? 4 : card.width >= 560 ? 3 : 2
|
||||||
|
Rectangle {
|
||||||
|
id: card
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Math.max(0, Math.min(1140, parent.width - 32))
|
||||||
|
// WHY: deux rangées de cinq cartes tenaient à 30 px près ; ce
|
||||||
|
// profil garde 16 px de marge d'écran et supprime le scroll normal.
|
||||||
|
height: Math.max(0, Math.min(keyboardRoot.columns === 5 ? 566 : 730, parent.height - 32))
|
||||||
|
radius: 4
|
||||||
|
color: Theme.popupBackground
|
||||||
|
border.color: Theme.outline
|
||||||
|
border.width: 1
|
||||||
|
MouseArea { anchors.fill: parent; onClicked: {} }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 10
|
||||||
|
Item {
|
||||||
|
id: header
|
||||||
|
width: parent.width; height: 44
|
||||||
|
Rectangle {
|
||||||
|
id: headerAccent
|
||||||
|
anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 3; height: 32; radius: 1
|
||||||
|
color: Theme.lavender
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
anchors.left: headerAccent.right; anchors.leftMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 2
|
||||||
|
Text { text: "Vue d’ensemble"; color: Theme.foreground; font.pixelSize: 20; font.bold: true }
|
||||||
|
Text { text: "Espaces de travail · captures et disposition actuelle";
|
||||||
|
color: Theme.secondaryForeground; font.pixelSize: 11 }
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 94; height: 24; radius: 3
|
||||||
|
color: Theme.surface0; border.color: Theme.separator
|
||||||
|
visible: header.width > 520
|
||||||
|
Text { anchors.centerIn: parent; text: "10 ESPACES";
|
||||||
|
color: Theme.accentForeground; font.pixelSize: 10; font.bold: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: messageLabel
|
||||||
|
visible: overview.message !== ""; height: visible ? 18 : 0
|
||||||
|
text: overview.message; color: Theme.warningForeground; font.pixelSize: 12
|
||||||
|
}
|
||||||
|
Flickable {
|
||||||
|
id: scroll
|
||||||
|
width: parent.width
|
||||||
|
// CONTRACT: la grille utilise toute la hauteur réellement
|
||||||
|
// restante ; le footer et l'espacement ne sont comptés qu'une fois.
|
||||||
|
height: Math.max(0, parent.height - y - footer.height - content.spacing)
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: grid.height
|
||||||
|
clip: true
|
||||||
|
interactive: contentHeight > height + 1
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
Grid {
|
||||||
|
id: grid
|
||||||
|
width: scroll.width
|
||||||
|
columns: keyboardRoot.columns
|
||||||
|
spacing: 10
|
||||||
|
Repeater {
|
||||||
|
id: workspaceRepeater
|
||||||
|
model: overview.cards
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: workspaceCard
|
||||||
|
required property var modelData
|
||||||
|
readonly property int number: modelData.number
|
||||||
|
readonly property bool active: modelData.focused
|
||||||
|
readonly property bool occupied: modelData.windows.length > 0
|
||||||
|
readonly property bool hovered: cardHover.hovered || cardPointer.containsMouse
|
||||||
|
readonly property bool dropHighlighted: overview.dropTarget === number || dropArea.containsDrag
|
||||||
|
width: (grid.width - (grid.columns - 1) * grid.spacing) / grid.columns
|
||||||
|
height: grid.columns === 5 ? 210 : grid.columns === 4 ? 190
|
||||||
|
: grid.columns === 3 ? 140 : 175
|
||||||
|
radius: 4
|
||||||
|
color: dropHighlighted ? Theme.surface2
|
||||||
|
: active ? Theme.surface1
|
||||||
|
: hovered ? Theme.buttonHover
|
||||||
|
: occupied ? Theme.surface0 : Theme.mantle
|
||||||
|
border.color: dropHighlighted ? Theme.lavender
|
||||||
|
: overview.selectedWorkspace === number ? Theme.lavender
|
||||||
|
: hovered ? Theme.overlay1
|
||||||
|
: occupied ? Theme.strongBorder : Theme.separator
|
||||||
|
border.width: dropHighlighted || overview.selectedWorkspace === number ? 2 : 1
|
||||||
|
Behavior on color { ColorAnimation { duration: 110 } }
|
||||||
|
HoverHandler { id: cardHover }
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top; anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
width: parent.width - 16; height: 2; radius: 1
|
||||||
|
color: workspaceCard.active || workspaceCard.dropHighlighted
|
||||||
|
? Theme.lavender : "transparent"
|
||||||
|
}
|
||||||
|
DropArea {
|
||||||
|
id: dropArea
|
||||||
|
anchors.fill: parent
|
||||||
|
keys: ["labfy-workspace-window"]
|
||||||
|
onDropped: drop => {
|
||||||
|
if (drop.source && drop.source.conId > 0
|
||||||
|
&& drop.source.sourceWorkspace !== workspaceCard.number) {
|
||||||
|
overview.moveWindow(drop.source.conId, workspaceCard.number);
|
||||||
|
drop.acceptProposedAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: cardPointer
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: overview.activateWorkspace(workspaceCard.number)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent; anchors.margins: 8; spacing: 5
|
||||||
|
Item {
|
||||||
|
id: cardHeader
|
||||||
|
width: parent.width; height: 24
|
||||||
|
Rectangle {
|
||||||
|
id: numberBadge
|
||||||
|
anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 30; height: 22; radius: 3
|
||||||
|
color: workspaceCard.active || workspaceCard.dropHighlighted
|
||||||
|
? Theme.lavender : Theme.surface1
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: String(workspaceCard.number).padStart(2, "0")
|
||||||
|
color: workspaceCard.active || workspaceCard.dropHighlighted
|
||||||
|
? Theme.onAccent : Theme.foreground
|
||||||
|
font.bold: true; font.pixelSize: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.left: numberBadge.right; anchors.leftMargin: 7
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: Math.max(0, parent.width - 78)
|
||||||
|
text: workspaceCard.modelData.output || "Disponible"
|
||||||
|
color: workspaceCard.occupied ? Theme.foreground : Theme.secondaryForeground
|
||||||
|
font.pixelSize: 11; elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: workspaceCard.occupied ? String(workspaceCard.modelData.windows.length) : "—"
|
||||||
|
color: workspaceCard.occupied ? Theme.accentForeground : Theme.disabledForeground
|
||||||
|
font.pixelSize: 11; font.bold: workspaceCard.occupied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Item {
|
||||||
|
id: preview
|
||||||
|
width: parent.width
|
||||||
|
height: Math.max(0, parent.height - cardHeader.height
|
||||||
|
- provenance.implicitHeight - parent.spacing * 2)
|
||||||
|
clip: true
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent; radius: 3
|
||||||
|
color: Theme.crust; border.color: Theme.separator
|
||||||
|
}
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
source: workspaceCard.modelData.snapshot ? workspaceCard.modelData.snapshot
|
||||||
|
+ "?t=" + workspaceCard.modelData.snapshotTime : ""
|
||||||
|
visible: workspaceCard.modelData.snapshot !== ""
|
||||||
|
&& workspaceCard.modelData.windows.length > 0
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
cache: false
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: workspaceCard.modelData.windows.length === 0
|
||||||
|
&& !workspaceCard.dropHighlighted
|
||||||
|
spacing: 7
|
||||||
|
Rectangle {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
width: 30; height: 21; radius: 2
|
||||||
|
color: "transparent"; border.color: Theme.overlay0
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left; anchors.right: parent.right
|
||||||
|
anchors.top: parent.top; anchors.margins: 3
|
||||||
|
height: 2; radius: 1; color: Theme.overlay0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text { anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: "Espace vide"; color: Theme.secondaryForeground;
|
||||||
|
font.pixelSize: 12; font.bold: true }
|
||||||
|
}
|
||||||
|
Repeater {
|
||||||
|
model: workspaceCard.modelData.windows
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: windowVisual
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
property int conId: modelData.id
|
||||||
|
property int sourceWorkspace: workspaceCard.number
|
||||||
|
readonly property var outputRect: workspaceCard.modelData.outputRect || workspaceCard.modelData.rect
|
||||||
|
readonly property real sx: outputRect && outputRect.width ? preview.width / outputRect.width : 1
|
||||||
|
readonly property real sy: outputRect && outputRect.height ? preview.height / outputRect.height : 1
|
||||||
|
x: Math.max(0, (modelData.rect.x - (outputRect ? outputRect.x : 0)) * sx)
|
||||||
|
y: Math.max(0, (modelData.rect.y - (outputRect ? outputRect.y : 0)) * sy)
|
||||||
|
width: Math.max(15, Math.min(preview.width - x, modelData.rect.width * sx))
|
||||||
|
height: Math.max(15, Math.min(preview.height - y, modelData.rect.height * sy))
|
||||||
|
radius: 2
|
||||||
|
clip: true
|
||||||
|
color: workspaceCard.modelData.snapshot ? "#1e1e2e35" : Theme.surface1
|
||||||
|
border.color: modelData.focused || (overview.selectedWorkspace === workspaceCard.number
|
||||||
|
&& overview.selectedWindow === index + 1) ? Theme.lavender : Theme.overlay0
|
||||||
|
border.width: modelData.focused ? 2 : 1
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
source: !workspaceCard.modelData.snapshot && windowVisual.modelData.preview
|
||||||
|
? windowVisual.modelData.preview + "?t=" + windowVisual.modelData.previewTime : ""
|
||||||
|
visible: !workspaceCard.modelData.snapshot
|
||||||
|
&& windowVisual.modelData.preview !== ""
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
cache: false
|
||||||
|
}
|
||||||
|
// WHY: déplacer la zone source hors du preview rogné
|
||||||
|
// perdait les positions de la souris et l'état de dépôt.
|
||||||
|
// Le proxy vit dans la surface, la miniature reste en place.
|
||||||
|
Rectangle {
|
||||||
|
id: dragProxy
|
||||||
|
parent: keyboardRoot
|
||||||
|
z: 1000
|
||||||
|
width: 138; height: 34; radius: 3
|
||||||
|
opacity: windowPointer.drag.active ? 1 : 0
|
||||||
|
color: Theme.surface2
|
||||||
|
border.color: Theme.lavender; border.width: 1
|
||||||
|
Drag.active: windowPointer.drag.active
|
||||||
|
Drag.source: windowVisual
|
||||||
|
Drag.keys: ["labfy-workspace-window"]
|
||||||
|
Drag.hotSpot.x: width / 2
|
||||||
|
Drag.hotSpot.y: height / 2
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent; anchors.margins: 6
|
||||||
|
text: windowVisual.modelData.app
|
||||||
|
color: Theme.foreground; font.pixelSize: 11
|
||||||
|
font.bold: true; elide: Text.ElideRight
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom
|
||||||
|
height: Math.min(28, parent.height)
|
||||||
|
leftPadding: 3
|
||||||
|
text: windowVisual.modelData.app + " · " + windowVisual.modelData.title
|
||||||
|
color: Theme.foreground; font.pixelSize: 9; elide: Text.ElideRight
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
Rectangle { anchors.fill: parent; z: -1; color: "#11111bc9" }
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: windowPointer
|
||||||
|
anchors.fill: parent
|
||||||
|
drag.target: dragProxy
|
||||||
|
drag.threshold: 6
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
onPressed: {
|
||||||
|
const point = windowVisual.mapToItem(keyboardRoot, 0, 0);
|
||||||
|
dragProxy.x = point.x;
|
||||||
|
dragProxy.y = point.y;
|
||||||
|
overview.selectedWorkspace = workspaceCard.number;
|
||||||
|
}
|
||||||
|
onClicked: mouse => {
|
||||||
|
overview.selectedWindow = windowVisual.index + 1;
|
||||||
|
if (mouse.button === Qt.RightButton) {
|
||||||
|
overview.moveTarget = workspaceCard.number === 10 ? 1 : workspaceCard.number + 1;
|
||||||
|
keyboardRoot.forceActiveFocus();
|
||||||
|
} else if (!drag.active)
|
||||||
|
overview.focusWindow(workspaceCard.number, windowVisual.conId);
|
||||||
|
}
|
||||||
|
onPositionChanged: mouse => {
|
||||||
|
if (!drag.active) return;
|
||||||
|
const point = windowPointer.mapToItem(keyboardRoot, mouse.x, mouse.y);
|
||||||
|
const target = overview.workspaceAt(point.x, point.y);
|
||||||
|
overview.dropTarget = target !== workspaceCard.number ? target : 0;
|
||||||
|
}
|
||||||
|
onReleased: mouse => {
|
||||||
|
if (drag.active) {
|
||||||
|
const point = windowPointer.mapToItem(keyboardRoot, mouse.x, mouse.y);
|
||||||
|
const target = overview.workspaceAt(point.x, point.y);
|
||||||
|
if (target && target !== workspaceCard.number)
|
||||||
|
overview.moveWindow(windowVisual.conId, target);
|
||||||
|
}
|
||||||
|
overview.dropTarget = 0;
|
||||||
|
}
|
||||||
|
onCanceled: overview.dropTarget = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent; z: 2; radius: 3
|
||||||
|
color: "transparent"; border.color: Theme.overlay0
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent; z: 3; radius: 3
|
||||||
|
visible: workspaceCard.dropHighlighted
|
||||||
|
// Qt lit #AARRGGBB : alpha faible, accent Lavender.
|
||||||
|
color: "#32b4befe"
|
||||||
|
border.color: Theme.lavender; border.width: 2
|
||||||
|
Text { anchors.centerIn: parent; text: "Déposer ici";
|
||||||
|
color: Theme.foreground; font.pixelSize: 12; font.bold: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: provenance
|
||||||
|
text: workspaceCard.modelData.windows.length === 0
|
||||||
|
? "Aucune fenêtre"
|
||||||
|
: workspaceCard.modelData.snapshot
|
||||||
|
? (workspaceCard.modelData.visible ? "Capture · sortie visible" : "Dernière capture · fenêtre actualisée")
|
||||||
|
: workspaceCard.modelData.windows.some(window => window.preview)
|
||||||
|
? "Dernières images · fenêtres"
|
||||||
|
: "Géométrie · arbre Sway"
|
||||||
|
color: Theme.secondaryForeground; font.pixelSize: 10; elide: Text.ElideRight
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
id: footer
|
||||||
|
width: parent.width; height: 32; radius: 3
|
||||||
|
color: Theme.mantle; border.color: Theme.separator
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left; anchors.leftMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 13
|
||||||
|
visible: overview.moveTarget === 0
|
||||||
|
Row { spacing: 5
|
||||||
|
Text { text: "← ↑ ↓ →"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
|
||||||
|
Text { text: "naviguer"; color: Theme.secondaryForeground; font.pixelSize: 11 }
|
||||||
|
}
|
||||||
|
Row { spacing: 5
|
||||||
|
Text { text: "Tab"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
|
||||||
|
Text { text: "fenêtre"; color: Theme.secondaryForeground; font.pixelSize: 11 }
|
||||||
|
}
|
||||||
|
Row { spacing: 5
|
||||||
|
Text { text: "M"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
|
||||||
|
Text { text: "déplacer"; color: Theme.secondaryForeground; font.pixelSize: 11 }
|
||||||
|
}
|
||||||
|
Row { spacing: 5
|
||||||
|
Text { text: "Entrée"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
|
||||||
|
Text { text: "activer"; color: Theme.secondaryForeground; font.pixelSize: 11 }
|
||||||
|
visible: footer.width > 620
|
||||||
|
}
|
||||||
|
Text { text: "Clic droit ou glisser : déplacer"; color: Theme.secondaryForeground;
|
||||||
|
font.pixelSize: 11; visible: footer.width > 820 }
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left; anchors.leftMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: overview.moveTarget !== 0
|
||||||
|
text: "Déplacer vers " + overview.moveTarget + " · 1–9 ou 0=10 · Entrée confirmer"
|
||||||
|
color: Theme.accentForeground; font.pixelSize: 11; font.bold: true
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right; anchors.rightMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Échap fermer"; color: Theme.secondaryForeground; font.pixelSize: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
quickshell/.config/quickshell/labfy-sway/overview/backend.py
Normal file
390
quickshell/.config/quickshell/labfy-sway/overview/backend.py
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Source bornée des cartes Sway et des captures de sorties visibles."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def sway(kind):
|
||||||
|
result = subprocess.run(["swaymsg", "-t", kind, "-r"], capture_output=True,
|
||||||
|
text=True, timeout=5, check=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir():
|
||||||
|
# CONTRACT: cache éphémère et privé ; aucune capture de bureau sur disque durable.
|
||||||
|
base = Path(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
|
||||||
|
path = base / "labfy-workspace-overview"
|
||||||
|
path.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_path(output, number):
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", output)
|
||||||
|
return cache_dir() / f"{safe}-workspace-{number}.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_metadata_path(output, number):
|
||||||
|
return snapshot_path(output, number).with_suffix(".json")
|
||||||
|
|
||||||
|
|
||||||
|
def window_preview_path(con_id):
|
||||||
|
return cache_dir() / f"window-{con_id}.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def window_preview_metadata_path(con_id):
|
||||||
|
return window_preview_path(con_id).with_suffix(".json")
|
||||||
|
|
||||||
|
|
||||||
|
def valid_window_preview(con_id, foreign_id):
|
||||||
|
# CONTRACT: le con_id donne l'identité Sway, l'identifiant foreign toplevel
|
||||||
|
# est celui attendu par grim -T. Leur égalité n'est jamais supposée.
|
||||||
|
if not isinstance(foreign_id, str) or not re.fullmatch(r"[0-9a-f]{32}", foreign_id):
|
||||||
|
return None
|
||||||
|
path = window_preview_path(con_id)
|
||||||
|
metadata_path = window_preview_metadata_path(con_id)
|
||||||
|
try:
|
||||||
|
image_stat = path.stat()
|
||||||
|
if image_stat.st_size == 0 or metadata_path.stat().st_size > 2048:
|
||||||
|
return None
|
||||||
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
|
if (metadata.get("version") != 1 or metadata.get("conId") != con_id
|
||||||
|
or metadata.get("foreignId") != foreign_id
|
||||||
|
or metadata.get("imageMtimeNs") != image_stat.st_mtime_ns
|
||||||
|
or not isinstance(metadata.get("capturedNs"), int)):
|
||||||
|
return None
|
||||||
|
return path.as_uri(), str(metadata["capturedNs"])
|
||||||
|
except (OSError, ValueError, AttributeError, TypeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def prune_window_previews(live_ids):
|
||||||
|
# INVARIANT: un fichier de fenêtre fermée ne peut ni devenir une fenêtre
|
||||||
|
# fantôme ni croître indéfiniment dans le cache de la session.
|
||||||
|
for path in cache_dir().glob("window-*.*"):
|
||||||
|
match = re.fullmatch(r"window-([0-9]+)\.(?:jpg|json)", path.name)
|
||||||
|
if match and int(match.group(1)) not in live_ids:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
# WHY: une interruption brutale peut laisser un JPEG temporaire. Une
|
||||||
|
# période de grâce protège les captures encore en cours dans un autre helper.
|
||||||
|
cutoff = time.time() - 600
|
||||||
|
for path in cache_dir().glob("tmp*.*"):
|
||||||
|
try:
|
||||||
|
if path.suffix in (".jpg", ".json") and path.stat().st_mtime < cutoff:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def structure_revision(node, workspace, output_rect):
|
||||||
|
# CONTRACT: seuls identité, hiérarchie, ordre, disposition et géométrie
|
||||||
|
# rendent un JPEG incompatible. Un titre ou un focus ne le font pas.
|
||||||
|
def geometry(item):
|
||||||
|
rect = item.get("rect") or {}
|
||||||
|
return [rect.get(key) for key in ("x", "y", "width", "height")]
|
||||||
|
|
||||||
|
def structure(item):
|
||||||
|
return [item.get("type"), item.get("id"), item.get("layout"),
|
||||||
|
item.get("floating"), geometry(item),
|
||||||
|
[structure(child) for child in item.get("nodes") or []],
|
||||||
|
[structure(child) for child in item.get("floating_nodes") or []]]
|
||||||
|
|
||||||
|
value = [workspace["id"], workspace["num"], workspace.get("output"),
|
||||||
|
geometry({"rect": output_rect}), structure(node)]
|
||||||
|
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def forget_snapshot(output, number):
|
||||||
|
# INVARIANT: un workspace vide ne garde pas une ancienne image susceptible
|
||||||
|
# de réapparaître après une autre transition.
|
||||||
|
snapshot_metadata_path(output, number).unlink(missing_ok=True)
|
||||||
|
snapshot_path(output, number).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def forget_other_outputs(number, output):
|
||||||
|
# WHY: un workspace supprimé ou déplacé de sortie n'est plus atteignable
|
||||||
|
# par son ancien chemin de cache ; retirer uniquement ses anciens fichiers.
|
||||||
|
keep = {snapshot_path(output, number), snapshot_metadata_path(output, number)} if output else set()
|
||||||
|
for suffix in ("jpg", "json"):
|
||||||
|
for path in cache_dir().glob(f"*-workspace-{number}.{suffix}"):
|
||||||
|
if path not in keep:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_snapshot(output, number, workspace_id, revision):
|
||||||
|
path = snapshot_path(output, number)
|
||||||
|
metadata_path = snapshot_metadata_path(output, number)
|
||||||
|
try:
|
||||||
|
image_stat = path.stat()
|
||||||
|
if image_stat.st_size == 0 or metadata_path.stat().st_size > 2048:
|
||||||
|
return None
|
||||||
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
|
if (metadata.get("version") != 1 or metadata.get("output") != output
|
||||||
|
or metadata.get("number") != number
|
||||||
|
or metadata.get("workspaceId") != workspace_id
|
||||||
|
or metadata.get("revision") != revision
|
||||||
|
or metadata.get("imageMtimeNs") != image_stat.st_mtime_ns
|
||||||
|
or not isinstance(metadata.get("capturedNs"), int)):
|
||||||
|
return None
|
||||||
|
return path.as_uri(), str(metadata["capturedNs"])
|
||||||
|
except (OSError, ValueError, AttributeError, TypeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def windows_in(node):
|
||||||
|
found = []
|
||||||
|
|
||||||
|
def visit(item, floating=False):
|
||||||
|
app = item.get("app_id") or (item.get("window_properties") or {}).get("class")
|
||||||
|
# SwayFX publie une fenêtre flottante directement comme floating_con.
|
||||||
|
# Son wrapper possède le vrai con_id, app_id et rect ; l'ignorer ferait
|
||||||
|
# disparaître précisément les fenêtres flottantes de l'Overview.
|
||||||
|
if item.get("type") in ("con", "floating_con") and app:
|
||||||
|
rect = item.get("rect") or {}
|
||||||
|
if all(isinstance(rect.get(key), int) for key in ("x", "y", "width", "height")):
|
||||||
|
found.append({"id": item["id"], "foreignId": item.get("foreign_toplevel_identifier") or "",
|
||||||
|
"title": item.get("name") or app,
|
||||||
|
"app": app, "rect": rect, "focused": item.get("focused") is True,
|
||||||
|
"floating": floating or item.get("floating") in ("user_on", "auto_on"),
|
||||||
|
"layout": item.get("layout") or ""})
|
||||||
|
for child in item.get("nodes") or []:
|
||||||
|
visit(child, floating)
|
||||||
|
for child in item.get("floating_nodes") or []:
|
||||||
|
visit(child, True)
|
||||||
|
|
||||||
|
visit(node)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def state():
|
||||||
|
tree = sway("get_tree")
|
||||||
|
workspaces = sway("get_workspaces")
|
||||||
|
outputs = sway("get_outputs")
|
||||||
|
by_number = {item["num"]: item for item in workspaces if item.get("num") in range(1, 11)}
|
||||||
|
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") == "labfy-workspace-overview"
|
||||||
|
for surface in item.get("layer_shell_surfaces") or [])]
|
||||||
|
workspace_nodes = {}
|
||||||
|
|
||||||
|
def visit(item):
|
||||||
|
if item.get("type") == "workspace":
|
||||||
|
workspace_nodes[item.get("id")] = item
|
||||||
|
for child in (item.get("nodes") or []) + (item.get("floating_nodes") or []):
|
||||||
|
visit(child)
|
||||||
|
|
||||||
|
visit(tree)
|
||||||
|
cards = []
|
||||||
|
for number in range(1, 11):
|
||||||
|
workspace = by_number.get(number)
|
||||||
|
node = workspace_nodes.get(workspace["id"]) if workspace else None
|
||||||
|
output = workspace.get("output") if workspace else ""
|
||||||
|
forget_other_outputs(number, output)
|
||||||
|
windows = windows_in(node) if node else []
|
||||||
|
for window in windows:
|
||||||
|
preview = valid_window_preview(window["id"], window["foreignId"])
|
||||||
|
window["preview"] = preview[0] if preview else ""
|
||||||
|
window["previewTime"] = preview[1] if preview else ""
|
||||||
|
revision = (structure_revision(node, workspace, output_info.get(output, {}).get("rect"))
|
||||||
|
if node and workspace else "")
|
||||||
|
snapshot = valid_snapshot(output, number, workspace["id"], revision) if revision else None
|
||||||
|
if not windows:
|
||||||
|
snapshot = None
|
||||||
|
if output:
|
||||||
|
forget_snapshot(output, number)
|
||||||
|
cards.append({"number": number, "exists": bool(workspace),
|
||||||
|
"workspaceId": workspace["id"] if workspace else 0,
|
||||||
|
"revision": revision,
|
||||||
|
"visible": bool(workspace and workspace.get("visible")),
|
||||||
|
"focused": bool(workspace and workspace.get("focused")),
|
||||||
|
"output": output, "outputRect": output_info.get(output, {}).get("rect"),
|
||||||
|
"rect": node.get("rect") if node else None,
|
||||||
|
"layout": node.get("layout") if node else "",
|
||||||
|
"windows": windows,
|
||||||
|
"snapshot": snapshot[0] if snapshot else "",
|
||||||
|
# INVARIANT: l'URL QML change à chaque remplacement, même
|
||||||
|
# si deux captures surviennent pendant la même seconde.
|
||||||
|
"snapshotTime": snapshot[1] if snapshot else ""})
|
||||||
|
prune_window_previews({window["id"] for card in cards for window in card["windows"]})
|
||||||
|
return {"cards": cards, "outputs": [item["name"] for item in outputs if item.get("active")],
|
||||||
|
"focusedOutput": next((item["name"] for item in outputs if item.get("focused")), ""),
|
||||||
|
"overlayOutputs": overlay_outputs}
|
||||||
|
|
||||||
|
|
||||||
|
def capture(output, expected_number=None, expected_workspace_id=None,
|
||||||
|
expected_revision=None, settle_ms=0):
|
||||||
|
if settle_ms < 0 or settle_ms > 1000:
|
||||||
|
raise ValueError("Délai de capture invalide")
|
||||||
|
if settle_ms:
|
||||||
|
time.sleep(settle_ms / 1000)
|
||||||
|
before = state()
|
||||||
|
current = next((card for card in before["cards"] if card["visible"] and card["output"] == output), None)
|
||||||
|
if (output in before["overlayOutputs"] or not current or not current["windows"]
|
||||||
|
or (expected_number is not None and current["number"] != expected_number)
|
||||||
|
or (expected_workspace_id is not None and current["workspaceId"] != expected_workspace_id)
|
||||||
|
or (expected_revision is not None and current["revision"] != expected_revision)):
|
||||||
|
return {"captured": False}
|
||||||
|
path = snapshot_path(output, current["number"])
|
||||||
|
metadata_path = snapshot_metadata_path(output, current["number"])
|
||||||
|
# WHY: grim capture uniquement une sortie effectivement affichée. Écriture
|
||||||
|
# atomique pour qu'un Image QML ne voie jamais un JPEG partiel.
|
||||||
|
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".jpg", delete=False) as temp:
|
||||||
|
temporary = Path(temp.name)
|
||||||
|
try:
|
||||||
|
subprocess.run(["grim", "-o", output, "-s", "0.25", "-t", "jpeg", "-q", "72",
|
||||||
|
str(temporary)], check=True, timeout=8, capture_output=True)
|
||||||
|
if not temporary.is_file() or temporary.stat().st_size == 0:
|
||||||
|
raise RuntimeError("Capture vide")
|
||||||
|
after = state()
|
||||||
|
updated = next((card for card in after["cards"] if card["number"] == current["number"]), None)
|
||||||
|
# INVARIANT: un changement pendant grim ou la présence de l'Overview
|
||||||
|
# interdit de publier une image associée au mauvais arbre.
|
||||||
|
if (output in after["overlayOutputs"] or not updated or not updated["visible"]
|
||||||
|
or updated["output"] != output or updated["workspaceId"] != current["workspaceId"]
|
||||||
|
or updated["revision"] != current["revision"]):
|
||||||
|
return {"captured": False}
|
||||||
|
temporary.replace(path)
|
||||||
|
metadata = {"version": 1, "output": output, "number": current["number"],
|
||||||
|
"workspaceId": current["workspaceId"], "revision": current["revision"],
|
||||||
|
"capturedNs": time.time_ns(), "imageMtimeNs": path.stat().st_mtime_ns}
|
||||||
|
# WHY: le manifeste est publié après le JPEG ; un lecteur concurrent
|
||||||
|
# n'associe jamais l'ancienne révision aux nouveaux pixels.
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
|
||||||
|
suffix=".json", delete=False) as temp_meta:
|
||||||
|
json.dump(metadata, temp_meta, separators=(",", ":"))
|
||||||
|
temporary_meta = Path(temp_meta.name)
|
||||||
|
try:
|
||||||
|
temporary_meta.replace(metadata_path)
|
||||||
|
finally:
|
||||||
|
temporary_meta.unlink(missing_ok=True)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return {"captured": True, "number": current["number"], "path": path.as_uri(),
|
||||||
|
"revision": current["revision"]}
|
||||||
|
|
||||||
|
|
||||||
|
def capture_windows(numbers, cursor=0):
|
||||||
|
if cursor < 0:
|
||||||
|
raise ValueError("Curseur de capture invalide")
|
||||||
|
before = state()
|
||||||
|
candidates = [window for card in before["cards"] if card["number"] in numbers
|
||||||
|
for window in card["windows"]
|
||||||
|
if re.fullmatch(r"[0-9a-f]{32}", window["foreignId"])]
|
||||||
|
# CONTRACT: une invocation capture au plus 16 fenêtres ; l'interface peut
|
||||||
|
# reprendre au curseur suivant sans créer de service ou de pool permanent.
|
||||||
|
batch = candidates[cursor:cursor + 16]
|
||||||
|
captures = []
|
||||||
|
try:
|
||||||
|
for window in batch:
|
||||||
|
with tempfile.NamedTemporaryFile(dir=cache_dir(), suffix=".jpg", delete=False) as temp:
|
||||||
|
temporary = Path(temp.name)
|
||||||
|
captures.append((window, temporary))
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["grim", "-T", window["foreignId"], "-s", "0.25",
|
||||||
|
"-t", "jpeg", "-q", "72", str(temporary)],
|
||||||
|
capture_output=True, timeout=5, check=False)
|
||||||
|
if result.returncode != 0 or temporary.stat().st_size == 0:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
after = state()
|
||||||
|
current = {window["id"]: window for card in after["cards"] for window in card["windows"]}
|
||||||
|
published = []
|
||||||
|
for window, temporary in captures:
|
||||||
|
if not temporary.is_file() or current.get(window["id"], {}).get("foreignId") != window["foreignId"]:
|
||||||
|
continue
|
||||||
|
path = window_preview_path(window["id"])
|
||||||
|
metadata_path = window_preview_metadata_path(window["id"])
|
||||||
|
temporary.replace(path)
|
||||||
|
metadata = {"version": 1, "conId": window["id"], "foreignId": window["foreignId"],
|
||||||
|
"capturedNs": time.time_ns(), "imageMtimeNs": path.stat().st_mtime_ns}
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=cache_dir(),
|
||||||
|
suffix=".json", delete=False) as temp_meta:
|
||||||
|
json.dump(metadata, temp_meta, separators=(",", ":"))
|
||||||
|
temporary_meta = Path(temp_meta.name)
|
||||||
|
try:
|
||||||
|
temporary_meta.replace(metadata_path)
|
||||||
|
finally:
|
||||||
|
temporary_meta.unlink(missing_ok=True)
|
||||||
|
published.append(window["id"])
|
||||||
|
finally:
|
||||||
|
for _, temporary in captures:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
next_cursor = cursor + len(batch) if cursor + len(batch) < len(candidates) else 0
|
||||||
|
return {"captured": published, "nextCursor": next_cursor}
|
||||||
|
|
||||||
|
|
||||||
|
def command(action, number=None, con_id=None):
|
||||||
|
if number not in range(1, 11):
|
||||||
|
raise ValueError("Workspace invalide")
|
||||||
|
before = state() if action in ("move", "focus") else None
|
||||||
|
if action in ("move", "focus"):
|
||||||
|
# CONTRACT: seul un con_id d'une vraie fenêtre de l'arbre courant peut
|
||||||
|
# commander Sway. Aucun titre ni texte libre n'entre dans le critère.
|
||||||
|
known = {window["id"] for card in before["cards"] for window in card["windows"]}
|
||||||
|
if con_id not in known:
|
||||||
|
raise ValueError("Fenêtre absente de l'arbre Sway")
|
||||||
|
source_number = next((card["number"] for card in before["cards"]
|
||||||
|
if any(window["id"] == con_id for window in card["windows"])), 0) if before else 0
|
||||||
|
if action == "move":
|
||||||
|
args = [f"[con_id={con_id}]", "move", "container", "to", "workspace", "number", str(number)]
|
||||||
|
elif action == "focus":
|
||||||
|
args = ["workspace", "number", str(number), ";", f"[con_id={con_id}]", "focus"]
|
||||||
|
else:
|
||||||
|
args = ["workspace", "number", str(number)]
|
||||||
|
result = subprocess.run(["swaymsg", "-r", *args], capture_output=True, text=True,
|
||||||
|
timeout=5, check=True)
|
||||||
|
responses = json.loads(result.stdout)
|
||||||
|
if not all(item.get("success") for item in responses):
|
||||||
|
raise RuntimeError(result.stdout)
|
||||||
|
after = state()
|
||||||
|
changed = ([card["number"] for old, card in zip(before["cards"], after["cards"])
|
||||||
|
if old.get("revision") != card.get("revision")] if before else [])
|
||||||
|
return {"ok": True, "state": after, "source": source_number if action == "move" else 0,
|
||||||
|
"destination": number if action == "move" else 0, "changedWorkspaces": changed}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("action", choices=("state", "capture", "capture-windows", "move", "focus", "workspace"))
|
||||||
|
parser.add_argument("--output")
|
||||||
|
parser.add_argument("--number", type=int)
|
||||||
|
parser.add_argument("--con-id", type=int)
|
||||||
|
parser.add_argument("--workspace-id", type=int)
|
||||||
|
parser.add_argument("--revision")
|
||||||
|
parser.add_argument("--settle-ms", type=int, default=0)
|
||||||
|
parser.add_argument("--numbers")
|
||||||
|
parser.add_argument("--cursor", type=int, default=0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.action == "state":
|
||||||
|
result = state()
|
||||||
|
elif args.action == "capture":
|
||||||
|
if not args.output or args.output not in state()["outputs"]:
|
||||||
|
raise ValueError("Sortie inactive")
|
||||||
|
if args.revision is not None and not re.fullmatch(r"[0-9a-f]{64}", args.revision):
|
||||||
|
raise ValueError("Révision de capture invalide")
|
||||||
|
result = capture(args.output, args.number, args.workspace_id, args.revision, args.settle_ms)
|
||||||
|
elif args.action == "capture-windows":
|
||||||
|
if not args.numbers or not re.fullmatch(r"(?:10|[1-9])(?:,(?:10|[1-9]))*", args.numbers):
|
||||||
|
raise ValueError("Liste de workspaces invalide")
|
||||||
|
result = capture_windows(set(map(int, args.numbers.split(","))), args.cursor)
|
||||||
|
else:
|
||||||
|
result = command(args.action, args.number, args.con_id)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (ValueError, RuntimeError, subprocess.SubprocessError, OSError, json.JSONDecodeError) as error:
|
||||||
|
print(json.dumps({"error": str(error)}, ensure_ascii=False))
|
||||||
|
sys.exit(1)
|
||||||
|
|
@ -1,10 +1,45 @@
|
||||||
//@ pragma UseQApplication
|
//@ pragma UseQApplication
|
||||||
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
|
import Quickshell.I3
|
||||||
import "notifications"
|
import "notifications"
|
||||||
import "theme"
|
import "theme"
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
|
id: shell
|
||||||
|
property bool resizeMode: false
|
||||||
|
IpcHandler {
|
||||||
|
target: "barMode"
|
||||||
|
function active(): bool { return shell.resizeMode; }
|
||||||
|
}
|
||||||
|
// CONTRACT: un seul abonnement mode pour toutes les barres ; les événements
|
||||||
|
// IPC reflètent aussi les changements déclenchés hors du clavier.
|
||||||
|
I3IpcListener {
|
||||||
|
subscriptions: ["mode"]
|
||||||
|
onIpcEvent: event => {
|
||||||
|
if (event.type !== "mode") return;
|
||||||
|
try { shell.resizeMode = JSON.parse(event.data).change === "resize"; }
|
||||||
|
catch (error) { console.warn("Événement mode Sway invalide", error); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// WHY: l'abonnement ne rejoue pas le mode courant après un redémarrage.
|
||||||
|
// Une requête ponctuelle au démarrage et à la reconnexion réconcilie l'état.
|
||||||
|
Process {
|
||||||
|
id: modeSnapshot
|
||||||
|
command: ["swaymsg", "-t", "get_binding_state"]
|
||||||
|
stdout: StdioCollector { id: modeSnapshotOutput; waitForEnd: true }
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code !== 0) return;
|
||||||
|
try { shell.resizeMode = JSON.parse(modeSnapshotOutput.text).name === "resize"; }
|
||||||
|
catch (error) { console.warn("État mode Sway invalide", error); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Component.onCompleted: modeSnapshot.running = true
|
||||||
|
Connections {
|
||||||
|
target: I3
|
||||||
|
function onConnected() { if (!modeSnapshot.running) modeSnapshot.running = true; }
|
||||||
|
}
|
||||||
// CONTRACT: seul ce point d'entrée reçoit les changements live du backend.
|
// CONTRACT: seul ce point d'entrée reçoit les changements live du backend.
|
||||||
// Aucun état de thème n'est écrit par Theme.qml.
|
// Aucun état de thème n'est écrit par Theme.qml.
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
|
|
@ -33,6 +68,7 @@ ShellRoot {
|
||||||
model: Quickshell.screens
|
model: Quickshell.screens
|
||||||
|
|
||||||
Bar {
|
Bar {
|
||||||
|
resizeMode: shell.resizeMode
|
||||||
notificationService: rootNotificationService
|
notificationService: rootNotificationService
|
||||||
// Une seule barre possède le chooser, même en configuration multi-écran.
|
// Une seule barre possède le chooser, même en configuration multi-écran.
|
||||||
startupHost: Quickshell.screens.length > 0
|
startupHost: Quickshell.screens.length > 0
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import Quickshell.Io
|
||||||
import Quickshell.Services.UPower
|
import Quickshell.Services.UPower
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
|
import "StatusColorRoles.js" as StatusColorRoles
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: indicator
|
id: indicator
|
||||||
|
|
@ -33,7 +34,7 @@ Item {
|
||||||
+ Math.floor(remaining / 3600) + " h " + String(Math.floor(remaining % 3600 / 60)).padStart(2, "0") : "")
|
+ Math.floor(remaining / 3600) + " h " + String(Math.floor(remaining % 3600 / 60)).padStart(2, "0") : "")
|
||||||
+ (battery.healthSupported ? "\nSanté : " + Math.round(battery.healthPercentage * 100) + " %" : "")
|
+ (battery.healthSupported ? "\nSanté : " + Math.round(battery.healthPercentage * 100) + " %" : "")
|
||||||
+ (configuredThreshold >= 0 ? "\nLimite configurée : " + configuredThreshold + " %" : "")
|
+ (configuredThreshold >= 0 ? "\nLimite configurée : " + configuredThreshold + " %" : "")
|
||||||
width: available ? icon.width + value.implicitWidth + 10 : 26
|
width: content.width
|
||||||
height: 26
|
height: 26
|
||||||
|
|
||||||
FileView {
|
FileView {
|
||||||
|
|
@ -44,32 +45,21 @@ Item {
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
|
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
|
||||||
NerdIcon {
|
PercentIndicatorContent {
|
||||||
id: icon
|
id: content
|
||||||
width: 18
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: 3
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
// Les seuils 30/15 % sont visuels ; ils ne modifient aucune politique UPower.
|
// Les seuils 30/15 % sont visuels ; ils ne modifient aucune politique UPower.
|
||||||
text: !indicator.available ? ""
|
glyph: !indicator.available ? ""
|
||||||
: indicator.charging ? ""
|
: indicator.charging ? ""
|
||||||
: indicator.full ? ""
|
: indicator.full ? ""
|
||||||
: indicator.percent <= 15 ? ""
|
: indicator.percent <= 15 ? ""
|
||||||
: indicator.percent <= 30 ? ""
|
: indicator.percent <= 30 ? ""
|
||||||
: indicator.percent <= 50 ? ""
|
: indicator.percent <= 50 ? ""
|
||||||
: indicator.percent <= 75 ? "" : ""
|
: indicator.percent <= 75 ? "" : ""
|
||||||
color: indicator.percent <= 15 ? Theme.danger
|
available: indicator.available
|
||||||
: indicator.percent <= 30 ? Theme.urgentForeground : Theme.foreground
|
percent: indicator.percent
|
||||||
}
|
tint: Theme[StatusColorRoles.battery(indicator.available, indicator.percent)]
|
||||||
Text {
|
valueTint: Theme[StatusColorRoles.batteryValue(indicator.available, indicator.percent)]
|
||||||
id: value
|
glyphPixelSize: 18
|
||||||
anchors.left: icon.right
|
|
||||||
anchors.leftMargin: 2
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: indicator.available ? indicator.percent + "%" : ""
|
|
||||||
color: icon.color
|
|
||||||
font.family: "JetBrainsMono Nerd Font Mono"
|
|
||||||
font.pixelSize: 12
|
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: pointer
|
id: pointer
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
|
import "StatusColorRoles.js" as StatusColorRoles
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: indicator
|
id: indicator
|
||||||
|
|
@ -20,11 +21,12 @@ Item {
|
||||||
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
|
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
|
||||||
NerdIcon {
|
NerdIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
// WHY: la silhouette Bluetooth est étroite ; seul le dessin grandit, pas la hitbox.
|
// WHY: le glyphe Bluetooth remplit toute la hauteur de sa police ;
|
||||||
font.pixelSize: 22
|
// 18 px l'aligne visuellement sur les autres statuts sans changer sa cible.
|
||||||
|
font.pixelSize: 18
|
||||||
text: indicator.adapter && indicator.adapter.enabled ? "" : ""
|
text: indicator.adapter && indicator.adapter.enabled ? "" : ""
|
||||||
color: !indicator.adapter || !indicator.adapter.enabled ? Theme.secondaryForeground
|
// L'infobulle conserve la distinction appareil connecté / radio active.
|
||||||
: indicator.connectedDevices.length > 0 ? Theme.accent : Theme.foreground
|
color: Theme[StatusColorRoles.bluetooth(!!indicator.adapter && indicator.adapter.enabled)]
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: pointer
|
id: pointer
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import QtQuick
|
||||||
|
import "../components"
|
||||||
|
import "../theme"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: indicator
|
||||||
|
required property bool open
|
||||||
|
signal toggled()
|
||||||
|
width: 26
|
||||||
|
height: 26
|
||||||
|
|
||||||
|
// CONTRACT: ce témoin ne possède aucun état d'historique ; la barre reste
|
||||||
|
// l'unique propriétaire du LazyLoader et de l'ouverture par IPC ou clic.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 4
|
||||||
|
color: indicator.open ? Theme.accent
|
||||||
|
: pointer.containsMouse ? Theme.border : "transparent"
|
||||||
|
}
|
||||||
|
NerdIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: ""
|
||||||
|
font.pixelSize: 24
|
||||||
|
color: indicator.open ? Theme.onAccent : Theme.pink
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: pointer
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: indicator.toggled()
|
||||||
|
}
|
||||||
|
StatusTooltip {
|
||||||
|
target: indicator
|
||||||
|
hovered: pointer.containsMouse && !indicator.open
|
||||||
|
message: "Presse-papiers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import QtQuick
|
||||||
import Quickshell.Networking
|
import Quickshell.Networking
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
|
import "StatusColorRoles.js" as StatusColorRoles
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: indicator
|
id: indicator
|
||||||
|
|
@ -25,14 +26,14 @@ Item {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
// WHY: md-wifi remplit mieux la hauteur visuelle que les quatre glyphes
|
// WHY: md-wifi remplit mieux la hauteur visuelle que les quatre glyphes
|
||||||
// de niveau ; le pourcentage exact reste disponible dans le tooltip.
|
// de niveau ; le pourcentage exact reste disponible dans le tooltip.
|
||||||
font.pixelSize: 26
|
font.pixelSize: 30
|
||||||
// CONTRACT: les glyphes connecté, déconnecté et off existent dans la
|
// CONTRACT: les glyphes connecté, déconnecté et off existent dans la
|
||||||
// Nerd Font installée ; seul l'état connecté est simplifié dans la barre.
|
// Nerd Font installée ; seul l'état connecté est simplifié dans la barre.
|
||||||
text: !Networking.wifiHardwareEnabled ? ""
|
text: !Networking.wifiHardwareEnabled ? ""
|
||||||
: !Networking.wifiEnabled ? ""
|
: !Networking.wifiEnabled ? ""
|
||||||
: !indicator.connectedNetwork ? "" : ""
|
: !indicator.connectedNetwork ? "" : ""
|
||||||
color: !Networking.wifiHardwareEnabled ? Theme.danger
|
color: Theme[StatusColorRoles.wifi(Networking.wifiHardwareEnabled,
|
||||||
: !Networking.wifiEnabled ? Theme.secondaryForeground : Theme.foreground
|
Networking.wifiEnabled, !!indicator.connectedNetwork)]
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: pointer
|
id: pointer
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import QtQuick
|
||||||
|
import "../components"
|
||||||
|
import "../theme"
|
||||||
|
import "PercentPresentation.js" as PercentPresentation
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: content
|
||||||
|
required property string glyph
|
||||||
|
required property bool available
|
||||||
|
required property int percent
|
||||||
|
property color tint: Theme.foreground
|
||||||
|
// CONTRACT: l'accent colore le pictogramme ; la valeur reste du texte,
|
||||||
|
// sauf lorsqu'un indicateur transmet déjà une alerte par sa valeur.
|
||||||
|
property color valueTint: Theme.foreground
|
||||||
|
// Les glyphes Nerd Font ont des silhouettes différentes à taille égale.
|
||||||
|
property int glyphPixelSize: 22
|
||||||
|
readonly property int iconSlotWidth: 22
|
||||||
|
// Cinq caractères monospace à 12 px (« 100 % ») tiennent dans 38 px.
|
||||||
|
readonly property int valueSlotWidth: 38
|
||||||
|
readonly property int iconValueGap: 4
|
||||||
|
width: 2 + iconSlotWidth + iconValueGap + valueSlotWidth
|
||||||
|
height: 26
|
||||||
|
|
||||||
|
NerdIcon {
|
||||||
|
width: content.iconSlotWidth
|
||||||
|
height: parent.height
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 2
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: content.glyph
|
||||||
|
font.pixelSize: content.glyphPixelSize
|
||||||
|
color: content.tint
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 2 + content.iconSlotWidth + content.iconValueGap
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: content.valueSlotWidth
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
text: PercentPresentation.label(content.available, content.percent)
|
||||||
|
color: content.valueTint
|
||||||
|
font.family: "JetBrainsMono Nerd Font Mono"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
// CONTRACT: les indicateurs système utilisent le même espace avant « % » et
|
||||||
|
// signalent explicitement l'absence de valeur sans afficher un faux zéro.
|
||||||
|
function label(available, percent) {
|
||||||
|
return available ? percent + " %" : "—";
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import Quickshell.Io
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../controlcenter"
|
import "../controlcenter"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
|
import "StatusColorRoles.js" as StatusColorRoles
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: indicator
|
id: indicator
|
||||||
|
|
@ -20,6 +21,13 @@ Item {
|
||||||
readonly property var devices: testState ? fakeDevices(testState) : readDevices()
|
readonly property var devices: testState ? fakeDevices(testState) : readDevices()
|
||||||
readonly property int deviceCount: devices.length
|
readonly property int deviceCount: devices.length
|
||||||
readonly property bool hasBusyDevice: devices.some(device => device.busy)
|
readonly property bool hasBusyDevice: devices.some(device => device.busy)
|
||||||
|
readonly property bool hasErrorDevice: devices.some(device => Boolean(device.error))
|
||||||
|
readonly property var publicDeviceKeys: [
|
||||||
|
"actions", "busy", "display_name", "ejectable", "error", "filesystem",
|
||||||
|
"kind", "label", "mount_point", "mounted", "power_off_capable",
|
||||||
|
"removable", "runtime_id", "safe_remove_runtime_id", "size_bytes"
|
||||||
|
]
|
||||||
|
readonly property var allowedActions: ["mount", "unmount", "open", "safe-remove"]
|
||||||
|
|
||||||
visible: deviceCount > 0
|
visible: deviceCount > 0
|
||||||
width: visible ? 26 : 0
|
width: visible ? 26 : 0
|
||||||
|
|
@ -27,10 +35,26 @@ Item {
|
||||||
|
|
||||||
onDeviceCountChanged: if (deviceCount === 0) popup.visible = false
|
onDeviceCountChanged: if (deviceCount === 0) popup.visible = false
|
||||||
|
|
||||||
|
function hasExactKeys(device, expectedKeys) {
|
||||||
|
const keys = Object.keys(device).sort();
|
||||||
|
if (keys.length !== expectedKeys.length) return false;
|
||||||
|
return keys.every((key, index) => key === expectedKeys[index]);
|
||||||
|
}
|
||||||
|
|
||||||
function validDevice(device) {
|
function validDevice(device) {
|
||||||
return device !== null && typeof device === "object"
|
return device !== null && typeof device === "object"
|
||||||
|
// CONTRACT: la vue ne tolère aucune extension implicite du protocole
|
||||||
|
// public, notamment une URI ou une identité matérielle MTP.
|
||||||
|
&& hasExactKeys(device, publicDeviceKeys)
|
||||||
&& typeof device.runtime_id === "string" && device.runtime_id.length > 0
|
&& typeof device.runtime_id === "string" && device.runtime_id.length > 0
|
||||||
&& typeof device.drive_runtime_id === "string" && device.drive_runtime_id.length > 0
|
&& typeof device.safe_remove_runtime_id === "string"
|
||||||
|
&& device.safe_remove_runtime_id.length > 0
|
||||||
|
&& (device.kind === "block" || device.kind === "mtp")
|
||||||
|
&& Array.isArray(device.actions)
|
||||||
|
&& device.actions.every((action, index) =>
|
||||||
|
typeof action === "string"
|
||||||
|
&& allowedActions.indexOf(action) !== -1
|
||||||
|
&& device.actions.indexOf(action) === index)
|
||||||
&& typeof device.display_name === "string"
|
&& typeof device.display_name === "string"
|
||||||
&& (device.label === null || typeof device.label === "string")
|
&& (device.label === null || typeof device.label === "string")
|
||||||
&& (device.filesystem === null || typeof device.filesystem === "string")
|
&& (device.filesystem === null || typeof device.filesystem === "string")
|
||||||
|
|
@ -51,7 +75,9 @@ Item {
|
||||||
const value = JSON.parse(raw);
|
const value = JSON.parse(raw);
|
||||||
// CONTRACT: ignorer intégralement un snapshot d'un autre protocole ou
|
// CONTRACT: ignorer intégralement un snapshot d'un autre protocole ou
|
||||||
// partiellement écrit ; aucune commande ne doit viser ses identifiants.
|
// partiellement écrit ; aucune commande ne doit viser ses identifiants.
|
||||||
if (value.schema !== "labfy.removable-media" || value.version !== 1
|
if (value === null || typeof value !== "object"
|
||||||
|
|| !hasExactKeys(value, ["devices", "schema", "updated_at", "version"])
|
||||||
|
|| value.schema !== "labfy.removable-media" || value.version !== 2
|
||||||
|| !Array.isArray(value.devices) || !Number.isSafeInteger(value.updated_at)
|
|| !Array.isArray(value.devices) || !Number.isSafeInteger(value.updated_at)
|
||||||
|| value.updated_at < 0
|
|| value.updated_at < 0
|
||||||
|| !value.devices.every(validDevice))
|
|| !value.devices.every(validDevice))
|
||||||
|
|
@ -63,19 +89,41 @@ Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeDevice(id, mounted, busy, error) {
|
function fakeBlockDevice(busy, error) {
|
||||||
return {
|
return {
|
||||||
runtime_id: "test-volume-" + id,
|
runtime_id: "test-volume-usb",
|
||||||
drive_runtime_id: "test-drive-" + id,
|
safe_remove_runtime_id: "test-drive-usb",
|
||||||
display_name: id === 1 ? "Clé USB de test" : "Disque de sauvegarde",
|
kind: "block",
|
||||||
label: id === 1 ? "LABFY" : "ARCHIVES",
|
actions: ["unmount", "open", "safe-remove"],
|
||||||
filesystem: id === 1 ? "exfat" : "ext4",
|
display_name: "Clé USB de test",
|
||||||
size_bytes: id === 1 ? 64000000000 : 1000000000000,
|
label: "LABFY",
|
||||||
mount_point: mounted ? "/run/media/test/" + (id === 1 ? "LABFY" : "ARCHIVES") : "",
|
filesystem: "exfat",
|
||||||
mounted: mounted,
|
size_bytes: 64000000000,
|
||||||
|
mount_point: "/run/media/test/LABFY",
|
||||||
|
mounted: true,
|
||||||
removable: true,
|
removable: true,
|
||||||
ejectable: id === 1,
|
ejectable: true,
|
||||||
power_off_capable: id === 2,
|
power_off_capable: false,
|
||||||
|
busy: busy,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeMtpDevice(busy, error) {
|
||||||
|
return {
|
||||||
|
runtime_id: "test-mtp-session",
|
||||||
|
safe_remove_runtime_id: "test-mtp-session",
|
||||||
|
kind: "mtp",
|
||||||
|
actions: ["unmount", "open", "safe-remove"],
|
||||||
|
display_name: "Stockage du téléphone",
|
||||||
|
label: "Téléphone Android",
|
||||||
|
filesystem: "mtp",
|
||||||
|
size_bytes: 128000000000,
|
||||||
|
mount_point: "/run/user/1000/gvfs/mtp:host=test",
|
||||||
|
mounted: true,
|
||||||
|
removable: true,
|
||||||
|
ejectable: false,
|
||||||
|
power_off_capable: false,
|
||||||
busy: busy,
|
busy: busy,
|
||||||
error: error
|
error: error
|
||||||
};
|
};
|
||||||
|
|
@ -83,18 +131,21 @@ Item {
|
||||||
|
|
||||||
function fakeDevices(mode) {
|
function fakeDevices(mode) {
|
||||||
if (mode === "0") return [];
|
if (mode === "0") return [];
|
||||||
if (mode === "1" || mode === "mounted" || mode === "1-mounted")
|
if (mode === "USB") return [fakeBlockDevice(false, "")];
|
||||||
return [fakeDevice(1, true, false, "")];
|
if (mode === "MTP") return [fakeMtpDevice(false, "")];
|
||||||
if (mode === "unmounted" || mode === "1-unmounted")
|
if (mode === "USB+MTP")
|
||||||
return [fakeDevice(1, false, false, "")];
|
return [fakeBlockDevice(false, ""), fakeMtpDevice(false, "")];
|
||||||
if (mode === "2" || mode === "busy-error" || mode === "2-busy-error") return [
|
if (mode === "busy") return [fakeMtpDevice(true, "")];
|
||||||
fakeDevice(1, true, true, ""),
|
if (mode === "error")
|
||||||
fakeDevice(2, false, false, "Le support nécessite une vérification.")
|
return [fakeBlockDevice(false, "Le support nécessite une vérification.")];
|
||||||
];
|
|
||||||
console.warn("LABFY_REMOVABLE_MEDIA_TEST_STATE inconnu :", mode);
|
console.warn("LABFY_REMOVABLE_MEDIA_TEST_STATE inconnu :", mode);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAction(device, action) {
|
||||||
|
return device.actions.indexOf(action) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
if (bytes <= 0) return "Taille inconnue";
|
if (bytes <= 0) return "Taille inconnue";
|
||||||
const units = ["o", "Kio", "Mio", "Gio", "Tio"];
|
const units = ["o", "Kio", "Mio", "Gio", "Tio"];
|
||||||
|
|
@ -110,9 +161,10 @@ Item {
|
||||||
|
|
||||||
function runAction(action, device) {
|
function runAction(action, device) {
|
||||||
// INVARIANT: les identifiants TEST_ONLY ne quittent jamais le renderer.
|
// INVARIANT: les identifiants TEST_ONLY ne quittent jamais le renderer.
|
||||||
if (testState || actionProcess.running || device.busy) return;
|
if (testState || actionProcess.running || device.busy || !hasAction(device, action))
|
||||||
|
return;
|
||||||
let target = device.runtime_id;
|
let target = device.runtime_id;
|
||||||
if (action === "safe-remove") target = device.drive_runtime_id;
|
if (action === "safe-remove") target = device.safe_remove_runtime_id;
|
||||||
// CONTRACT: le client est l'unique frontière de commande et reçoit un
|
// CONTRACT: le client est l'unique frontière de commande et reçoit un
|
||||||
// argv direct. Les identifiants opaques ne traversent jamais un shell.
|
// argv direct. Les identifiants opaques ne traversent jamais un shell.
|
||||||
actionProcess.command = [clientPath, action, target];
|
actionProcess.command = [clientPath, action, target];
|
||||||
|
|
@ -162,8 +214,8 @@ Item {
|
||||||
NerdIcon {
|
NerdIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: indicator.hasBusyDevice || actionProcess.running ? "" : ""
|
text: indicator.hasBusyDevice || actionProcess.running ? "" : ""
|
||||||
color: indicator.hasBusyDevice || actionProcess.running
|
color: Theme[StatusColorRoles.removable(indicator.hasErrorDevice,
|
||||||
? Theme.warningForeground : Theme.foreground
|
indicator.hasBusyDevice || actionProcess.running)]
|
||||||
font.pixelSize: 18
|
font.pixelSize: 18
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,8 +303,11 @@ Item {
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: (modelData.label ? modelData.label + " • " : "")
|
text: modelData.kind === "mtp"
|
||||||
+ (modelData.filesystem || "Système de fichiers inconnu")
|
? "Appareil MTP • " + indicator.formatSize(modelData.size_bytes)
|
||||||
|
: (modelData.label ? modelData.label + " • " : "")
|
||||||
|
+ (modelData.filesystem
|
||||||
|
|| "Système de fichiers inconnu")
|
||||||
+ " • " + indicator.formatSize(modelData.size_bytes)
|
+ " • " + indicator.formatSize(modelData.size_bytes)
|
||||||
color: Theme.secondaryForeground
|
color: Theme.secondaryForeground
|
||||||
font.pixelSize: 11
|
font.pixelSize: 11
|
||||||
|
|
@ -262,6 +317,11 @@ Item {
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: modelData.busy ? "Opération en cours…"
|
text: modelData.busy ? "Opération en cours…"
|
||||||
|
: modelData.kind === "mtp" && modelData.mounted
|
||||||
|
? (modelData.mount_point
|
||||||
|
? "Connexion ouverte sur " + modelData.mount_point
|
||||||
|
: "Connexion ouverte")
|
||||||
|
: modelData.kind === "mtp" ? "Connexion fermée"
|
||||||
: modelData.mounted ? "Monté sur " + modelData.mount_point
|
: modelData.mounted ? "Monté sur " + modelData.mount_point
|
||||||
: "Non monté"
|
: "Non monté"
|
||||||
color: modelData.busy ? Theme.warningForeground
|
color: modelData.busy ? Theme.warningForeground
|
||||||
|
|
@ -271,6 +331,16 @@ Item {
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: modelData.kind === "mtp"
|
||||||
|
&& indicator.hasAction(modelData, "safe-remove")
|
||||||
|
width: parent.width
|
||||||
|
text: "Fermer la connexion déconnecte proprement l’appareil de cette session."
|
||||||
|
color: Theme.secondaryForeground
|
||||||
|
font.pixelSize: 11
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
visible: Boolean(modelData.error)
|
visible: Boolean(modelData.error)
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
|
@ -285,22 +355,30 @@ Item {
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
ActionButton {
|
ActionButton {
|
||||||
visible: modelData.mounted
|
visible: indicator.hasAction(modelData, "open")
|
||||||
label: "Ouvrir"
|
label: "Ouvrir"
|
||||||
enabled: !indicator.testState && !modelData.busy
|
enabled: !indicator.testState && !modelData.busy
|
||||||
&& !actionProcess.running
|
&& !actionProcess.running
|
||||||
onClicked: indicator.runAction("open", modelData)
|
onClicked: indicator.runAction("open", modelData)
|
||||||
}
|
}
|
||||||
ActionButton {
|
ActionButton {
|
||||||
label: modelData.mounted ? "Démonter" : "Monter"
|
visible: indicator.hasAction(modelData, "mount")
|
||||||
|
label: "Monter"
|
||||||
enabled: !indicator.testState && !modelData.busy
|
enabled: !indicator.testState && !modelData.busy
|
||||||
&& !actionProcess.running
|
&& !actionProcess.running
|
||||||
onClicked: indicator.runAction(
|
onClicked: indicator.runAction("mount", modelData)
|
||||||
modelData.mounted ? "unmount" : "mount", modelData)
|
|
||||||
}
|
}
|
||||||
ActionButton {
|
ActionButton {
|
||||||
visible: modelData.ejectable || modelData.power_off_capable
|
visible: indicator.hasAction(modelData, "unmount")
|
||||||
label: "Retirer en sécurité"
|
label: "Démonter"
|
||||||
|
enabled: !indicator.testState && !modelData.busy
|
||||||
|
&& !actionProcess.running
|
||||||
|
onClicked: indicator.runAction("unmount", modelData)
|
||||||
|
}
|
||||||
|
ActionButton {
|
||||||
|
visible: indicator.hasAction(modelData, "safe-remove")
|
||||||
|
label: modelData.kind === "mtp"
|
||||||
|
? "Fermer la connexion" : "Retirer en sécurité"
|
||||||
enabled: !indicator.testState && !modelData.busy
|
enabled: !indicator.testState && !modelData.busy
|
||||||
&& !actionProcess.running
|
&& !actionProcess.running
|
||||||
onClicked: indicator.runAction("safe-remove", modelData)
|
onClicked: indicator.runAction("safe-remove", modelData)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import "../controlcenter"
|
import "../controlcenter"
|
||||||
|
import "../theme"
|
||||||
|
import "StatusColorRoles.js" as StatusColorRoles
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: area
|
id: area
|
||||||
|
|
@ -7,24 +9,69 @@ Row {
|
||||||
required property var wifiDevice
|
required property var wifiDevice
|
||||||
required property var adapter
|
required property var adapter
|
||||||
required property bool controlCenterOpen
|
required property bool controlCenterOpen
|
||||||
|
required property bool clipboardOpen
|
||||||
|
required property var audioBrightnessController
|
||||||
signal openPageRequested(int page)
|
signal openPageRequested(int page)
|
||||||
signal toggleControlCenterRequested()
|
signal toggleControlCenterRequested()
|
||||||
signal toggleRemovableMediaRequested()
|
signal toggleRemovableMediaRequested()
|
||||||
|
signal toggleClipboardRequested()
|
||||||
function refreshBatteryThreshold() { batteryIndicator.refreshThreshold() }
|
function refreshBatteryThreshold() { batteryIndicator.refreshThreshold() }
|
||||||
function closeRemovableMedia() { removableMediaIndicator.popupOpen = false }
|
function closeRemovableMedia() { removableMediaIndicator.popupOpen = false }
|
||||||
function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open }
|
function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open }
|
||||||
readonly property bool removableMediaOpen: removableMediaIndicator.popupOpen
|
readonly property bool removableMediaOpen: removableMediaIndicator.popupOpen
|
||||||
spacing: 5
|
// INVARIANT: huit pixels de part et d'autre des deux séparateurs identiques.
|
||||||
|
spacing: 8
|
||||||
height: 26
|
height: 26
|
||||||
|
|
||||||
|
// CONTRACT: statuts, actions, puis tray en dernier ; callbacks inchangés.
|
||||||
|
Row {
|
||||||
|
spacing: 6
|
||||||
|
height: 26
|
||||||
NetworkIndicator { wifiDevice: area.wifiDevice; onActivated: area.openPageRequested(1) }
|
NetworkIndicator { wifiDevice: area.wifiDevice; onActivated: area.openPageRequested(1) }
|
||||||
BluetoothIndicator { adapter: area.adapter; onActivated: area.openPageRequested(2) }
|
BluetoothIndicator { adapter: area.adapter; onActivated: area.openPageRequested(2) }
|
||||||
BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) }
|
BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) }
|
||||||
|
ScrollIcon {
|
||||||
|
glyph: area.audioBrightnessController.volumeMuted ? "" : ""
|
||||||
|
glyphColor: Theme[StatusColorRoles.volume(
|
||||||
|
area.audioBrightnessController.volumeAvailable,
|
||||||
|
area.audioBrightnessController.volumeMuted)]
|
||||||
|
glyphPixelSize: 28
|
||||||
|
label: "Volume"
|
||||||
|
percent: area.audioBrightnessController.volumePercent
|
||||||
|
available: area.audioBrightnessController.volumeAvailable
|
||||||
|
onAdjusted: steps => area.audioBrightnessController.adjustVolume(steps)
|
||||||
|
}
|
||||||
|
ScrollIcon {
|
||||||
|
glyph: ""
|
||||||
|
glyphColor: Theme.yellow
|
||||||
|
glyphPixelSize: 26
|
||||||
|
label: "Luminosité"
|
||||||
|
percent: area.audioBrightnessController.brightnessPercent
|
||||||
|
available: area.audioBrightnessController.brightnessAvailable
|
||||||
|
onAdjusted: steps => area.audioBrightnessController.adjustBrightness(steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1; height: 14
|
||||||
|
color: Theme.separator
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 5
|
||||||
|
height: 26
|
||||||
UpdatesIndicator { }
|
UpdatesIndicator { }
|
||||||
RemovableMediaIndicator {
|
RemovableMediaIndicator {
|
||||||
id: removableMediaIndicator
|
id: removableMediaIndicator
|
||||||
onToggleRequested: area.toggleRemovableMediaRequested()
|
onToggleRequested: area.toggleRemovableMediaRequested()
|
||||||
}
|
}
|
||||||
Tray { barWindow: area.barWindow }
|
ClipboardIndicator { open: area.clipboardOpen; onToggled: area.toggleClipboardRequested() }
|
||||||
ControlCenterButton { open: area.controlCenterOpen; onToggled: area.toggleControlCenterRequested() }
|
ControlCenterButton { open: area.controlCenterOpen; onToggled: area.toggleControlCenterRequested() }
|
||||||
}
|
}
|
||||||
|
Rectangle {
|
||||||
|
visible: tray.implicitWidth > 0
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1; height: 14
|
||||||
|
color: Theme.separator
|
||||||
|
}
|
||||||
|
Tray { id: tray; barWindow: area.barWindow }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import "../theme"
|
||||||
|
import "ScrollSteps.js" as ScrollSteps
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: iconButton
|
||||||
|
required property string glyph
|
||||||
|
required property string label
|
||||||
|
required property int percent
|
||||||
|
property int glyphPixelSize: 22
|
||||||
|
property color glyphColor: Theme.foreground
|
||||||
|
property bool available: false
|
||||||
|
signal adjusted(int steps)
|
||||||
|
// CONTRACT: la hitbox et la valeur partagent la même largeur stable que
|
||||||
|
// la batterie ; le texte débute à 4 px de l'icône, sans vide variable.
|
||||||
|
width: content.width
|
||||||
|
height: 26
|
||||||
|
property var scrollState: ({ remainder: 0, source: "" })
|
||||||
|
// CONTRACT: le chemin Qt et le diagnostic injecté traversent exactement
|
||||||
|
// la même accumulation et le même signal vers le contrôle existant.
|
||||||
|
function applyWheel(pixel, angle, inverted, phase, touchpad) {
|
||||||
|
if (!available) return;
|
||||||
|
const result = ScrollSteps.advance(scrollState,
|
||||||
|
pixel, angle, inverted, phase, touchpad);
|
||||||
|
scrollState = result;
|
||||||
|
if (result.steps) adjusted(result.steps);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: label
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 4
|
||||||
|
color: pointer.containsMouse ? Theme.border : "transparent"
|
||||||
|
}
|
||||||
|
PercentIndicatorContent {
|
||||||
|
id: content
|
||||||
|
glyph: iconButton.glyph
|
||||||
|
available: iconButton.available
|
||||||
|
percent: iconButton.percent
|
||||||
|
tint: iconButton.available ? iconButton.glyphColor : Theme.disabledForeground
|
||||||
|
valueTint: iconButton.available ? Theme.foreground : Theme.disabledForeground
|
||||||
|
glyphPixelSize: iconButton.glyphPixelSize
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: pointer
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
hoverEnabled: true
|
||||||
|
}
|
||||||
|
StatusTooltip {
|
||||||
|
target: iconButton
|
||||||
|
hovered: pointer.containsMouse
|
||||||
|
message: iconButton.available ? iconButton.label + " : " + iconButton.percent
|
||||||
|
+ " %\nDéfiler pour régler par pas de 1 %" : iconButton.label + " indisponible"
|
||||||
|
}
|
||||||
|
WheelHandler {
|
||||||
|
target: null
|
||||||
|
invertible: false
|
||||||
|
// Qt n'accepte que Mouse par défaut : activer explicitement TouchPad.
|
||||||
|
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||||
|
// WHY: le handler est limité à la largeur de cet indicateur ; aucun autre
|
||||||
|
// élément de la barre ne transforme un mouvement en réglage.
|
||||||
|
onWheel: event => {
|
||||||
|
// Qt Wayland peut exposer un geste à deux doigts sans pixelDelta ;
|
||||||
|
// les petits angleDelta restent donc classés comme touchpad.
|
||||||
|
const touchpad = event.pixelDelta.y !== 0
|
||||||
|
|| (event.device && event.device.type === PointerDevice.TouchPad)
|
||||||
|
|| (event.angleDelta.y !== 0 && Math.abs(event.angleDelta.y) < 120);
|
||||||
|
const result = iconButton.applyWheel(event.pixelDelta.y,
|
||||||
|
event.angleDelta.y, event.inverted, event.phase, touchpad);
|
||||||
|
if (Quickshell.env("LABFY_SCROLL_DEBUG") === "1")
|
||||||
|
console.log("Scroll", iconButton.label, "pixel", event.pixelDelta.y,
|
||||||
|
"angle", event.angleDelta.y, "phase", event.phase,
|
||||||
|
"inverted", event.inverted, "touchpad", touchpad,
|
||||||
|
"steps", result ? result.steps : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
// CONTRACT: pixelDelta et angleDelta décrivent le même événement : choisir
|
||||||
|
// une seule source. 120 unités angulaires valent un cran de souris ; le seuil
|
||||||
|
// pixel est une valeur initiale à calibrer sur le touchpad réel.
|
||||||
|
function advance(state, pixel, angle, inverted, phase, touchpad) {
|
||||||
|
if (phase === 3) return { remainder: 0, source: "", steps: 0 };
|
||||||
|
const source = pixel !== 0 ? "pixel" : "angle";
|
||||||
|
// WHY: sur le touchpad ASUS, le signe normalisé de V2 produisait
|
||||||
|
// l'action opposée au mouvement physique observé. Une seule correction
|
||||||
|
// s'applique ici, après la normalisation de QWheelEvent.inverted.
|
||||||
|
const delta = (source === "pixel" ? pixel : angle)
|
||||||
|
* (inverted ? -1 : 1) * (touchpad ? -1 : 1);
|
||||||
|
if (!delta) return { remainder: state.remainder, source: state.source, steps: 0 };
|
||||||
|
const threshold = source === "pixel" ? 32 : 120;
|
||||||
|
let remainder = state.source === source ? state.remainder : 0;
|
||||||
|
if (phase === 1 || (remainder !== 0 && Math.sign(remainder) !== Math.sign(delta)))
|
||||||
|
remainder = 0;
|
||||||
|
remainder += delta;
|
||||||
|
const count = Math.trunc(remainder / threshold);
|
||||||
|
const steps = count === 0 ? 0 : count;
|
||||||
|
return { remainder: remainder - steps * threshold, source: source, steps: steps };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// CONTRACT: ces fonctions choisissent des rôles de Theme, jamais des couleurs
|
||||||
|
// fixes ; les états matériels priment toujours sur l'accent décoratif.
|
||||||
|
function wifi(hardwareEnabled, enabled, connected) {
|
||||||
|
if (!hardwareEnabled) return "disabledForeground";
|
||||||
|
return enabled && connected ? "sky" : "secondaryForeground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bluetooth(active) {
|
||||||
|
return active ? "blue" : "disabledForeground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function battery(available, percent) {
|
||||||
|
if (!available) return "disabledForeground";
|
||||||
|
if (percent <= 15) return "danger";
|
||||||
|
if (percent <= 30) return "urgentForeground";
|
||||||
|
return "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryValue(available, percent) {
|
||||||
|
return available && percent > 30 ? "foreground" : battery(available, percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function volume(available, muted) {
|
||||||
|
return !available || muted ? "disabledForeground" : "mauve";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updates(hasError, pending) {
|
||||||
|
return pending ? "secondaryForeground" : hasError ? "danger" : "peach";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removable(hasError, busy) {
|
||||||
|
return hasError ? "danger" : busy ? "warningForeground" : "teal";
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import Quickshell.Services.SystemTray
|
||||||
Row {
|
Row {
|
||||||
id: tray
|
id: tray
|
||||||
required property var barWindow
|
required property var barWindow
|
||||||
spacing: 2
|
spacing: 4
|
||||||
// Le modèle natif suit les inscriptions et retraits D-Bus sans scrutation.
|
// Le modèle natif suit les inscriptions et retraits D-Bus sans scrutation.
|
||||||
Repeater {
|
Repeater {
|
||||||
model: SystemTray.items
|
model: SystemTray.items
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ Item {
|
||||||
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
|
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
|
||||||
IconImage {
|
IconImage {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
width: 18
|
// CONTRACT: l'image fournie par l'application reste inchangée ; seul
|
||||||
height: 18
|
// son emplacement dans le slot de 26 px est harmonisé.
|
||||||
|
width: 20
|
||||||
|
height: 20
|
||||||
source: entry.item.icon
|
source: entry.item.icon
|
||||||
opacity: entry.item.status === Status.Passive ? 0.7 : 1
|
opacity: entry.item.status === Status.Passive ? 0.7 : 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import "../components"
|
import "../components"
|
||||||
import "../theme"
|
import "../theme"
|
||||||
|
import "StatusColorRoles.js" as StatusColorRoles
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: indicator
|
id: indicator
|
||||||
|
|
@ -22,6 +23,7 @@ Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
readonly property bool hasError: Boolean(state.error)
|
readonly property bool hasError: Boolean(state.error)
|
||||||
|
readonly property bool pending: state.error === "Vérification en attente"
|
||||||
visible: hasError || state.totalUpdates > 0
|
visible: hasError || state.totalUpdates > 0
|
||||||
width: visible ? icon.width + (hasError ? 0 : count.implicitWidth + 3) + 6 : 0
|
width: visible ? icon.width + (hasError ? 0 : count.implicitWidth + 3) + 6 : 0
|
||||||
height: 26
|
height: 26
|
||||||
|
|
@ -52,12 +54,12 @@ Item {
|
||||||
id: icon
|
id: icon
|
||||||
width: 18
|
width: 18
|
||||||
// WHY: le trait circulaire paraît plus petit que le glyphe batterie à 18 px.
|
// WHY: le trait circulaire paraît plus petit que le glyphe batterie à 18 px.
|
||||||
font.pixelSize: 22
|
font.pixelSize: 26
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 3
|
anchors.leftMargin: 3
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: indicator.hasError ? "" : ""
|
text: indicator.hasError ? "" : ""
|
||||||
color: indicator.hasError ? Theme.urgentForeground : Theme.accent
|
color: Theme[StatusColorRoles.updates(indicator.hasError, indicator.pending)]
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
id: count
|
id: count
|
||||||
|
|
@ -66,7 +68,7 @@ Item {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: !indicator.hasError
|
visible: !indicator.hasError
|
||||||
text: indicator.state.totalUpdates
|
text: indicator.state.totalUpdates
|
||||||
color: Theme.accent
|
color: Theme.foreground
|
||||||
font.family: "JetBrainsMono Nerd Font Mono"
|
font.family: "JetBrainsMono Nerd Font Mono"
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,11 @@ Item {
|
||||||
function closeMenu() {
|
function closeMenu() {
|
||||||
windowMenu.visible = false;
|
windowMenu.visible = false;
|
||||||
}
|
}
|
||||||
|
function openActiveMenu() {
|
||||||
|
if (!activeWindow) return false;
|
||||||
|
showMenu(activeButton, activeWindow);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function showMenu(item, windowInfo) {
|
function showMenu(item, windowInfo) {
|
||||||
if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) {
|
if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) {
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,9 @@ bindsym $mod+v splitv
|
||||||
|
|
||||||
# Switch the current container between different layout styles
|
# Switch the current container between different layout styles
|
||||||
bindsym $mod+s layout stacking
|
bindsym $mod+s layout stacking
|
||||||
bindsym $mod+w layout tabbed
|
# Super+W appartient à l'Overview ; conserver tabbed sur Super+Shift+W.
|
||||||
|
bindsym $mod+Shift+w layout tabbed
|
||||||
|
bindsym $mod+w exec ~/.config/sway/scripts/overview-quickshell
|
||||||
bindsym $mod+e layout toggle split
|
bindsym $mod+e layout toggle split
|
||||||
|
|
||||||
# Make the current focus fullscreen
|
# Make the current focus fullscreen
|
||||||
|
|
@ -212,7 +214,7 @@ bindsym $mod+Alt+p exec proton-pass
|
||||||
bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton
|
bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton
|
||||||
bindsym $mod+Alt+y exec kitty -- sh -c "yazi"
|
bindsym $mod+Alt+y exec kitty -- sh -c "yazi"
|
||||||
bindsym $mod+Alt+w exec gtk-launch chrome-hnpfjngllnobngcgfapefoaidbinmjnm-Default
|
bindsym $mod+Alt+w exec gtk-launch chrome-hnpfjngllnobngcgfapefoaidbinmjnm-Default
|
||||||
bindsym $mod+Alt+v exec ~/.config/sway/scripts/copypast
|
bindsym $mod+Alt+v exec ~/.config/sway/scripts/clipboard-quickshell
|
||||||
bindsym $mod+Alt+c exec --no-startup-id kitty --app-id=lardon-freecad-log --title="Lardon · FreeCAD autopush" --directory=/home/fy59/Documents/Lardon /home/fy59/Documents/Lardon/tools/lardon-freecad-launcher
|
bindsym $mod+Alt+c exec --no-startup-id kitty --app-id=lardon-freecad-log --title="Lardon · FreeCAD autopush" --directory=/home/fy59/Documents/Lardon /home/fy59/Documents/Lardon/tools/lardon-freecad-launcher
|
||||||
bindsym $mod+Alt+s exec sqlitebrowser
|
bindsym $mod+Alt+s exec sqlitebrowser
|
||||||
bindsym $mod+Alt+e exec sh -c 'cd /home/fy59/Documents/Investigations/Template/labfy-investigation && exec ./labfy-investigation'
|
bindsym $mod+Alt+e exec sh -c 'cd /home/fy59/Documents/Investigations/Template/labfy-investigation && exec ./labfy-investigation'
|
||||||
|
|
@ -231,7 +233,7 @@ bindsym $mod+c exec codex-desktop
|
||||||
bindsym Shift+Print exec grim - | wl-copy -t image/png
|
bindsym Shift+Print exec grim - | wl-copy -t image/png
|
||||||
|
|
||||||
# Print = zone -> presse-papier
|
# Print = zone -> presse-papier
|
||||||
bindsym Print exec grim -g "$(slurp)" - | wl-copy -t image/png
|
bindsym Print exec ~/.config/sway/scripts/screenshot-region
|
||||||
|
|
||||||
|
|
||||||
###############################################
|
###############################################
|
||||||
|
|
|
||||||
13
sway/.config/sway/scripts/clipboard-quickshell
Executable file
13
sway/.config/sway/scripts/clipboard-quickshell
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# CONTRACT: seul l'écran focalisé reçoit toggle ; aucun contenu de presse-papiers ne traverse IPC.
|
||||||
|
output="$(swaymsg -t get_outputs -r | jq -r '.[] | select(.focused == true) | .name' | head -n 1)"
|
||||||
|
[[ -n "$output" ]] || exit 1
|
||||||
|
# Une relance ciblée de QuickShell laisse une courte fenêtre sans socket IPC.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if quickshell ipc -c labfy-sway call "clipboardUi-$output" toggle >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
13
sway/.config/sway/scripts/overview-quickshell
Executable file
13
sway/.config/sway/scripts/overview-quickshell
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
# CONTRACT: le raccourci vise la sortie focalisée ; aucun workspace n'est
|
||||||
|
# activé avant le clic de l'utilisateur dans l'Overview.
|
||||||
|
output="$(swaymsg -t get_outputs -r | jq -r '.[] | select(.focused == true) | .name' | head -n 1)"
|
||||||
|
[[ -n "$output" ]] || exit 1
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if quickshell ipc -c labfy-sway call "overviewUi-$output" toggle >/dev/null 2>&1; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
42
sway/.config/sway/scripts/screenshot-region
Executable file
42
sway/.config/sway/scripts/screenshot-region
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# WHY: la surface plein écran du presse-papiers prend le focus exclusif et
|
||||||
|
# peut faire échouer slurp sur SwayFX. La détruire avant de créer le sélecteur.
|
||||||
|
output="$(swaymsg -t get_outputs -r | jq -r '.[] | select(.focused == true) | .name' | head -n 1)"
|
||||||
|
if [[ -n "$output" ]]; then
|
||||||
|
target="clipboardUi-$output"
|
||||||
|
state="$(quickshell ipc -c labfy-sway call "$target" state 2>/dev/null || true)"
|
||||||
|
if [[ -n "$state" ]] && jq -e '.loaded == true' >/dev/null 2>&1 <<<"$state"; then
|
||||||
|
quickshell ipc -c labfy-sway call "$target" toggle >/dev/null
|
||||||
|
# CONTRACT: slurp ne démarre qu'une fois le layer-shell exclusif détruit.
|
||||||
|
for ((attempt = 0; attempt < 40; attempt++)); do
|
||||||
|
state="$(quickshell ipc -c labfy-sway call "$target" state 2>/dev/null || true)"
|
||||||
|
if [[ -n "$state" ]] && jq -e '.loaded == false' >/dev/null 2>&1 <<<"$state"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.05
|
||||||
|
done
|
||||||
|
[[ -n "$state" ]] && jq -e '.loaded == false' >/dev/null 2>&1 <<<"$state" || exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# INVARIANT: Échap ou une géométrie vide ne lance ni grim ni wl-copy.
|
||||||
|
selector="$HOME/.local/libexec/labfy-slurp"
|
||||||
|
[[ -x "$selector" ]] || { echo "Sélecteur slurp corrigé absent : exécuter tools/build-slurp-keymap-guard" >&2; exit 1; }
|
||||||
|
geometry="$("$selector")" || exit 0
|
||||||
|
[[ -n "$geometry" ]] || exit 0
|
||||||
|
runtime="${XDG_RUNTIME_DIR:?}"
|
||||||
|
image="$(mktemp "$runtime/labfy-screenshot-XXXXXXXX.png")"
|
||||||
|
chmod 600 "$image"
|
||||||
|
trap 'rm -f -- "$image"' EXIT
|
||||||
|
grim -g "$geometry" "$image"
|
||||||
|
# CONTRACT: seul un PNG complet et non vide peut remplacer le presse-papiers.
|
||||||
|
[[ -s "$image" ]] || exit 1
|
||||||
|
python3 - "$image" <<'PY'
|
||||||
|
import sys
|
||||||
|
with open(sys.argv[1], 'rb') as image:
|
||||||
|
if image.read(8) != b'\x89PNG\r\n\x1a\n':
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
wl-copy --type image/png < "$image"
|
||||||
71
tests/clipboard/test_backend.py
Normal file
71
tests/clipboard/test_backend.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
BACKEND = Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell/labfy-sway/clipboard/backend.py"
|
||||||
|
PNG = subprocess.run(["magick", "-size", "1x1", "xc:red", "png:-"], capture_output=True, check=True).stdout
|
||||||
|
|
||||||
|
|
||||||
|
class BackendTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.db = str(self.root / "db")
|
||||||
|
self.bin = self.root / "bin"
|
||||||
|
self.bin.mkdir()
|
||||||
|
fake = self.bin / "wl-copy"
|
||||||
|
fake.write_text("#!/usr/bin/env python3\nimport os,sys,pathlib\n"
|
||||||
|
"pathlib.Path(os.environ['TEST_CLIP_BYTES']).write_bytes(sys.stdin.buffer.read())\n"
|
||||||
|
"pathlib.Path(os.environ['TEST_CLIP_MIME']).write_text(sys.argv[2])\n")
|
||||||
|
fake.chmod(0o700)
|
||||||
|
self.env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"],
|
||||||
|
XDG_RUNTIME_DIR=str(self.root), TEST_CLIP_BYTES=str(self.root / "copied"),
|
||||||
|
TEST_CLIP_MIME=str(self.root / "mime"))
|
||||||
|
|
||||||
|
def store(self, data):
|
||||||
|
subprocess.run(["cliphist", "-db-path", self.db, "store"], input=data, check=True)
|
||||||
|
|
||||||
|
def action(self, action, ident=None, success=True):
|
||||||
|
argv = [sys.executable, str(BACKEND), action]
|
||||||
|
if ident is not None:
|
||||||
|
argv.append(str(ident))
|
||||||
|
argv.extend(["--db-path", self.db])
|
||||||
|
argv.extend(["--wl-copy-path", str(self.bin / "wl-copy")])
|
||||||
|
result = subprocess.run(argv, env=self.env, capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode == 0, success)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
def test_text_image_exact_delete_wipe_and_cache(self):
|
||||||
|
self.assertEqual(self.action("list")["items"], [])
|
||||||
|
payloads = [b"first", "accent é\nline\t$`".encode(), PNG]
|
||||||
|
for payload in payloads:
|
||||||
|
self.store(payload)
|
||||||
|
items = self.action("list")["items"]
|
||||||
|
self.assertEqual(len(items), 3)
|
||||||
|
self.assertTrue(items[0]["image"])
|
||||||
|
self.assertFalse(items[1]["image"])
|
||||||
|
self.action("select", items[1]["id"])
|
||||||
|
self.assertEqual((self.root / "copied").read_bytes(), payloads[1])
|
||||||
|
self.assertEqual((self.root / "mime").read_text(), "text/plain;charset=utf-8")
|
||||||
|
self.action("select", items[0]["id"])
|
||||||
|
self.assertEqual((self.root / "copied").read_bytes(), PNG)
|
||||||
|
self.assertEqual((self.root / "mime").read_text(), "image/png")
|
||||||
|
self.assertTrue(self.action("thumb", items[0]["id"])["path"].startswith("file:"))
|
||||||
|
self.action("clean")
|
||||||
|
self.assertEqual(list(self.root.glob("labfy-cliphist-panel-*/*.png")), [])
|
||||||
|
self.action("delete", items[1]["id"])
|
||||||
|
remaining = self.action("list")["items"]
|
||||||
|
self.assertEqual([x["id"] for x in remaining], [items[0]["id"], items[2]["id"]])
|
||||||
|
self.action("delete", "999999", success=False)
|
||||||
|
self.assertEqual(len(self.action("list")["items"]), 2)
|
||||||
|
self.action("wipe")
|
||||||
|
self.assertEqual(self.action("list")["items"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
204
tests/clipboard/ui_quickshell_smoke.py
Normal file
204
tests/clipboard/ui_quickshell_smoke.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Exécuter dans une session Wayland ; base et wl-copy restent privés au test.
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
db = root / "db"
|
||||||
|
output = root / "copied"
|
||||||
|
mime = root / "mime"
|
||||||
|
fake = root / "wl-copy"
|
||||||
|
png = subprocess.run(["magick", "-size", "2x2", "xc:blue", "png:-"],
|
||||||
|
capture_output=True, check=True).stdout
|
||||||
|
for index in range(14):
|
||||||
|
subprocess.run(["cliphist", "-db-path", str(db), "store"],
|
||||||
|
input=("fixture-%02d" % index).encode(), check=True)
|
||||||
|
fake.write_text("#!/usr/bin/env python3\nimport os,sys,pathlib\n"
|
||||||
|
"pathlib.Path(os.environ['TEST_OUT']).write_bytes(sys.stdin.buffer.read())\n"
|
||||||
|
"pathlib.Path(os.environ['TEST_MIME']).write_text(sys.argv[2])\n")
|
||||||
|
fake.chmod(0o700)
|
||||||
|
qml = root / "shell.qml"
|
||||||
|
qml.write_text('''import QtQuick
|
||||||
|
import QtTest
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import "file:/home/fy59/.dotfiles/quickshell/.config/quickshell/labfy-sway/status" as S
|
||||||
|
import "file:/home/fy59/.dotfiles/quickshell/.config/quickshell/labfy-sway/clipboard" as C
|
||||||
|
ShellRoot {
|
||||||
|
id: root
|
||||||
|
PanelWindow {
|
||||||
|
id: bar
|
||||||
|
screen: Quickshell.screens[0]
|
||||||
|
anchors { top: true; right: true }
|
||||||
|
implicitWidth: 100
|
||||||
|
implicitHeight: 34
|
||||||
|
S.ClipboardIndicator { id: icon; open: loader.active; anchors.centerIn: parent;
|
||||||
|
onToggled: { if (loader.active) loader.item.dismiss(true); else loader.active = true; } }
|
||||||
|
}
|
||||||
|
LazyLoader { id: loader; active: false; C.ClipboardPanel { barWindow: bar; onCleanupFinished: loader.active = false } }
|
||||||
|
TestCase { id: tester; name: "Fixture"; when: false }
|
||||||
|
IpcHandler {
|
||||||
|
target: "clipboardMouseFixture"
|
||||||
|
function state(): string {
|
||||||
|
const p = loader.item;
|
||||||
|
return JSON.stringify({loaded: loader.active, visible: !!p && p.visible, focused: !!p && p.searchFocused,
|
||||||
|
results: p ? p.results.length : 0, images: p ? p.results.filter(x => x.image).length : 0,
|
||||||
|
thumbnail: p ? p.thumbnail : "", operation: p ? p.operation : "",
|
||||||
|
message: p ? p.message : "", selected: p ? p.selected : -1,
|
||||||
|
confirming: !!p && p.confirming, listY: p ? p.resultList.contentY : 0,
|
||||||
|
contentHeight: p ? p.resultList.contentHeight : 0, listHeight: p ? p.resultList.height : 0,
|
||||||
|
bounds: p ? {x:p.cardX,y:p.cardY,w:p.cardWidth,h:p.cardHeight} : null});
|
||||||
|
}
|
||||||
|
function clickIcon(): bool { tester.mouseClick(icon, 13, 13); return true; }
|
||||||
|
function clickItem(index: int): bool {
|
||||||
|
const item = loader.item.resultList.itemAtIndex(index);
|
||||||
|
if (!item) return false;
|
||||||
|
tester.mouseClick(item, 30, item.height / 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function clickClear(): bool {
|
||||||
|
const item = loader.item.clearActionItem;
|
||||||
|
tester.mouseClick(item, item.width / 2, item.height / 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function clickConfirm(): bool {
|
||||||
|
const item = loader.item.confirmActionItem;
|
||||||
|
tester.mouseClick(item, item.width / 2, item.height / 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function clickCancel(): bool {
|
||||||
|
const item = loader.item.cancelActionItem;
|
||||||
|
tester.mouseClick(item, item.width / 2, item.height / 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function clickRemove(): bool {
|
||||||
|
const item = loader.item.removeActionItem;
|
||||||
|
tester.mouseClick(item, item.width / 2, item.height / 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function wheel(): bool {
|
||||||
|
const item = loader.item.resultList;
|
||||||
|
tester.mouseWheel(item, item.width / 2, item.height / 2, 0, -120);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function clickOutside(): bool {
|
||||||
|
tester.mouseClick(loader.item.interactionRoot, 2, 2);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function refresh(): bool { loader.item.refresh(); return true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''.replace("file:/home/fy59/.dotfiles", REPO.as_uri()))
|
||||||
|
environment = dict(os.environ, LABFY_CLIPHIST_TEST_DB=str(db),
|
||||||
|
LABFY_CLIPHIST_TEST_CACHE_DIR=str(root),
|
||||||
|
LABFY_CLIPHIST_TEST_WL_COPY=str(fake), TEST_OUT=str(output),
|
||||||
|
TEST_MIME=str(mime))
|
||||||
|
# CONTRACT: le chemin absolu injecté dans QML doit viser le collecteur factice,
|
||||||
|
# car un vrai wl-copy dans la session écraserait les données personnelles.
|
||||||
|
assert Path(environment["LABFY_CLIPHIST_TEST_WL_COPY"]).resolve() == fake.resolve()
|
||||||
|
assert fake.is_file() and os.access(fake, os.X_OK)
|
||||||
|
process = subprocess.Popen(["quickshell", "-p", str(qml)], env=environment,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||||
|
|
||||||
|
def call(name, *args):
|
||||||
|
result = subprocess.run(["quickshell", "ipc", "-p", str(qml), "call",
|
||||||
|
"clipboardMouseFixture", name, *map(str, args)],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if result.returncode:
|
||||||
|
raise AssertionError((name, result.stdout[:200], result.stderr[:200]))
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
def wait_for(predicate, seconds=3):
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
value = call("state")
|
||||||
|
if predicate(value):
|
||||||
|
return value
|
||||||
|
time.sleep(0.04)
|
||||||
|
raise AssertionError(("state timeout", call("state")))
|
||||||
|
|
||||||
|
try:
|
||||||
|
time.sleep(0.3)
|
||||||
|
assert call("clickIcon")
|
||||||
|
opened = wait_for(lambda state: state["loaded"] and state["focused"] and state["results"] == 14)
|
||||||
|
assert opened["bounds"]["w"] == 540 and opened["bounds"]["y"] == 46
|
||||||
|
if os.environ.get("CLIPBOARD_UI_CAPTURE"):
|
||||||
|
box = opened["bounds"]
|
||||||
|
# Le recadrage intérieur ne montre que la carte alimentée par les données factices.
|
||||||
|
geometry = f"{box['x'] + 2},{box['y'] + 2} {box['w'] - 4}x{box['h'] - 4}"
|
||||||
|
subprocess.run(["grim", "-g", geometry, os.environ["CLIPBOARD_UI_CAPTURE"]], check=True)
|
||||||
|
assert call("clickIcon")
|
||||||
|
wait_for(lambda state: not state["loaded"])
|
||||||
|
assert call("clickIcon")
|
||||||
|
wait_for(lambda state: state["results"] == 14)
|
||||||
|
assert call("wheel")
|
||||||
|
scrolled = wait_for(lambda state: state["listY"] > 0)
|
||||||
|
assert scrolled["contentHeight"] > scrolled["listHeight"]
|
||||||
|
time.sleep(0.4)
|
||||||
|
assert call("clickItem", 5)
|
||||||
|
wait_for(lambda state: not state["loaded"])
|
||||||
|
assert output.read_bytes() == b"fixture-08"
|
||||||
|
assert call("clickIcon")
|
||||||
|
wait_for(lambda state: state["results"] == 14)
|
||||||
|
assert call("clickRemove")
|
||||||
|
wait_for(lambda state: state["results"] == 13)
|
||||||
|
current = subprocess.run(["cliphist", "-db-path", str(db), "list"],
|
||||||
|
capture_output=True, check=True).stdout.splitlines()
|
||||||
|
assert len(current) == 13
|
||||||
|
assert call("clickClear")
|
||||||
|
assert call("state")["confirming"]
|
||||||
|
assert call("clickCancel")
|
||||||
|
assert not call("state")["confirming"]
|
||||||
|
assert len(subprocess.run(["cliphist", "-db-path", str(db), "list"],
|
||||||
|
capture_output=True, check=True).stdout.splitlines()) == 13
|
||||||
|
assert call("clickClear")
|
||||||
|
subprocess.run(["wtype", "-k", "Escape"], check=True)
|
||||||
|
wait_for(lambda state: not state["confirming"] and state["visible"])
|
||||||
|
assert call("clickClear")
|
||||||
|
assert call("clickIcon")
|
||||||
|
wait_for(lambda state: not state["loaded"])
|
||||||
|
assert call("clickIcon")
|
||||||
|
wait_for(lambda state: state["results"] == 13)
|
||||||
|
assert call("clickClear")
|
||||||
|
assert call("clickConfirm")
|
||||||
|
wait_for(lambda state: state["results"] == 0)
|
||||||
|
print("icon_click_toggle=true geometry=true focus=true mouse_selection=true click_restore=true"
|
||||||
|
" scroll=true delete=true purge_confirm=true purge_cancel=true escape_cancel=true"
|
||||||
|
" purge_forced_close=true empty=true")
|
||||||
|
subprocess.run(["cliphist", "-db-path", str(db), "store"], input=png, check=True)
|
||||||
|
assert call("refresh")
|
||||||
|
state = wait_for(lambda state: state["results"] == 1 and state["images"] == 1
|
||||||
|
and state["thumbnail"].startswith("file:"))
|
||||||
|
assert state["thumbnail"]
|
||||||
|
assert call("clickItem", 0)
|
||||||
|
wait_for(lambda state: not state["loaded"])
|
||||||
|
assert output.read_bytes() == png and mime.read_text() == "image/png"
|
||||||
|
print("image_mouse_restore=true image_mime=true")
|
||||||
|
assert call("clickIcon")
|
||||||
|
wait_for(lambda state: state["results"] == 1)
|
||||||
|
subprocess.run(["ydotool", "key", "28:1", "28:0"], check=True)
|
||||||
|
wait_for(lambda state: not state["loaded"])
|
||||||
|
assert output.read_bytes() == png and mime.read_text() == "image/png"
|
||||||
|
print("image_visible=true thumbnail=true image_keyboard_restore=true")
|
||||||
|
assert call("clickIcon")
|
||||||
|
wait_for(lambda state: state["results"] == 1)
|
||||||
|
assert call("clickOutside")
|
||||||
|
wait_for(lambda state: not state["loaded"])
|
||||||
|
assert not list(root.glob("labfy-cliphist-panel-*/*.png"))
|
||||||
|
print("outside_click_close=true")
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
log = process.communicate(timeout=4)[0]
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
log = process.communicate()[0]
|
||||||
|
errors = [line for line in log.splitlines() if "ERROR" in line]
|
||||||
|
if errors:
|
||||||
|
raise AssertionError("QuickShell reported errors: " + str(errors[-2:]))
|
||||||
245
tests/overview/test_backend.py
Normal file
245
tests/overview/test_backend.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND = Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell/labfy-sway/overview/backend.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("overview_backend", BACKEND)
|
||||||
|
backend = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(backend)
|
||||||
|
|
||||||
|
|
||||||
|
class OverviewBackendTests(unittest.TestCase):
|
||||||
|
def test_state_keeps_floating_and_excludes_layer_shell(self):
|
||||||
|
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
|
||||||
|
tiled = {"type": "con", "id": 10, "app_id": "native-app", "name": "Native",
|
||||||
|
"foreign_toplevel_identifier": "a" * 32,
|
||||||
|
"rect": rect, "nodes": [], "floating_nodes": []}
|
||||||
|
floating = {"type": "floating_con", "id": 11, "app_id": "xwayland-app",
|
||||||
|
"window_properties": {"class": "xwayland-app"}, "name": "Floating",
|
||||||
|
"rect": rect, "floating": "user_on", "nodes": [], "floating_nodes": []}
|
||||||
|
workspace = {"type": "workspace", "id": 4, "name": "1: travail", "rect": rect,
|
||||||
|
"layout": "splith", "nodes": [tiled], "floating_nodes": [floating]}
|
||||||
|
output = {"type": "output", "id": 3, "name": "eDP-1", "nodes": [workspace],
|
||||||
|
"floating_nodes": []}
|
||||||
|
tree = {"type": "root", "nodes": [output], "floating_nodes": []}
|
||||||
|
ws = [{"id": 4, "name": "1: travail", "num": 1, "output": "eDP-1",
|
||||||
|
"visible": True, "focused": True}]
|
||||||
|
outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect,
|
||||||
|
"layer_shell_surfaces": [{"namespace": "labfy-sway-bar"}]}]
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}):
|
||||||
|
with patch.object(backend, "sway", side_effect=lambda kind: {
|
||||||
|
"get_tree": tree, "get_workspaces": ws, "get_outputs": outputs}[kind]):
|
||||||
|
result = backend.state()
|
||||||
|
self.assertEqual(len(result["cards"]), 10)
|
||||||
|
self.assertEqual([window["id"] for window in result["cards"][0]["windows"]], [10, 11])
|
||||||
|
self.assertTrue(result["cards"][0]["windows"][1]["floating"])
|
||||||
|
self.assertEqual(result["cards"][0]["windows"][0]["foreignId"], "a" * 32)
|
||||||
|
self.assertFalse(result["cards"][1]["exists"])
|
||||||
|
|
||||||
|
def test_move_uses_only_valid_con_id_and_workspace(self):
|
||||||
|
cards = [{"number": 1, "revision": "before", "windows": [{"id": 42}]},
|
||||||
|
{"number": 4, "revision": "destination", "windows": []}]
|
||||||
|
moved = [{"number": 1, "revision": "after", "windows": []},
|
||||||
|
{"number": 4, "revision": "after-destination", "windows": [{"id": 42}]}]
|
||||||
|
fake = subprocess.CompletedProcess([], 0, '[{"success":true}]', "")
|
||||||
|
with patch.object(backend, "state", side_effect=[{"cards": cards}, {"cards": moved},
|
||||||
|
{"cards": cards}, {"cards": cards}]), \
|
||||||
|
patch.object(backend.subprocess, "run", return_value=fake) as run:
|
||||||
|
result = backend.command("move", 4, 42)
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual((result["source"], result["destination"], result["changedWorkspaces"]),
|
||||||
|
(1, 4, [1, 4]))
|
||||||
|
self.assertEqual(run.call_args.args[0], ["swaymsg", "-r", "[con_id=42]",
|
||||||
|
"move", "container", "to", "workspace", "number", "4"])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
backend.command("move", 11, 42)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
backend.command("move", 4, 43)
|
||||||
|
self.assertEqual(run.call_count, 1)
|
||||||
|
|
||||||
|
def test_revision_changes_for_layout_geometry_and_move_but_not_title_or_focus(self):
|
||||||
|
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
|
||||||
|
window = {"type": "con", "id": 42, "app_id": "test", "name": "Original",
|
||||||
|
"focused": False, "rect": dict(rect), "nodes": [], "floating_nodes": []}
|
||||||
|
workspace = {"type": "workspace", "id": 4, "num": 1, "output": "eDP-1",
|
||||||
|
"layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []}
|
||||||
|
first = backend.structure_revision(workspace, workspace, rect)
|
||||||
|
window["name"] = "Nouveau titre"
|
||||||
|
window["focused"] = True
|
||||||
|
self.assertEqual(first, backend.structure_revision(workspace, workspace, rect))
|
||||||
|
workspace["layout"] = "tabbed"
|
||||||
|
self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect))
|
||||||
|
workspace["layout"] = "splith"
|
||||||
|
window["rect"] = {**rect, "width": 500}
|
||||||
|
self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect))
|
||||||
|
window["rect"] = rect
|
||||||
|
workspace["nodes"] = []
|
||||||
|
self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect))
|
||||||
|
|
||||||
|
def test_changed_workspace_rejects_old_snapshot_and_empty_workspace_removes_it(self):
|
||||||
|
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
|
||||||
|
window = {"type": "con", "id": 42, "app_id": "test", "name": "Test",
|
||||||
|
"rect": dict(rect), "nodes": [], "floating_nodes": []}
|
||||||
|
workspace = {"type": "workspace", "id": 4, "num": 1, "output": "eDP-1",
|
||||||
|
"layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []}
|
||||||
|
tree = {"type": "root", "nodes": [{"type": "output", "nodes": [workspace]}]}
|
||||||
|
workspaces = [{"id": 4, "num": 1, "output": "eDP-1", "visible": True, "focused": True}]
|
||||||
|
outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect}]
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
|
||||||
|
patch.object(backend, "sway", side_effect=lambda kind: {
|
||||||
|
"get_tree": tree, "get_workspaces": workspaces, "get_outputs": outputs}[kind]):
|
||||||
|
path = backend.snapshot_path("eDP-1", 1)
|
||||||
|
path.write_bytes(b"JPEG")
|
||||||
|
original = backend.structure_revision(workspace, workspaces[0], rect)
|
||||||
|
backend.snapshot_metadata_path("eDP-1", 1).write_text(json.dumps({
|
||||||
|
"version": 1, "output": "eDP-1", "number": 1, "workspaceId": 4,
|
||||||
|
"revision": original, "capturedNs": 123, "imageMtimeNs": path.stat().st_mtime_ns}))
|
||||||
|
self.assertNotEqual(backend.state()["cards"][0]["snapshot"], "")
|
||||||
|
window["name"] = "Titre changé"
|
||||||
|
self.assertNotEqual(backend.state()["cards"][0]["snapshot"], "")
|
||||||
|
window["rect"] = {**rect, "width": 500}
|
||||||
|
changed = backend.state()["cards"][0]
|
||||||
|
self.assertEqual(changed["snapshot"], "")
|
||||||
|
self.assertEqual([item["id"] for item in changed["windows"]], [42])
|
||||||
|
workspace["nodes"] = []
|
||||||
|
empty = backend.state()["cards"][0]
|
||||||
|
self.assertEqual(empty["snapshot"], "")
|
||||||
|
self.assertEqual(empty["windows"], [])
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
self.assertFalse(backend.snapshot_metadata_path("eDP-1", 1).exists())
|
||||||
|
|
||||||
|
def test_capture_rejects_overlay_and_mid_capture_change(self):
|
||||||
|
card = {"number": 1, "workspaceId": 4, "revision": "first", "visible": True,
|
||||||
|
"output": "eDP-1", "windows": [{"id": 42}]}
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}):
|
||||||
|
with patch.object(backend, "state", return_value={
|
||||||
|
"cards": [card], "overlayOutputs": ["eDP-1"]}):
|
||||||
|
self.assertFalse(backend.capture("eDP-1")["captured"])
|
||||||
|
|
||||||
|
def fake_grim(args, **_kwargs):
|
||||||
|
Path(args[-1]).write_bytes(b"JPEG")
|
||||||
|
return subprocess.CompletedProcess(args, 0, "", "")
|
||||||
|
|
||||||
|
changed = {**card, "revision": "second"}
|
||||||
|
with patch.object(backend, "state", side_effect=[
|
||||||
|
{"cards": [card], "overlayOutputs": []},
|
||||||
|
{"cards": [changed], "overlayOutputs": []}]), \
|
||||||
|
patch.object(backend.subprocess, "run", side_effect=fake_grim):
|
||||||
|
self.assertFalse(backend.capture("eDP-1")["captured"])
|
||||||
|
self.assertFalse(backend.snapshot_path("eDP-1", 1).exists())
|
||||||
|
|
||||||
|
def test_capture_publishes_metadata_only_for_stable_visible_workspace(self):
|
||||||
|
card = {"number": 7, "workspaceId": 164, "revision": "stable", "visible": True,
|
||||||
|
"output": "eDP-1", "windows": [{"id": 42}]}
|
||||||
|
|
||||||
|
def fake_grim(args, **_kwargs):
|
||||||
|
Path(args[-1]).write_bytes(b"JPEG")
|
||||||
|
return subprocess.CompletedProcess(args, 0, "", "")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
|
||||||
|
patch.object(backend, "state", return_value={"cards": [card], "overlayOutputs": []}), \
|
||||||
|
patch.object(backend.subprocess, "run", side_effect=fake_grim):
|
||||||
|
result = backend.capture("eDP-1", expected_number=7, expected_workspace_id=164)
|
||||||
|
self.assertTrue(result["captured"])
|
||||||
|
self.assertIsNotNone(backend.valid_snapshot("eDP-1", 7, 164, "stable"))
|
||||||
|
self.assertIsNone(backend.valid_snapshot("eDP-1", 7, 164, "changed"))
|
||||||
|
self.assertIsNone(backend.valid_snapshot("eDP-1", 7, 999, "stable"))
|
||||||
|
self.assertFalse(backend.capture("eDP-1", expected_number=8)["captured"])
|
||||||
|
self.assertFalse(backend.capture("eDP-1", expected_revision="changed")["captured"])
|
||||||
|
|
||||||
|
def test_removed_workspace_cleans_only_its_cache_entries(self):
|
||||||
|
tree = {"type": "root", "nodes": [], "floating_nodes": []}
|
||||||
|
outputs = [{"name": "eDP-1", "active": True, "focused": True}]
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
|
||||||
|
patch.object(backend, "sway", side_effect=lambda kind: {
|
||||||
|
"get_tree": tree, "get_workspaces": [], "get_outputs": outputs}[kind]):
|
||||||
|
obsolete = backend.snapshot_path("eDP-1", 8)
|
||||||
|
retained = backend.cache_dir() / "unrelated.txt"
|
||||||
|
obsolete.write_bytes(b"old")
|
||||||
|
retained.write_bytes(b"other")
|
||||||
|
backend.state()
|
||||||
|
self.assertFalse(obsolete.exists())
|
||||||
|
self.assertTrue(retained.exists())
|
||||||
|
|
||||||
|
def test_window_preview_survives_workspace_move_but_not_window_close(self):
|
||||||
|
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
|
||||||
|
window = {"type": "con", "id": 42, "app_id": "test", "name": "Test",
|
||||||
|
"foreign_toplevel_identifier": "a" * 32,
|
||||||
|
"rect": rect, "nodes": [], "floating_nodes": []}
|
||||||
|
source = {"type": "workspace", "id": 4, "num": 7, "output": "eDP-1",
|
||||||
|
"layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []}
|
||||||
|
destination = {"type": "workspace", "id": 5, "num": 8, "output": "eDP-1",
|
||||||
|
"layout": "splith", "rect": rect, "nodes": [], "floating_nodes": []}
|
||||||
|
tree = {"type": "root", "nodes": [source, destination]}
|
||||||
|
workspaces = [{"id": 4, "num": 7, "output": "eDP-1", "visible": True, "focused": True},
|
||||||
|
{"id": 5, "num": 8, "output": "eDP-1", "visible": False, "focused": False}]
|
||||||
|
outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect}]
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
|
||||||
|
patch.object(backend, "sway", side_effect=lambda kind: {
|
||||||
|
"get_tree": tree, "get_workspaces": workspaces, "get_outputs": outputs}[kind]):
|
||||||
|
path = backend.window_preview_path(42)
|
||||||
|
path.write_bytes(b"JPEG")
|
||||||
|
backend.window_preview_metadata_path(42).write_text(json.dumps({
|
||||||
|
"version": 1, "conId": 42, "foreignId": "a" * 32,
|
||||||
|
"capturedNs": 123, "imageMtimeNs": path.stat().st_mtime_ns}))
|
||||||
|
before = backend.state()["cards"]
|
||||||
|
self.assertNotEqual(before[6]["windows"][0]["preview"], "")
|
||||||
|
source["nodes"] = []
|
||||||
|
destination["nodes"] = [window]
|
||||||
|
moved = backend.state()["cards"]
|
||||||
|
self.assertEqual(moved[6]["windows"], [])
|
||||||
|
self.assertEqual(moved[7]["windows"][0]["preview"], path.as_uri())
|
||||||
|
destination["nodes"] = []
|
||||||
|
backend.state()
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
def test_individual_capture_uses_foreign_id_and_checks_window_lifetime(self):
|
||||||
|
foreign_id = "b" * 32
|
||||||
|
window = {"id": 42, "foreignId": foreign_id, "preview": ""}
|
||||||
|
before = {"cards": [{"number": 7, "windows": [window]}]}
|
||||||
|
closed = {"cards": [{"number": 7, "windows": []}]}
|
||||||
|
|
||||||
|
def fake_grim(args, **_kwargs):
|
||||||
|
Path(args[-1]).write_bytes(b"JPEG")
|
||||||
|
return subprocess.CompletedProcess(args, 0, b"", b"")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
|
||||||
|
patch.object(backend.subprocess, "run", side_effect=fake_grim) as run:
|
||||||
|
with patch.object(backend, "state", side_effect=[before, before]):
|
||||||
|
result = backend.capture_windows({7})
|
||||||
|
self.assertEqual(result["captured"], [42])
|
||||||
|
self.assertEqual(run.call_args.args[0][0:3], ["grim", "-T", foreign_id])
|
||||||
|
self.assertIsNotNone(backend.valid_window_preview(42, foreign_id))
|
||||||
|
self.assertIsNone(backend.valid_window_preview(42, "c" * 32))
|
||||||
|
with patch.object(backend, "state", side_effect=[before, closed]):
|
||||||
|
result = backend.capture_windows({7})
|
||||||
|
self.assertEqual(result["captured"], [])
|
||||||
|
|
||||||
|
def test_individual_captures_are_batched(self):
|
||||||
|
windows = [{"id": ident, "foreignId": f"{ident:032x}", "preview": ""}
|
||||||
|
for ident in range(1, 18)]
|
||||||
|
cards = {"cards": [{"number": 7, "windows": windows}]}
|
||||||
|
|
||||||
|
def fake_grim(args, **_kwargs):
|
||||||
|
Path(args[-1]).write_bytes(b"JPEG")
|
||||||
|
return subprocess.CompletedProcess(args, 0, b"", b"")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
|
||||||
|
patch.object(backend, "state", return_value=cards), \
|
||||||
|
patch.object(backend.subprocess, "run", side_effect=fake_grim) as run:
|
||||||
|
first = backend.capture_windows({7})
|
||||||
|
self.assertEqual(first["captured"], list(range(1, 17)))
|
||||||
|
self.assertEqual(first["nextCursor"], 16)
|
||||||
|
second = backend.capture_windows({7}, cursor=first["nextCursor"])
|
||||||
|
self.assertEqual(second["captured"], [17])
|
||||||
|
self.assertEqual(second["nextCursor"], 0)
|
||||||
|
self.assertEqual(run.call_count, 17)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
18
tests/quickshell/test_percent_math.js
Normal file
18
tests/quickshell/test_percent_math.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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/controlcenter/PercentMath.js"), "utf8");
|
||||||
|
const context = {};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
const bounded = context.bounded;
|
||||||
|
assert.equal(bounded(-1, 0), 0);
|
||||||
|
assert.equal(bounded(35 + 1, 0), 36);
|
||||||
|
assert.equal(bounded(101, 0), 100);
|
||||||
|
assert.equal(bounded(9, 10), 10);
|
||||||
|
assert.equal(bounded(93 + 1, 10), 94);
|
||||||
|
assert.equal(bounded(101, 10), 100);
|
||||||
|
console.log("PercentMath: PASS");
|
||||||
14
tests/quickshell/test_percent_presentation.js
Normal file
14
tests/quickshell/test_percent_presentation.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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/status/PercentPresentation.js"), "utf8");
|
||||||
|
const context = {};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
for (const value of [9, 10, 99, 100])
|
||||||
|
assert.equal(context.label(true, value), `${value} %`);
|
||||||
|
assert.equal(context.label(false, 0), "—");
|
||||||
|
console.log("PercentPresentation: PASS");
|
||||||
50
tests/quickshell/test_scroll_steps.js
Normal file
50
tests/quickshell/test_scroll_steps.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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/status/ScrollSteps.js"), "utf8");
|
||||||
|
const context = {};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
const advance = context.advance;
|
||||||
|
const empty = () => ({ remainder: 0, source: "" });
|
||||||
|
|
||||||
|
let state = empty();
|
||||||
|
for (const part of [3, 4, 5, 7, 8]) state = advance(state, part, 0, false, 2, false);
|
||||||
|
assert.equal(state.steps, 0);
|
||||||
|
state = advance(state, 5, 120, false, 2, false);
|
||||||
|
assert.equal(state.steps, 1); // pixelDelta a priorité, sans compter angleDelta.
|
||||||
|
assert.equal(state.remainder, 0);
|
||||||
|
|
||||||
|
state = advance(empty(), 0, 120, false, 0, false);
|
||||||
|
assert.equal(state.steps, 1); // Un cran de souris = un point.
|
||||||
|
state = advance(empty(), 0, -240, false, 0, false);
|
||||||
|
assert.equal(state.steps, -2);
|
||||||
|
state = advance(empty(), -32, 0, false, 2, true);
|
||||||
|
assert.equal(state.steps, 1); // Deltas ASUS normalisés : geste haut = hausse.
|
||||||
|
state = advance(empty(), 32, 0, false, 2, true);
|
||||||
|
assert.equal(state.steps, -1); // Geste bas = baisse.
|
||||||
|
state = advance(empty(), 32, 0, true, 2, true);
|
||||||
|
assert.equal(state.steps, 1); // inverted est traité une seule fois.
|
||||||
|
state = advance(empty(), 0, -120, false, 0, true);
|
||||||
|
assert.equal(state.steps, 1); // Touchpad à delta angulaire seul.
|
||||||
|
|
||||||
|
state = advance({ remainder: 20, source: "pixel" }, -16, 0, false, 2, false);
|
||||||
|
assert.equal(state.steps, 0);
|
||||||
|
assert.equal(state.remainder, -16); // Direction inversée : ancien reliquat abandonné.
|
||||||
|
state = advance({ remainder: 20, source: "pixel" }, 0, 120, false, 0, false);
|
||||||
|
assert.equal(state.steps, 1); // Changement de source : reliquat incomparable abandonné.
|
||||||
|
state = advance({ remainder: 20, source: "pixel" }, 0, 0, false, 3, false);
|
||||||
|
assert.equal(state.remainder, 0); // Fin de geste.
|
||||||
|
|
||||||
|
state = empty();
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < 320; i++) {
|
||||||
|
state = advance(state, 1, 0, false, 2, false);
|
||||||
|
total += state.steps;
|
||||||
|
}
|
||||||
|
assert.equal(total, 10);
|
||||||
|
assert.equal(state.remainder, 0); // Dix points réguliers, sans accélération.
|
||||||
|
console.log("ScrollSteps: PASS");
|
||||||
39
tests/quickshell/test_status_color_roles.js
Normal file
39
tests/quickshell/test_status_color_roles.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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/status/StatusColorRoles.js"), "utf8");
|
||||||
|
const roles = {};
|
||||||
|
vm.createContext(roles);
|
||||||
|
vm.runInContext(source, roles);
|
||||||
|
|
||||||
|
assert.equal(roles.wifi(true, true, true), "sky");
|
||||||
|
assert.equal(roles.wifi(true, true, false), "secondaryForeground");
|
||||||
|
assert.equal(roles.wifi(true, false, true), "secondaryForeground");
|
||||||
|
assert.equal(roles.wifi(false, true, true), "disabledForeground");
|
||||||
|
assert.equal(roles.bluetooth(true), "blue");
|
||||||
|
assert.equal(roles.bluetooth(false), "disabledForeground");
|
||||||
|
|
||||||
|
assert.equal(roles.battery(false, 60), "disabledForeground");
|
||||||
|
assert.equal(roles.battery(true, 15), "danger");
|
||||||
|
assert.equal(roles.battery(true, 16), "urgentForeground");
|
||||||
|
assert.equal(roles.battery(true, 30), "urgentForeground");
|
||||||
|
assert.equal(roles.battery(true, 31), "green");
|
||||||
|
assert.equal(roles.batteryValue(true, 15), "danger");
|
||||||
|
assert.equal(roles.batteryValue(true, 30), "urgentForeground");
|
||||||
|
assert.equal(roles.batteryValue(true, 31), "foreground");
|
||||||
|
assert.equal(roles.batteryValue(false, 0), "disabledForeground");
|
||||||
|
|
||||||
|
assert.equal(roles.volume(true, false), "mauve");
|
||||||
|
assert.equal(roles.volume(true, true), "disabledForeground");
|
||||||
|
assert.equal(roles.volume(false, false), "disabledForeground");
|
||||||
|
assert.equal(roles.updates(false, false), "peach");
|
||||||
|
assert.equal(roles.updates(true, false), "danger");
|
||||||
|
assert.equal(roles.updates(true, true), "secondaryForeground");
|
||||||
|
assert.equal(roles.removable(false, false), "teal");
|
||||||
|
assert.equal(roles.removable(false, true), "warningForeground");
|
||||||
|
assert.equal(roles.removable(true, true), "danger");
|
||||||
|
|
||||||
|
console.log("StatusColorRoles: PASS");
|
||||||
22
tools/build-slurp-keymap-guard
Executable file
22
tools/build-slurp-keymap-guard
Executable file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# CONTRACT: build the pinned upstream release with the narrow keymap guard;
|
||||||
|
# install only in the user's libexec, never over the system package.
|
||||||
|
repo="$(cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
source_dir="${XDG_CACHE_HOME:-$HOME/.cache}/labfy-slurp-v1.5.0"
|
||||||
|
binary="$HOME/.local/libexec/labfy-slurp"
|
||||||
|
if [[ ! -d "$source_dir/.git" ]]; then
|
||||||
|
git clone --depth 1 --branch v1.5.0 https://github.com/emersion/slurp.git "$source_dir"
|
||||||
|
fi
|
||||||
|
[[ "$(git -C "$source_dir" rev-parse HEAD)" == fc921b603ee02afff42aba9eb073e82fab900048 ]] || exit 1
|
||||||
|
if ! git -C "$source_dir" diff --quiet -- main.c; then
|
||||||
|
git -C "$source_dir" apply --reverse --check "$repo/tools/slurp-keymap-guard.patch"
|
||||||
|
else
|
||||||
|
patch -d "$source_dir" -p1 < "$repo/tools/slurp-keymap-guard.patch"
|
||||||
|
fi
|
||||||
|
meson setup "$source_dir/build" "$source_dir" -Dman-pages=disabled --reconfigure 2>/dev/null || \
|
||||||
|
meson setup "$source_dir/build" "$source_dir" -Dman-pages=disabled
|
||||||
|
ninja -C "$source_dir/build"
|
||||||
|
install -Dm755 "$source_dir/build/slurp" "$binary"
|
||||||
|
printf '%s\n' "$binary"
|
||||||
64
tools/quickshell-qt-compat-v1/PKGBUILD
Normal file
64
tools/quickshell-qt-compat-v1/PKGBUILD
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Maintainer: Caleb Maclennan <caleb@alerque.com>
|
||||||
|
|
||||||
|
pkgname=quickshell
|
||||||
|
pkgver=0.3.1
|
||||||
|
pkgrel=1.1
|
||||||
|
pkgdesc='Flexible toolkit for making desktop shells with QtQuick'
|
||||||
|
arch=(x86_64 aarch64)
|
||||||
|
url="https://git.outfoxxed.me/$pkgname/$pkgname"
|
||||||
|
license=(LGPL-3.0-only)
|
||||||
|
depends=(cpptrace libcpptrace.so
|
||||||
|
glibc # libc.so libm.so
|
||||||
|
hicolor-icon-theme
|
||||||
|
jemalloc libjemalloc.so
|
||||||
|
libdrm # libdrm.so
|
||||||
|
libgcc libgcc_s.so
|
||||||
|
libglvnd libEGL.so libOpenGL.so
|
||||||
|
libpipewire libpipewire-0.3.so
|
||||||
|
libstdc++ libstdc++.so
|
||||||
|
libxcb # libxcb.so
|
||||||
|
mesa # libgbm.so
|
||||||
|
pam libpam.so
|
||||||
|
polkit
|
||||||
|
qt6-base # libQt6Core.so libQt6DBus.so libQt6Gui.so libQt6Network.so libQt6Widgets.so
|
||||||
|
qt6-declarative # libQt6Qml.so libQt6Quick.so
|
||||||
|
qt6-svg
|
||||||
|
qt6-wayland # libQt6WaylandClient.so
|
||||||
|
wayland libwayland-client.so)
|
||||||
|
makedepends=(cli11
|
||||||
|
cmake
|
||||||
|
ninja
|
||||||
|
qt6-shadertools
|
||||||
|
spirv-tools
|
||||||
|
vulkan-headers
|
||||||
|
wayland-protocols)
|
||||||
|
_archive="$pkgname-$pkgver"
|
||||||
|
source=("$_archive.tar.gz::$url/archive/v$pkgver.tar.gz"
|
||||||
|
qt612-metatypes-v031.patch)
|
||||||
|
sha256sums=('d60592622f1aa1cbb853d4814f605dfde827bc692befbfecffaccf4c90e352d8'
|
||||||
|
'e254e199a8f6b1147fa4cba5786ea08d5473279c187feeedb52f02908a3a397f')
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "$pkgname"
|
||||||
|
patch --batch -Np1 -i "$srcdir/qt612-metatypes-v031.patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
local cmake_options=(
|
||||||
|
-D CMAKE_BUILD_TYPE=RelWithDebInfo
|
||||||
|
-D DISTRIBUTOR='Arch Linux'
|
||||||
|
-D DISTRIBUTOR_DEBUGINFO_AVAILABLE=Yes
|
||||||
|
-D CRASH_REPORTER=Off
|
||||||
|
-D CMAKE_INSTALL_PREFIX=/usr
|
||||||
|
-D INSTALL_QML_PREFIX=lib/qt6/qml
|
||||||
|
)
|
||||||
|
cmake -G Ninja -B build -W no-dev "${cmake_options[@]}"
|
||||||
|
cmake --build build
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
DESTDIR="$pkgdir" cmake --install build
|
||||||
|
install -Dm0644 -t "$pkgdir/usr/share/licenses/$pkgname/" LICENSE
|
||||||
|
}
|
||||||
65
tools/quickshell-qt-compat-v1/README.md
Normal file
65
tools/quickshell-qt-compat-v1/README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# QuickShell 0.3.1 et Qt 6.12
|
||||||
|
|
||||||
|
Ce dossier conserve les **sources de construction**, jamais le binaire compilé.
|
||||||
|
Il dérive du `PKGBUILD` officiel Arch `extra/quickshell 0.3.1-1` : `pkgrel=1.1`
|
||||||
|
identifie la reconstruction locale, et `prepare()` applique le correctif amont
|
||||||
|
fusionné dans [Quickshell PR 1002](https://github.com/quickshell-mirror/quickshell/pull/1002).
|
||||||
|
Le fichier `changelog/next.md` de cette PR n'existe pas dans l'archive `v0.3.1` ;
|
||||||
|
le patch conservé ici ne contient que ses changements de code applicables.
|
||||||
|
|
||||||
|
## Pourquoi
|
||||||
|
|
||||||
|
Le paquet officiel du 21 août 2026 a été construit avec Qt 6.11.2. Après le
|
||||||
|
passage du système à Qt 6.12.0, QuickShell signale ce décalage au démarrage.
|
||||||
|
Une reconstruction sans correctif échoue aussi à la compilation `moc` sur des
|
||||||
|
métatypes incomplets dans les modules Hyprland et I3/Sway. Le correctif amont
|
||||||
|
résout ces erreurs sans désactiver de module.
|
||||||
|
|
||||||
|
## Dépendances et construction
|
||||||
|
|
||||||
|
En plus des dépendances d'exécution déclarées dans `PKGBUILD`, la construction
|
||||||
|
demande `base-devel`, `cli11`, `cmake`, `ninja`, `qt6-shadertools`,
|
||||||
|
`spirv-tools`, `vulkan-headers` et `wayland-protocols`. Installer les
|
||||||
|
dépendances de compilation par le gestionnaire de paquets selon la politique de
|
||||||
|
la machine ; `makepkg -s` peut les demander. Ne pas utiliser une version de Qt
|
||||||
|
différente de celle qui sera chargée à l'exécution.
|
||||||
|
|
||||||
|
Depuis ce dossier :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
makepkg --verifysource
|
||||||
|
makepkg --cleanbuild --syncdeps
|
||||||
|
sha256sum quickshell-0.3.1-1.1-x86_64.pkg.tar
|
||||||
|
pacman -Qip quickshell-0.3.1-1.1-x86_64.pkg.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
La construction du 8 octobre 2026 a utilisé les sources amont `v0.3.1`
|
||||||
|
(SHA-256 vérifié par `makepkg`) et le patch de ce dossier. `cli11 2.7.2-1`
|
||||||
|
était absent du système : son paquet Arch signé a été vérifié par `gpgv`, puis
|
||||||
|
extrait dans un préfixe de construction utilisateur. Dans ce cas particulier,
|
||||||
|
`makepkg --nodeps` a ignoré uniquement le contrôle de présence système de
|
||||||
|
`cli11` ; CMake a trouvé ses en-têtes dans ce préfixe. Ce contournement de
|
||||||
|
**construction** ne s'applique jamais à l'installation du paquet QuickShell.
|
||||||
|
|
||||||
|
Le paquet produit a été conservé hors dépôt sous
|
||||||
|
`~/.cache/quickshell-qt-compat-v1/`, avec le SHA-256
|
||||||
|
`68d75a1421d4d78ea1ab72f00194788298b275e58933b48dfa86aace596df2a5`.
|
||||||
|
Une reconstruction future peut produire un hash différent, même à partir des
|
||||||
|
mêmes sources ; vérifier toujours les métadonnées et les dépendances du nouvel
|
||||||
|
artefact. Installer uniquement par Pacman, sans copie manuelle de fichiers.
|
||||||
|
|
||||||
|
## Maintenance et retour arrière
|
||||||
|
|
||||||
|
Après une mise à jour de Qt, vérifier d'abord si Arch fournit un paquet
|
||||||
|
QuickShell plus récent construit pour cette version. Sinon, reconstruire avec
|
||||||
|
le Qt installé et le correctif encore nécessaire. Si Arch intègre le correctif
|
||||||
|
dans une nouvelle version, revenir au paquet officiel. La révision locale
|
||||||
|
`0.3.1-1.1` ne sera pas remplacée automatiquement par l'ancien `0.3.1-1` ;
|
||||||
|
surveiller les versions du dépôt.
|
||||||
|
|
||||||
|
Avant une installation, conserver le paquet officiel précédent dans le cache
|
||||||
|
Pacman et relever l'état du service. Un retour à `0.3.1-1` est possible par
|
||||||
|
Pacman, mais ce paquet reste compilé contre Qt 6.11.2 : sous Qt 6.12, il ne
|
||||||
|
constitue pas un retour à la compatibilité. En cas de régression, garder les
|
||||||
|
journaux, identifier la cause et ne réinstaller l'ancien paquet que si son
|
||||||
|
fonctionnement est jugé plus sûr que celui du nouveau.
|
||||||
287
tools/quickshell-qt-compat-v1/qt612-metatypes-v031.patch
Normal file
287
tools/quickshell-qt-compat-v1/qt612-metatypes-v031.patch
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
diff --git a/src/services/pipewire/peak.hpp b/src/services/pipewire/peak.hpp
|
||||||
|
index c4af3c2f7..346585273 100644
|
||||||
|
--- a/src/services/pipewire/peak.hpp
|
||||||
|
+++ b/src/services/pipewire/peak.hpp
|
||||||
|
@@ -8,18 +8,12 @@
|
||||||
|
#include <qvector.h>
|
||||||
|
|
||||||
|
#include "node.hpp"
|
||||||
|
+#include "qml.hpp"
|
||||||
|
|
||||||
|
namespace qs::service::pipewire {
|
||||||
|
|
||||||
|
-class PwNodeIface;
|
||||||
|
class PwPeakStream;
|
||||||
|
|
||||||
|
-} // namespace qs::service::pipewire
|
||||||
|
-
|
||||||
|
-Q_DECLARE_OPAQUE_POINTER(qs::service::pipewire::PwNodeIface*);
|
||||||
|
-
|
||||||
|
-namespace qs::service::pipewire {
|
||||||
|
-
|
||||||
|
///! Monitors peak levels of an audio node.
|
||||||
|
/// Tracks volume peaks for a node across all its channels.
|
||||||
|
///
|
||||||
|
diff --git a/src/wayland/hyprland/ipc/connection.cpp b/src/wayland/hyprland/ipc/connection.cpp
|
||||||
|
index 942270a75..45d25da37 100644
|
||||||
|
--- a/src/wayland/hyprland/ipc/connection.cpp
|
||||||
|
+++ b/src/wayland/hyprland/ipc/connection.cpp
|
||||||
|
@@ -37,6 +37,18 @@ QS_LOGGING_CATEGORY(logHyprlandIpc, "quickshell.hyprland.ipc", QtWarningMsg);
|
||||||
|
QS_LOGGING_CATEGORY(logHyprlandIpcEvents, "quickshell.hyprland.ipc.events", QtWarningMsg);
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
+QBindable<HyprlandMonitor*> HyprlandIpc::bindableFocusedMonitor() const {
|
||||||
|
+ return &this->bFocusedMonitor;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+QBindable<HyprlandWorkspace*> HyprlandIpc::bindableFocusedWorkspace() const {
|
||||||
|
+ return &this->bFocusedWorkspace;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+QBindable<HyprlandToplevel*> HyprlandIpc::bindableActiveToplevel() const {
|
||||||
|
+ return &this->bActiveToplevel;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
HyprlandIpc::HyprlandIpc() {
|
||||||
|
auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE");
|
||||||
|
if (his.isEmpty()) {
|
||||||
|
diff --git a/src/wayland/hyprland/ipc/connection.hpp b/src/wayland/hyprland/ipc/connection.hpp
|
||||||
|
index 8d79184e3..c4264e9a5 100644
|
||||||
|
--- a/src/wayland/hyprland/ipc/connection.hpp
|
||||||
|
+++ b/src/wayland/hyprland/ipc/connection.hpp
|
||||||
|
@@ -25,9 +25,9 @@ class HyprlandToplevel;
|
||||||
|
|
||||||
|
} // namespace qs::hyprland::ipc
|
||||||
|
|
||||||
|
-Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandWorkspace*);
|
||||||
|
-Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandMonitor*);
|
||||||
|
-Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandToplevel*);
|
||||||
|
+Q_MOC_INCLUDE("hyprland_toplevel.hpp")
|
||||||
|
+Q_MOC_INCLUDE("monitor.hpp")
|
||||||
|
+Q_MOC_INCLUDE("workspace.hpp")
|
||||||
|
|
||||||
|
namespace qs::hyprland::ipc {
|
||||||
|
|
||||||
|
@@ -83,17 +83,9 @@ class HyprlandIpc: public QObject {
|
||||||
|
|
||||||
|
[[nodiscard]] QBindable<bool> bindableUsingLua() const { return &this->bUsingLua; }
|
||||||
|
|
||||||
|
- [[nodiscard]] QBindable<HyprlandMonitor*> bindableFocusedMonitor() const {
|
||||||
|
- return &this->bFocusedMonitor;
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- [[nodiscard]] QBindable<HyprlandWorkspace*> bindableFocusedWorkspace() const {
|
||||||
|
- return &this->bFocusedWorkspace;
|
||||||
|
- }
|
||||||
|
-
|
||||||
|
- [[nodiscard]] QBindable<HyprlandToplevel*> bindableActiveToplevel() const {
|
||||||
|
- return &this->bActiveToplevel;
|
||||||
|
- }
|
||||||
|
+ [[nodiscard]] QBindable<HyprlandMonitor*> bindableFocusedMonitor() const;
|
||||||
|
+ [[nodiscard]] QBindable<HyprlandWorkspace*> bindableFocusedWorkspace() const;
|
||||||
|
+ [[nodiscard]] QBindable<HyprlandToplevel*> bindableActiveToplevel() const;
|
||||||
|
|
||||||
|
void setFocusedMonitor(HyprlandMonitor* monitor);
|
||||||
|
|
||||||
|
diff --git a/src/wayland/hyprland/ipc/hyprland_toplevel.cpp b/src/wayland/hyprland/ipc/hyprland_toplevel.cpp
|
||||||
|
index e02dcdc5f..1c25dda1e 100644
|
||||||
|
--- a/src/wayland/hyprland/ipc/hyprland_toplevel.cpp
|
||||||
|
+++ b/src/wayland/hyprland/ipc/hyprland_toplevel.cpp
|
||||||
|
@@ -8,6 +8,7 @@
|
||||||
|
|
||||||
|
#include "../../toplevel/qml.hpp"
|
||||||
|
#include "connection.hpp"
|
||||||
|
+#include "monitor.hpp"
|
||||||
|
#include "toplevel_mapping.hpp"
|
||||||
|
#include "workspace.hpp"
|
||||||
|
|
||||||
|
@@ -15,6 +16,10 @@ using namespace qs::wayland::toplevel;
|
||||||
|
|
||||||
|
namespace qs::hyprland::ipc {
|
||||||
|
|
||||||
|
+QBindable<HyprlandWorkspace*> HyprlandToplevel::bindableWorkspace() { return &this->bWorkspace; }
|
||||||
|
+
|
||||||
|
+QBindable<HyprlandMonitor*> HyprlandToplevel::bindableMonitor() { return &this->bMonitor; }
|
||||||
|
+
|
||||||
|
HyprlandToplevel::HyprlandToplevel(HyprlandIpc* ipc): QObject(ipc), ipc(ipc) {
|
||||||
|
this->bMonitor.setBinding([this]() {
|
||||||
|
return this->bWorkspace ? this->bWorkspace->bindableMonitor().value() : nullptr;
|
||||||
|
diff --git a/src/wayland/hyprland/ipc/hyprland_toplevel.hpp b/src/wayland/hyprland/ipc/hyprland_toplevel.hpp
|
||||||
|
index 8e6aadb35..fe1262647 100644
|
||||||
|
--- a/src/wayland/hyprland/ipc/hyprland_toplevel.hpp
|
||||||
|
+++ b/src/wayland/hyprland/ipc/hyprland_toplevel.hpp
|
||||||
|
@@ -81,10 +81,10 @@ class HyprlandToplevel: public QObject {
|
||||||
|
return &this->bLastIpcObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
- [[nodiscard]] QBindable<HyprlandWorkspace*> bindableWorkspace() { return &this->bWorkspace; }
|
||||||
|
+ [[nodiscard]] QBindable<HyprlandWorkspace*> bindableWorkspace();
|
||||||
|
void setWorkspace(HyprlandWorkspace* workspace);
|
||||||
|
|
||||||
|
- [[nodiscard]] QBindable<HyprlandMonitor*> bindableMonitor() { return &this->bMonitor; }
|
||||||
|
+ [[nodiscard]] QBindable<HyprlandMonitor*> bindableMonitor();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void addressChanged();
|
||||||
|
diff --git a/src/wayland/hyprland/ipc/monitor.cpp b/src/wayland/hyprland/ipc/monitor.cpp
|
||||||
|
index 7519a92e9..083dfccec 100644
|
||||||
|
--- a/src/wayland/hyprland/ipc/monitor.cpp
|
||||||
|
+++ b/src/wayland/hyprland/ipc/monitor.cpp
|
||||||
|
@@ -13,6 +13,10 @@ namespace qs::hyprland::ipc {
|
||||||
|
|
||||||
|
QVariantMap HyprlandMonitor::lastIpcObject() const { return this->mLastIpcObject; }
|
||||||
|
|
||||||
|
+QBindable<HyprlandWorkspace*> HyprlandMonitor::bindableActiveWorkspace() const {
|
||||||
|
+ return &this->bActiveWorkspace;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
void HyprlandMonitor::updateInitial(qint32 id, const QString& name, const QString& description) {
|
||||||
|
Qt::beginPropertyUpdateGroup();
|
||||||
|
this->bId = id;
|
||||||
|
diff --git a/src/wayland/hyprland/ipc/monitor.hpp b/src/wayland/hyprland/ipc/monitor.hpp
|
||||||
|
index b9ac43492..d368adf16 100644
|
||||||
|
--- a/src/wayland/hyprland/ipc/monitor.hpp
|
||||||
|
+++ b/src/wayland/hyprland/ipc/monitor.hpp
|
||||||
|
@@ -54,9 +54,7 @@ class HyprlandMonitor: public QObject {
|
||||||
|
[[nodiscard]] QBindable<qint32> bindableHeight() { return &this->bHeight; }
|
||||||
|
[[nodiscard]] QBindable<qreal> bindableScale() { return &this->bScale; }
|
||||||
|
|
||||||
|
- [[nodiscard]] QBindable<HyprlandWorkspace*> bindableActiveWorkspace() const {
|
||||||
|
- return &this->bActiveWorkspace;
|
||||||
|
- }
|
||||||
|
+ [[nodiscard]] QBindable<HyprlandWorkspace*> bindableActiveWorkspace() const;
|
||||||
|
|
||||||
|
[[nodiscard]] QBindable<bool> bindableFocused() const { return &this->bFocused; }
|
||||||
|
|
||||||
|
diff --git a/src/wayland/hyprland/ipc/workspace.cpp b/src/wayland/hyprland/ipc/workspace.cpp
|
||||||
|
index a527d825d..6205c818e 100644
|
||||||
|
--- a/src/wayland/hyprland/ipc/workspace.cpp
|
||||||
|
+++ b/src/wayland/hyprland/ipc/workspace.cpp
|
||||||
|
@@ -15,6 +15,8 @@ namespace qs::hyprland::ipc {
|
||||||
|
|
||||||
|
QVariantMap HyprlandWorkspace::lastIpcObject() const { return this->mLastIpcObject; }
|
||||||
|
|
||||||
|
+QBindable<HyprlandMonitor*> HyprlandWorkspace::bindableMonitor() { return &this->bMonitor; }
|
||||||
|
+
|
||||||
|
HyprlandWorkspace::HyprlandWorkspace(HyprlandIpc* ipc): QObject(ipc), ipc(ipc) {
|
||||||
|
Qt::beginPropertyUpdateGroup();
|
||||||
|
|
||||||
|
diff --git a/src/wayland/hyprland/ipc/workspace.hpp b/src/wayland/hyprland/ipc/workspace.hpp
|
||||||
|
index 86e0426d6..d36c6ad41 100644
|
||||||
|
--- a/src/wayland/hyprland/ipc/workspace.hpp
|
||||||
|
+++ b/src/wayland/hyprland/ipc/workspace.hpp
|
||||||
|
@@ -73,7 +73,7 @@ class HyprlandWorkspace: public QObject {
|
||||||
|
[[nodiscard]] QBindable<bool> bindableFocused() { return &this->bFocused; }
|
||||||
|
[[nodiscard]] QBindable<bool> bindableUrgent() { return &this->bUrgent; }
|
||||||
|
[[nodiscard]] QBindable<bool> bindableHasFullscreen() { return &this->bHasFullscreen; }
|
||||||
|
- [[nodiscard]] QBindable<HyprlandMonitor*> bindableMonitor() { return &this->bMonitor; }
|
||||||
|
+ [[nodiscard]] QBindable<HyprlandMonitor*> bindableMonitor();
|
||||||
|
[[nodiscard]] ObjectModel<HyprlandToplevel>* toplevels() { return &this->mToplevels; }
|
||||||
|
|
||||||
|
[[nodiscard]] QVariantMap lastIpcObject() const;
|
||||||
|
diff --git a/src/x11/i3/ipc/controller.cpp b/src/x11/i3/ipc/controller.cpp
|
||||||
|
index e4f32ff90..dfb52b46d 100644
|
||||||
|
--- a/src/x11/i3/ipc/controller.cpp
|
||||||
|
+++ b/src/x11/i3/ipc/controller.cpp
|
||||||
|
@@ -31,6 +31,14 @@ QS_LOGGING_CATEGORY(logI3Ipc, "quickshell.I3.ipc", QtWarningMsg);
|
||||||
|
QS_LOGGING_CATEGORY(logI3IpcEvents, "quickshell.I3.ipc.events", QtWarningMsg);
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
+QBindable<I3Monitor*> I3IpcController::bindableFocusedMonitor() const {
|
||||||
|
+ return &this->bFocusedMonitor;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+QBindable<I3Workspace*> I3IpcController::bindableFocusedWorkspace() const {
|
||||||
|
+ return &this->bFocusedWorkspace;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
I3IpcController::I3IpcController(): I3Ipc({"workspace", "output"}) {
|
||||||
|
// bind focused workspace to focused monitor's active workspace
|
||||||
|
this->bFocusedWorkspace.setBinding([this]() -> I3Workspace* {
|
||||||
|
diff --git a/src/x11/i3/ipc/controller.hpp b/src/x11/i3/ipc/controller.hpp
|
||||||
|
index 464f6f655..9e9d9141c 100644
|
||||||
|
--- a/src/x11/i3/ipc/controller.hpp
|
||||||
|
+++ b/src/x11/i3/ipc/controller.hpp
|
||||||
|
@@ -20,8 +20,8 @@ class I3Workspace;
|
||||||
|
class I3Monitor;
|
||||||
|
} // namespace qs::i3::ipc
|
||||||
|
|
||||||
|
-Q_DECLARE_OPAQUE_POINTER(qs::i3::ipc::I3Workspace*);
|
||||||
|
-Q_DECLARE_OPAQUE_POINTER(qs::i3::ipc::I3Monitor*);
|
||||||
|
+Q_MOC_INCLUDE("monitor.hpp")
|
||||||
|
+Q_MOC_INCLUDE("workspace.hpp")
|
||||||
|
|
||||||
|
namespace qs::i3::ipc {
|
||||||
|
|
||||||
|
@@ -43,13 +43,8 @@ class I3IpcController: public I3Ipc {
|
||||||
|
|
||||||
|
I3Monitor* monitorFor(QuickshellScreenInfo* screen);
|
||||||
|
|
||||||
|
- [[nodiscard]] QBindable<I3Monitor*> bindableFocusedMonitor() const {
|
||||||
|
- return &this->bFocusedMonitor;
|
||||||
|
- };
|
||||||
|
-
|
||||||
|
- [[nodiscard]] QBindable<I3Workspace*> bindableFocusedWorkspace() const {
|
||||||
|
- return &this->bFocusedWorkspace;
|
||||||
|
- };
|
||||||
|
+ [[nodiscard]] QBindable<I3Monitor*> bindableFocusedMonitor() const;
|
||||||
|
+ [[nodiscard]] QBindable<I3Workspace*> bindableFocusedWorkspace() const;
|
||||||
|
|
||||||
|
[[nodiscard]] ObjectModel<I3Monitor>* monitors();
|
||||||
|
[[nodiscard]] ObjectModel<I3Workspace>* workspaces();
|
||||||
|
diff --git a/src/x11/i3/ipc/monitor.cpp b/src/x11/i3/ipc/monitor.cpp
|
||||||
|
index 7afb68ef9..17178b7d1 100644
|
||||||
|
--- a/src/x11/i3/ipc/monitor.cpp
|
||||||
|
+++ b/src/x11/i3/ipc/monitor.cpp
|
||||||
|
@@ -12,6 +12,10 @@
|
||||||
|
|
||||||
|
namespace qs::i3::ipc {
|
||||||
|
|
||||||
|
+QBindable<I3Workspace*> I3Monitor::bindableActiveWorkspace() const {
|
||||||
|
+ return &this->bActiveWorkspace;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
I3Monitor::I3Monitor(I3IpcController* ipc): QObject(ipc), ipc(ipc) {
|
||||||
|
// clang-format off
|
||||||
|
this->bFocused.setBinding([this]() { return this->ipc->bindableFocusedMonitor().value() == this; });
|
||||||
|
diff --git a/src/x11/i3/ipc/monitor.hpp b/src/x11/i3/ipc/monitor.hpp
|
||||||
|
index 3630460ea..54e834e1f 100644
|
||||||
|
--- a/src/x11/i3/ipc/monitor.hpp
|
||||||
|
+++ b/src/x11/i3/ipc/monitor.hpp
|
||||||
|
@@ -55,9 +55,7 @@ class I3Monitor: public QObject {
|
||||||
|
[[nodiscard]] QBindable<qreal> bindableScale() { return &this->bScale; }
|
||||||
|
[[nodiscard]] QBindable<bool> bindableFocused() { return &this->bFocused; }
|
||||||
|
|
||||||
|
- [[nodiscard]] QBindable<I3Workspace*> bindableActiveWorkspace() const {
|
||||||
|
- return &this->bActiveWorkspace;
|
||||||
|
- }
|
||||||
|
+ [[nodiscard]] QBindable<I3Workspace*> bindableActiveWorkspace() const;
|
||||||
|
|
||||||
|
[[nodiscard]] QVariantMap lastIpcObject() const;
|
||||||
|
|
||||||
|
diff --git a/src/x11/i3/ipc/workspace.cpp b/src/x11/i3/ipc/workspace.cpp
|
||||||
|
index 530f0a2bf..b9d5968eb 100644
|
||||||
|
--- a/src/x11/i3/ipc/workspace.cpp
|
||||||
|
+++ b/src/x11/i3/ipc/workspace.cpp
|
||||||
|
@@ -12,6 +12,8 @@
|
||||||
|
|
||||||
|
namespace qs::i3::ipc {
|
||||||
|
|
||||||
|
+QBindable<I3Monitor*> I3Workspace::bindableMonitor() const { return &this->bMonitor; }
|
||||||
|
+
|
||||||
|
I3Workspace::I3Workspace(I3IpcController* ipc): QObject(ipc), ipc(ipc) {
|
||||||
|
Qt::beginPropertyUpdateGroup();
|
||||||
|
|
||||||
|
diff --git a/src/x11/i3/ipc/workspace.hpp b/src/x11/i3/ipc/workspace.hpp
|
||||||
|
index c08e926c1..6c802e416 100644
|
||||||
|
--- a/src/x11/i3/ipc/workspace.hpp
|
||||||
|
+++ b/src/x11/i3/ipc/workspace.hpp
|
||||||
|
@@ -57,7 +57,7 @@ class I3Workspace: public QObject {
|
||||||
|
[[nodiscard]] QBindable<bool> bindableActive() { return &this->bActive; }
|
||||||
|
[[nodiscard]] QBindable<bool> bindableFocused() { return &this->bFocused; }
|
||||||
|
[[nodiscard]] QBindable<bool> bindableUrgent() { return &this->bUrgent; }
|
||||||
|
- [[nodiscard]] QBindable<I3Monitor*> bindableMonitor() const { return &this->bMonitor; }
|
||||||
|
+ [[nodiscard]] QBindable<I3Monitor*> bindableMonitor() const;
|
||||||
|
[[nodiscard]] QVariantMap lastIpcObject() const;
|
||||||
|
|
||||||
|
void updateFromObject(const QVariantMap& obj);
|
||||||
28
tools/slurp-keymap-guard.patch
Normal file
28
tools/slurp-keymap-guard.patch
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
diff --git a/main.c b/main.c
|
||||||
|
index 135c98a..75b6f4f 100644
|
||||||
|
--- a/main.c
|
||||||
|
+++ b/main.c
|
||||||
|
@@ -305,6 +305,11 @@ static void keyboard_handle_key(void *data, struct wl_keyboard *wl_keyboard,
|
||||||
|
const uint32_t key_state) {
|
||||||
|
struct slurp_seat *seat = data;
|
||||||
|
struct slurp_state *state = seat->state;
|
||||||
|
+ /* WHY: SwayFX may send keyboard events before a keymap. Without an
|
||||||
|
+ * xkb_state, xkb_state_key_get_one_sym dereferences NULL. */
|
||||||
|
+ if (seat->xkb_state == NULL) {
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
const xkb_keysym_t keysym = xkb_state_key_get_one_sym(seat->xkb_state, key + 8);
|
||||||
|
|
||||||
|
switch (key_state) {
|
||||||
|
@@ -351,6 +356,11 @@ static void keyboard_handle_modifiers(void *data, struct wl_keyboard *wl_keyboar
|
||||||
|
const uint32_t mods_latched, const uint32_t mods_locked,
|
||||||
|
const uint32_t group) {
|
||||||
|
struct slurp_seat *seat = data;
|
||||||
|
+ /* CONTRACT: modifier events received before keymap do not carry a
|
||||||
|
+ * usable XKB state; the next event after keymap will update it. */
|
||||||
|
+ if (seat->xkb_state == NULL) {
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
xkb_state_update_mask(seat->xkb_state, mods_depressed, mods_latched,
|
||||||
|
mods_locked, 0, 0, group);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue