// Selection mode: a GNOME-style rubber band over the frozen screen. // // Behaviours copied from GNOME 42: the overlay opens with a sensible centred // selection already in place (so the shutter is immediately useful), dragging // inside moves it, dragging a corner resizes from the opposite corner, and // dragging anywhere else starts a fresh band. // // A single MouseArea owns all of it. Nested MouseAreas for the handles would // fight the move/create area for the press, and the hit priorities are much // easier to reason about in one place. import QtQuick import qs.config Item { id: root // Anything smaller than this on release is treated as a stray click and the // previous selection is restored. readonly property real minSize: 12 // Pointer distance from a corner that counts as grabbing its handle. readonly property real handleGrab: 24 readonly property real handleSize: 14 property real selX: 0 property real selY: 0 property real selW: 0 property real selH: 0 readonly property bool hasSelection: root.selW >= root.minSize && root.selH >= root.minSize readonly property rect selection: Qt.rect(root.selX, root.selY, root.selW, root.selH) // Double-clicking inside the band captures, like confirming a crop. signal committed // "none" | "new" | "move" | "nw" | "ne" | "sw" | "se" property string _grab: "none" property real _anchorX: 0 property real _anchorY: 0 property real _pressX: 0 property real _pressY: 0 property real _origX: 0 property real _origY: 0 property rect _prev: Qt.rect(0, 0, 0, 0) onWidthChanged: root._resetIfEmpty() onHeightChanged: root._resetIfEmpty() Component.onCompleted: root._resetIfEmpty() function _resetIfEmpty(): void { if (root.selW === 0 && root.selH === 0) root.resetSelection(); } // Centred, roughly the middle half of the screen. function resetSelection(): void { if (root.width <= 0 || root.height <= 0) return; root.selW = Math.round(root.width * 0.55); root.selH = Math.round(root.height * 0.55); root.selX = Math.round((root.width - root.selW) / 2); root.selY = Math.round((root.height - root.selH) / 2); } function _clampX(v: real): real { return Math.max(0, Math.min(root.width, v)); } function _clampY(v: real): real { return Math.max(0, Math.min(root.height, v)); } function _near(a: real, b: real): bool { return Math.abs(a - b) <= root.handleGrab; } function _hitTest(px: real, py: real): string { const x2 = root.selX + root.selW; const y2 = root.selY + root.selH; if (root.hasSelection) { if (root._near(px, root.selX) && root._near(py, root.selY)) return "nw"; if (root._near(px, x2) && root._near(py, root.selY)) return "ne"; if (root._near(px, root.selX) && root._near(py, y2)) return "sw"; if (root._near(px, x2) && root._near(py, y2)) return "se"; if (px > root.selX && px < x2 && py > root.selY && py < y2) return "move"; } return "new"; } function _cursorFor(m: string): int { switch (m) { case "nw": case "se": return Qt.SizeFDiagCursor; case "ne": case "sw": return Qt.SizeBDiagCursor; case "move": return Qt.SizeAllCursor; default: return Qt.CrossCursor; } } // Rebuild the band from a fixed anchor corner and the live pointer. function _dragTo(px: real, py: real): void { const cx = root._clampX(px); const cy = root._clampY(py); root.selX = Math.min(root._anchorX, cx); root.selY = Math.min(root._anchorY, cy); root.selW = Math.abs(cx - root._anchorX); root.selH = Math.abs(cy - root._anchorY); } Spotlight { anchors.fill: parent holeVisible: root.hasSelection hole: root.selection } Frame { x: root.selX y: root.selY width: root.selW height: root.selH visible: root.hasSelection strokeWidth: 2 strokeColor: Theme.accent } // ── Corner handles ────────────────────────────────────────────────────── Repeater { model: [ { gx: 0, gy: 0 }, { gx: 1, gy: 0 }, { gx: 0, gy: 1 }, { gx: 1, gy: 1 } ] Rectangle { required property var modelData visible: root.hasSelection width: root.handleSize height: root.handleSize radius: width / 2 border.width: 0 color: Theme.accent x: root.selX + modelData.gx * root.selW - width / 2 y: root.selY + modelData.gy * root.selH - height / 2 // A dark core keeps the handle readable on top of a light photo. Rectangle { anchors.centerIn: parent width: parent.width - 6 height: width radius: width / 2 border.width: 0 color: Theme.bgDark } } } // ── Live size readout ─────────────────────────────────────────────────── Rectangle { id: readout visible: root.hasSelection implicitWidth: readoutText.implicitWidth + 24 implicitHeight: 34 radius: Theme.pillRadius border.width: 0 color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) x: Math.max(0, Math.min(root.width - width, root.selX + (root.selW - width) / 2)) // Inside the band when there is room, otherwise tucked just above it. y: root.selH > 110 ? root.selY + (root.selH - height) / 2 : Math.max(0, root.selY - height - 10) Text { id: readoutText anchors.centerIn: parent text: Math.round(root.selW) + " × " + Math.round(root.selH) color: Theme.fg // Tabular figures so the readout doesn't twitch while dragging. font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSize } } MouseArea { id: area anchors.fill: parent hoverEnabled: true cursorShape: root._cursorFor(root._grab !== "none" ? root._grab : root._hitTest(area.mouseX, area.mouseY)) onPressed: event => { root._prev = root.selection; root._grab = root._hitTest(event.x, event.y); root._pressX = event.x; root._pressY = event.y; root._origX = root.selX; root._origY = root.selY; const x2 = root.selX + root.selW; const y2 = root.selY + root.selH; switch (root._grab) { case "nw": root._anchorX = x2; root._anchorY = y2; break; case "ne": root._anchorX = root.selX; root._anchorY = y2; break; case "sw": root._anchorX = x2; root._anchorY = root.selY; break; case "se": root._anchorX = root.selX; root._anchorY = root.selY; break; case "new": root._anchorX = root._clampX(event.x); root._anchorY = root._clampY(event.y); root.selX = root._anchorX; root.selY = root._anchorY; root.selW = 0; root.selH = 0; break; } } onPositionChanged: event => { if (root._grab === "none") return; if (root._grab === "move") { root.selX = Math.max(0, Math.min(root.width - root.selW, root._origX + (event.x - root._pressX))); root.selY = Math.max(0, Math.min(root.height - root.selH, root._origY + (event.y - root._pressY))); } else { root._dragTo(event.x, event.y); } } onReleased: { // A plain click outside the band would otherwise wipe the selection // down to nothing; treat it as "no change" instead. if (root._grab !== "move" && !root.hasSelection) { root.selX = root._prev.x; root.selY = root._prev.y; root.selW = root._prev.width; root.selH = root._prev.height; } root._grab = "none"; } onDoubleClicked: event => { if (root.hasSelection && event.x > root.selX && event.x < root.selX + root.selW && event.y > root.selY && event.y < root.selY + root.selH) root.committed(); } } }