Finish the wonderland: System told truthfully, in eight tabs instead of ten

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 23:31:52 -04:00
parent 9ffaf45a4d
commit be0e55214b
57 changed files with 5040 additions and 925 deletions
@@ -1,13 +1,30 @@
import QtQuick
// About.
//
// The landing page of the System category, and the answer to "what am I
// running". Every line here is read from the machine rather than written down:
// the versions block used to carry a hardcoded Quickshell version that had been
// wrong for two releases, and a Design principles card restating opinions the
// manual argues properly.
//
// Rows come from MachineInfo, except graphics, which is joined from
// GraphicsDevices rather than read a second time -- two readouts of the same
// hardware are two things that can disagree. Facts the cards below do not claim
// by name are still shown, in Software, so a row the helper learns to report
// cannot go missing here.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "about"
title: "About"
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
lede: "This machine, plainly."
Component.onCompleted: {
if (!MachineInfo.scanned)
@@ -16,22 +33,298 @@ SettingsPage {
GraphicsDevices.refresh();
}
// ── Reading the machine ──────────────────────────────────────────────────
function fact(label: string): string {
const row = (MachineInfo.facts ?? []).find(entry => entry.label === label);
return row ? String(row.value ?? "") : "";
}
// Joined with a middle dot, skipping whatever is absent. Firmware and
// Secure Boot are both absent-tolerant on the helper's side, so either half
// of that row can be missing on a given machine.
function joined(parts: var): string {
return parts.filter(part => String(part ?? "") !== "").join(" · ");
}
readonly property string hostname: root.fact("Hostname")
// "Fedora Linux 44 (Workstation Edition)" says the edition twice for the
// one line where brevity matters most.
readonly property string operatingSystem:
root.fact("Operating system").replace(/\s*\(.*\)\s*$/, "")
readonly property string uptime: root.fact("Uptime").split(",")[0].trim()
readonly property string identity: root.joined([
root.operatingSystem === "" ? "" : "Panama on " + root.operatingSystem,
SystemSettings.hyprlandVersion === "" ? "" : "Hyprland " + SystemSettings.hyprlandVersion,
root.uptime === "" ? "" : "up " + root.uptime
])
readonly property var graphicsRows: {
const gpus = GraphicsDevices.devices ?? [];
return gpus.map((device, index) => ({
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
value: String(device.name ?? "")
}));
}
readonly property string displayValue: root.joined([
SystemSettings.monitorName, root.fact("Resolution")
])
// Everything the three cards below name explicitly. Whatever the helper
// reports that is not in here lands in Software rather than nowhere.
readonly property var claimedFacts: [
"Hostname", "Operating system", "Uptime", "Panama", "Kernel",
"Firmware", "Secure Boot", "Model", "Processor", "Memory", "Swap",
"Disk", "Resolution"
]
readonly property var otherFacts: (MachineInfo.facts ?? [])
.filter(entry => root.claimedFacts.indexOf(String(entry.label ?? "")) < 0)
// ── Copying it ───────────────────────────────────────────────────────────
// The same facts, as plain text, for a bug report or a message to somebody
// trying to help. Built from what is on screen so the two cannot disagree.
function systemInfoText(): string {
const lines = [];
if (root.hostname !== "")
lines.push(root.hostname);
if (root.identity !== "")
lines.push(root.identity);
lines.push("");
for (const row of root.versionRows.concat(root.hardwareRows, root.otherFacts)) {
if (String(row.value ?? "") !== "")
lines.push(row.label + ": " + row.value);
}
return lines.join("\n") + "\n";
}
property string copyResult: ""
Process {
id: copyRun
property string payload: ""
command: ["wl-copy"]
stdinEnabled: true
onStarted: {
copyRun.write(copyRun.payload);
// wl-copy reads stdin until EOF before it exits; leaving the
// channel open would hang it forever waiting for more. Same
// stdinEnabled close that Health.copyReport uses.
copyRun.stdinEnabled = false;
}
onExited: exitCode => {
root.copyResult = exitCode === 0
? "Copied."
: "Could not reach the clipboard.";
copyRun.payload = "";
}
}
function copySystemInfo(): void {
if (copyRun.running)
return;
root.copyResult = "";
copyRun.payload = root.systemInfoText();
copyRun.stdinEnabled = true;
copyRun.running = true;
}
// ── The hero ─────────────────────────────────────────────────────────────
readonly property var versionRows: [
{ label: "Panama", value: root.fact("Panama") },
{ label: "Quickshell", value: SystemSettings.quickshellVersion },
{ label: "Kernel", value: root.fact("Kernel") },
{ label: "Firmware", value: root.joined([
root.fact("Firmware"),
root.fact("Secure Boot") === "" ? "" : "Secure Boot " + root.fact("Secure Boot")
]) }
].filter(row => String(row.value ?? "") !== "")
SettingsCard {
title: "Desktop"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
Item {
width: parent.width
implicitHeight: Math.max(96, heroCopy.implicitHeight + 16)
// The house tile: a rounded plate carrying the System glyph the
// sidebar uses, in the accent, drawn rather than shipped as an
// image so it follows the theme like everything else.
Rectangle {
id: heroArt
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 84
height: 84
radius: 24
color: Theme.alpha(Theme.accent, 0.13)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.3)
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0; color: Theme.alpha(Theme.accentSecondary, 0.14) }
GradientStop { position: 1; color: Theme.alpha(Theme.accent, 0.02) }
}
}
Text {
anchors.centerIn: parent
text: "\u{F02FD}"
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 38
}
}
Column {
id: heroCopy
anchors.left: heroArt.right
anchors.leftMargin: 20
anchors.right: copyButton.left
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
spacing: 5
Text {
width: parent.width
text: root.hostname === "" ? "This machine" : root.hostname
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.Bold
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.identity !== ""
text: root.identity
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: root.copyResult !== ""
text: root.copyResult
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
SettingsButton {
id: copyButton
objectName: "about-copy-button"
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Copy system info"
enabled: !copyRun.running
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.copySystemInfo()
Accessible.role: Accessible.Button
Accessible.name: "Copy system info"
Keys.onReturnPressed: root.copySystemInfo()
Keys.onSpacePressed: root.copySystemInfo()
}
}
Repeater {
model: root.versionRows
TextRow {
required property var modelData
required property int index
label: modelData.label
value: modelData.value
divider: index < root.versionRows.length - 1
}
}
}
// ── Hardware ─────────────────────────────────────────────────────────────
readonly property var hardwareRows: [
{ label: "Model", value: root.fact("Model") },
{ label: "Processor", value: root.fact("Processor") }
].concat(root.graphicsRows, [
{ label: "Memory", value: root.joined([
root.fact("Memory"),
root.fact("Swap") === "" ? "" : root.fact("Swap") + " swap"
]) },
{ label: "Disk", value: root.fact("Disk") },
{ label: "Display", value: root.displayValue }
]).filter(row => String(row.value ?? "") !== "")
SettingsCard {
title: "Hardware"
Repeater {
model: root.hardwareRows
TextRow {
required property var modelData
label: modelData.label
value: modelData.value
}
}
TextRow {
label: "Hyprland"
value: SystemSettings.hyprlandVersion || "Detecting…"
visible: MachineInfo.scanned && root.hardwareRows.length === 0
label: "Hardware"
detail: "The system did not report anything readable"
value: "Unavailable"
}
TextRow {
label: "Quickshell"
value: SystemSettings.quickshellVersion
// The machine's name is a network-facing setting rather than a fact
// about the hardware, and Sharing is the page that owns it. Saying so
// here is cheaper than a second field that writes the same hostname.
ActionRow {
label: "Device name"
detail: root.hostname === ""
? "What this machine calls itself on the network"
: root.hostname + " — what this machine calls itself on the network"
action: "Open Sharing"
divider: false
onTriggered: ShellState.openSettings("sharing")
}
TextRow {
label: "Display"
value: SystemSettings.monitorName || "Detecting…"
}
// ── Software ─────────────────────────────────────────────────────────────
SettingsCard {
title: "Software"
Repeater {
model: root.otherFacts
TextRow {
required property var modelData
label: String(modelData.label ?? "")
value: String(modelData.value ?? "")
}
}
TextRow {
label: "Configuration"
detail: Quickshell.shellDir
@@ -40,74 +333,34 @@ SettingsPage {
}
}
// What GNOME's About panel answers and this page did not: what am I running
// on. Rows come from MachineInfo, except graphics, which is joined from
// GraphicsDevices rather than read a second time -- two readouts of the same
// hardware are two things that can disagree.
//
// The join splices the GPU rows in directly after Processor rather than
// appending them, so the card reads the way fastfetch does: what the system
// is, then what is installed on it, then the hardware underneath. A GPU
// listed after "Disk" reads as an afterthought.
readonly property var machineRows: {
const rows = (MachineInfo.facts ?? []).slice();
const gpus = GraphicsDevices.devices ?? [];
if (gpus.length === 0)
return rows;
// ── Manual ───────────────────────────────────────────────────────────────
const graphics = gpus.map((device, index) => ({
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
value: device.name
}));
const after = rows.findIndex(row => row.label === "Processor");
if (after < 0)
return rows.concat(graphics);
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
}
ManualChapters { id: contents }
SettingsCard {
title: "This machine"
subtitle: "Hardware and system, as the kernel reports it."
title: "Manual"
subtitle: "How this desktop works, written for the person using it. Links inside a chapter open the settings page they name."
Repeater {
model: root.machineRows
id: chapterRows
model: contents.titled
TextRow {
id: machineRow
ActionRow {
required property var modelData
required property int index
label: machineRow.modelData.label
value: machineRow.modelData.value
divider: machineRow.index < root.machineRows.length - 1
label: modelData.title
action: "Read"
onTriggered: ShellState.openSettingsSection("manual", modelData.file)
}
}
TextRow {
visible: MachineInfo.scanned && root.machineRows.length === 0
label: "Hardware"
detail: "The system did not report anything readable"
value: "Unavailable"
divider: false
}
}
SettingsCard {
title: "Design principles"
TextRow {
label: "Curated by default"
detail: "Strong choices instead of an incoherent matrix of switches"
}
TextRow {
label: "Quiet while idle"
detail: "No continuous decorative repaint loops"
}
TextRow {
label: "Real system boundaries"
detail: "Every control either works or clearly hands off to its owner"
ActionRow {
label: "Open the manual"
detail: "Starts at the beginning"
action: "Open"
divider: false
onTriggered: ShellState.openSettings("manual")
}
}
}
@@ -16,11 +16,18 @@ import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
objectName: "containers"
title: "Containers"
lede: "Local services you run for development — what they expose, and what they cost."
// The log reader replaces the page while it is open; SettingsPage owns the
// swap so this file is one page rather than two stacked on a bare Item.
drilledIn: Containers.logTarget !== ""
// "images", "volumes", or empty. Removal never happens on a first press.
property string confirmingPrune: ""
@@ -58,371 +65,269 @@ Item {
// ── the list ────────────────────────────────────────────────────────────
SettingsPage {
anchors.fill: parent
visible: Containers.logTarget === ""
TextRow {
visible: Containers.lastError !== ""
label: "That did not work"
detail: Containers.lastError
value: ""
divider: false
}
title: "Containers"
lede: "Local services you run for development — what they expose, and what they cost."
TextRow {
visible: Containers.scanned && !Containers.available
label: "podman is not available"
detail: "Nothing here can be shown until podman is installed."
value: ""
divider: false
}
TextRow {
visible: Containers.lastError !== ""
label: "That did not work"
detail: Containers.lastError
value: ""
divider: false
}
// ── finding: published to the network ───────────────────────────────
TextRow {
visible: Containers.scanned && !Containers.available
label: "podman is not available"
detail: "Nothing here can be shown until podman is installed."
value: ""
divider: false
}
// ── finding: published to the network ───────────────────────────────
SettingsCard {
visible: Containers.reachable.length > 0
title: Containers.reachable.length === 1
? "A container is published to your whole network"
: "Containers are published to your whole network"
subtitle: "These bind every interface, so any machine on your network can connect. "
+ "For a development database, loopback is almost always what you want. "
+ "Binding adds an address to the compose file and changes nothing else."
Repeater {
model: Containers.reachable
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.container ?? "")
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
+ " · " + String(modelData.image ?? "")
+ (modelData.configFile
? "\n" + Containers.shorten(String(modelData.configFile))
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Open compose file"
visible: String(modelData.configFile ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.openFile(String(modelData.configFile))
}
SettingsButton {
text: "Bind to localhost"
tone: "accent"
// Needs both halves of the label to find the entry in
// the compose file; the Supabase CLI records neither.
visible: String(modelData.configFile ?? "") !== ""
&& String(modelData.service ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.bindLocal(
String(modelData.project), String(modelData.service))
}
}
}
}
TextRow {
visible: Containers.reachable.length > 0
label: "After binding"
detail: "The change takes effect the next time the stack comes up, because a "
+ "published port is fixed when the container is created."
value: ""
divider: false
}
}
// ── finding: disk nothing references ────────────────────────────────
SettingsCard {
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
subtitle: "Images and volumes no container references. Removing them frees the space; "
+ "anything still needed downloads again on next use."
Repeater {
model: Containers.unusedImages.slice(0, 5)
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: ""
value: Containers.formatBytes(Number(modelData.size ?? 0))
}
}
TextRow {
visible: Containers.unusedImages.length > 5
label: "and " + (Containers.unusedImages.length - 5) + " more"
detail: ""
value: ""
}
SettingRow {
width: parent.width
label: root.confirmingPrune === "images"
? "Remove " + Containers.unusedImages.length + " images?"
: "Unused images"
detail: root.confirmingPrune === "images"
? "Each is removed by name. Nothing that a container references is touched."
: Containers.unusedImages.length + " images, "
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
visible: Containers.unusedImages.length > 0
controlWidth: 230
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "images" ? "" : "images"
}
SettingsButton {
visible: root.confirmingPrune === "images"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneImages();
}
}
}
}
SettingRow {
width: parent.width
visible: Containers.unusedVolumes.length > 0
label: root.confirmingPrune === "volumes"
? "Remove " + Containers.unusedVolumes.length + " volumes?"
: "Unused volumes"
detail: root.confirmingPrune === "volumes"
? "A volume holds data. These are the ones podman reports as referenced by "
+ "nothing, but removing them cannot be undone."
: Containers.unusedVolumes.length + " volumes, "
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
controlWidth: 230
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "volumes" ? "" : "volumes"
}
SettingsButton {
visible: root.confirmingPrune === "volumes"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneVolumes();
}
}
}
}
}
// ── the stacks ──────────────────────────────────────────────────────
SettingsCard {
visible: Containers.reachable.length > 0
title: Containers.reachable.length === 1
? "A container is published to your whole network"
: "Containers are published to your whole network"
subtitle: "These bind every interface, so any machine on your network can connect. "
+ "For a development database, loopback is almost always what you want. "
+ "Binding adds an address to the compose file and changes nothing else."
Repeater {
model: Containers.projects
delegate: SettingsCard {
id: projectCard
model: Containers.reachable
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.container ?? "")
detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp")
+ " · " + String(modelData.image ?? "")
+ (modelData.configFile
? "\n" + Containers.shorten(String(modelData.configFile))
: "\nNo compose file is recorded for this container, so it cannot be bound from here.")
controlWidth: 250
readonly property var runningSet:
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
readonly property var stoppedSet:
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
readonly property string projectName: String(projectCard.modelData.name ?? "")
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
title: String(projectCard.modelData.title ?? "")
subtitle: {
const total = Number(projectCard.modelData.total ?? 0);
const up = Number(projectCard.modelData.running ?? 0);
const where = String(projectCard.modelData.configFile ?? "");
const counts = up === 0
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
: up + " of " + total + " running";
return where === "" ? counts : counts + " · " + Containers.shorten(where);
}
SettingRow {
width: parent.width
label: "The whole stack"
detail: projectCard.runningSet.length === 0
? "Starts every container this project defines."
: "Stopping leaves the containers in place; nothing is removed."
controlWidth: 250
divider: projectCard.runningSet.length > 0
|| projectCard.stoppedSet.length > 0
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Start all"
visible: projectCard.stoppedSet.length > 0
enabled: !Containers.busy
onClicked: Containers.startProject(projectCard.projectName)
}
SettingsButton {
text: "Restart all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.restartProject(projectCard.projectName)
}
SettingsButton {
text: "Stop all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.stopProject(projectCard.projectName)
}
SettingsButton {
text: "Open compose file"
visible: String(modelData.configFile ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.openFile(String(modelData.configFile))
}
}
Repeater {
model: projectCard.runningSet
delegate: SettingRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
value: {
const health = String(modelData.health ?? "");
const up = root.uptimeOf(modelData);
if (health !== "" && up !== "")
return health + " · " + up;
return health !== "" ? health : up;
}
controlWidth: 250
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Restart"
enabled: !Containers.busy
onClicked: Containers.restart(String(modelData.name))
}
SettingsButton {
text: "Stop"
enabled: !Containers.busy
onClicked: Containers.stop(String(modelData.name))
}
}
}
}
SettingRow {
width: parent.width
visible: projectCard.stoppedSet.length > 0
activatable: true
divider: root.isExpanded(projectCard.projectName)
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
+ projectCard.stoppedSet.length
+ (projectCard.stoppedSet.length === 1
? " stopped container" : " stopped containers")
detail: {
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
return bad.length === 0
? ""
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
}
onActivated: root.toggleExpanded(projectCard.projectName)
}
Repeater {
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < projectCard.stoppedSet.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Start"
enabled: !Containers.busy
onClicked: Containers.start(String(modelData.name))
}
}
SettingsButton {
text: "Bind to localhost"
tone: "accent"
// Needs both halves of the label to find the entry in
// the compose file; the Supabase CLI records neither.
visible: String(modelData.configFile ?? "") !== ""
&& String(modelData.service ?? "") !== ""
enabled: !Containers.busy
onClicked: Containers.bindLocal(
String(modelData.project), String(modelData.service))
}
}
}
}
// ── containers no compose project claims ────────────────────────────
TextRow {
visible: Containers.reachable.length > 0
label: "After binding"
detail: "The change takes effect the next time the stack comes up, because a "
+ "published port is fixed when the container is created."
value: ""
divider: false
}
}
SettingsCard {
visible: Containers.loose.length > 0
title: "Not part of a project"
subtitle: "Started directly rather than by a compose file."
// ── finding: disk nothing references ────────────────────────────────
SettingsCard {
visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0
title: Containers.formatBytes(Containers.reclaimable) + " nothing is using"
subtitle: "Images and volumes no container references. Removing them frees the space; "
+ "anything still needed downloads again on next use."
Repeater {
model: Containers.unusedImages.slice(0, 5)
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.name ?? "")
detail: ""
value: Containers.formatBytes(Number(modelData.size ?? 0))
}
}
TextRow {
visible: Containers.unusedImages.length > 5
label: "and " + (Containers.unusedImages.length - 5) + " more"
detail: ""
value: ""
}
SettingRow {
width: parent.width
label: root.confirmingPrune === "images"
? "Remove " + Containers.unusedImages.length + " images?"
: "Unused images"
detail: root.confirmingPrune === "images"
? "Each is removed by name. Nothing that a container references is touched."
: Containers.unusedImages.length + " images, "
+ Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0))
visible: Containers.unusedImages.length > 0
controlWidth: 230
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "images" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "images" ? "" : "images"
}
SettingsButton {
visible: root.confirmingPrune === "images"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneImages();
}
}
}
}
SettingRow {
width: parent.width
visible: Containers.unusedVolumes.length > 0
label: root.confirmingPrune === "volumes"
? "Remove " + Containers.unusedVolumes.length + " volumes?"
: "Unused volumes"
detail: root.confirmingPrune === "volumes"
? "A volume holds data. These are the ones podman reports as referenced by "
+ "nothing, but removing them cannot be undone."
: Containers.unusedVolumes.length + " volumes, "
+ Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0))
controlWidth: 230
divider: false
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…"
enabled: !Containers.busy
onClicked: root.confirmingPrune =
root.confirmingPrune === "volumes" ? "" : "volumes"
}
SettingsButton {
visible: root.confirmingPrune === "volumes"
text: "Remove them"
tone: "danger"
enabled: !Containers.busy
onClicked: {
root.confirmingPrune = "";
Containers.pruneVolumes();
}
}
}
}
}
// ── the stacks ──────────────────────────────────────────────────────
Repeater {
model: Containers.projects
delegate: SettingsCard {
id: projectCard
required property var modelData
readonly property var runningSet:
(projectCard.modelData.containers ?? []).filter(c => c.state === "running")
readonly property var stoppedSet:
(projectCard.modelData.containers ?? []).filter(c => c.state !== "running")
readonly property string projectName: String(projectCard.modelData.name ?? "")
title: String(projectCard.modelData.title ?? "")
subtitle: {
const total = Number(projectCard.modelData.total ?? 0);
const up = Number(projectCard.modelData.running ?? 0);
const where = String(projectCard.modelData.configFile ?? "");
const counts = up === 0
? total + (total === 1 ? " container, stopped" : " containers, all stopped")
: up + " of " + total + " running";
return where === "" ? counts : counts + " · " + Containers.shorten(where);
}
SettingRow {
width: parent.width
label: "The whole stack"
detail: projectCard.runningSet.length === 0
? "Starts every container this project defines."
: "Stopping leaves the containers in place; nothing is removed."
controlWidth: 250
divider: projectCard.runningSet.length > 0
|| projectCard.stoppedSet.length > 0
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Start all"
visible: projectCard.stoppedSet.length > 0
enabled: !Containers.busy
onClicked: Containers.startProject(projectCard.projectName)
}
SettingsButton {
text: "Restart all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.restartProject(projectCard.projectName)
}
SettingsButton {
text: "Stop all"
visible: projectCard.runningSet.length > 0
enabled: !Containers.busy
onClicked: Containers.stopProject(projectCard.projectName)
}
}
}
Repeater {
model: Containers.loose
model: projectCard.runningSet
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < Containers.loose.length - 1
detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData)
value: {
const health = String(modelData.health ?? "");
const up = root.uptimeOf(modelData);
if (health !== "" && up !== "")
return health + " · " + up;
return health !== "" ? health : up;
}
controlWidth: 250
Row {
anchors.right: parent.right
@@ -435,122 +340,217 @@ Item {
}
SettingsButton {
text: modelData.state === "running" ? "Stop" : "Start"
text: "Restart"
enabled: !Containers.busy
onClicked: modelData.state === "running"
? Containers.stop(String(modelData.name))
: Containers.start(String(modelData.name))
onClicked: Containers.restart(String(modelData.name))
}
SettingsButton {
text: "Stop"
enabled: !Containers.busy
onClicked: Containers.stop(String(modelData.name))
}
}
}
}
SettingRow {
width: parent.width
visible: projectCard.stoppedSet.length > 0
activatable: true
divider: root.isExpanded(projectCard.projectName)
label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ")
+ projectCard.stoppedSet.length
+ (projectCard.stoppedSet.length === 1
? " stopped container" : " stopped containers")
detail: {
const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0);
return bad.length === 0
? ""
: bad.length + (bad.length === 1 ? " exited" : " exited") + " badly.";
}
onActivated: root.toggleExpanded(projectCard.projectName)
}
Repeater {
model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : []
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < projectCard.stoppedSet.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: "Start"
enabled: !Containers.busy
onClicked: Containers.start(String(modelData.name))
}
}
}
}
}
}
TextRow {
visible: Containers.scanned && Containers.available && Containers.total === 0
label: "No containers"
detail: "Nothing has been created on this machine yet."
value: ""
divider: false
// ── containers no compose project claims ────────────────────────────
SettingsCard {
visible: Containers.loose.length > 0
title: "Not part of a project"
subtitle: "Started directly rather than by a compose file."
Repeater {
model: Containers.loose
delegate: SettingRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "")
controlWidth: 180
divider: index < Containers.loose.length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Logs"
onClicked: Containers.openLogs(String(modelData.name))
}
SettingsButton {
text: modelData.state === "running" ? "Stop" : "Start"
enabled: !Containers.busy
onClicked: modelData.state === "running"
? Containers.stop(String(modelData.name))
: Containers.start(String(modelData.name))
}
}
}
}
}
TextRow {
visible: Containers.scanned && Containers.available && Containers.total === 0
label: "No containers"
detail: "Nothing has been created on this machine yet."
value: ""
divider: false
}
// ── logs ────────────────────────────────────────────────────────────────
//
// A drill-in rather than a panel inside the scrolling page: logs need their
// own scrollback, and nesting one scrolling view inside another makes the
// wheel ambiguous over the region where you most want to use it.
Item {
anchors.fill: parent
visible: Containers.logTarget !== ""
drillIn: Component {
Item {
anchors.fill: parent
Column {
id: logHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
spacing: 6
Column {
id: logHeader
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
spacing: 6
Row {
width: parent.width
spacing: 12
Row {
width: parent.width
spacing: 12
SettingsButton {
text: " Back"
anchors.verticalCenter: parent.verticalCenter
onClicked: Containers.closeLogs()
SettingsButton {
text: " Back"
anchors.verticalCenter: parent.verticalCenter
onClicked: Containers.closeLogs()
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Containers.logTarget
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 22
font.weight: Font.DemiBold
}
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Containers.logTarget
color: Theme.fg
width: parent.width
text: Containers.logError !== ""
? Containers.logError
: (Containers.logFollowing
? "Following. The last " + Containers.logLines.count + " lines are shown."
: "The stream has ended.")
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 22
font.weight: Font.DemiBold
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Text {
width: parent.width
text: Containers.logError !== ""
? Containers.logError
: (Containers.logFollowing
? "Following. The last " + Containers.logLines.count + " lines are shown."
: "The stream has ended.")
color: Containers.logError !== "" ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: logHeader.bottom
anchors.bottom: parent.bottom
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 14
anchors.bottomMargin: 30
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.bgDark, 0.75)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: logHeader.bottom
anchors.bottom: parent.bottom
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 14
anchors.bottomMargin: 30
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.bgDark, 0.75)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
ListView {
id: logList
ListView {
id: logList
anchors.fill: parent
anchors.margins: 12
clip: true
model: Containers.logLines
spacing: 1
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 400
anchors.fill: parent
anchors.margins: 12
clip: true
model: Containers.logLines
spacing: 1
boundsBehavior: Flickable.StopAtBounds
cacheBuffer: 400
// Stay pinned to the newest line while the reader is already at
// the bottom, and leave the view alone the moment they scroll up
// to read something.
property bool pinned: true
onContentYChanged: logList.pinned =
logList.contentY >= logList.contentHeight - logList.height - 24
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
// Stay pinned to the newest line while the reader is already at
// the bottom, and leave the view alone the moment they scroll up
// to read something.
property bool pinned: true
onContentYChanged: logList.pinned =
logList.contentY >= logList.contentHeight - logList.height - 24
onCountChanged: if (logList.pinned) logList.positionViewAtEnd()
delegate: Text {
required property string line
width: logList.width - 24
text: line
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
textFormat: Text.PlainText
delegate: Text {
required property string line
width: logList.width - 24
text: line
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
textFormat: Text.PlainText
}
}
}
}
@@ -1,16 +1,22 @@
// Date & Time.
// Date, Time & Region.
//
// These belong to the machine rather than to Panama, so nothing here is stored
// in Panama's settings file -- it would be a second answer to a question the
// system already answers. Timezone and network time are read from and written
// to timedatectl directly. 24-hour time is the one presentation choice that
// belongs here rather than on Shell Bar: it drives the date menu,
// notification timestamps and the lock screen as well as the bar. The rest of
// the bar clock's presentation -- seconds, weekday -- stays with the bar.
// One tab, because they are one question: what does this machine consider
// local. It used to be two -- Date & Time here, Region & Language one tab over
// -- and the second was mostly a button that opened GNOME.
//
// Changing the timezone or network time needs privilege. timedatectl asks
// polkit, and a canceled dialog surfaces as an error rather than as a value
// that appears to have been accepted.
// Nothing on this page is stored in Panama's settings file except 24-hour time.
// The timezone and network time belong to timedatectl; the language and the
// per-category formats belong to localectl. A preference here would be a second
// answer to a question the system already answers, and the two would drift the
// moment anything else changed one of them.
//
// Changing any of it needs privilege. Both helpers ask polkit, and a canceled
// dialog surfaces as an error rather than as a value that appears to have been
// accepted.
//
// The preview at the bottom is rendered by Qt from the *chosen* locales rather
// than described in prose, because "1,234,567.89" settles in one glance what a
// sentence about separators does not.
import QtQuick
import Quickshell
@@ -21,29 +27,42 @@ import qs.modules.clipboard
SettingsPage {
id: root
title: "Date & Time"
lede: "Timezone and network time, shared with the whole machine."
objectName: "datetime"
title: "Date, Time & Region"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: root.nowLine
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
readonly property string nowLine:
Qt.formatDateTime(clock.date, Settings.use24Hour ? "HH:mm" : "h:mm AP")
+ " · " + Qt.formatDate(clock.date, "dddd, MMMM d")
+ (DateTime.timezone === "" ? "" : " · " + DateTime.timezone) + "."
// ── The clock ────────────────────────────────────────────────────────────
// What the system itself says the time is, in the shape `timedatectl
// set-time` accepts, so the manual field starts from the truth rather than
// from Qt's idea of it. Falls back to the shell's own clock on a machine
// whose timedatectl phrases TimeUSec differently.
function seedTime(): string {
const match = /(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/.exec(String(DateTime.localTime ?? ""));
return match
? match[1] + " " + match[2]
: Qt.formatDateTime(clock.date, "yyyy-MM-dd HH:mm");
}
SettingsCard {
title: "Clock"
TextRow {
label: "Current time"
detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone
value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP")
}
// Not just the bar's. The same choice reads out in the date menu,
// every notification's timestamp, and the lock screen, so it belongs
// beside the clock the whole machine shares rather than on a page
// about one surface.
ToggleRow { setting: "use24Hour" }
SettingRow {
label: "Set automatically"
detail: DateTime.ntpEnabled
? (DateTime.ntpSynchronized ? "Synchronized with a time server" : "Waiting to synchronize")
: "The clock is set by hand"
controlWidth: 48
divider: false
SettingsToggle {
anchors.right: parent.right
@@ -53,6 +72,32 @@ SettingsPage {
onToggled: value => DateTime.setNtp(value)
}
}
// Only offered when there is something to do: with a time server in
// charge, setting the clock by hand is refused by timedatectl anyway,
// and a field that always fails is worse than no field.
FieldActionRow {
id: manualClock
visible: !DateTime.ntpEnabled
label: "Set the clock"
detail: "Date and time as YYYY-MM-DD HH:MM"
placeholder: "2026-01-31 09:00"
action: "Set"
fieldWidth: 160
enabled: !DateTime.busy
onSubmitted: value => DateTime.setTime(value)
// Seeded when the row appears rather than bound, so typing is not
// yanked out from under anyone by the next status read.
onVisibleChanged: if (visible && text === "") text = root.seedTime()
}
// Not just the bar's. The same choice reads out in the date menu,
// every notification's timestamp, and the lock screen, so it belongs
// beside the clock the whole machine shares rather than on a page
// about one surface.
ToggleRow { setting: "use24Hour"; divider: false }
}
SettingsCard {
@@ -109,6 +154,265 @@ SettingsPage {
subtitle: DateTime.lastError
}
// ── Language and formats ─────────────────────────────────────────────────
// Each category may follow the language or override it. An empty override
// is what "Match language" means, and it is what localectl stores: no
// LC_TIME line at all, rather than a copy of LANG that stops tracking it.
function categoryValue(category: string): string {
return String(SystemLocale.categoryValue(category) ?? "");
}
// The locale actually in force for a category, which is what the preview
// must be rendered from.
function effectiveLocale(category: string): string {
const override = root.categoryValue(category);
return override === "" ? SystemLocale.current : override;
}
function localeLabel(value: string): string {
const match = (SystemLocale.locales ?? []).find(entry => entry.value === value);
return match ? match.label : value;
}
// "Match language" first, then everything installed. The first entry has an
// empty value because that is literally what it sets.
readonly property var categoryChoices: [{
value: "",
label: "Match language",
detail: root.localeLabel(SystemLocale.current)
}].concat(SystemLocale.locales ?? [])
// Qt wants "en_US"; localectl deals in "en_US.UTF-8". QLocale tolerates the
// codeset, but the modifier forms do not all round-trip, so it is trimmed.
function qtLocale(value: string): var {
return Qt.locale(String(value ?? "").split(".")[0].split("@")[0]);
}
readonly property var timeLocale: root.qtLocale(root.effectiveLocale("LC_TIME"))
readonly property var numberLocale: root.qtLocale(root.effectiveLocale("LC_NUMERIC"))
readonly property var currencyLocale: root.qtLocale(root.effectiveLocale("LC_MONETARY"))
readonly property var measurementLocale: root.qtLocale(root.effectiveLocale("LC_MEASUREMENT"))
readonly property var weekdayNames: [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
]
// Measurement and paper are chosen together by one row, because glibc ships
// them from the same country and nobody sets one without the other. When
// they have been set apart by hand the row says so rather than hiding it.
readonly property bool paperDiffers:
root.categoryValue("LC_PAPER") !== root.categoryValue("LC_MEASUREMENT")
SettingsCard {
title: "Language & formats"
subtitle: "Changing any of these needs your password, and takes effect for programs started afterwards."
PickerRow {
id: languagePicker
label: "Language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => {
SystemLocale.set(value);
languagePicker.collapse();
}
}
}
PickerRow {
id: timePicker
label: "Dates and times"
detail: "LC_TIME — the order of day and month, and the shape of the clock"
value: SystemLocale.categoryLabel("LC_TIME")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_TIME")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_TIME", value);
timePicker.collapse();
}
}
}
PickerRow {
id: numberPicker
label: "Numbers"
detail: "LC_NUMERIC — the decimal mark and the thousands separator"
value: SystemLocale.categoryLabel("LC_NUMERIC")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_NUMERIC")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_NUMERIC", value);
numberPicker.collapse();
}
}
}
PickerRow {
id: currencyPicker
label: "Currency"
detail: "LC_MONETARY — which symbol, and which side of the number it sits on"
value: SystemLocale.categoryLabel("LC_MONETARY")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_MONETARY")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_MONETARY", value);
currencyPicker.collapse();
}
}
}
PickerRow {
id: measurementPicker
label: "Measurements and paper"
detail: root.paperDiffers
? "LC_MEASUREMENT and LC_PAPER — set apart from each other on this machine; choosing here sets both"
: "LC_MEASUREMENT and LC_PAPER — metric or imperial, and the default page size"
value: SystemLocale.categoryLabel("LC_MEASUREMENT")
SearchPicker {
width: parent.width
items: root.categoryChoices
current: root.categoryValue("LC_MEASUREMENT")
placeholder: "Search locales"
onPicked: value => {
SystemLocale.setCategory("LC_MEASUREMENT", value);
SystemLocale.setCategory("LC_PAPER", value);
measurementPicker.collapse();
}
}
}
// A fact, not a switch: glibc ships the first day of the week with the
// date format, and there is no separate thing to set.
TextRow {
label: "First day of the week"
detail: "Comes with the date format — glibc owns it, so this is a readout rather than a choice"
value: root.weekdayNames[root.timeLocale.firstDayOfWeek] ?? ""
}
// ── The preview ──────────────────────────────────────────────────────
Item {
width: parent.width
implicitHeight: previewFrame.implicitHeight + 20
Rectangle {
id: previewFrame
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 6
implicitHeight: previewGrid.implicitHeight + 22
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
Grid {
id: previewGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
columns: previewFrame.width >= 520 ? 2 : 1
columnSpacing: 18
rowSpacing: 5
Repeater {
model: root.previewCells
Row {
required property var modelData
width: (previewGrid.width - (previewGrid.columns - 1) * previewGrid.columnSpacing)
/ previewGrid.columns
spacing: 10
Text {
width: 78
text: modelData.label
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width - 88
text: modelData.value
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
}
}
}
}
}
}
// Paper size is deliberately absent above. Qt reports a locale's
// measurement system and not its page size, and deriving Letter from a
// country list would be Panama inventing an answer glibc already holds
// -- a preview that guesses is worse than a preview that stops.
TextRow {
label: "Paper size is not previewed"
detail: "Qt can report a locale's measurement system but not its page size, and guessing it from the country would be this page making something up."
value: ""
divider: false
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "The system did not accept that"
subtitle: SystemLocale.lastError
}
readonly property var previewCells: {
const sample = clock.date;
return [
{ label: "Today", value: sample.toLocaleDateString(root.timeLocale, Locale.LongFormat) },
{ label: "Time", value: sample.toLocaleTimeString(root.timeLocale, Locale.ShortFormat) },
{ label: "Number", value: (1234567.89).toLocaleString(root.numberLocale) },
{ label: "Currency", value: (1234.56).toLocaleCurrencyString(root.currencyLocale) },
{ label: "Units", value: root.measurementLocale.measurementSystem === Locale.MetricSystem
? "Metric — °C, km, kg"
: "Imperial — °F, miles, lb" },
{ label: "Short date", value: sample.toLocaleDateString(root.timeLocale, Locale.ShortFormat) }
];
}
// Bounded so a blank search does not try to lay out six hundred rows. The
// current zone is always included, so the card never looks empty when the
// field is untouched.
@@ -0,0 +1,120 @@
// A row whose button takes a short piece of typed text as its argument.
//
// FieldActionRow {
// label: "Back up now"
// placeholder: "Name (optional)"
// action: "Back up"
// onSubmitted: value => SettingsBackup.save(value)
// }
//
// TextFieldRow is for a value that IS the setting -- a hostname, an account's
// real name -- and keeps showing whatever the system currently holds. This is
// for a one-shot whose argument happens to be typed: naming a backup, setting
// the clock by hand. The field holds an argument rather than a value, so the
// caller clears it once the action is away instead of leaving text sitting
// there looking like something that took effect.
import QtQuick
import qs.config
SettingRow {
id: root
property string placeholder: ""
property string action: ""
property bool enabled: true
property int fieldWidth: 150
// What is typed right now, so a caller can require it before enabling the
// button, or seed it with a sensible starting point.
property alias text: input.text
signal submitted(value: string)
function clear(): void { input.text = ""; }
function press(): void {
if (root.enabled)
root.submitted(input.text.trim());
}
controlWidth: root.fieldWidth + submitButton.implicitWidth + 10
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: root.fieldWidth
height: 32
radius: 9
color: Theme.alpha(Theme.fg, root.enabled ? 0.06 : 0.03)
border.width: input.activeFocus ? 2 : 1
border.color: input.activeFocus
? Theme.alpha(Theme.accent, 0.55)
: Theme.alpha(Theme.fg, 0.1)
opacity: root.enabled ? 1 : 0.5
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 11
anchors.rightMargin: 11
enabled: root.enabled
activeFocusOnTab: root.enabled
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
verticalAlignment: TextInput.AlignVCenter
clip: true
Accessible.role: Accessible.EditableText
Accessible.name: root.label
Accessible.description: root.placeholder
// Enter in the field is the same press as the button. Typing a
// name and hitting return is what everybody does first.
onAccepted: root.press()
Text {
anchors.fill: parent
visible: input.text === ""
text: root.placeholder
color: Theme.fgMuted
font: input.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
SettingsButton {
id: submitButton
anchors.verticalCenter: parent.verticalCenter
text: root.action
enabled: root.enabled
activeFocusOnTab: root.enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.press()
Accessible.role: Accessible.Button
Accessible.name: root.label
Accessible.description: root.detail === ""
? root.action
: `${root.detail} ${root.action}`
Accessible.focusable: root.enabled
Accessible.onPressAction: root.press()
Keys.onReturnPressed: root.press()
Keys.onEnterPressed: root.press()
Keys.onSpacePressed: root.press()
}
}
}
@@ -15,6 +15,13 @@ Item {
readonly property bool repairFailed: root.check.status !== "ok"
&& Health.lastRepair.checkId === root.check.id
&& (Health.lastRepair.accepted === false || Health.lastRepair.exitCode !== 0)
readonly property bool rechecking: Health.refreshingId === root.check.id
&& Health.refreshingCheck
// What the row says under its title. Defaults to the check's own detail;
// the page overrides it where it has more to say -- a repair row is given
// the command it would run.
property string detailText: String(root.check.detail ?? "")
objectName: `health-check-row:${root.issue ? "issue" : "quiet"}:${root.check.id}`
implicitHeight: 62
@@ -86,7 +93,7 @@ Item {
Text {
width: parent.width
text: root.check.detail
text: root.detailText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -104,7 +111,7 @@ Item {
Text {
objectName: `health-status-text:${root.issue ? "issue" : "quiet"}:${root.check.id}`
anchors.verticalCenter: parent.verticalCenter
text: root.displayedStatus()
text: root.rechecking ? "Checking…" : root.displayedStatus()
color: root.statusColor(root.check.status)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
@@ -124,6 +131,26 @@ Item {
Keys.onReturnPressed: if (enabled) root.actionRequested(root.check)
Keys.onSpacePressed: if (enabled) root.actionRequested(root.check)
}
// One probe rather than thirty. After a repair -- or after fixing
// something by hand in a terminal -- the question is whether THIS row
// is happy now, and a full rescan to answer it costs nine seconds and
// re-renders the whole page. Offered on rows that are unhappy, since a
// healthy row has nothing to re-ask.
SettingsButton {
id: recheckButton
objectName: `health-row-recheck:${root.check.id}`
visible: root.issue
text: root.rechecking ? "Checking…" : "Re-check"
enabled: visible && !Health.busy && !Health.refreshingCheck
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: Health.refreshCheck(root.check.id)
Keys.onReturnPressed: if (enabled) Health.refreshCheck(root.check.id)
Keys.onSpacePressed: if (enabled) Health.refreshCheck(root.check.id)
}
}
Rectangle {
@@ -8,7 +8,17 @@ SettingsPage {
objectName: "system-health-page"
title: "System Health"
lede: "Checks the parts of the desktop this app owns, and explains what needs attention."
lede: root.ledeText
readonly property string ledeText: {
if (Health.checks.length === 0)
return "Checks the parts of the desktop this app owns, and explains what needs attention.";
const count = Health.checks.length + (Health.checks.length === 1 ? " check" : " checks");
if (root.issueChecks.length === 0)
return count + " · everything healthy.";
return count + " · " + root.issueChecks.length
+ (root.issueChecks.length === 1 ? " needs" : " need") + " a look.";
}
property var pendingConfirmation: null
property string instructionTarget: ""
@@ -43,11 +53,17 @@ SettingsPage {
"integration.bluebubbles": "bluebubbles"
})
function statusLabel(status: string): string {
if (status === "ok") return "Healthy";
if (status === "warning") return "Needs attention";
if (status === "error") return "Action required";
return "Not set up";
// What a row says under its title. For a repair, that includes the command
// the button would run -- said before it runs rather than in a log
// afterwards. "Restart Vicinae" is what the button says; what it does is
// run something as the person pressing it, and by the time a log could
// tell them, the decision is already made.
function repairDetail(check: var): string {
const detail = String(check.detail ?? "");
const command = String(check.repairCommand ?? "");
if (command === "" || check.action?.kind !== "repair")
return detail;
return detail + " · Repair runs: " + command;
}
function checksForGroup(group: string): var {
@@ -285,6 +301,7 @@ SettingsPage {
width: issueRows.width
check: modelData
detailText: root.repairDetail(modelData)
issue: true
divider: index < issueRepeater.count - 1
onActionRequested: check => root.handleAction(check)
@@ -379,6 +396,7 @@ SettingsPage {
width: groupRows.width
check: modelData
detailText: root.repairDetail(modelData)
divider: index < groupRepeater.count - 1
onActionRequested: check => root.handleAction(check)
}
@@ -388,6 +406,26 @@ SettingsPage {
}
}
// Copying the report lives on the hero, where it has always been. Saving
// one is the other half: a clipboard survives until the next copy, and a
// report somebody is going to attach to a message has to be a file.
SettingsCard {
title: "Report"
subtitle: "Everything the last check saw, with the repair commands, redacted the same way the clipboard copy is."
ActionRow {
objectName: "health-save-report-row"
label: "Save the report to a file"
detail: Health.lastSaveResult !== ""
? Health.lastSaveResult
: Health.defaultReportPath
action: "Save"
enabled: Health.checks.length > 0
divider: false
onTriggered: Health.saveReport(Health.defaultReportPath)
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "These areas remain owned by Fedora and GNOME's mature system panels."
@@ -427,14 +465,12 @@ SettingsPage {
}
}
ActionRow {
objectName: "health-fedora-handoff:color"
label: "Color profiles"
detail: "Assigning an ICC profile here has no visible effect in this session: the colord daemon that loads it onto a display isn't running under Hyprland"
action: "Open color"
onTriggered: SystemSettings.openGnomePanel("color")
}
// Color profiles used to have a row here whose own detail explained
// that pressing it changed nothing -- the colord daemon that applies an
// ICC profile is not running under Hyprland. A handoff that documents
// its own uselessness is not a boundary, it is a dead button with an
// apology attached, so it is gone. Digital wellbeing stays: GNOME
// genuinely owns screen time, and that button genuinely works.
ActionRow {
objectName: "health-fedora-handoff:wellbeing"
label: "Digital wellbeing"
@@ -0,0 +1,83 @@
// The manual's table of contents, with the titles read from the manual itself.
//
// Chapter files are numbered so their order is their filename, and each one
// begins with its own title as a first-level heading. Reading that heading from
// the file rather than repeating it in QML means a chapter cannot be renamed in
// one place and not the other -- which is what had already happened by the time
// anybody compared the two. There is deliberately no `label:` beside a filename
// below; if one appears, the drift is back.
//
// Two pages need this list: the reader, and About's Manual card. Neither owns
// it, so it lives here and both instantiate it. This is the one place the
// chapter files are named.
//
// The chapters live beside the shell in manual/, reached through
// Quickshell.shellDir: that resolves whether or not the repository is where it
// usually is, and a path walked upward out of the shell directory does not.
import Quickshell
import Quickshell.Io
import QtQml
import QtQuick
Item {
id: root
// Display order is filename order.
readonly property var chapters: [
{ file: "01-coming-from-another-desktop.md" },
{ file: "02-the-keyboard.md" },
{ file: "03-windows-and-workspaces.md" },
{ file: "04-when-something-breaks.md" },
{ file: "05-making-it-yours.md" }
]
// file -> first heading, filled in as each file is read.
property var headings: ({})
// [{ file, title }]. A chapter whose file has not been read yet -- or
// cannot be -- falls back to a title made out of its filename, so the list
// is never blank and never a row of paths.
readonly property var titled: root.chapters.map(chapter => ({
file: chapter.file,
title: String(root.headings[chapter.file] ?? "") !== ""
? root.headings[chapter.file]
: root.titleFromFilename(chapter.file)
}))
function titleFromFilename(file: string): string {
const stem = file.replace(/^\d+-/, "").replace(/\.md$/, "").replace(/-/g, " ");
return stem.charAt(0).toUpperCase() + stem.slice(1);
}
// The first level-one heading, which every chapter opens with.
function headingOf(text: string): string {
for (const line of String(text ?? "").split("\n")) {
const match = /^#\s+(.+?)\s*$/.exec(line);
if (match)
return match[1];
}
return "";
}
// Reassigned rather than mutated: a property change on a plain object does
// not fire, and `titled` would keep answering with the old headings.
function absorb(file: string, text: string): void {
const next = Object.assign({}, root.headings);
next[file] = root.headingOf(text);
root.headings = next;
}
Instantiator {
model: root.chapters
delegate: FileView {
required property var modelData
path: Quickshell.shellDir + "/manual/" + modelData.file
printErrors: false
onLoaded: root.absorb(modelData.file, this.text())
onLoadFailed: root.absorb(modelData.file, "")
}
}
}
@@ -7,12 +7,12 @@
// So the manual is prose, in chapters, rendered here rather than opened in a
// browser. Keeping it in Settings means it is reachable from the same place
// as everything else it talks about, and a link to a settings page can just
// be a settings page.
// be a settings page -- see onLinkActivated below, where that stopped being a
// figure of speech.
//
// The chapters live beside the shell in manual/, not at the repository root.
// They are read at runtime through Quickshell.shellDir, which resolves whether
// or not the repository is where it usually is; a path walked upward out of
// the shell directory does not.
// The chapter list and the chapter titles both come from ManualChapters, which
// reads each file's own first heading. This page used to carry a hand-written
// copy of those titles under a comment claiming they were read from the files.
//
// One Text per chapter, never the whole manual at once: Text has an implicit
// texture size limit, and a document long enough to hit it fails by going
@@ -30,16 +30,7 @@ SettingsPage {
title: "Manual"
lede: "How this desktop works, for the person using it."
// Chapter files are numbered so their order is their filename. The title
// shown here is the first heading of each, read from the file, so a
// chapter cannot be renamed in one place and not the other.
readonly property var chapters: [
{ file: "01-coming-from-another-desktop.md", label: "Coming from another desktop" },
{ file: "02-the-keyboard.md", label: "The keyboard" },
{ file: "03-windows-and-workspaces.md", label: "Windows and workspaces" },
{ file: "04-when-something-breaks.md", label: "When something breaks" },
{ file: "05-making-it-yours.md", label: "Making it yours" }
]
readonly property var chapters: contents.titled
property int current: 0
@@ -56,10 +47,12 @@ SettingsPage {
}
}
ManualChapters { id: contents }
SettingsTabs {
tabs: root.chapters.map((chapter, index) => ({
value: String(index),
label: chapter.label
label: chapter.title
}))
current: String(root.current)
onSelected: value => root.current = parseInt(value, 10)
@@ -90,7 +83,30 @@ SettingsPage {
// size; a document read once needs to be easy on the first pass.
lineHeight: 1.35
onLinkActivated: link => Qt.openUrlExternally(link)
// A link that names a settings page opens that page instead of a
// browser, which is the whole reason the manual lives inside
// Settings. Anything else -- a project URL, a wiki -- still leaves
// the desktop.
//
// The scheme is Panama's own, so a chapter can never accidentally
// hand an http link to openSettings, and an unknown page cannot be
// smuggled in either: SettingsRoutes.resolve turns anything it does
// not recognise into Home rather than a blank loader. A fragment,
// where there is one, is the section within the page.
onLinkActivated: link => {
const target = String(link ?? "");
const scheme = "panama://settings/";
if (target.indexOf(scheme) !== 0) {
Qt.openUrlExternally(target);
return;
}
const rest = target.slice(scheme.length);
const hash = rest.indexOf("#");
if (hash < 0)
ShellState.openSettings(rest);
else
ShellState.openSettingsSection(rest.slice(0, hash), rest.slice(hash + 1));
}
}
}
}
@@ -1,81 +0,0 @@
// Region & Language.
//
// GNOME keeps language and formats under System; the setting itself is the
// machine's locale, which localectl owns. Panama does not store a copy of it --
// there is exactly one system locale and localectl is where it lives, so a
// preference here would be a second source of truth that drifts the moment
// anything else changes it.
//
// Keyboard layout is deliberately not repeated here even though GNOME groups it
// with region. It is a compositor setting that applies instantly, it lives on
// the Keyboard page with the rest of the typing settings, and showing it twice
// invites the two views to disagree.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Region & Language"
lede: SystemLocale.pendingRestart
? "Your new language applies to programs started after you sign out and back in."
: "The language and regional formats this machine uses."
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
SettingsCard {
title: "Language"
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
PickerRow {
id: languagePicker
label: "Current language"
detail: SystemLocale.pendingRestart
? "Chosen, but not in use until you sign out and back in"
: "Used by programs that ask the system what language to speak"
value: SystemLocale.currentLabel || "Reading…"
divider: false
SearchPicker {
width: parent.width
items: SystemLocale.locales
current: SystemLocale.current
placeholder: "Search languages and regions"
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
onPicked: value => {
SystemLocale.set(value);
languagePicker.collapse();
}
}
}
}
SettingsCard {
visible: SystemLocale.lastError !== ""
title: "Language problem"
subtitle: SystemLocale.lastError
}
SettingsCard {
title: "Formats"
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
ActionRow {
label: "Clock and date display"
detail: "How the desktop itself shows the time"
action: "Open appearance"
onTriggered: ShellState.openSettings("appearance")
}
ActionRow {
label: "Regional formats"
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
action: "Open system"
divider: false
onTriggered: SystemSettings.openGnomePanel("system", "region")
}
}
}
@@ -28,6 +28,14 @@ Item {
// scrolls -- the Appearance page pins its live preview here.
property Component header: null
// A view that replaces the page entirely while it is up. Containers' log
// reader is the case: logs need their own scrollback, and a scrolling view
// nested inside a scrolling page makes the wheel ambiguous over exactly the
// region where somebody wants to use it. A page that owns one stays a
// SettingsPage rather than becoming a bare Item wrapping two of them.
property Component drillIn: null
property bool drilledIn: false
Loader {
id: pinnedHeader
@@ -37,11 +45,19 @@ Item {
anchors.leftMargin: 34
anchors.rightMargin: 34
anchors.topMargin: 30
visible: !root.drilledIn
active: root.header !== null
sourceComponent: root.header
z: 1
}
Loader {
anchors.fill: parent
active: root.drillIn !== null && root.drilledIn
sourceComponent: root.drillIn
z: 2
}
Flickable {
id: pageScroll
@@ -50,6 +66,7 @@ Item {
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
anchors.bottom: parent.bottom
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
visible: !root.drilledIn
clip: true
contentWidth: width
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
@@ -11,9 +11,16 @@ Rectangle {
&& pageLoader.item.objectName === "my-home-page"
? pageLoader.item.pageDiagnostics
: ({})
// The objectName check alone is not enough. This binding re-evaluates
// while the loader is swapping pages, and for a moment `item` is the new
// page carrying the old objectName -- calling uiDiagnostics() on it threw
// a TypeError every time somebody navigated away from System Health.
// Asking whether the function is actually there is the only test that
// holds during the swap.
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item
&& pageLoader.item.objectName === "system-health-page"
&& typeof pageLoader.item.uiDiagnostics === "function"
? pageLoader.item.uiDiagnostics()
: ({})
@@ -181,7 +188,6 @@ Rectangle {
case "mouse": return mousePage;
case "dictation": return dictationPage;
case "privacy": return privacyPage;
case "region": return regionPage;
case "accounts": return onlineAccountsPage;
case "accessibility": return accessibilityPage;
case "power": return powerPage;
@@ -267,7 +273,6 @@ Rectangle {
Component { id: mousePage; MousePage {} }
Component { id: dictationPage; DictationPage {} }
Component { id: privacyPage; PrivacyPage {} }
Component { id: regionPage; RegionPage {} }
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
Component { id: healthPage; HealthPage {} }
Component { id: manualPage; ManualPage {} }
@@ -8,6 +8,12 @@
// snapshots one tab over in System Snapshots, which put the whole filesystem
// back -- the card says so, because the two used to share the word "Snapshots"
// and nothing distinguished them.
//
// Everything on this page that cannot be undone asks twice. Restoring a backup
// and restoring defaults both used to happen on a single press, which is the
// one interaction on this page nobody can take back: the two-stage confirm is
// the same one Snapshots and Privacy use, where the first press only arms the
// second and the second is the one wearing the danger colour.
import QtQuick
import qs.config
@@ -16,8 +22,35 @@ import qs.services
SettingsPage {
id: root
objectName: "sync"
title: "Sync & Backup"
lede: "Carry your settings to another machine, keep copies of them, or start over."
lede: "Carry this desktop with you, and come back from anything."
// "restore:<name>", "delete:<name>", or "reset". One at a time: two armed
// destructive actions on screen at once is how the wrong one gets pressed.
property string confirming: ""
function arm(token: string): void {
root.confirming = root.confirming === token ? "" : token;
}
// Diff values come from a JSON file somebody may have edited by hand, so
// they are whatever they are: booleans, numbers, objects, missing. Long
// ones are cut rather than allowed to push the row off the card.
function shortValue(value: var): string {
if (value === null || value === undefined)
return "not set";
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
return text.length > 44 ? text.slice(0, 43) + "…" : text;
}
// The helper already caps what it hands over and reports the true total
// separately; this caps again for the rows, so the count in the copy is
// the real one either way.
readonly property int previewLimit: 8
readonly property var shownChanges:
(SettingsSync.changes ?? []).slice(0, root.previewLimit)
// Distinct from the backups below, which put THIS machine back as it was.
// This carries settings to a different one, and deliberately leaves behind
@@ -41,32 +74,122 @@ SettingsPage {
ActionRow {
label: "See what an import would change"
detail: SettingsSync.previewed
? SettingsSync.changes.length + " would change, "
? SettingsSync.changeCount + " would change, "
+ SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview"
enabled: !SettingsSync.busy
divider: !SettingsSync.previewed
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
}
// Key by key, old value and new. "14 would change" is a number nobody
// can act on; this is the same information in the form somebody can
// actually read before agreeing to it.
Item {
width: parent.width
visible: SettingsSync.previewed && SettingsSync.changeCount > 0
implicitHeight: visible ? diffFrame.implicitHeight + 18 : 0
Rectangle {
id: diffFrame
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 4
implicitHeight: diffRows.implicitHeight + 20
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
Column {
id: diffRows
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 10
spacing: 0
Repeater {
model: root.shownChanges
Column {
required property var modelData
width: parent.width
spacing: 0
bottomPadding: 6
Text {
width: parent.width
text: " " + String(modelData.key ?? "")
+ " " + root.shortValue(modelData.from)
color: Theme.danger
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
width: parent.width
text: "+ " + String(modelData.key ?? "")
+ " " + root.shortValue(modelData.to)
color: Theme.ok
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
Text {
width: parent.width
visible: SettingsSync.changeCount > root.shownChanges.length
text: "· " + (SettingsSync.changeCount - root.shownChanges.length)
+ " more would change"
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: SettingsSync.skipped.length > 0
text: "· " + SettingsSync.skipped.length
+ " skipped — they describe this machine rather than your taste"
color: Theme.fgMuted
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
// Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their
// desktop changed.
ActionRow {
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
label: "Apply those " + SettingsSync.changes.length + " changes"
visible: SettingsSync.previewed && SettingsSync.changeCount > 0
label: "Apply those " + SettingsSync.changeCount + " changes"
detail: "Settings the file does not mention are left alone"
action: "Import"
enabled: !SettingsSync.busy
divider: false
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
}
Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : []
delegate: TextRow {
TextRow {
required property var modelData
width: parent.width
label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "")
value: ""
@@ -84,51 +207,187 @@ SettingsPage {
}
}
// ── Backups ──────────────────────────────────────────────────────────────
SettingsCard {
title: "Settings backups"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Copies of your preferences, not of the filesystem — System Snapshots keeps the btrfs ones. Your whole desktop configuration is a single file, so a backup is a copy of it. Restoring also backs up what it replaces, so it is itself undoable."
ActionRow {
// Naming one is optional and worth it. "Before the displays
// experiment" is findable a week later; a timestamp is not.
FieldActionRow {
id: nameField
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No backups yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept") + ", newest first"
action: "Back up now"
: SettingsBackup.snapshots.length
+ (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept")
+ ", newest first"
placeholder: "Name (optional)"
action: SettingsBackup.busy ? "Working…" : "Back up"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
onTriggered: SettingsBackup.save()
onSubmitted: value => {
// An unnamed backup is the plain save the automatic path uses;
// naming one goes through create, which is the verb that knows
// how to sanitise a name.
if (value === "")
SettingsBackup.save();
else
SettingsBackup.create(value);
nameField.clear();
}
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
ActionRow {
SettingRow {
id: backupRow
required property var modelData
required property int index
label: modelData.when
detail: modelData.keys + " settings"
action: "Restore"
enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1
onTriggered: SettingsBackup.restore(modelData.name)
readonly property string backupName: String(backupRow.modelData.name ?? "")
readonly property bool confirmingRestore:
root.confirming === "restore:" + backupRow.backupName
readonly property bool confirmingDelete:
root.confirming === "delete:" + backupRow.backupName
// The helper names a file by its timestamp with whatever the
// person called it appended, so what is left after the
// timestamp is the name they gave it. An unnamed backup falls
// back to when it was taken rather than showing a filename.
readonly property string displayName: {
const given = String(backupRow.modelData.label ?? "");
if (given !== "")
return given;
const stem = backupRow.backupName.replace(/\.json$/, "");
const named = stem
.replace(/^[0-9][0-9T:_.\-]*/, "")
.replace(/^[-_]+/, "")
.replace(/[-_]+/g, " ")
.trim();
return named === "" ? String(backupRow.modelData.when ?? stem) : named;
}
width: parent.width
label: backupRow.displayName
detail: {
const when = String(backupRow.modelData.when ?? "");
const parts = when === backupRow.displayName ? [] : [when];
if (backupRow.modelData.keys)
parts.push(backupRow.modelData.keys + " settings");
const bytes = Number(backupRow.modelData.bytes ?? 0);
if (bytes > 0)
parts.push(root.formatBytes(bytes));
if (backupRow.confirmingRestore)
parts.push("Restoring replaces every setting on this machine, and reloads the desktop");
if (backupRow.confirmingDelete)
parts.push("Deleting a backup cannot be undone");
return parts.filter(part => part !== "").join(" · ");
}
controlWidth: 250
divider: backupRow.index < snapshotRows.count - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
visible: !backupRow.confirmingDelete
text: backupRow.confirmingRestore ? "Cancel" : "Restore…"
enabled: !SettingsBackup.busy
onClicked: root.arm("restore:" + backupRow.backupName)
}
SettingsButton {
visible: backupRow.confirmingRestore
text: "Restore it"
tone: "danger"
enabled: !SettingsBackup.busy
onClicked: {
root.confirming = "";
SettingsBackup.restore(backupRow.backupName);
}
}
SettingsButton {
visible: !backupRow.confirmingRestore
text: backupRow.confirmingDelete ? "Cancel" : "Delete…"
enabled: !SettingsBackup.busy
onClicked: root.arm("delete:" + backupRow.backupName)
}
SettingsButton {
visible: backupRow.confirmingDelete
text: "Delete it"
tone: "danger"
enabled: !SettingsBackup.busy
onClicked: {
root.confirming = "";
SettingsBackup.deleteBackup(backupRow.backupName);
}
}
}
}
}
}
// ── Reset ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Reset"
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
subtitle: "Restores the appearance and theme, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
SettingRow {
id: resetRow
readonly property bool armed: root.confirming === "reset"
ActionRow {
label: "Restore defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
detail: resetRow.armed
? "Every setting goes back to how Panama ships, immediately and including the compositor. A backup is taken first, so this is itself undoable from the card above."
: "Applies immediately, including to the compositor. A backup is taken first."
controlWidth: 240
divider: false
onTriggered: SystemSettings.restoreDefaults()
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: resetRow.armed ? "Cancel" : "Reset…"
onClicked: root.arm("reset")
}
SettingsButton {
visible: resetRow.armed
text: "Restore defaults"
tone: "danger"
onClicked: {
root.confirming = "";
SystemSettings.restoreDefaults();
}
}
}
}
}
// Decimal units, the way both the Storage page and About report sizes.
function formatBytes(bytes: real): string {
if (!(bytes > 0))
return "";
if (bytes >= 1e6)
return (bytes / 1e6).toFixed(1) + " MB";
if (bytes >= 1e3)
return Math.round(bytes / 1e3) + " KB";
return Math.round(bytes) + " B";
}
}
@@ -9,6 +9,14 @@
// happen, so the Snapshots page shows "before 32 package updates" rather than a
// timestamp. That is the thing neither macOS nor Windows does cleanly, and it
// is nearly free here.
//
// A package row can say what it changes. "1 update available" tells you nothing
// about whether to install it now or after the meeting; the changelog does, and
// it is fetched per package on request rather than for a list nobody read.
//
// The automatic switches sit in their own card. The one that downloads packages
// used to live at the bottom of Firmware, which is where somebody put it once
// and nobody could find it since.
import Quickshell
import QtQuick
@@ -20,9 +28,16 @@ SettingsPage {
objectName: "updates"
title: "Software Update"
lede: "Packages, applications, and firmware, each from the place it actually comes from."
lede: root.headline
property string expandedSource: ""
// Rows are shown a screenful at a time. A dnf update is routinely thirty
// packages, and a page that opens on thirty rows is a page nobody reads.
readonly property int previewCount: 6
property bool showAllPackages: false
// "dnf:name" or "flatpak:id" -- the one row whose changelog is open.
property string expandedPackage: ""
Component.onCompleted: {
Updates.refresh();
@@ -32,6 +47,50 @@ SettingsPage {
Updates.check();
}
function joined(parts: var): string {
return parts.filter(part => String(part ?? "") !== "").join(" · ");
}
readonly property string headline: {
if (Updates.checking)
return "Checking every source…";
const security = Updates.securityCount > 0
? Updates.securityCount + " carrying a security advisory"
: (Updates.total > 0 ? "nothing security-critical" : "");
return root.joined([
Updates.summary(), security,
Updates.lastCheckedText().replace("Checked", "checked")
]) + ".";
}
readonly property var packages: Updates.dnf?.packages ?? []
readonly property var shownPackages: root.showAllPackages
? root.packages
: root.packages.slice(0, root.previewCount)
readonly property var applications: Updates.flatpak?.applications ?? []
readonly property var firmwareDevices: Updates.firmware?.devices ?? []
function toggleChangelog(source: string, name: string): void {
const key = source + ":" + name;
root.expandedPackage = root.expandedPackage === key ? "" : key;
}
// Asking for a changelog is what starts fetching one -- the page requests
// it by rendering it, and Updates keeps the answer for the life of the
// shell. Null means the fetch is still out.
function changelogText(source: string, name: string): string {
const record = Updates.changelogFor(source, name);
if (record === null || record === undefined)
return "Reading the changelog…";
if (String(record.text ?? "") !== "")
return String(record.text);
return String(record.error ?? "") !== ""
? String(record.error)
: "No changelog published for this update.";
}
// ── What just happened ───────────────────────────────────────────────────
TextRow {
visible: Updates.lastError !== ""
label: "Updates need attention"
@@ -54,43 +113,7 @@ SettingsPage {
divider: false
}
// ── The headline ─────────────────────────────────────────────────────────
SettingsCard {
Column {
width: parent.width
spacing: 10
Row {
width: parent.width
spacing: 12
Text {
text: Updates.checking ? "Checking…" : Updates.summary()
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: Updates.securityCount > 0
text: Updates.securityCount + " carry a security advisory"
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Text {
text: Updates.lastCheckedText()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
ActionRow {
label: "Check for updates"
detail: "Refreshes package metadata, application remotes, and firmware. Takes a few seconds."
@@ -113,55 +136,90 @@ SettingsPage {
}
}
// ── One card per source ──────────────────────────────────────────────────
// ── System packages ──────────────────────────────────────────────────────
SettingsCard {
title: "System packages"
subtitle: Updates.dnf?.available === false
? "dnf is not available on this machine."
: (Number(Updates.dnf?.count ?? 0) === 0
: (root.packages.length === 0
? "Nothing waiting."
: Updates.dnf.count + " package"
+ (Updates.dnf.count === 1 ? "" : "s") + " ready to install"
+ (Updates.securityCount > 0
? ", " + Updates.securityCount + " carrying an advisory" : ""))
ActionRow {
visible: Number(Updates.dnf?.count ?? 0) > 0
label: "Install package updates"
detail: "Asks for your password, and takes a snapshot first so this can be undone"
action: Updates.applying ? "Working…" : "Install"
enabled: !Updates.busy
onTriggered: Updates.apply("dnf")
}
ActionRow {
visible: Number(Updates.dnf?.count ?? 0) > 0
label: "What would change"
detail: root.expandedSource === "dnf"
? "Every package that would be replaced"
: Updates.dnf.count + " packages"
action: root.expandedSource === "dnf" ? "Hide" : "Show"
divider: root.expandedSource === "dnf"
onTriggered: root.expandedSource = root.expandedSource === "dnf" ? "" : "dnf"
}
: root.joined([
root.packages.length + " ready to install",
Updates.securityCount > 0
? Updates.securityCount + " carrying an advisory" : "",
Updates.sourceDownloadSize("dnf") === ""
? "" : Updates.sourceDownloadSize("dnf") + " to download"
]))
Repeater {
model: root.expandedSource === "dnf" ? (Updates.dnf?.packages ?? []) : []
id: packageRows
model: root.shownPackages
Column {
id: packageRow
delegate: TextRow {
required property var modelData
required property int index
readonly property string name: String(packageRow.modelData.name ?? "")
readonly property bool open: root.expandedPackage === "dnf:" + packageRow.name
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.repository ?? "")
value: String(modelData.version ?? "")
divider: index < (Updates.dnf?.packages ?? []).length - 1
ActionRow {
width: parent.width
label: packageRow.name
detail: root.joined([
String(packageRow.modelData.version ?? ""),
String(packageRow.modelData.repository ?? "") === ""
? "" : "from " + packageRow.modelData.repository
])
action: packageRow.open ? "Hide" : "Changelog"
divider: !packageRow.open && packageRow.index < packageRows.count - 1
onTriggered: root.toggleChangelog("dnf", packageRow.name)
}
Text {
width: parent.width
visible: packageRow.open
text: root.changelogText("dnf", packageRow.name)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
lineHeight: 1.3
topPadding: 2
bottomPadding: 14
}
}
}
ActionRow {
visible: root.packages.length > root.previewCount
label: root.showAllPackages
? "Showing all " + root.packages.length + " packages"
: (root.packages.length - root.previewCount) + " more waiting"
detail: root.showAllPackages ? "" : "Each one can say what it changes"
action: root.showAllPackages ? "Show fewer" : "Show all"
onTriggered: {
root.expandedPackage = "";
root.showAllPackages = !root.showAllPackages;
}
}
ActionRow {
visible: root.packages.length > 0
label: "Install package updates"
detail: "Asks for your password, and takes a snapshot first — undo lives in System Snapshots"
action: Updates.applying ? "Working…" : "Install updates"
enabled: !Updates.busy
divider: false
onTriggered: Updates.apply("dnf")
}
TextRow {
visible: Number(Updates.dnf?.count ?? 0) === 0 && Updates.everChecked
visible: root.packages.length === 0 && Updates.everChecked
label: "Packages are current"
detail: "Nothing from the system repositories is waiting"
value: ""
@@ -169,36 +227,31 @@ SettingsPage {
}
}
// ── Automatic ────────────────────────────────────────────────────────────
//
// Both switches, in the one card about the same idea. The package one used
// to sit at the bottom of Firmware.
SettingsCard {
title: "Applications"
subtitle: Updates.flatpak?.available === false
? "Flatpak is not installed."
: (Number(Updates.flatpak?.count ?? 0) === 0
? "Nothing waiting."
: Updates.flatpak.count + " application"
+ (Updates.flatpak.count === 1 ? "" : "s") + " ready to update")
title: "Automatic"
Repeater {
model: Updates.flatpak?.applications ?? []
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.id ?? "")
detail: "Flatpak"
value: String(modelData.version ?? "")
divider: true
}
SwitchRow {
visible: Updates.automatic?.dnfAutomaticAvailable === true
label: "Download package updates automatically"
detail: "Fetches them in the background each morning so installing is quick. It does not install them: a machine that updates packages unattended can reboot into a kernel nobody chose."
checked: Updates.automatic?.dnfAutomaticEnabled === true
enabled: !Updates.busy
onToggled: value => Updates.setAutomaticDnf(value)
}
ActionRow {
visible: Number(Updates.flatpak?.count ?? 0) > 0
label: "Update applications"
detail: "Needs no password: these are installed for your account"
action: Updates.applying ? "Working…" : "Update"
enabled: !Updates.busy
onTriggered: Updates.apply("flatpak")
// Offered only when the machinery exists. When it does not, this says
// so rather than showing a switch that could not do anything --
// installing software is not a settings action.
TextRow {
visible: Updates.automatic?.dnfAutomaticAvailable === false
label: "Download package updates automatically"
detail: "Not set up. dnf-automatic is not installed, and Settings does not install software."
value: "Off"
}
SwitchRow {
@@ -211,32 +264,122 @@ SettingsPage {
}
}
// ── Applications & firmware ──────────────────────────────────────────────
SettingsCard {
title: "Firmware"
subtitle: Updates.firmware?.available === false
? "Firmware updating is not available on this machine."
: (Number(Updates.firmware?.count ?? 0) === 0
? "No firmware updates are offered for this hardware."
: Updates.firmware.count + " device"
+ (Updates.firmware.count === 1 ? "" : "s") + " have firmware available")
title: "Applications & firmware"
TextRow {
visible: Updates.flatpak?.available === false
label: "Flatpak"
detail: "Flatpak is not installed."
value: "Unavailable"
}
TextRow {
visible: Updates.flatpak?.available !== false && root.applications.length === 0
label: "Flatpak"
detail: "Everything current — each application gets its own row when an update appears"
value: "Current"
}
// Per application, because they update independently and one of them
// being 400 MB is a reason to do the other three now and that one
// later.
Repeater {
model: Updates.firmware?.devices ?? []
id: applicationRows
model: root.applications
Column {
id: applicationRow
delegate: TextRow {
required property var modelData
required property int index
readonly property string appId: String(applicationRow.modelData.id ?? "")
readonly property bool open: root.expandedPackage === "flatpak:" + applicationRow.appId
width: parent.width
label: String(modelData.name ?? "")
detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "")
+ (modelData.needsReboot ? " · installs on restart" : "")
value: ""
divider: true
ActionRow {
width: parent.width
label: applicationRow.appId
detail: root.joined([
String(applicationRow.modelData.version ?? ""), "Flatpak",
Updates.formatBytes(Number(applicationRow.modelData.downloadBytes ?? 0))
])
action: Updates.applying ? "Working…" : "Update"
enabled: !Updates.busy
divider: false
onTriggered: Updates.applyFlatpakApp(applicationRow.appId)
}
ActionRow {
width: parent.width
label: "What changed"
detail: applicationRow.open
? ""
: "Release notes, when the application publishes them"
action: applicationRow.open ? "Hide" : "Changelog"
divider: !applicationRow.open
onTriggered: root.toggleChangelog("flatpak", applicationRow.appId)
}
Text {
width: parent.width
visible: applicationRow.open
text: root.changelogText("flatpak", applicationRow.appId)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
lineHeight: 1.3
topPadding: 2
bottomPadding: 14
}
}
}
ActionRow {
visible: Number(Updates.firmware?.count ?? 0) > 0
visible: root.applications.length > 1
label: "Update every application"
detail: "Needs no password: these are installed for your account"
action: Updates.applying ? "Working…" : "Update all"
enabled: !Updates.busy
onTriggered: Updates.apply("flatpak")
}
TextRow {
visible: Updates.firmware?.available === false
label: "Firmware"
detail: "Firmware updating is not available on this machine."
value: "Unavailable"
divider: false
}
TextRow {
visible: Updates.firmware?.available !== false && root.firmwareDevices.length === 0
label: "Firmware"
detail: "No firmware updates are offered for this hardware"
value: "Current"
divider: false
}
Repeater {
model: root.firmwareDevices
TextRow {
required property var modelData
label: String(modelData.name ?? "")
detail: String(modelData.version ?? "") + " → " + String(modelData.target ?? "")
+ (modelData.needsReboot ? " · installs on restart" : "")
value: ""
}
}
ActionRow {
visible: root.firmwareDevices.length > 0
label: "Install firmware"
detail: "Some devices only finish updating after a restart"
action: Updates.applying ? "Working…" : "Install"
@@ -244,27 +387,6 @@ SettingsPage {
divider: false
onTriggered: Updates.apply("firmware")
}
// Offered only when the machinery exists. When it does not, this says
// so rather than showing a switch that could not do anything --
// installing software is not a settings action.
SwitchRow {
visible: Updates.automatic?.dnfAutomaticAvailable === true
label: "Download package updates automatically"
detail: "Fetches them in the background each morning so installing is quick. It does not install them: a machine that updates packages unattended can reboot into a kernel nobody chose."
checked: Updates.automatic?.dnfAutomaticEnabled === true
enabled: !Updates.busy
divider: false
onToggled: value => Updates.setAutomaticDnf(value)
}
TextRow {
visible: Updates.automatic?.dnfAutomaticAvailable === false
label: "Automatic package updates"
detail: "Not set up. dnf-automatic is not installed, and Settings does not install software."
value: "Off"
divider: false
}
}
// Worth showing because automatic updates leave no other trace. Something
@@ -288,10 +410,10 @@ SettingsPage {
Repeater {
model: Updates.history
delegate: TextRow {
TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.summary ?? "")
detail: Updates.agoText(Number(modelData.at ?? 0))
value: Updates.describeHistory(modelData)
@@ -114,7 +114,6 @@ TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml
PrivacyLiveTile 1.0 PrivacyLiveTile.qml
SectionLabel 1.0 SectionLabel.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
TextFieldRow 1.0 TextFieldRow.qml
@@ -135,3 +134,5 @@ FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml
OnlineAccountRow 1.0 OnlineAccountRow.qml
IdleTimeline 1.0 IdleTimeline.qml
PowerProfileTiles 1.0 PowerProfileTiles.qml
FieldActionRow 1.0 FieldActionRow.qml
ManualChapters 1.0 ManualChapters.qml