522 lines
19 KiB
QML
522 lines
19 KiB
QML
/*
|
|
* SPDX-FileCopyrightText: 2026 enne2
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import org.kde.plasma.plasmoid
|
|
import org.kde.plasma.core as PlasmaCore
|
|
import org.kde.plasma.components as PlasmaComponents3
|
|
import org.kde.plasma.extras as PlasmaExtras
|
|
import org.kde.plasma.plasma5support as P5Support
|
|
import org.kde.kirigami as Kirigami
|
|
|
|
PlasmoidItem {
|
|
id: root
|
|
|
|
// ---------------------------------------------------------------- percorsi
|
|
readonly property string pkgPath: Qt.resolvedUrl("..").toString().replace("file://", "")
|
|
readonly property string scriptPath: pkgPath + "/scripts/caffeine.sh"
|
|
readonly property string unitSrcPath: pkgPath + "/systemd"
|
|
readonly property string iconOnPath: Qt.resolvedUrl("../icons/caffeine-on.svg").toString()
|
|
readonly property string iconOffPath: Qt.resolvedUrl("../icons/caffeine-off.svg").toString()
|
|
readonly property string statusCommand: scriptPath + " status"
|
|
|
|
// ------------------------------------------------------------------ stato
|
|
property string backendState: "off" // on | off
|
|
property string backendMode: "none" // screen | sleep | none
|
|
property string backend: "unknown" // systemd | unsupported | unknown
|
|
property int deadlineEpoch: 0
|
|
property int remainingSec: 0
|
|
property bool unitsReady: false
|
|
property string lastError: ""
|
|
// scadenza del timer già gestita (evita rientranze sullo stesso deadline)
|
|
property int expiryHandledEpoch: 0
|
|
property int expiryAttempts: 0
|
|
// ultimo comando inviato al backend (per la pulizia del source)
|
|
property string lastActionSource: ""
|
|
property int actionCounter: 0
|
|
// operazione in corso verso il backend (feedback ottimistico)
|
|
property bool pending: false
|
|
property string pendingTarget: "" // stato atteso: on | off
|
|
|
|
readonly property bool active: backendState === "on"
|
|
readonly property bool timerActive: active && remainingSec > 0
|
|
readonly property bool keepScreenOn: plasmoid.configuration.keepScreenOn
|
|
readonly property string startMode: keepScreenOn ? "screen" : "sleep"
|
|
|
|
// ------------------------------------------------------------ presentazione
|
|
Plasmoid.icon: active ? iconOnPath : iconOffPath
|
|
Plasmoid.status: active ? PlasmaCore.Types.ActiveStatus : PlasmaCore.Types.PassiveStatus
|
|
Plasmoid.busy: pending
|
|
toolTipMainText: pending
|
|
? "Caffeine: aggiornamento…"
|
|
: (active ? "Caffeine attivo" : "Caffeine inattivo")
|
|
toolTipTextFormat: Text.PlainText
|
|
toolTipSubText: {
|
|
let text = pending ? "Operazione in corso verso il backend…" : modeText()
|
|
if (timerActive) {
|
|
text += "\nSi disattiva automaticamente tra " + formatDuration(remainingSec)
|
|
}
|
|
text += "\nClic sinistro: attiva/disattiva · clic centrale: opzioni"
|
|
return text
|
|
}
|
|
|
|
// un clic sinistro non deve aprire il popup: lo gestiamo noi
|
|
activationTogglesExpanded: false
|
|
|
|
// ---------------------------------------------------------------- funzioni
|
|
function formatDuration(seconds) {
|
|
const total = Math.max(0, Math.floor(seconds))
|
|
const hours = Math.floor(total / 3600)
|
|
const minutes = Math.floor((total % 3600) / 60)
|
|
const secs = total % 60
|
|
const pad = value => (value < 10 ? "0" : "") + value
|
|
if (hours > 0) {
|
|
return hours + "h " + pad(minutes) + "m"
|
|
}
|
|
if (minutes > 0) {
|
|
return minutes + "m " + pad(secs) + "s"
|
|
}
|
|
return secs + "s"
|
|
}
|
|
|
|
function modeText() {
|
|
if (!active) {
|
|
return "Nessuna inibizione attiva: schermo e sospensione gestiti automaticamente"
|
|
}
|
|
if (backendMode === "sleep") {
|
|
return "Sospensione automatica bloccata · lo schermo può spegnersi"
|
|
}
|
|
return "Schermo, blocco schermo e sospensione automatica bloccati"
|
|
}
|
|
|
|
function applyStatus(output) {
|
|
const values = {}
|
|
String(output || "").split("\n").forEach(function(line) {
|
|
const index = line.indexOf("=")
|
|
if (index > 0) {
|
|
values[line.substring(0, index)] = line.substring(index + 1).trim()
|
|
}
|
|
})
|
|
// durante un'operazione in corso i risultati del polling periodico
|
|
// potrebbero essere ancora quelli vecchi: non smentire l'icona
|
|
// ottimistica finché il backend non conferma il cambio di stato
|
|
if (pending && pendingTarget !== "") {
|
|
const confirmed = pendingTarget === "off"
|
|
? values["state"] === "off"
|
|
: values["state"] === "on"
|
|
if (!confirmed) {
|
|
return
|
|
}
|
|
clearPending()
|
|
}
|
|
if (values["backend"] !== undefined) {
|
|
backend = values["backend"]
|
|
}
|
|
if (values["state"] !== undefined) {
|
|
backendState = values["state"]
|
|
}
|
|
if (values["mode"] !== undefined) {
|
|
backendMode = values["mode"]
|
|
}
|
|
const deadline = parseInt(values["deadline"] || "0", 10)
|
|
deadlineEpoch = isNaN(deadline) ? 0 : deadline
|
|
if (deadlineEpoch > 0) {
|
|
const remaining = deadlineEpoch - Math.floor(Date.now() / 1000)
|
|
remainingSec = remaining > 0 ? remaining : 0
|
|
if (remaining <= 0) {
|
|
// timer scaduto: disattiva, con ritentativi limitati se il
|
|
// backend non conferma (il flag sul deadline evita rientranze)
|
|
if (expiryHandledEpoch !== deadlineEpoch) {
|
|
expiryHandledEpoch = deadlineEpoch
|
|
expiryAttempts = 1
|
|
deactivate(true)
|
|
} else if (expiryAttempts > 0 && expiryAttempts < 3) {
|
|
expiryAttempts += 1
|
|
deactivate(true)
|
|
}
|
|
}
|
|
} else {
|
|
remainingSec = 0
|
|
expiryHandledEpoch = 0
|
|
expiryAttempts = 0
|
|
}
|
|
}
|
|
|
|
function run(command) {
|
|
if (backend === "unsupported") {
|
|
return
|
|
}
|
|
// ogni comando ha un source univoco (contatore): così il motore
|
|
// "executable" lo esegue sempre, anche se lo stesso comando era già
|
|
// stato eseguito prima.
|
|
if (lastActionSource !== "") {
|
|
actionSource.disconnectSource(lastActionSource)
|
|
}
|
|
actionCounter += 1
|
|
const source = scriptPath + " " + command + " " + actionCounter
|
|
lastActionSource = source
|
|
actionSource.connectSource(source)
|
|
}
|
|
|
|
function refresh() {
|
|
statusSource.disconnectSource(statusCommand)
|
|
statusSource.connectSource(statusCommand)
|
|
}
|
|
|
|
// ------------------------------------------------- feedback ottimistico
|
|
// Il backend risponde in ~70 ms, ma la conferma poteva arrivare solo al
|
|
// giro di polling successivo: l'icona viene quindi aggiornata subito e
|
|
// messa in stato "pending" (pulsazione + busy) finché il backend conferma.
|
|
function beginPending(target) {
|
|
pending = true
|
|
pendingTarget = target
|
|
pendingTimeout.restart()
|
|
}
|
|
|
|
function clearPending() {
|
|
pending = false
|
|
pendingTarget = ""
|
|
pendingTimeout.stop()
|
|
}
|
|
|
|
// output autorevole del comando appena eseguito (contiene state=/mode=)
|
|
function finishPending(output) {
|
|
clearPending()
|
|
applyStatus(output)
|
|
}
|
|
|
|
function activate() {
|
|
const minutes = plasmoid.configuration.autoOffMinutes
|
|
backendState = "on"
|
|
backendMode = startMode
|
|
remainingSec = minutes > 0 ? minutes * 60 : 0
|
|
expiryAttempts = 0
|
|
beginPending("on")
|
|
run("on " + startMode + " " + minutes)
|
|
}
|
|
|
|
function deactivate(fromTimer) {
|
|
const notify = (fromTimer === true && plasmoid.configuration.notifyOnTimerEnd) ? " notify" : ""
|
|
backendState = "off"
|
|
backendMode = "none"
|
|
remainingSec = 0
|
|
deadlineEpoch = 0
|
|
beginPending("off")
|
|
run("off" + notify)
|
|
}
|
|
|
|
function toggle() {
|
|
if (active) {
|
|
deactivate(false)
|
|
} else {
|
|
activate()
|
|
}
|
|
}
|
|
|
|
// Gestione dei clic sull'icona di pannello.
|
|
// Sinistra: attiva/disattiva. Centrale: apre le opzioni.
|
|
function handleCompactClick(button) {
|
|
if (button === Qt.MiddleButton) {
|
|
expanded = !expanded
|
|
} else {
|
|
toggle()
|
|
}
|
|
}
|
|
|
|
function changeMode(keepScreen) {
|
|
if (!active) {
|
|
return
|
|
}
|
|
backendMode = keepScreen ? "screen" : "sleep"
|
|
beginPending("on")
|
|
run("switch " + (keepScreen ? "screen" : "sleep"))
|
|
}
|
|
|
|
function changeTimer(minutes) {
|
|
if (!active) {
|
|
return
|
|
}
|
|
expiryAttempts = 0
|
|
beginPending("on")
|
|
if (minutes > 0) {
|
|
remainingSec = minutes * 60
|
|
run("on " + startMode + " " + minutes)
|
|
} else {
|
|
remainingSec = 0
|
|
run("switch " + startMode)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- backend
|
|
// Stato: polling periodico (systemctl --user is-active); la conferma di
|
|
// un'operazione arriva però dall'output del comando stesso (~70 ms).
|
|
P5Support.DataSource {
|
|
id: statusSource
|
|
engine: "executable"
|
|
interval: 3000
|
|
connectedSources: [root.statusCommand]
|
|
onNewData: function(sourceName, data) {
|
|
root.applyStatus(data ? data["stdout"] : "")
|
|
}
|
|
}
|
|
|
|
// Comandi una tantum (on/off/switch), un source per comando.
|
|
P5Support.DataSource {
|
|
id: actionSource
|
|
engine: "executable"
|
|
connectedSources: []
|
|
onNewData: function(sourceName, data) {
|
|
const output = data ? String(data["stdout"] || "") : ""
|
|
root.lastError = output.indexOf("error=") === 0 ? output.trim() : ""
|
|
if (root.lastError !== "") {
|
|
console.warn("enne2-caffeine:", root.lastError)
|
|
root.clearPending()
|
|
}
|
|
if (output.indexOf("state=") !== -1) {
|
|
root.finishPending(output)
|
|
} else {
|
|
root.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Installa/aggiorna le unità systemd utente al primo avvio.
|
|
P5Support.DataSource {
|
|
id: unitsSource
|
|
engine: "executable"
|
|
connectedSources: [root.scriptPath + " ensure-units " + root.unitSrcPath]
|
|
onNewData: function(sourceName, data) {
|
|
root.unitsReady = Boolean(data && Number(data["exit code"]) === 0
|
|
&& String(data["stdout"] || "").indexOf("ok=1") === 0)
|
|
root.refresh()
|
|
}
|
|
}
|
|
|
|
// Il backend non ha confermato entro il timeout: torna a fidarsi del polling.
|
|
Timer {
|
|
id: pendingTimeout
|
|
interval: 6000
|
|
repeat: false
|
|
onTriggered: {
|
|
root.clearPending()
|
|
root.refresh()
|
|
}
|
|
}
|
|
|
|
// Countdown del timer di disattivazione automatica.
|
|
Timer {
|
|
interval: 1000
|
|
repeat: true
|
|
running: root.timerActive
|
|
onTriggered: {
|
|
const remaining = root.deadlineEpoch - Math.floor(Date.now() / 1000)
|
|
root.remainingSec = remaining > 0 ? remaining : 0
|
|
if (remaining <= 0) {
|
|
root.deactivate(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------- rappresentazione compatta
|
|
compactRepresentation: Item {
|
|
id: compactRoot
|
|
implicitWidth: compactRow.implicitWidth
|
|
implicitHeight: Kirigami.Units.iconSizes.smallMedium
|
|
activeFocusOnTab: true
|
|
|
|
Keys.onPressed: function(event) {
|
|
switch (event.key) {
|
|
case Qt.Key_Space:
|
|
case Qt.Key_Enter:
|
|
case Qt.Key_Return:
|
|
case Qt.Key_Select:
|
|
root.toggle()
|
|
event.accepted = true
|
|
break
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
id: compactRow
|
|
anchors.centerIn: parent
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.Icon {
|
|
id: compactIcon
|
|
source: root.active ? root.iconOnPath : root.iconOffPath
|
|
implicitWidth: Kirigami.Units.iconSizes.smallMedium
|
|
implicitHeight: Kirigami.Units.iconSizes.smallMedium
|
|
|
|
// operazione in corso: pulsazione dell'icona come feedback
|
|
SequentialAnimation on opacity {
|
|
running: root.pending
|
|
loops: Animation.Infinite
|
|
NumberAnimation { to: 0.4; duration: 220; easing.type: Easing.InOutQuad }
|
|
NumberAnimation { to: 1.0; duration: 220; easing.type: Easing.InOutQuad }
|
|
onStopped: compactIcon.opacity = 1.0
|
|
}
|
|
|
|
// Kirigami.Icon può assorbire i clic (bug KDE 518024):
|
|
// area cliccabile direttamente sopra l'icona.
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function(mouse) {
|
|
root.handleCompactClick(mouse.button)
|
|
}
|
|
}
|
|
}
|
|
|
|
PlasmaComponents3.Label {
|
|
visible: root.timerActive
|
|
text: Math.ceil(root.remainingSec / 60) + "m"
|
|
font: Kirigami.Theme.smallFont
|
|
color: Kirigami.Theme.textColor
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: function(mouse) {
|
|
root.handleCompactClick(mouse.button)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------- popup opzioni
|
|
fullRepresentation: PlasmaExtras.Representation {
|
|
Layout.minimumWidth: Kirigami.Units.gridUnit * 20
|
|
Layout.minimumHeight: Kirigami.Units.gridUnit * 13
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
Layout.preferredHeight: popupColumn.implicitHeight + Kirigami.Units.gridUnit * 2
|
|
collapseMarginsHint: true
|
|
|
|
ColumnLayout {
|
|
id: popupColumn
|
|
anchors.fill: parent
|
|
anchors.margins: Kirigami.Units.largeSpacing
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.Icon {
|
|
source: root.active ? root.iconOnPath : root.iconOffPath
|
|
implicitWidth: Kirigami.Units.iconSizes.medium
|
|
implicitHeight: Kirigami.Units.iconSizes.medium
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
|
|
Kirigami.Heading {
|
|
Layout.fillWidth: true
|
|
level: 3
|
|
text: root.active ? "Caffeine attivo" : "Caffeine inattivo"
|
|
}
|
|
|
|
PlasmaComponents3.Label {
|
|
Layout.fillWidth: true
|
|
text: root.modeText()
|
|
wrapMode: Text.WordWrap
|
|
opacity: 0.7
|
|
}
|
|
}
|
|
}
|
|
|
|
PlasmaComponents3.Label {
|
|
Layout.fillWidth: true
|
|
visible: root.timerActive
|
|
text: "Disattivazione automatica tra " + root.formatDuration(root.remainingSec)
|
|
color: Kirigami.Theme.positiveTextColor
|
|
}
|
|
|
|
Kirigami.Separator {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: Kirigami.Units.smallSpacing
|
|
}
|
|
|
|
PlasmaComponents3.CheckBox {
|
|
Layout.fillWidth: true
|
|
text: "Impedisci anche lo spegnimento dello schermo"
|
|
checked: root.keepScreenOn
|
|
onToggled: {
|
|
plasmoid.configuration.keepScreenOn = checked
|
|
root.changeMode(checked)
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: Kirigami.Units.smallSpacing
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
PlasmaComponents3.Label {
|
|
text: "Disattiva dopo:"
|
|
}
|
|
|
|
PlasmaComponents3.ComboBox {
|
|
id: timerCombo
|
|
Layout.fillWidth: true
|
|
model: ["Mai", "5 minuti", "15 minuti", "30 minuti", "1 ora", "2 ore", "4 ore"]
|
|
readonly property var minutes: [0, 5, 15, 30, 60, 120, 240]
|
|
currentIndex: Math.max(0, minutes.indexOf(plasmoid.configuration.autoOffMinutes))
|
|
onActivated: function(index) {
|
|
const value = minutes[index]
|
|
plasmoid.configuration.autoOffMinutes = value
|
|
root.changeTimer(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
PlasmaComponents3.CheckBox {
|
|
Layout.fillWidth: true
|
|
visible: plasmoid.configuration.autoOffMinutes > 0
|
|
text: "Notifica quando scade il timer"
|
|
checked: plasmoid.configuration.notifyOnTimerEnd
|
|
onToggled: plasmoid.configuration.notifyOnTimerEnd = checked
|
|
}
|
|
|
|
PlasmaComponents3.Label {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: Kirigami.Units.smallSpacing
|
|
visible: root.backend === "unsupported" || root.lastError !== ""
|
|
color: Kirigami.Theme.negativeTextColor
|
|
wrapMode: Text.WordWrap
|
|
text: root.backend === "unsupported"
|
|
? "Backend non disponibile: serve systemd con gestore utente attivo."
|
|
: "Errore dal backend: " + root.lastError
|
|
}
|
|
|
|
Item {
|
|
Layout.fillHeight: true
|
|
}
|
|
|
|
PlasmaComponents3.Button {
|
|
Layout.fillWidth: true
|
|
text: root.active ? "Disattiva Caffeine" : "Attiva Caffeine"
|
|
icon.name: root.active ? "media-playback-stop" : "media-playback-start"
|
|
onClicked: root.toggle()
|
|
}
|
|
|
|
PlasmaComponents3.Label {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: Kirigami.Units.smallSpacing
|
|
horizontalAlignment: Text.AlignHCenter
|
|
font: Kirigami.Theme.smallFont
|
|
opacity: 0.6
|
|
wrapMode: Text.WordWrap
|
|
text: "Clic sinistro sull'icona: attiva/disattiva\nClic centrale: queste opzioni"
|
|
}
|
|
}
|
|
}
|
|
}
|