Watch
1
0
Fork
You've already forked souveraine
0

lock: notifications card + NotifyEvents fan-out seam, battery fraction fix

This commit is contained in:
Fimeg 2026-07-21 08:17:54 -04:00
commit 90077bb1ea
8 changed files with 207 additions and 7 deletions

View file

@ -40,6 +40,7 @@ services/SessionEvents.qml souveraine/services/SessionEvents.qml
services/SessiondBridge.qml souveraine/services/SessiondBridge.qml
services/StepUpAuth.qml souveraine/services/StepUpAuth.qml
services/SessionAudit.qml souveraine/services/SessionAudit.qml
services/NotifyEvents.qml souveraine/services/NotifyEvents.qml
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml
modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml
modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml
@ -75,6 +76,7 @@ modules/ii/lock/Lock.qml souveraine/modules/ii/lock/Lock.qml
modules/ii/lock/TouchLockSurface.qml souveraine/modules/ii/lock/TouchLockSurface.qml
modules/souveraine/lock/LockMediaCard.qml souveraine/modules/souveraine/lock/LockMediaCard.qml
modules/souveraine/lock/LockAgentCard.qml souveraine/modules/souveraine/lock/LockAgentCard.qml
modules/souveraine/lock/LockNotifyCard.qml souveraine/modules/souveraine/lock/LockNotifyCard.qml
modules/souveraine/lock/LockSurfaceHost.qml souveraine/modules/souveraine/lock/LockSurfaceHost.qml
modules/souveraine/lock/qmldir souveraine/modules/souveraine/lock/qmldir
modules/souveraine/navigation/SystemGestureRail.qml souveraine/modules/souveraine/navigation/SystemGestureRail.qml

View file

@ -469,6 +469,10 @@ Singleton {
property bool showMediaControls: true
property bool mediaMetadataAmbient: false
property bool showBattery: true
// App identity + count are ambient; the summary line is
// personal until promoted. Bodies never render on lock.
property bool showNotifications: true
property bool notificationContentAmbient: false
}
property JsonObject idle: JsonObject {
// Native idle-notify remains opt-in until verified on the

View file

@ -227,6 +227,27 @@ ContentPage {
checked: Config.options.lock.content.showBattery
onCheckedChanged: Config.options.lock.content.showBattery = checked
}
ConfigSwitch {
buttonIcon: "notifications"
text: Translation.tr("Show notifications while locked")
checked: Config.options.lock.content.showNotifications
onCheckedChanged: Config.options.lock.content.showNotifications = checked
StyledToolTip {
text: Translation.tr("Shows which apps had notifications arrive and how many. Content stays private unless enabled below.")
}
}
ConfigSwitch {
buttonIcon: "visibility"
text: Translation.tr("Show notification summaries")
checked: Config.options.lock.content.notificationContentAmbient
enabled: Config.options.lock.content.showNotifications
onCheckedChanged: Config.options.lock.content.notificationContentAmbient = checked
StyledToolTip {
text: Translation.tr("Treats the notification title line as ambient. Bodies never show on the lock screen.")
}
}
}
ContentSection {

View file

@ -0,0 +1,116 @@
// Ambient "notifications arrived while locked" card. Same posture as
// LockAgentCard: the lock surface announces that something landed, the
// content waits for unlock. App identity and count are ambient; the summary
// line is personal and renders only when promoted
// (lock.content.notificationContentAmbient). Bodies never render here.
import QtQuick
import QtQuick.Layouts
import qs
import qs.services
Item {
id: root
// Newest-first, only what arrived during this lock. Transient
// notifications are excluded they asked not to persist.
property var entries: []
implicitHeight: card.visible ? card.implicitHeight : 0
visible: LockContentPolicy.notificationsVisible && entries.length > 0
Connections {
target: NotifyEvents
function onLanded(evt) {
if (!GlobalStates.screenLocked || evt.isTransient) return;
root.entries = [evt, ...root.entries].slice(0, 16);
}
}
Connections {
target: GlobalStates
// Fresh slate each lock cycle; once unlocked the shell's own
// surfaces own notification history.
function onScreenLockedChanged() { root.entries = []; }
}
// Grouped by app, newest app first: [{ app, count, latest }]
readonly property var groups: {
const byApp = {};
const order = [];
for (const e of entries) {
const key = e.app || qsTr("App");
if (!byApp[key]) {
byApp[key] = { app: key, count: 0, latest: e };
order.push(key);
}
byApp[key].count++;
}
return order.map(k => byApp[k]);
}
Rectangle {
id: card
anchors.fill: parent
visible: root.entries.length > 0
radius: 16
color: "#22000000"
border.color: "#33ffffff"
border.width: 1
implicitHeight: layout.implicitHeight + 24
ColumnLayout {
id: layout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 12
spacing: 4
Text {
Layout.fillWidth: true
text: qsTr("Arrived while locked")
color: "#ccffffff"
font.pixelSize: 12
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Repeater {
model: root.groups.slice(0, 4)
delegate: ColumnLayout {
required property var modelData
Layout.fillWidth: true
spacing: 1
Text {
Layout.fillWidth: true
text: modelData.count > 1
? modelData.app + " · " + modelData.count
: modelData.app
color: "#e6ffffff"
font.pixelSize: 14
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
Layout.fillWidth: true
visible: LockContentPolicy.notificationContentVisible
&& (modelData.latest.summary ?? "").length > 0
text: "• " + modelData.latest.summary
color: "#b3ffffff"
font.pixelSize: 13
elide: Text.ElideRight
maximumLineCount: 1
}
}
}
Text {
visible: root.groups.length > 4
text: qsTr("+%1 more").arg(root.groups.length - 4)
color: "#99ffffff"
font.pixelSize: 12
}
}
}
}

View file

@ -56,19 +56,22 @@ Item {
visible: LockContentPolicy.batteryVisible && UPower.displayDevice?.isPresent
text: {
const dev = UPower.displayDevice;
const pct = Math.round(dev?.percentage ?? 0);
// UPowerDevice.percentage is a 01 fraction (TouchLockSurface
// multiplies too) rounding it raw is what rendered the
// eternal "Charging 0%" here.
const pct = Math.round((dev?.percentage ?? 0) * 100);
// State is authoritative don't infer "Charging" from
// onBattery alone. onBattery is false for fully-charged,
// pending-charge, and unknown, which is what made the line
// read "Charging N%" forever on a topped-off pack. The
// recent driver change also leaves energy-full unreliable,
// so a stale percentage can read ~0%; never call a
// fully-charged pack "1%".
// pending-charge, and unknown, which would read "Charging N%"
// forever on a topped-off pack.
switch (dev?.state ?? UPowerDeviceState.Unknown) {
case UPowerDeviceState.FullyCharged:
return "Full " + pct + "%";
case UPowerDeviceState.Charging:
return "Charging " + pct + "%";
// Right after a driver rebind the pack can briefly
// report 0% while charging. Drop the number rather
// than lie.
return pct > 0 ? "Charging " + pct + "%" : "Charging";
case UPowerDeviceState.Discharging:
return "Battery " + pct + "%";
default:
@ -92,5 +95,11 @@ Item {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
}
LockNotifyCard {
Layout.topMargin: root.compact ? 6 : 10
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
}
}
}

View file

@ -2,3 +2,4 @@ module qs.modules.souveraine.lock
LockMediaCard 1.0 LockMediaCard.qml
LockSurfaceHost 1.0 LockSurfaceHost.qml
LockAgentCard 1.0 LockAgentCard.qml
LockNotifyCard 1.0 LockNotifyCard.qml

View file

@ -24,4 +24,8 @@ Singleton {
readonly property bool mediaMetadataVisible: root.allowsOnLock(
root.personal, Config.options.lock.content.mediaMetadataAmbient)
readonly property bool batteryVisible: Config.options.lock.content.showBattery
readonly property bool notificationsVisible: Config.options.lock.content.showNotifications
readonly property bool notificationContentVisible: root.allowsOnLock(
root.personal, Config.options.lock.content.notificationContentAmbient)
}

View file

@ -0,0 +1,43 @@
// Notification event fan-out. The freedesktop server itself lives in ii's
// Notifications.qml (it owns org.freedesktop.Notifications on the bus); this
// singleton is the Souveraine-side seam everything else attaches to when a
// notification lands: the lock glance card today, crash-report surfacing
// (TASK-05) and agent message events (queue 5a/b) later.
//
// Deliberately NOT here: any decision about what the agent may see. That is
// capability-gate territory (queue 5b) this file is only the wire. Events
// are plain objects, not Notif wrappers, so consumers can't reach actions or
// dismissal through this path.
pragma Singleton
import QtQuick
import Quickshell
Singleton {
id: root
// Newest-first ring of recent events: { app, summary, body, urgency,
// isTransient, time }. Session-scoped; ii already persists the full
// notification list to disk, this ring is for reactive consumers.
property var recent: []
readonly property int capacity: 32
// Fired once per incoming notification, after `recent` is updated.
signal landed(var evt)
Connections {
target: Notifications
function onNotify(notif) {
const evt = {
app: notif.appName,
summary: notif.summary,
body: notif.body,
urgency: notif.urgency,
isTransient: notif.isTransient,
time: notif.time,
};
root.recent = [evt, ...root.recent].slice(0, root.capacity);
root.landed(evt);
}
}
}