Files
Panama/config/dot/quickshell/modules/capture/SelectionLayer.qml
T

279 lines
9.1 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
}
}