Files
Panama/config/dot/quickshell/modules/settings/NotificationsPage.qml
T
Gabriel Brown 3d21e20041 Make every control tell the truth
The audit's first tier, in one change: every case found where the interface
asserted something the system did not do.

Twenty-one compositor-owned preferences -- the whole Mouse & Touchpad page,
plus window layout, snapping, dim-inactive and the magnifier -- had the live
half (hyprctl eval) and not the config-time half, so they quietly reverted on
every hyprctl reload. All 70 hypr-backed keys now have a prefs.get() in the
Lua, and hypr-prefs-contract pins both the presence and that the Lua fallback
equals the schema default, which is how the touchpad page misreported natural
scrolling on first boot.

The idle generator fell back from unwritten battery keys to the AC values
while the Power page displayed the schema defaults: a fresh laptop showed
"suspend at 20 minutes" and generated no suspend listener, then discharged to
zero in a bag. Unwritten keys now use the defaults the page shows
(idle-defaults-contract pins generator to schema; idle-config-contract
re-pinned to the new rule with the tradeoff recorded), and change-settings
enables managed idle on any machine with a battery -- without starting
hypridle in whatever session the installer runs under.

The per-app lock-screen notification switches wrote fields nothing read:
hyprlock cannot render notifications. Removed, with the rule model shrunk to
{enabled}, stale stored fields dropped at normalization, and the contract now
forbidding the page from growing lock-screen switches it cannot honor.

The battery warning thresholds were searchable, documented as "Found on
Power & Lock", and rendered nowhere -- and crossing the low threshold changed
only a glyph's color. Both sliders now exist where search was already sending
people, and low battery publishes a real notification at important priority.

Three handoffs opened GNOME panels that are inert in a Hyprland session. The
keyboard handoff is gone (that panel writes gsettings nothing here reads, and
the working controls sat on the same page); Connectivity gains a Wi-Fi row
that opens GNOME's actual Wi-Fi panel -- hidden SSIDs and 802.1X finally have
a road -- beside the network row that legitimately drives NetworkManager; the
universal-access handoff is gone, its few working toggles being controls this
app already owns. And the accessibility page now gives the true reason sticky
keys are missing: each Wayland compositor implements its own and Hyprland
does not yet -- not "an X11 feature with no Wayland equivalent," which sent
people to the wrong conclusion about the platform.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:43:39 -04:00

438 lines
17 KiB
QML

import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
// Only one application is expanded at a time; the point is a card you
// can read, not twenty open at once.
// Which focus mode is open for editing. One at a time, like the app rules.
property string expandedMode: ""
// Which mode's exception list is open. Separate from expandedMode so the
// list does not unfold every time a mode is opened to change a switch.
property string expandedAllow: ""
function toggleAllowed(mode: var, appId: string, allowed: bool): void {
const current = Array.isArray(mode.allow) ? mode.allow.map(String) : [];
const at = current.indexOf(appId);
if (allowed && at < 0)
current.push(appId);
else if (!allowed && at >= 0)
current.splice(at, 1);
FocusModes.update(String(mode.id), { allow: current });
}
// Schedule edits go through the mode's trigger list rather than replacing
// it, so a mode that also has a game or fullscreen trigger keeps it.
function reschedule(mode: var, changes: var): void {
FocusModes.update(String(mode.id), {
triggers: (mode.triggers ?? []).map(trigger =>
trigger.kind === "schedule" ? Object.assign({}, trigger, changes) : trigger)
});
}
function toggleDay(mode: var, day: int): void {
const schedule = (mode.triggers ?? []).find(trigger => trigger.kind === "schedule");
if (!schedule)
return;
const days = Array.isArray(schedule.days) ? schedule.days.slice() : [];
const at = days.indexOf(day);
if (at >= 0)
days.splice(at, 1);
else
days.push(day);
days.sort((a, b) => a - b);
root.reschedule(mode, { days: days });
}
title: "Notifications & Focus"
lede: "Control interruptions without losing useful history."
SettingsCard {
title: "Notifications"
SettingRow {
label: "Do Not Disturb"
detail: "Keep notifications in the center but suppress banners"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
}
TextRow {
label: "Notification history"
detail: "Live notifications retained by the shell"
value: Notifs.history.length === 1 ? "1 item" : `${Notifs.history.length} items`
}
ActionRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
divider: false
action: "Clear all"
enabled: Notifs.history.length > 0
onTriggered: Notifs.dismissAll()
}
}
SettingsCard {
title: "Banner behavior"
SliderRow { setting: "notificationTimeoutMs" }
SliderRow {
setting: "notificationTimeoutCriticalMs"
zeroLabel: "Never"
}
SliderRow { setting: "notificationHistoryLimit" }
SliderRow { setting: "maxVisibleToasts"; divider: false }
}
SettingsCard {
title: "Application rules"
subtitle: "Apps appear here after they send a notification."
TextRow {
visible: Notifs.applications.length === 0
label: "No applications remembered yet"
detail: "Application controls will appear after the first notification arrives."
divider: false
}
Repeater {
model: Notifs.applications
Column {
id: appEntry
required property var modelData
readonly property var app: appEntry.modelData
readonly property var rule: Notifs.appRule(appEntry.app.id)
width: parent.width
SettingRow {
width: parent.width
label: appEntry.app.name
detail: appEntry.rule.enabled ? String(appEntry.app.id) : "Notifications off"
divider: true
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: appEntry.rule.enabled
onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value })
}
}
}
}
}
SettingsCard {
title: "Focus modes"
subtitle: FocusModes.active
? FocusModes.activeName + " is on because " + FocusModes.activeReason + "."
: "What quiets this machine, and what turns it on. The first mode whose condition is true wins, so order is priority."
Repeater {
model: FocusModes.modes
delegate: Column {
id: modeEntry
required property var modelData
readonly property var mode: modeEntry.modelData
readonly property string modeId: String(modeEntry.mode.id ?? "")
readonly property bool open: root.expandedMode === modeEntry.modeId
readonly property bool running: FocusModes.activeMode?.id === modeEntry.modeId
readonly property var schedule: (modeEntry.mode.triggers ?? [])
.find(trigger => trigger.kind === "schedule") ?? null
width: parent.width
SettingRow {
width: parent.width
label: String(modeEntry.mode.name ?? "")
detail: modeEntry.running
? "On now — " + FocusModes.activeReason
: FocusModes.summary(modeEntry.mode)
activatable: true
divider: !modeEntry.open
controlWidth: 92
onActivated: root.expandedMode = modeEntry.open ? "" : modeEntry.modeId
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 11
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.enabled === true
onToggled: value => FocusModes.setEnabled(modeEntry.modeId, value)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modeEntry.open ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
SettingRow {
width: parent.width
visible: modeEntry.open
label: "Silence notifications"
detail: "Banners are held until the mode ends"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.silence === true
onToggled: value => FocusModes.update(modeEntry.modeId, { silence: value })
}
}
SettingRow {
width: parent.width
visible: modeEntry.open
label: "Keep the screen awake"
detail: "Caffeine, for as long as the mode is on"
controlWidth: 48
divider: modeEntry.schedule !== null
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: modeEntry.mode.keepAwake === true
onToggled: value => FocusModes.update(modeEntry.modeId, { keepAwake: value })
}
}
// Only modes that actually have a schedule get the schedule
// controls; a game trigger has no times to set.
TextFieldRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "From"
detail: "24-hour, such as 23:30"
text: String(modeEntry.schedule?.start ?? "")
placeholder: "23:30"
onAccepted: value => root.reschedule(modeEntry.mode, { start: value })
}
TextFieldRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "Until"
detail: "A time earlier than the start means the window crosses midnight"
text: String(modeEntry.schedule?.end ?? "")
placeholder: "07:00"
onAccepted: value => root.reschedule(modeEntry.mode, { end: value })
}
SettingRow {
width: parent.width
visible: modeEntry.open && modeEntry.mode.silence === true
label: "May interrupt"
detail: (modeEntry.mode.allow ?? []).length === 0
? "Nothing gets through while this mode is on"
: "Everything else is held until the mode ends"
activatable: true
controlWidth: 150
onActivated: root.expandedAllow =
root.expandedAllow === modeEntry.modeId ? "" : modeEntry.modeId
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
Text {
anchors.verticalCenter: parent.verticalCenter
text: (modeEntry.mode.allow ?? []).length === 0
? "Nothing"
: (modeEntry.mode.allow ?? []).length + " apps"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.expandedAllow === modeEntry.modeId ? "\u25B4" : "\u25BE"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
// Drawn from the applications that have actually sent a
// notification, which is the same list the rules above use --
// an exception for something that never notifies is not a
// choice worth offering.
Repeater {
model: (modeEntry.open && root.expandedAllow === modeEntry.modeId)
? Notifs.applications
: []
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.id ?? "")
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: (modeEntry.mode.allow ?? []).indexOf(String(modelData.id)) >= 0
onToggled: value => root.toggleAllowed(modeEntry.mode, String(modelData.id), value)
}
}
}
TextRow {
width: parent.width
visible: modeEntry.open && root.expandedAllow === modeEntry.modeId
&& Notifs.applications.length === 0
label: "No applications yet"
detail: "They appear here once they have sent a notification."
value: ""
}
SettingRow {
width: parent.width
visible: modeEntry.open && modeEntry.schedule !== null
label: "On these days"
detail: "A window that crosses midnight belongs to the day it starts on"
controlWidth: 250
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Repeater {
model: [
{ value: 1, label: "M" }, { value: 2, label: "T" },
{ value: 3, label: "W" }, { value: 4, label: "T" },
{ value: 5, label: "F" }, { value: 6, label: "S" },
{ value: 0, label: "S" }
]
delegate: Rectangle {
id: dayPill
required property var modelData
readonly property bool on:
(modeEntry.schedule?.days ?? []).indexOf(dayPill.modelData.value) >= 0
width: 30
height: 28
radius: 8
color: dayPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: dayPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
anchors.centerIn: parent
text: String(dayPill.modelData.label)
color: dayPill.on ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: dayPill.on ? Font.DemiBold : Font.Medium
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler {
onTapped: root.toggleDay(modeEntry.mode, dayPill.modelData.value)
}
}
}
}
}
}
}
}
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
label: "Keep the screen awake"
detail: Caffeine.enabled
? "The display will not blank or lock while this is on"
: "Idle timings on Power & Lock apply normally"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Caffeine.enabled
onToggled: value => Caffeine.enabled = value
}
}
SettingRow {
id: durationRow
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
controlWidth: 264
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: [25, 45, 60, 90]
SettingsButton {
required property int modelData
text: `${modelData}m`
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
}
}
}
}
SettingRow {
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
divider: false
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "Show controls" : "Start focus"
tone: FocusSession.active ? "normal" : "accent"
onClicked: FocusSession.reveal()
}
}
}
}