diff --git a/config/dot/quickshell/modules/settings/HomePage.qml b/config/dot/quickshell/modules/settings/HomePage.qml index 19a2465..f768faf 100644 --- a/config/dot/quickshell/modules/settings/HomePage.qml +++ b/config/dot/quickshell/modules/settings/HomePage.qml @@ -1,3 +1,19 @@ +// Home — the page you open to do something, not to read a report. +// +// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000, +// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest +// thing on the screen, and nobody has ever needed it here. Below it sat a +// permanently open search field for a weather location that is set about once a +// year, and then roughly half a page of nothing. +// +// What Panama already knows is the useful material: whether anything needs +// attention, what is running, and the two or three things worth doing next. A +// finding is shown when there is one and the page is quiet when there is not, +// which is the same shape the Firewall and Containers pages use. +// +// Weather stays. It is the one genuinely ambient thing here, and it belongs +// next to a greeting rather than in a card of its own with a search box open. + import QtQuick import qs.config import qs.services @@ -6,119 +22,101 @@ SettingsPage { id: root readonly property int openedHour: new Date().getHours() - readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening") + readonly property string greeting: openedHour < 12 + ? "Good morning" + : (openedHour < 18 ? "Good afternoon" : "Good evening") + + // Findings, in the order they deserve attention. Each names the page that + // can actually resolve it, because a home page that reports a problem it + // cannot help with is just an alarm. + readonly property var findings: { + const found = []; + const exposed = Firewall.exposedDataStores ?? []; + if (exposed.length > 0) { + found.push({ + label: exposed.length === 1 + ? "A database is reachable from your network" + : "Databases are reachable from your network", + detail: exposed.map(entry => String(entry.name)).join(" and ") + + ", published on every interface", + page: "firewall", + action: "Review" + }); + } + if (Updates.securityCount > 0) { + found.push({ + label: Updates.securityCount + " update" + + (Updates.securityCount === 1 ? "" : "s") + " carry a security advisory", + detail: "Worth installing before the rest", + page: "updates", + action: "Open" + }); + } + if (Updates.rebootNeeded) { + found.push({ + label: "A newer kernel is installed than the one running", + detail: "Restart to use it", + page: "updates", + action: "Open" + }); + } + if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) { + const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0); + found.push({ + label: count + " health check" + (count === 1 ? "" : "s") + " need attention", + detail: "Something the desktop owns is not in its expected state", + page: "services", + action: "Open" + }); + } + return found; + } + + // One line of everything that is fine, so the quiet case still says + // something rather than showing an empty page. + readonly property string reassurance: { + const parts = []; + if (Health.checks.length > 0) + parts.push(Health.checks.length + " health checks pass"); + if (Disks.rootFilesystem) + parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.available ?? 0)) + " free"); + const newest = Snapshots.configs.length > 0 + ? (Snapshots.configs[0].snapshots ?? [])[0] + : null; + if (newest) + parts.push("snapshots ran at " + root.clockOf(String(newest.date ?? ""))); + return parts.join(" · "); + } + + // snapper prints a full date; only the time is wanted in a summary line. + function clockOf(stamp: string): string { + const match = /(\d{1,2}:\d{2})(?::\d{2})?\s*(AM|PM)?/i.exec(stamp); + if (!match) + return stamp; + return match[2] ? match[1] + " " + match[2].toUpperCase() : match[1]; + } title: `${root.greeting}, Gabriel` - lede: "Your desktop is configured and ready." + lede: Weather.available + ? Math.round(Weather.temperature) + Weather.unitSuffix + " and " + + Weather.description.toLowerCase() + " in " + Settings.weatherLocation + : "Your desktop is configured and ready." - SettingsCard { - title: SystemSettings.monitorDescription || "Active display" - subtitle: SystemSettings.monitorName || "Detecting your display…" - - Grid { - id: monitorLayout - - width: parent.width - columns: width >= 620 ? 2 : 1 - columnSpacing: 28 - rowSpacing: 16 - - Item { - width: monitorLayout.columns === 2 - ? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47 - : monitorLayout.width - height: 164 - - Rectangle { - width: Math.min(parent.width - 24, 260) - height: width * 0.64 - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: 5 - radius: 12 - color: Theme.alpha(Theme.bgDark, 0.94) - border.width: 1 - border.color: Theme.alpha(Theme.accent, 0.34) - - Rectangle { - anchors.fill: parent - anchors.margins: 10 - radius: 7 - gradient: Gradient { - orientation: Gradient.Horizontal - GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) } - GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) } - } - - Text { - anchors.centerIn: parent - text: SystemSettings.monitorName || "DISPLAY" - color: Theme.fgMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - font.letterSpacing: 1.5 - } - } - } - } - - Column { - width: monitorLayout.columns === 2 - ? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47 - : monitorLayout.width - height: monitorLayout.columns === 2 ? 164 : implicitHeight - spacing: 13 - - Text { - text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}` - color: Theme.fg - font.family: Theme.fontFamily - font.features: Theme.tabularFigures - font.pixelSize: Theme.fontSizeLarge - font.weight: Font.DemiBold - } - Text { - text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale` - color: Theme.fgDim - font.family: Theme.fontFamily - font.features: Theme.tabularFigures - font.pixelSize: Theme.fontSize - } - Text { - text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}` - color: Theme.fgDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - } - Text { - text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off" - color: SystemSettings.autoHdr ? Theme.ok : Theme.warn - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - } - } - } + Component.onCompleted: { + if (!Firewall.scanned) + Firewall.refresh(); + if (!Containers.scanned) + Containers.refresh(); + if (!Snapshots.scanned) + Snapshots.refresh(); + if (!Disks.scanned) + Disks.refresh(); } - SettingsCard { - title: "Weather" - subtitle: "Local conditions in the date menu" - TextRow { - label: "Location" - detail: "Only the search term is sent; the name below is a label kept on this machine" - value: Settings.weatherLocation - } - - LocationPicker { - width: parent.width - } - - ChoiceRow { setting: "temperatureUnit" } - SliderRow { setting: "weatherRefreshMinutes"; divider: false } - } + // ── What you came here to do ──────────────────────────────────────────── Grid { - id: summaryCards + id: doing width: parent.width columns: width >= 720 ? 2 : 1 @@ -126,20 +124,46 @@ SettingsPage { rowSpacing: 16 SettingsCard { - width: summaryCards.columns === 2 - ? (summaryCards.width - summaryCards.columnSpacing) / 2 - : summaryCards.width - title: "Quiet focus" - subtitle: "Notifications and focused work" + width: doing.columns === 2 + ? (doing.width - doing.columnSpacing) / 2 + : doing.width + title: "Focus" + subtitle: FocusSession.active + ? "A session is running · " + FocusSession.remainingText + " left" + : (FocusModes.active + ? FocusModes.activeName + " is on because " + FocusModes.activeReason + : (Notifs.doNotDisturb + ? "Do Not Disturb is on" + : "Nothing is quieting this machine")) + + SettingRow { + label: FocusSession.active ? "End the session" : "Start a focus session" + detail: FocusSession.active + ? "Puts Do Not Disturb and Caffeine back as they were" + : Settings.focusDurationMinutes + " minutes · Super+Shift+F" + controlWidth: 120 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: FocusSession.active ? "End" : "Start" + tone: FocusSession.active ? "normal" : "accent" + onClicked: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault() + } + } SettingRow { label: "Do Not Disturb" - detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible" + detail: FocusModes.active + ? "Held on by the " + FocusModes.activeName + " mode" + : "Banners are held; the notification centre still fills" divider: false controlWidth: 48 + SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter + enabled: !FocusModes.active && !FocusSession.active checked: Notifs.doNotDisturb onToggled: value => Notifs.doNotDisturb = value } @@ -147,18 +171,119 @@ SettingsPage { } SettingsCard { - width: summaryCards.columns === 2 - ? (summaryCards.width - summaryCards.columnSpacing) / 2 - : summaryCards.width - title: "Desktop services" - subtitle: "The essentials are running" + width: doing.columns === 2 + ? (doing.width - doing.columnSpacing) / 2 + : doing.width + title: "Right now" + subtitle: Containers.running > 0 + ? Containers.running + " of " + Containers.total + " containers running" + : "No containers are running" TextRow { - label: "Sync & remote access" - detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}` + label: Containers.running > 0 + ? Containers.projects.filter(project => project.running > 0) + .map(project => String(project.title)).join(", ") + : "Nothing to report" + detail: Containers.running > 0 + ? "Started for development, still up" + : "Start a stack from the Containers page" + value: "" + } + + ActionRow { + label: "Containers" + detail: "Projects, what they expose, and what they cost" + action: "Open" divider: false - value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review" + onTriggered: ShellState.settingsPage = "containers" } } } + + // ── This machine ──────────────────────────────────────────────────────── + + SettingsCard { + title: "This machine" + subtitle: root.reassurance + + TextRow { + visible: root.findings.length === 0 + label: "Nothing needs your attention" + detail: "Anything worth acting on would appear here." + value: "" + divider: false + } + + Repeater { + model: root.findings + + delegate: SettingRow { + required property var modelData + required property int index + width: parent.width + label: String(modelData.label ?? "") + detail: String(modelData.detail ?? "") + controlWidth: 110 + divider: index < root.findings.length - 1 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: String(modelData.action ?? "Open") + onClicked: ShellState.settingsPage = String(modelData.page) + } + } + } + } + + // ── Worth doing, when there is something ──────────────────────────────── + + SettingsCard { + visible: Updates.total > 0 || Containers.reclaimable > 0 + title: "Do next" + subtitle: "Small things this machine is waiting on." + + ActionRow { + visible: Updates.total > 0 + label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s") + detail: "Packages, applications and firmware, from wherever each comes from" + action: "Open" + divider: Containers.reclaimable > 0 + onTriggered: ShellState.settingsPage = "updates" + } + + ActionRow { + visible: Containers.reclaimable > 0 + label: "Reclaim " + Containers.formatBytes(Containers.reclaimable) + detail: "Container images and volumes nothing references" + action: "Review" + divider: false + onTriggered: ShellState.settingsPage = "containers" + } + } + + // ── Weather ───────────────────────────────────────────────────────────── + + SettingsCard { + title: "Weather" + subtitle: "Shown above and in the date menu." + + // The location is set roughly once a year, so the search that changes it + // is behind a press rather than permanently open. + PickerRow { + id: locationPicker + + label: "Location" + detail: "Only the search term is sent; the name is a label kept on this machine" + value: Settings.weatherLocation + + LocationPicker { + width: parent.width + onPicked: locationPicker.collapse() + } + } + + ChoiceRow { setting: "temperatureUnit" } + SliderRow { setting: "weatherRefreshMinutes"; divider: false } + } } diff --git a/config/dot/quickshell/modules/settings/LocationPicker.qml b/config/dot/quickshell/modules/settings/LocationPicker.qml index 5884ca5..6706e5b 100644 --- a/config/dot/quickshell/modules/settings/LocationPicker.qml +++ b/config/dot/quickshell/modules/settings/LocationPicker.qml @@ -12,6 +12,10 @@ import qs.modules.clipboard Column { id: root + // Emitted once a place has been chosen, so a container can put the search + // away. Choosing is still this component's job; closing is not. + signal picked + spacing: 0 SearchField { @@ -37,8 +41,10 @@ Column { divider: place.index < Geocoding.results.length - 1 activatable: true onActivated: { - if (Geocoding.choose(place.modelData)) + if (Geocoding.choose(place.modelData)) { query.text = ""; + root.picked(); + } } } } diff --git a/config/dot/quickshell/services/FocusModes.qml b/config/dot/quickshell/services/FocusModes.qml index 77b4d03..d082f3f 100644 --- a/config/dot/quickshell/services/FocusModes.qml +++ b/config/dot/quickshell/services/FocusModes.qml @@ -19,6 +19,7 @@ pragma Singleton import Quickshell import Quickshell.Hyprland import QtQuick +import qs.config Singleton { id: root