aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 09:59:40 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 09:59:40 +0200
commit83a5dc24f6349ba99165dab850a83bf08b0f56c8 (patch)
treefea94ab5ec47f1f131f69865f52e315652ca7032 /desktop/modules
parent614bbba4af2c48d396eda90bb458f5cf3271fe1a (diff)
downloadquickshell-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')
-rw-r--r--desktop/modules/user/UserModule.qml29
-rw-r--r--desktop/modules/user/UserPage.qml256
2 files changed, 285 insertions, 0 deletions
diff --git a/desktop/modules/user/UserModule.qml b/desktop/modules/user/UserModule.qml
new file mode 100644
index 0000000..ef49829
--- /dev/null
+++ b/desktop/modules/user/UserModule.qml
@@ -0,0 +1,29 @@
+// 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 QtQuick
+import "../.."
+
+// The user's details. No tile: the drawer's greeting row opens the page.
+Module {
+ name: "user"
+ label: "User"
+ // nf-fa-user, present in Inconsolata Nerd Font.
+ icon: "\uf007"
+ tiled: false
+
+ page: Component {
+ Page {
+ title: "User"
+ UserPage { width: parent.width }
+ }
+ }
+}
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()
+ }
+}