// Copyright (C) 2026 Danilo M. // // 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. A "derived" field is // read-only, computed by derived() from the draft, and never saved on // its own: userctl writes it as part of the field it comes from. readonly property var fields: [ { key: "name", label: "Name", kind: "text" }, { key: "email", label: "Email", kind: "text" }, { key: "location", label: "Location", kind: "place" }, { key: "language", label: "Language", kind: "locale" }, { key: "gitname", label: "Name", kind: "derived", section: "Git" }, { key: "gitemail", label: "Email", kind: "derived", section: "Git" }, { key: "signingkey", label: "Signing key", kind: "key", section: "Git" }, ] function derived(key) { if (key === "gitname") return U.gitName(form.draft.name ?? ""); if (key === "gitemail") return form.draft.email ?? ""; return ""; } 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 // The module, which outlives the page: a save in flight finishes even if // the page is closed. Not named `um`, the module's id (it would bind to itself). required property QtObject owner // The "More git settings" button. The page has no reach into the drawer; // UserModule turns this into Page.go("git"). signal moreGit property string loadError: "" readonly property var changed: Object.keys(draft).filter(k => draft[k] !== saved[k] || !!errors[k]) // Back to what is saved, for the header's Discard. function discard() { form.draft = Object.assign({}, form.saved); form.errors = ({}); } function set(key, value) { const d = Object.assign({}, form.draft); d[key] = value; form.draft = d; const e = Object.assign({}, form.errors); delete e[key]; form.errors = e; } // 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.owner, { 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); form.busy = false; if (s.error) { form.loadError = s.error; return; } form.loadError = ""; 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.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); }); } // Location suggestions from OpenStreetMap's Nominatim, the user's choice // over an offline city list. Its policy allows one request a second and // asks for an identifying User-Agent: the timer fires 1s after typing // stops, and a reply to an older query is dropped by its sequence number. property var places: [] // The suggestion Up/Down has reached, -1 for none; any new list resets it. property int placeIndex: -1 onPlacesChanged: placeIndex = -1 property string placeQuery: "" property int placeSeq: 0 function pickPlace(key, label) { form.set(key, label); form.placeSeq++; form.places = []; } function lookup(text) { form.places = []; form.placeQuery = text.trim(); placeTimer.restart(); } Timer { id: placeTimer interval: 1000 onTriggered: { if (form.placeQuery.length < 3) return; const seq = ++form.placeSeq; const lang = (Quickshell.env("LANG") || "en").slice(0, 2); form.run(["curl", "-sf", "--max-time", "5", "-A", "keystone-drawer/1.0 (user details page)", "https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=6" + "&accept-language=" + lang + "&q=" + encodeURIComponent(form.placeQuery)], out => { if (seq === form.placeSeq) form.places = U.places(out); }); } } 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:\/\//, ""))) } // The picture glows on hover. The glow is what says "clickable": // the hand cursor never shows here, since the cursor theme only ships // hyprcursors and Qt finds no hand in it. Item { anchors.horizontalCenter: parent.horizontalCenter width: 176 height: 176 Glow { anchors.centerIn: parent width: 160 height: 160 radius: 80 on: avatarArea.containsMouse } ClippingRectangle { anchors.centerIn: parent width: 160 height: 160 radius: 80 color: Theme.surfaceAlt Image { id: pic anchors.fill: parent source: form.draft.avatar ? "file://" + form.draft.avatar.split("/").map(encodeURIComponent).join("/") : "" fillMode: Image.PreserveAspectCrop sourceSize: Qt.size(320, 320) cache: false } Text { anchors.centerIn: parent visible: pic.status !== Image.Ready text: "\uf007" font { family: Theme.iconFamily; pixelSize: 64 } color: Theme.subtext } } MouseArea { id: avatarArea anchors.fill: parent enabled: !form.busy hoverEnabled: true 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 enabled: !form.busy required property var modelData required property int index readonly property bool derived: modelData.kind === "derived" width: form.width spacing: 4 // A section's heading, above its first field. Text { visible: !!field.modelData.section && form.fields[field.index - 1]?.section !== field.modelData.section topPadding: 10 bottomPadding: 2 text: field.modelData.section ?? "" font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } color: Theme.text } Text { text: field.modelData.label font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } // Read-only fields are dimmer, but still selectable for copying. Rectangle { visible: field.modelData.kind === "text" || field.modelData.kind === "place" || field.derived width: parent.width height: 34 radius: 8 color: field.derived ? Qt.alpha(Theme.surface, 0.4) : Theme.surface TextInput { activeFocusOnTab: true anchors { fill: parent; leftMargin: 10; rightMargin: 10 } verticalAlignment: TextInput.AlignVCenter clip: true selectByMouse: true readOnly: field.derived color: field.derived ? Theme.subtext : Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } text: field.derived ? form.derived(field.modelData.key) : form.draft[field.modelData.key] ?? "" onTextEdited: { form.set(field.modelData.key, text); if (field.modelData.kind === "place") form.lookup(text); } onActiveFocusChanged: if (!activeFocus && field.modelData.kind === "place") form.places = [] // Up/Down move through the suggestions, Enter takes the // highlighted one, Escape closes the list. With no list // open the keys go on as usual, so Escape still leaves // the page. Keys.onPressed: event => { const n = field.modelData.kind === "place" ? form.places.length : 0; if (n === 0) return; if (event.key === Qt.Key_Down) form.placeIndex = (form.placeIndex + 1) % n; else if (event.key === Qt.Key_Up) form.placeIndex = (form.placeIndex + n - 1) % n; else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && form.placeIndex >= 0) form.pickPlace(field.modelData.key, form.places[form.placeIndex]); else if (event.key === Qt.Key_Escape) form.places = []; else return; event.accepted = true; } } } // Suggestions under a place field; a click takes one. Column { visible: field.modelData.kind === "place" && form.places.length > 0 width: parent.width Repeater { model: field.modelData.kind === "place" ? form.places : [] delegate: Rectangle { required property string modelData required property int index width: parent.width height: 30 radius: 6 color: placeArea.containsMouse || index === form.placeIndex ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.surface, 0.6) Text { anchors { fill: parent; leftMargin: 10; rightMargin: 10 } verticalAlignment: Text.AlignVCenter elide: Text.ElideRight text: parent.modelData color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } } MouseArea { id: placeArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: form.pickPlace(field.modelData.key, parent.modelData) } } } } ComboBox { readonly property bool used: field.modelData.kind === "locale" || field.modelData.kind === "key" visible: used width: parent.width textRole: "text" valueRole: "value" model: used ? 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) // The default style paints a light control; the palette // carries the theme into the popup and its rows too, and the // background matches the text fields above. implicitHeight: 34 font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } palette { button: Theme.surface buttonText: Theme.text window: Theme.base windowText: Theme.text base: Theme.surface text: Theme.text highlight: Theme.accent highlightedText: Theme.base } background: Rectangle { radius: 8; color: Theme.surface } } 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 } } } } // Every other global git key, on its own page. Button { text: "More git settings" onClicked: form.moreGit() } // Save and Discard are in the page header, with every settings page's. }