diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index b27e34a..60f9131 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -19,6 +19,11 @@ SettingsPage { property string expandedPicker: "" + // Which group of cards is on screen. Theme leads deliberately: light and + // dark is the control reached most often, and it used to be the third + // section down, below a wallpaper grid and the whole lock screen. + property string tab: "theme" + title: "Appearance" lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale." @@ -44,7 +49,20 @@ SettingsPage { } } + SettingsTabs { + tabs: [ + { value: "theme", label: "Theme" }, + { value: "background", label: "Background" }, + { value: "type", label: "Typography" }, + { value: "windows", label: "Windows" }, + { value: "shell", label: "Shell" }, + ] + current: root.tab + onSelected: value => root.tab = value + } + SettingsCard { + visible: root.tab === "background" title: "Background" subtitle: Wallpaper.lastError !== "" ? Wallpaper.lastError @@ -83,6 +101,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "background" title: "Lock screen" subtitle: LockScreen.lastError !== "" ? LockScreen.lastError @@ -101,6 +120,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "theme" title: "Color scheme" subtitle: ColorScheme.lastError !== "" ? ColorScheme.lastError @@ -116,6 +136,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "type" title: "Shell typography" subtitle: Fonts.lastError !== "" ? Fonts.lastError @@ -157,6 +178,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "type" title: "Application typography" subtitle: DesktopStyle.lastError !== "" ? DesktopStyle.lastError @@ -229,6 +251,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "type" title: "Icons & pointer" subtitle: DesktopStyle.lastError !== "" ? DesktopStyle.lastError @@ -279,6 +302,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "windows" title: "Titlebars" subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows." @@ -288,6 +312,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "windows" title: "Windows" subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." @@ -302,6 +327,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "theme" title: "Effects" subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming." @@ -318,6 +344,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "shell" title: "Clock" ToggleRow { setting: "use24Hour" } @@ -326,6 +353,7 @@ SettingsPage { } SettingsCard { + visible: root.tab === "shell" title: "System vitals" subtitle: "Choose what appears beside the workspace indicator." diff --git a/config/dot/quickshell/modules/settings/AvatarCropper.qml b/config/dot/quickshell/modules/settings/AvatarCropper.qml new file mode 100644 index 0000000..74881cd --- /dev/null +++ b/config/dot/quickshell/modules/settings/AvatarCropper.qml @@ -0,0 +1,231 @@ +// Choosing which part of a picture becomes the profile picture. +// +// Without this, whatever was picked went to accountsservice whole, and a +// landscape photograph became a squashed thumbnail with the subject off the +// edge. The circle is drawn where the avatar is actually round elsewhere in the +// shell, so what is framed here is what appears there. +// +// The crop is reported in the picture's OWN pixels, not screen ones, so the +// result does not depend on the size this happened to be displayed at. + +import QtQuick +import qs.config + +Item { + id: root + + // Absolute path of the picture being framed. + property string source: "" + + // Side of the square viewport, in screen pixels. + readonly property int viewport: 260 + + // How far in the picture is zoomed, as a multiple of the smallest scale + // that still covers the viewport. 1 means "just covers". + property real zoom: 1 + readonly property real maxZoom: 4 + + // Top-left of the drawn picture, relative to the viewport's top-left. + property real offsetX: 0 + property real offsetY: 0 + + readonly property bool ready: picture.status === Image.Ready + && picture.sourceSize.width > 0 && picture.sourceSize.height > 0 + + // The scale at which the shorter side exactly fills the viewport. Any + // smaller and the square would include area the picture does not cover. + readonly property real baseScale: root.ready + ? root.viewport / Math.min(picture.sourceSize.width, picture.sourceSize.height) + : 1 + readonly property real scale: root.baseScale * root.zoom + + readonly property real drawnWidth: root.ready ? picture.sourceSize.width * root.scale : 0 + readonly property real drawnHeight: root.ready ? picture.sourceSize.height * root.scale : 0 + + // Emitted with a square in the picture's own pixel coordinates. + signal cropped(int x, int y, int size) + signal cancelled + + implicitWidth: parent ? parent.width : 620 + implicitHeight: column.implicitHeight + + // Keeps the viewport covered: the picture may never be dragged far enough + // to expose an edge, so the crop is always entirely inside the image. + function clamp(): void { + root.offsetX = Math.min(0, Math.max(root.viewport - root.drawnWidth, root.offsetX)); + root.offsetY = Math.min(0, Math.max(root.viewport - root.drawnHeight, root.offsetY)); + } + + // Start centred, filling the viewport. + function reset(): void { + root.zoom = 1; + root.offsetX = (root.viewport - root.drawnWidth) / 2; + root.offsetY = (root.viewport - root.drawnHeight) / 2; + } + + onReadyChanged: if (root.ready) root.reset() + onZoomChanged: root.clamp() + + Column { + id: column + width: parent.width + spacing: 14 + + Row { + spacing: 20 + + Rectangle { + id: frame + width: root.viewport + height: root.viewport + radius: 12 + clip: true + color: Theme.bgDark + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.09) + + Image { + id: picture + source: root.source === "" ? "" : "file://" + root.source + x: root.offsetX + y: root.offsetY + width: root.drawnWidth + height: root.drawnHeight + fillMode: Image.Stretch + asynchronous: true + // The picture is drawn at whatever size the frame needs, so + // decoding it at full resolution wastes memory on a photo. + sourceSize.width: 1600 + sourceSize.height: 1600 + smooth: true + } + + // The round mask: a dimming fill with the circle punched out of + // it, then the ring drawn on top. Painted once per change, not + // continuously. + Canvas { + id: mask + anchors.fill: parent + onPaint: { + const context = mask.getContext("2d"); + context.clearRect(0, 0, mask.width, mask.height); + context.fillStyle = Qt.rgba(0.12, 0.13, 0.19, 0.62); + context.fillRect(0, 0, mask.width, mask.height); + context.globalCompositeOperation = "destination-out"; + context.beginPath(); + context.arc(mask.width / 2, mask.height / 2, + mask.width / 2 - 8, 0, Math.PI * 2); + context.fill(); + context.globalCompositeOperation = "source-over"; + context.strokeStyle = Qt.rgba(0.78, 0.83, 0.96, 0.85); + context.lineWidth = 2; + context.beginPath(); + context.arc(mask.width / 2, mask.height / 2, + mask.width / 2 - 8, 0, Math.PI * 2); + context.stroke(); + } + } + + // The offset is tracked from where the press started rather than + // accumulated per frame, which would drift. + MouseArea { + anchors.fill: parent + enabled: root.ready + cursorShape: Qt.OpenHandCursor + property real pressX: 0 + property real pressY: 0 + property real originX: 0 + property real originY: 0 + + onPressed: mouse => { + pressX = mouse.x; pressY = mouse.y; + originX = root.offsetX; originY = root.offsetY; + } + onPositionChanged: mouse => { + if (!pressed) + return; + root.offsetX = originX + (mouse.x - pressX); + root.offsetY = originY + (mouse.y - pressY); + root.clamp(); + } + onWheel: wheel => { + const before = root.scale; + root.zoom = Math.max(1, Math.min(root.maxZoom, + root.zoom * (wheel.angleDelta.y > 0 ? 1.1 : 1 / 1.1))); + // Keep the centre of the frame pointing at the same part + // of the picture, so zooming does not walk the subject + // out of the circle. + const ratio = root.scale / before; + root.offsetX = root.viewport / 2 - (root.viewport / 2 - root.offsetX) * ratio; + root.offsetY = root.viewport / 2 - (root.viewport / 2 - root.offsetY) * ratio; + root.clamp(); + } + } + } + + Column { + width: column.width - frame.width - 20 + spacing: 12 + + Text { + width: parent.width + text: root.ready + ? "Drag to reposition, scroll to zoom." + : (root.source === "" ? "" : "Opening the picture…") + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + wrapMode: Text.WordWrap + } + + Text { + width: parent.width + visible: root.ready + text: "The circle is what other people see. Written out at 512 × 512." + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + + Text { + width: parent.width + visible: picture.status === Image.Error + text: "That file could not be opened as a picture." + color: Theme.danger + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + + Row { + spacing: 8 + + SettingsButton { + text: "Set picture" + tone: "accent" + enabled: root.ready + onClicked: { + // Screen coordinates back into the picture's own. + const size = root.viewport / root.scale; + root.cropped(Math.round(-root.offsetX / root.scale), + Math.round(-root.offsetY / root.scale), + Math.round(size)); + } + } + + SettingsButton { + text: "Reset" + enabled: root.ready + onClicked: root.reset() + } + + SettingsButton { + text: "Cancel" + onClicked: root.cancelled() + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/DateTimePage.qml b/config/dot/quickshell/modules/settings/DateTimePage.qml index 80d6979..6ad4cfa 100644 --- a/config/dot/quickshell/modules/settings/DateTimePage.qml +++ b/config/dot/quickshell/modules/settings/DateTimePage.qml @@ -50,38 +50,50 @@ SettingsPage { SettingsCard { title: "Timezone" - subtitle: "Currently " + (DateTime.timezone === "" ? "unknown" : DateTime.timezone) - + ". Type to narrow the list." - SearchField { - id: zoneSearch - width: parent.width - placeholder: "Search timezones" - } + PickerRow { + id: zonePicker - Repeater { - model: root.matchingZones + label: "Time zone" + detail: DateTime.timezone === "" + ? "Reading the system clock" + : DateTime.cityOf(DateTime.timezone) + " — " + DateTime.regionOf(DateTime.timezone) + value: DateTime.timezone === "" ? "Unknown" : DateTime.cityOf(DateTime.timezone) + divider: false + + SearchField { + id: zoneSearch + width: parent.width + placeholder: "Search timezones" + } + + Repeater { + model: root.matchingZones + + TextRow { + required property var modelData + required property int index + + label: DateTime.cityOf(modelData) + detail: DateTime.regionOf(modelData) + value: modelData === DateTime.timezone ? "Current" : "" + controlWidth: 90 + divider: index < root.matchingZones.length - 1 + activatable: true + onActivated: { + DateTime.setTimezone(modelData); + zonePicker.collapse(); + } + } + } TextRow { - required property var modelData - required property int index - - label: DateTime.cityOf(modelData) - detail: DateTime.regionOf(modelData) - value: modelData === DateTime.timezone ? "Current" : "" - controlWidth: 90 - divider: index < root.matchingZones.length - 1 - activatable: true - onActivated: DateTime.setTimezone(modelData) + visible: root.matchingZones.length === 0 + label: "No timezone matches that" + detail: "Try a city or a region, such as \"Denver\" or \"Europe\"" + divider: false } } - - TextRow { - visible: root.matchingZones.length === 0 - label: "No timezone matches that" - detail: "Try a city or a region, such as \"Denver\" or \"Europe\"" - divider: false - } } SettingsCard { diff --git a/config/dot/quickshell/modules/settings/DisplayModePicker.qml b/config/dot/quickshell/modules/settings/DisplayModePicker.qml index 6bef329..a7e9da7 100644 --- a/config/dot/quickshell/modules/settings/DisplayModePicker.qml +++ b/config/dot/quickshell/modules/settings/DisplayModePicker.qml @@ -15,6 +15,9 @@ Column { property var monitor: null property bool enabled: true + // Emitted once a mode has been asked for, so a container can put the list + // away. Applying is still this component's job; closing is not. + signal picked spacing: 0 readonly property var grouped: { @@ -106,11 +109,14 @@ Column { TapHandler { enabled: root.enabled && !rate.selected - onTapped: Displays.apply( - root.monitor.name, - rate.modelData.mode, - Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale), - root.monitor.transform) + onTapped: { + Displays.apply( + root.monitor.name, + rate.modelData.mode, + Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale), + root.monitor.transform); + root.picked(); + } } } } diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index 81d79b1..232979d 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -175,10 +175,25 @@ SettingsPage { title: "Resolution" subtitle: "Applied straight away, then reverted automatically unless you confirm." - DisplayModePicker { - width: parent.width - monitor: root.monitor + PickerRow { + id: modePicker + + label: "Resolution" + detail: root.monitor + ? root.monitor.width + " × " + root.monitor.height + " native" + : "No display selected" + value: root.monitor + ? root.monitor.width + " × " + root.monitor.height + : "" enabled: !Displays.awaitingConfirmation && !Displays.busy + divider: false + + DisplayModePicker { + width: parent.width + monitor: root.monitor + enabled: !Displays.awaitingConfirmation && !Displays.busy + onPicked: modePicker.collapse() + } } } diff --git a/config/dot/quickshell/modules/settings/PickerRow.qml b/config/dot/quickshell/modules/settings/PickerRow.qml new file mode 100644 index 0000000..f02029e --- /dev/null +++ b/config/dot/quickshell/modules/settings/PickerRow.qml @@ -0,0 +1,83 @@ +// A setting whose value is chosen from far more options than belong on screen. +// +// PickerRow { +// label: "Language" +// value: SystemLocale.currentLabel +// SearchPicker { items: SystemLocale.locales; onPicked: ... } +// } +// +// Three pages rendered an entire dataset as rows -- every installed locale, the +// whole tz database, every mode a monitor advertises -- so the one line telling +// you what is currently set was buried under hundreds that were not. The chooser +// itself was never the problem and is unchanged: this only collapses it behind +// the current value, which is what a person came to the page to read. +// +// Collapsed by default, and closes again once something is picked, so the page +// returns to being readable rather than staying open on a list nobody needs any +// more. + +import QtQuick +import qs.config + +Column { + id: root + + property string label: "" + property string detail: "" + // What is set right now. Shown on the collapsed row, so the common case -- + // looking rather than changing -- needs no interaction at all. + property string value: "" + property bool enabled: true + property bool divider: true + property bool expanded: false + + default property alias content: body.data + + // Pages call this from their picker's onPicked, so choosing something puts + // the list away instead of leaving it open over the rest of the page. + function collapse(): void { root.expanded = false; } + + width: parent ? parent.width : 620 + spacing: 0 + + SettingRow { + width: parent.width + label: root.label + detail: root.detail + activatable: root.enabled + divider: root.divider && !root.expanded + opacity: root.enabled ? 1 : 0.5 + controlWidth: 210 + onActivated: root.expanded = !root.expanded + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 9 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.value + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + elide: Text.ElideRight + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.expanded ? "▴" : "▾" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + } + + Column { + id: body + width: parent.width + visible: root.expanded + } +} diff --git a/config/dot/quickshell/modules/settings/RegionPage.qml b/config/dot/quickshell/modules/settings/RegionPage.qml index 35093e8..05b06a7 100644 --- a/config/dot/quickshell/modules/settings/RegionPage.qml +++ b/config/dot/quickshell/modules/settings/RegionPage.qml @@ -29,21 +29,27 @@ SettingsPage { title: "Language" subtitle: "Changing this needs your password, and takes effect for programs started afterwards." - TextRow { + 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) + 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(); + } + } } } diff --git a/config/dot/quickshell/modules/settings/SettingsTabs.qml b/config/dot/quickshell/modules/settings/SettingsTabs.qml new file mode 100644 index 0000000..7bbddea --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsTabs.qml @@ -0,0 +1,89 @@ +// Page-level tabs, for a page that is genuinely several subjects. +// +// SettingsTabs { +// tabs: [{ value: "theme", label: "Theme" }, …] +// current: root.tab +// onSelected: value => root.tab = value +// } +// +// Only for pages long enough that scrolling hides the control someone came for. +// Appearance was six cards deep, so light and dark -- the thing reached most -- +// sat below a wallpaper grid and an entire lock screen. Tabs are not a way to +// make a short page look organised; they are for when the page is long enough +// that the order stops being a suggestion and starts being a burial. + +import QtQuick +import qs.config + +Item { + id: root + + // [{ value, label }] + property var tabs: [] + property string current: "" + + signal selected(string value) + + width: parent ? parent.width : 620 + implicitHeight: 40 + + Row { + id: strip + anchors.left: parent.left + anchors.bottom: parent.bottom + spacing: 2 + + Repeater { + model: root.tabs + + delegate: Item { + id: tab + + required property var modelData + + readonly property bool active: String(tab.modelData.value) === root.current + + implicitWidth: caption.implicitWidth + 30 + implicitHeight: 38 + + Text { + id: caption + anchors.centerIn: parent + text: String(tab.modelData.label ?? "") + color: tab.active ? Theme.fg : (hover.hovered ? Theme.fgDim : Theme.fgMuted) + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: tab.active ? Font.DemiBold : Font.Medium + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 2 + radius: 1 + visible: tab.active + color: Theme.accent + } + + HoverHandler { + id: hover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: root.selected(String(tab.modelData.value)) + } + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.alpha(Theme.fg, 0.08) + z: -1 + } +} diff --git a/config/dot/quickshell/modules/settings/SnapshotsPage.qml b/config/dot/quickshell/modules/settings/SnapshotsPage.qml index ce82577..ae0a8b4 100644 --- a/config/dot/quickshell/modules/settings/SnapshotsPage.qml +++ b/config/dot/quickshell/modules/settings/SnapshotsPage.qml @@ -24,6 +24,12 @@ SettingsPage { title: "Snapshots" lede: "Points in time you can go back to, taken automatically for each volume." + // Points in time shown without asking. The page used to show none: the + // timeline and its Delete buttons sat behind a row labelled "Browse…", a + // word that promises a file browser, so a page about points in time + // appeared to list only how many there were. + readonly property int previewCount: 3 + property string openConfig: "" property string confirmingDelete: "" property string confirmingRestore: "" @@ -90,6 +96,9 @@ SettingsPage { readonly property string configName: String(volumeCard.modelData.name ?? "") readonly property var snapshots: volumeCard.modelData.snapshots ?? [] readonly property bool open: root.openConfig === volumeCard.configName + readonly property int shownCount: volumeCard.open + ? volumeCard.snapshots.length + : Math.min(root.previewCount, volumeCard.snapshots.length) title: Snapshots.labelFor(volumeCard.modelData) subtitle: String(volumeCard.modelData.subvolume ?? "") @@ -118,30 +127,24 @@ SettingsPage { onTriggered: Snapshots.take(volumeCard.configName, "Taken from Settings") } - ActionRow { - label: "History" - detail: volumeCard.snapshots.length === 0 - ? "Nothing taken yet" - : volumeCard.snapshots.length + " point" - + (volumeCard.snapshots.length === 1 ? "" : "s") + " in time" - action: volumeCard.open ? "Hide" : "Browse…" - enabled: volumeCard.snapshots.length > 0 - divider: volumeCard.open - onTriggered: { - root.confirmingDelete = ""; - Snapshots.closeBrowser(); - root.openConfig = volumeCard.open ? "" : volumeCard.configName; - } + TextRow { + visible: volumeCard.snapshots.length === 0 + label: "No points in time yet" + detail: "One is taken automatically on the schedule above." + value: "" + divider: false } // ── The timeline ───────────────────────────────────────────────── Column { width: parent.width - visible: volumeCard.open && !root.browsingOpen + visible: !root.browsingOpen Repeater { - model: volumeCard.snapshots + model: volumeCard.open + ? volumeCard.snapshots + : volumeCard.snapshots.slice(0, root.previewCount) delegate: SettingRow { id: pointRow @@ -162,7 +165,7 @@ SettingsPage { + " · #" + pointRow.modelData.number + (pointRow.modelData.kept ? " · kept" : "") controlWidth: 250 - divider: pointRow.index < volumeCard.snapshots.length - 1 + divider: pointRow.index < volumeCard.shownCount - 1 Row { anchors.right: parent.right @@ -197,6 +200,36 @@ SettingsPage { } } } + + SettingRow { + width: parent.width + visible: volumeCard.snapshots.length > root.previewCount + activatable: true + divider: false + label: volumeCard.open + ? "Showing all " + volumeCard.snapshots.length + " points in time" + : (volumeCard.snapshots.length - root.previewCount) + + " older point" + + (volumeCard.snapshots.length - root.previewCount === 1 ? "" : "s") + + " in time" + detail: volumeCard.open + ? "" + : "Oldest is " + String(volumeCard.snapshots[volumeCard.snapshots.length - 1]?.date ?? "") + controlWidth: 110 + onActivated: { + root.confirmingDelete = ""; + root.openConfig = volumeCard.open ? "" : volumeCard.configName; + } + + Text { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: volumeCard.open ? "Show fewer \u25B4" : "Show all \u25BE" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } } // ── Inside one point in time ───────────────────────────────────── diff --git a/config/dot/quickshell/modules/settings/UsersPage.qml b/config/dot/quickshell/modules/settings/UsersPage.qml index 5d085ce..8e6fedb 100644 --- a/config/dot/quickshell/modules/settings/UsersPage.qml +++ b/config/dot/quickshell/modules/settings/UsersPage.qml @@ -67,8 +67,30 @@ SettingsPage { // ── You ────────────────────────────────────────────────────────────────── + // Chosen but not yet framed. While this is set the cropper replaces the + // account card, because framing is a decision to finish, not a setting to + // leave half-made. + property string pendingPicture: "" + SettingsCard { - visible: root.me !== null + visible: root.pendingPicture !== "" + title: "Frame the picture" + + AvatarCropper { + width: parent.width + source: root.pendingPicture + + onCropped: (x, y, size) => { + UserAccounts.setIconCropped(String(root.me?.userName ?? ""), + root.pendingPicture, x, y, size); + root.pendingPicture = ""; + } + onCancelled: root.pendingPicture = "" + } + } + + SettingsCard { + visible: root.me !== null && root.pendingPicture === "" Row { width: parent.width @@ -88,8 +110,10 @@ SettingsPage { source: UserAccounts.avatarUrl visible: UserAccounts.avatarUrl !== "" fillMode: Image.PreserveAspectCrop - // The file is replaced in place when the picture - // changes, so the cache has to be told to let go. + // accountsservice replaces the file in place, so the + // path never changes. cache:false is not enough on its + // own -- an unchanged source is never re-read at all -- + // which is why avatarUrl carries a revision fragment. cache: false asynchronous: true sourceSize.width: 192 @@ -374,6 +398,6 @@ SettingsPage { // chooser every other application gets and needs no privilege of its own. AvatarPicker { id: avatarPicker - onPicked: path => UserAccounts.setIcon(String(root.me?.userName ?? ""), path) + onPicked: path => root.pendingPicture = path } } diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 1b12c0b..3ef5dac 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -5,6 +5,9 @@ AvatarPicker 1.0 AvatarPicker.qml ConnectivityPage 1.0 ConnectivityPage.qml FirewallPage 1.0 FirewallPage.qml ContainersPage 1.0 ContainersPage.qml +AvatarCropper 1.0 AvatarCropper.qml +PickerRow 1.0 PickerRow.qml +SettingsTabs 1.0 SettingsTabs.qml GamingPage 1.0 GamingPage.qml HomePhonePage 1.0 HomePhonePage.qml HomeFavoriteCard 1.0 HomeFavoriteCard.qml diff --git a/config/dot/quickshell/scripts/panama-users b/config/dot/quickshell/scripts/panama-users index d35959c..13f3c70 100755 --- a/config/dot/quickshell/scripts/panama-users +++ b/config/dot/quickshell/scripts/panama-users @@ -15,7 +15,7 @@ passed that way is published to every process on the machine. panama-accounts snapshot panama-accounts set-real-name USER NAME - panama-accounts set-icon USER PATH + panama-accounts set-icon USER PATH [X Y SIZE] panama-accounts set-account-type USER standard|administrator panama-accounts set-automatic-login USER true|false panama-accounts set-password USER (new password on stdin) @@ -29,6 +29,7 @@ import json import os import re import subprocess +import tempfile import sys ACCOUNTS = "org.freedesktop.Accounts" @@ -145,13 +146,74 @@ def set_real_name(username: str, name: str) -> None: GLib.Variant("(s)", (name,))) -def set_icon(username: str, path: str) -> None: +# What an avatar is written out at. accountsservice stores whatever it is +# handed, so a 4000px photograph would sit on disk forever to be drawn at 48. +AVATAR_SIZE = 512 + + +def crop_square(path: str, x: int, y: int, size: int) -> str: + """Cut a square out of a picture and write it at avatar size. + + Returns a path to a new file; the caller is responsible for removing it. + GdkPixbuf rather than a new dependency -- gi is already required here for + accountsservice itself. + """ + import gi + gi.require_version("GdkPixbuf", "2.0") + from gi.repository import GdkPixbuf + + try: + picture = GdkPixbuf.Pixbuf.new_from_file(path) + except Exception as error: + raise BoundaryError("That file is not a picture this can read.") from error + + if size <= 0: + raise BoundaryError("That is not a region of the picture.") + + # Clamped rather than rejected: a drag that ends a pixel outside the image + # is a normal thing to do with a pointer, not an error worth refusing. + x = max(0, min(x, picture.get_width() - 1)) + y = max(0, min(y, picture.get_height() - 1)) + size = min(size, picture.get_width() - x, picture.get_height() - y) + if size <= 0: + raise BoundaryError("That region is outside the picture.") + + square = picture.new_subpixbuf(x, y, size, size) + scaled = square.scale_simple(AVATAR_SIZE, AVATAR_SIZE, GdkPixbuf.InterpType.BILINEAR) + if scaled is None: + raise BoundaryError("That picture could not be resized.") + + handle, out = tempfile.mkstemp(prefix="panama-avatar-", suffix=".png") + os.close(handle) + # accountsservice reads this as root and copies it; it must not be private + # to this user, and it is deleted as soon as that copy has happened. + os.chmod(out, 0o644) + scaled.savev(out, "png", [], []) + return out + + +def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None: from gi.repository import GLib if not os.path.isfile(path): raise BoundaryError("That picture no longer exists.") - call(user_path(username), USER_INTERFACE, "SetIconFile", - GLib.Variant("(s)", (path,))) + + source = path + temporary = None + if region is not None: + temporary = source = crop_square(path, *region) + + try: + call(user_path(username), USER_INTERFACE, "SetIconFile", + GLib.Variant("(s)", (source,))) + finally: + # SetIconFile copies the file before it returns, so this is safe here + # and leaving it behind would litter /tmp with every picture ever set. + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass def set_account_type(username: str, kind: str) -> None: @@ -236,6 +298,12 @@ def main(arguments: list[str]) -> int: set_real_name(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-icon": set_icon(arguments[1], arguments[2]) + elif len(arguments) == 6 and arguments[0] == "set-icon": + try: + region = tuple(int(value) for value in arguments[3:6]) + except ValueError: + raise BoundaryError("That is not a region of the picture.") + set_icon(arguments[1], arguments[2], region) elif len(arguments) == 3 and arguments[0] == "set-account-type": set_account_type(arguments[1], arguments[2]) elif len(arguments) == 3 and arguments[0] == "set-automatic-login": @@ -249,7 +317,7 @@ def main(arguments: list[str]) -> int: else: raise BoundaryError( "Usage: panama-accounts snapshot | set-real-name USER NAME | " - "set-icon USER PATH | set-account-type USER standard|administrator | " + "set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | " "set-automatic-login USER true|false | set-password USER | " "create-user USERNAME REALNAME standard|administrator | " "delete-user USERNAME keep-files|remove-files") diff --git a/config/dot/quickshell/services/UserAccounts.qml b/config/dot/quickshell/services/UserAccounts.qml index df73d87..0be8bbd 100644 --- a/config/dot/quickshell/services/UserAccounts.qml +++ b/config/dot/quickshell/services/UserAccounts.qml @@ -41,9 +41,22 @@ Singleton { readonly property var others: root.users.filter(user => user.userName !== root.currentUser) + // Bumped once a picture has actually been set, and only then. + // + // accountsservice writes every avatar to the same path, so choosing a new + // picture leaves iconFile byte-identical and the URL never changes. Qt keys + // its image cache on that URL, so the old picture stayed on screen and the + // page looked broken while the write had in fact succeeded. The fragment + // moves the cache key without changing the file the URL resolves to. + property int iconRevision: 0 + + // Set while a picture is being written, so the revision is bumped for a + // genuine change rather than on every refresh that happens to pass through. + property bool settingIcon: false + // The avatar, as a URL the shell can draw, or "" when none is set. readonly property string avatarUrl: root.me && String(root.me.iconFile ?? "") !== "" - ? "file://" + root.me.iconFile + ? "file://" + root.me.iconFile + "#v" + root.iconRevision : "" function displayName(user: var): string { @@ -65,6 +78,11 @@ Singleton { root.currentUser = String(parsed.currentUser ?? ""); root.administratorCount = Number(parsed.administratorCount ?? 0); root.lastError = String(parsed.error ?? ""); + if (root.settingIcon) { + root.settingIcon = false; + if (root.lastError === "") + root.iconRevision += 1; + } } catch (error) { root.lastError = "Could not read the account service's answer."; console.warn("Accounts: could not parse helper output:", error); @@ -85,9 +103,18 @@ Singleton { } function setIcon(userName: string, path: string): void { + root.settingIcon = true; root.run(["set-icon", userName, path]); } + // The same, from a square chosen in the picture's own pixels. + function setIconCropped(userName: string, path: string, + x: int, y: int, size: int): void { + root.settingIcon = true; + root.run(["set-icon", userName, path, + String(Math.round(x)), String(Math.round(y)), String(Math.round(size))]); + } + function setAccountType(userName: string, kind: string): void { root.run(["set-account-type", userName, kind]); } diff --git a/tests/quickshell/qmldir-registration-contract.sh b/tests/quickshell/qmldir-registration-contract.sh new file mode 100755 index 0000000..5a38b58 --- /dev/null +++ b/tests/quickshell/qmldir-registration-contract.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# Every QML component in a module directory must be listed in that directory's +# qmldir. +# +# An unregistered component is not a quiet problem. Quickshell fails the whole +# configuration with "X is not a type" -- so the settings window dies, and with +# it the bar, the dock and the rest of the shell. It has happened twice: once +# adding ContainersPage, once adding PickerRow and SettingsTabs. Both times the +# file was written, the live shell hot-reloaded, and the desktop went down while +# somebody was using it. +# +# The failure is trivial to prevent and expensive to discover, which is exactly +# what a contract is for. This one is pure file inspection: no shell is started, +# so it can be run before the change ever reaches the running desktop. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +shell_dir="$repo_dir/config/dot/quickshell" + +fail() { + printf 'qmldir registration contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -d "$shell_dir" ]] || fail "missing $shell_dir" + +checked=0 +missing=() + +while IFS= read -r qmldir; do + directory="$(dirname "$qmldir")" + for component in "$directory"/*.qml; do + [[ -e "$component" ]] || continue + name="$(basename "$component" .qml)" + checked=$((checked + 1)) + # A singleton declares itself with `singleton NAME`; everything else is + # `NAME VERSION FILE`. Either form counts as registered. + grep -qE "^(singleton +)?${name}( |$)" "$qmldir" \ + || missing+=("${directory#"$repo_dir"/}/$name.qml") + done +done < <(find "$shell_dir" -name qmldir) + +if (( ${#missing[@]} > 0 )); then + printf 'qmldir registration contract: %d component(s) are not registered:\n' "${#missing[@]}" >&2 + printf ' %s\n' "${missing[@]}" >&2 + printf 'Quickshell fails the entire configuration on these, taking the shell down with it.\n' >&2 + exit 1 +fi + +(( checked > 0 )) || fail 'no components were checked, so this proves nothing' + +printf 'qmldir registration contract: ok (%d components registered)\n' "$checked"