colour -> color, behaviour -> behavior, centre -> center, favourite -> favorite, and about twenty other pairs, applied consistently across comments, docs, error/UI copy, and a handful of QML identifiers that used the British spelling as their actual name: SystemSettings' serialiseValue/serialiseTable/normaliseGradient, Displays' normaliseModes, Wallpaper's normalisePolicy, SettingsBackup's serialiseHomeState, DateTime's ntpSynchronised property, Clipboard's _normalise helper, and ShortcutCapture's cancelled signal (with its onCancelled handler in ShortcutsPage.qml). Every call site and the two tests that assert on the literal source text (settings-ownership and settings-backup-live contracts) were updated in lockstep. Left untouched: config/dot/espanso/match/packages/misspell-en/ is a vendored third-party autocorrect dictionary -- its entries are typo corrections, not our prose, and rewriting them would fight the package's own purpose (and any future re-sync from upstream). The already-American `favorites` property (Home page pinned accessories) was never actually misspelled -- only nearby comments and error strings said "favourites" -- so no data migration was needed there. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
279 lines
9.1 KiB
QML
279 lines
9.1 KiB
QML
// Selection mode: a GNOME-style rubber band over the frozen screen.
|
||
//
|
||
// Behaviors copied from GNOME 42: the overlay opens with a sensible centered
|
||
// 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();
|
||
}
|
||
|
||
// Centered, 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();
|
||
}
|
||
}
|
||
}
|