aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
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()
+ }
+}