Files
kde-caffeine/contents/ui/main.qml
T

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"
}
}
}
}