// 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() } } } } } }