diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 09:59:40 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 09:59:40 +0200 |
| commit | 83a5dc24f6349ba99165dab850a83bf08b0f56c8 (patch) | |
| tree | fea94ab5ec47f1f131f69865f52e315652ca7032 /desktop/modules/user/UserPage.qml | |
| parent | 614bbba4af2c48d396eda90bb458f5cf3271fe1a (diff) | |
| download | quickshell-83a5dc24f6349ba99165dab850a83bf08b0f56c8.tar.gz quickshell-83a5dc24f6349ba99165dab850a83bf08b0f56c8.zip | |
feat(user): a drawer page for the user's details
Name, email, avatar, language, location and the git signing key, each
saved through userctl to every store that holds it. Only changed fields
are sent; a field whose write failed keeps its edit and its error, so
the next Save retries it.
A failed userctl get reports a single error= line. The page shows it in
red under the avatar, leaves saved and draft untouched, and disables
Save until a later load succeeds.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/user/UserPage.qml')
| -rw-r--r-- | desktop/modules/user/UserPage.qml | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/desktop/modules/user/UserPage.qml b/desktop/modules/user/UserPage.qml new file mode 100644 index 0000000..024a63b --- /dev/null +++ b/desktop/modules/user/UserPage.qml @@ -0,0 +1,256 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import "../.." +import "User.js" as U + +// The form. `saved` is what userctl read, `draft` the edits; Save sends each +// changed key to `userctl set` in turn. A failed key keeps its edit and shows +// the error under the field, so the next Save retries it. +Column { + id: form + + spacing: 14 + + // A new detail is one entry here and one case in userctl. Avatar is not + // listed: it is the picture at the top, not a row. + readonly property var fields: [ + { key: "name", label: "Name", kind: "text" }, + { key: "email", label: "Email", kind: "text" }, + { key: "location", label: "Location", kind: "text" }, + { key: "language", label: "Language", kind: "locale" }, + { key: "signingkey", label: "Signing key", kind: "key" }, + ] + + readonly property string ctl: Qt.resolvedUrl("userctl").toString().replace(/^file:\/\//, "") + property var saved: ({}) + property var draft: ({}) + property var errors: ({}) + property var keys: [] + property var locales: [] + property bool busy: false + property string loadError: "" + readonly property var changed: Object.keys(draft).filter(k => draft[k] !== saved[k]) + + function set(key, value) { + const d = Object.assign({}, form.draft); + d[key] = value; + form.draft = d; + } + + // ComboBox entries per kind. The current language is kept even if + // locale -a no longer lists it, so the combo never shows blank. + function options(kind) { + if (kind === "locale") { + const l = form.locales.slice(); + if (form.saved.language && !l.includes(form.saved.language)) l.unshift(form.saved.language); + return l.map(v => ({ text: v, value: v })); + } + return [{ text: "None", value: "" }] + .concat(form.keys.map(k => ({ text: k.uid + " · " + k.fpr.slice(-16), value: k.fpr }))); + } + + // One Process per call, so reads and writes never share one (a running + // Process ignores a second start, AGENTS.md). cb gets stdout. + function run(cmd, cb) { + op.createObject(form, { command: cmd, cb: cb }).running = true; + } + Component { + id: op + Process { + id: proc + property var cb: null + stdout: StdioCollector { + onStreamFinished: { + try { proc.cb(text); } + finally { proc.destroy(); } + } + } + } + } + + // keep: draft values to carry over the reload (the keys that failed). + function load(keep) { + form.run([form.ctl, "get"], out => { + const s = U.parse(out); + if (s.error) { + form.loadError = s.error; + return; + } + form.loadError = ""; + form.saved = ({}); + form.saved = s; + form.draft = Object.assign({}, s, keep); + }); + } + + function save() { + if (form.busy || form.changed.length === 0) return; + form.busy = true; + form.errors = ({}); + form.step(form.changed.slice(), {}); + } + function step(queue, failed) { + if (queue.length === 0) { + form.busy = false; + form.load(failed); + return; + } + const k = queue.shift(); + form.run([form.ctl, "set", k, form.draft[k]], out => { + if (out.trim() !== "") { + const e = Object.assign({}, form.errors); + e[k] = out.trim(); + form.errors = e; + failed[k] = form.draft[k]; + } + form.step(queue, failed); + }); + } + + Component.onCompleted: { + form.load({}); + form.run([form.ctl, "keys"], out => form.keys = U.keys(out)); + form.run(["locale", "-a"], out => form.locales = U.locales(out)); + } + + // A FileDialog hands back a file:// URL; AccountsService wants a path. + FileDialog { + id: picker + title: "Choose a picture" + fileMode: FileDialog.OpenFile + nameFilters: ["Images (*.png *.jpg *.jpeg)"] + onAccepted: form.set("avatar", decodeURIComponent(selectedFile.toString().replace(/^file:\/\//, ""))) + } + + ClippingRectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: 96 + height: 96 + radius: 48 + color: Theme.surfaceAlt + + Image { + id: pic + anchors.fill: parent + source: form.draft.avatar ? "file://" + form.draft.avatar : "" + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(192, 192) + cache: false + } + Text { + anchors.centerIn: parent + visible: pic.status !== Image.Ready + text: "\uf007" + font { family: Theme.iconFamily; pixelSize: 40 } + color: Theme.subtext + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: picker.open() + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + visible: !!form.errors.avatar + text: form.errors.avatar ?? "" + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Text { + width: parent.width + visible: form.loadError !== "" + text: form.loadError + wrapMode: Text.Wrap + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: form.fields + + delegate: Column { + id: field + required property var modelData + width: form.width + spacing: 4 + + Text { + text: field.modelData.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.subtext + } + + Rectangle { + visible: field.modelData.kind === "text" + width: parent.width + height: 34 + radius: 8 + color: Theme.surface + TextInput { + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + text: form.draft[field.modelData.key] ?? "" + onTextEdited: form.set(field.modelData.key, text) + } + } + + ComboBox { + visible: field.modelData.kind !== "text" + width: parent.width + textRole: "text" + valueRole: "value" + model: field.modelData.kind === "text" ? [] : form.options(field.modelData.kind) + // count is read so the index is recomputed once the list + // arrives, which is after the draft. + currentIndex: count >= 0 ? indexOfValue(form.draft[field.modelData.key] ?? "") : -1 + onActivated: form.set(field.modelData.key, currentValue) + } + + Text { + width: parent.width + visible: !!form.errors[field.modelData.key] + text: form.errors[field.modelData.key] ?? "" + wrapMode: Text.Wrap + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + } + + Text { + width: parent.width + text: "git: " + U.gitName(form.draft.name ?? "") + " <" + (form.draft.email ?? "") + ">" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Button { + anchors.right: parent.right + text: form.busy ? "Saving…" : "Save" + enabled: !form.busy && form.loadError === "" && form.changed.length > 0 + onClicked: form.save() + } +} |
