region selector: breathing for region recording

This commit is contained in:
end-4
2026-03-22 10:08:48 +01:00
parent d52fbe0b40
commit 8cc6087744
3 changed files with 282 additions and 245 deletions
@@ -14,11 +14,14 @@ Item {
required property color overlayColor
property bool showAimLines: Config.options.regionSelector.rect.showAimLines
property bool breathingBorderOnly: false
// Overlay to darken screen
// Base dark overlay around region
Rectangle {
id: darkenOverlay
z: 1
visible: !root.breathingBorderOnly
anchors {
left: parent.left
top: parent.top
@@ -32,25 +35,6 @@ Item {
border.width: Math.max(root.width, root.height)
}
// Selection border
// Rectangle {
// id: selectionBorder
// z: 1
// anchors {
// left: parent.left
// top: parent.top
// leftMargin: root.regionX
// topMargin: root.regionY
// }
// width: root.regionWidth
// height: root.regionHeight
// color: "transparent"
// border.color: root.color
// border.width: 2
// // radius: root.standardRounding
// radius: 0 // TODO: figure out how to make the overlay thing work with rounding
// }
DashedBorder {
id: selectionBorder
z: 9
@@ -64,13 +48,23 @@ Item {
height: Math.round(root.regionHeight) + borderWidth * 2
color: root.color
dashLength: 6
gapLength: 3
dashLength: 8
gapLength: 4
borderWidth: 1
// Breathing
opacity: 0.9
SequentialAnimation on opacity {
running: root.breathingBorderOnly
loops: Animation.Infinite
NumberAnimation { from: 0.9; to: 0.3; duration: 1200; easing.type: Easing.InOutQuad }
NumberAnimation { from: 0.3; to: 0.9; duration: 1200; easing.type: Easing.InOutQuad }
}
}
StyledText {
z: 2
visible: !root.breathingBorderOnly
anchors {
top: selectionBorder.bottom
right: selectionBorder.right
@@ -82,7 +76,7 @@ Item {
// Coord lines
Rectangle { // Vertical
visible: root.showAimLines
visible: root.showAimLines && !root.breathingBorderOnly
opacity: 0.2
z: 2
x: root.mouseX
@@ -94,7 +88,7 @@ Item {
color: root.color
}
Rectangle { // Horizontal
visible: root.showAimLines
visible: root.showAimLines && !root.breathingBorderOnly
opacity: 0.2
z: 2
y: root.mouseY
@@ -27,13 +27,17 @@ PanelWindow {
bottom: true
}
// Modes
// TODO: Ask: sidebar AI
enum SnipAction { Copy, Edit, Search, CharRecognition, Record, RecordWithSound }
enum SelectionMode { RectCorners, Circle }
enum Phase { Select, Post }
property var action: RegionSelection.SnipAction.Copy
property var selectionMode: RegionSelection.SelectionMode.RectCorners
property var phase: RegionSelection.Phase.Select
signal dismiss()
// Styles
property string screenshotDir: Directories.screenshotTemp
property color overlayColor: ColorUtils.transparentize("#000000", 0.4)
property color brightText: Appearance.m3colors.darkmode ? Appearance.colors.colOnLayer0 : Appearance.colors.colLayer0
@@ -46,6 +50,10 @@ PanelWindow {
property color imageBorderColor: brightTertiary
property color imageFillColor: ColorUtils.transparentize(imageBorderColor, 0.85)
property color onBorderColor: "#ff000000"
property real targetRegionOpacity: Config.options.regionSelector.targetRegions.opacity
property bool contentRegionOpacity: Config.options.regionSelector.targetRegions.contentRegionOpacity
// Vars for indicators
readonly property var windows: [...HyprlandData.windowList].sort((a, b) => {
// Sort floating=true windows before others
if (a.floating === b.floating) return 0;
@@ -54,6 +62,7 @@ PanelWindow {
readonly property var layers: HyprlandData.layers
readonly property real falsePositivePreventionRatio: 0.5
// Screen & interaction vars
readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(screen)
readonly property real monitorScale: hyprlandMonitor.scale
readonly property real monitorOffsetX: hyprlandMonitor.x
@@ -105,13 +114,13 @@ PanelWindow {
return offsetAdjustedLayers;
}
// Config
property bool isCircleSelection: (root.selectionMode === RegionSelection.SelectionMode.Circle)
property bool enableWindowRegions: Config.options.regionSelector.targetRegions.windows && !isCircleSelection
property bool enableLayerRegions: Config.options.regionSelector.targetRegions.layers && !isCircleSelection
property bool enableContentRegions: Config.options.regionSelector.targetRegions.content
property real targetRegionOpacity: Config.options.regionSelector.targetRegions.opacity
property bool contentRegionOpacity: Config.options.regionSelector.targetRegions.contentRegionOpacity
// Target
property real targetedRegionX: -1
property real targetedRegionY: -1
property real targetedRegionWidth: 0
@@ -175,6 +184,7 @@ PanelWindow {
property real regionX: Math.min(dragStartX, draggingX)
property real regionY: Math.min(dragStartY, draggingY)
// Screenshot stuff
TempScreenshotProcess {
id: screenshotProc
running: true
@@ -247,6 +257,7 @@ PanelWindow {
}
}
// Execution after selection
function snip() {
// Validity check
if (root.regionWidth <= 0 || root.regionHeight <= 0) {
@@ -277,21 +288,27 @@ PanelWindow {
screenshotAction, //
screenshotDir
)
snipProc.command = command;
// Image post-processing
snipProc.startDetached();
root.dismiss();
Quickshell.execDetached(command);
if (root.action == RegionSelection.SnipAction.Record || root.action == RegionSelection.SnipAction.RecordWithSound) {
root.phase = RegionSelection.Phase.Post
} else {
root.dismiss();
}
}
Process {
id: snipProc
// Only clickable in Selection phase
mask: Region {
item: switch(root.phase) {
case RegionSelection.Phase.Select: return mouseArea;
case RegionSelection.Phase.Post: return null;
}
}
ScreencopyView {
ScreencopyView { // For freezing
anchors.fill: parent
live: false
captureSource: root.screen
visible: root.phase === RegionSelection.Phase.Select
focus: root.visible
Keys.onPressed: (event) => { // Esc to close
@@ -299,220 +316,242 @@ PanelWindow {
root.dismiss();
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.CrossCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.CrossCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
// Controls
onPressed: (mouse) => {
root.dragStartX = mouse.x;
root.dragStartY = mouse.y;
root.draggingX = mouse.x;
root.draggingY = mouse.y;
root.dragging = true;
root.mouseButton = mouse.button;
}
onReleased: (mouse) => {
// Detect if it was a click -> Try to select targeted region
if (root.draggingX === root.dragStartX && root.draggingY === root.dragStartY) {
if (root.targetedRegionValid()) {
root.setRegionToTargeted();
}
}
// Circle dragging?
else if (root.selectionMode === RegionSelection.SelectionMode.Circle) {
const padding = Config.options.regionSelector.circle.padding + Config.options.regionSelector.circle.strokeWidth / 2;
const dragPoints = (root.points.length > 0) ? root.points : [{ x: mouseArea.mouseX, y: mouseArea.mouseY }];
const maxX = Math.max(...dragPoints.map(p => p.x));
const minX = Math.min(...dragPoints.map(p => p.x));
const maxY = Math.max(...dragPoints.map(p => p.y));
const minY = Math.min(...dragPoints.map(p => p.y));
root.regionX = minX - padding;
root.regionY = minY - padding;
root.regionWidth = maxX - minX + padding * 2;
root.regionHeight = maxY - minY + padding * 2;
}
root.snip();
}
onPositionChanged: (mouse) => {
root.updateTargetedRegion(mouse.x, mouse.y);
if (!root.dragging) return;
root.draggingX = mouse.x;
root.draggingY = mouse.y;
root.dragDiffX = mouse.x - root.dragStartX;
root.dragDiffY = mouse.y - root.dragStartY;
root.points.push({ x: mouse.x, y: mouse.y });
}
Loader {
z: 2
anchors.fill: parent
active: root.selectionMode === RegionSelection.SelectionMode.RectCorners
sourceComponent: RectCornersSelectionDetails {
regionX: root.regionX
regionY: root.regionY
regionWidth: root.regionWidth
regionHeight: root.regionHeight
mouseX: mouseArea.mouseX
mouseY: mouseArea.mouseY
color: root.selectionBorderColor
overlayColor: root.overlayColor
}
}
Loader {
z: 2
anchors.fill: parent
active: root.selectionMode === RegionSelection.SelectionMode.Circle
sourceComponent: CircleSelectionDetails {
color: root.selectionBorderColor
overlayColor: root.overlayColor
points: root.points
}
}
CursorGuide {
z: 9999
x: root.dragging ? root.regionX + root.regionWidth : mouseArea.mouseX
y: root.dragging ? root.regionY + root.regionHeight : mouseArea.mouseY
action: root.action
selectionMode: root.selectionMode
}
// Window regions
Repeater {
model: ScriptModel {
values: root.enableWindowRegions ? root.windowRegions : []
}
delegate: TargetRegion {
z: 2
required property var modelData
clientDimensions: modelData
showIcon: true
targeted: !root.draggedAway &&
(root.targetedRegionX === modelData.at[0]
&& root.targetedRegionY === modelData.at[1]
&& root.targetedRegionWidth === modelData.size[0]
&& root.targetedRegionHeight === modelData.size[1])
opacity: root.draggedAway ? 0 : root.targetRegionOpacity
borderColor: root.windowBorderColor
fillColor: targeted ? root.windowFillColor : "transparent"
text: `${modelData.class}`
radius: Appearance.rounding.windowRounding
}
}
// Layer regions
Repeater {
model: ScriptModel {
values: root.enableLayerRegions ? root.layerRegions : []
}
delegate: TargetRegion {
z: 3
required property var modelData
clientDimensions: modelData
targeted: !root.draggedAway &&
(root.targetedRegionX === modelData.at[0]
&& root.targetedRegionY === modelData.at[1]
&& root.targetedRegionWidth === modelData.size[0]
&& root.targetedRegionHeight === modelData.size[1])
opacity: root.draggedAway ? 0 : root.targetRegionOpacity
borderColor: root.windowBorderColor
fillColor: targeted ? root.windowFillColor : "transparent"
text: `${modelData.namespace}`
radius: Appearance.rounding.windowRounding
}
}
// Content regions
Repeater {
model: ScriptModel {
values: root.enableContentRegions ? root.imageRegions : []
}
delegate: TargetRegion {
z: 4
required property var modelData
clientDimensions: modelData
targeted: !root.draggedAway &&
(root.targetedRegionX === modelData.at[0]
&& root.targetedRegionY === modelData.at[1]
&& root.targetedRegionWidth === modelData.size[0]
&& root.targetedRegionHeight === modelData.size[1])
opacity: root.draggedAway ? 0 : root.contentRegionOpacity
borderColor: root.imageBorderColor
fillColor: targeted ? root.imageFillColor : "transparent"
text: Translation.tr("Content region")
}
}
// Controls
Row {
id: regionSelectionControls
z: 10
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: -height
}
opacity: 0
Connections {
target: root
function onVisibleChanged() {
if (!visible) return;
regionSelectionControls.anchors.bottomMargin = 8;
regionSelectionControls.opacity = 1;
}
}
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on anchors.bottomMargin {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
spacing: 6
OptionsToolbar {
Synchronizer on action {
property alias source: root.action
}
Synchronizer on selectionMode {
property alias source: root.selectionMode
}
onDismiss: root.dismiss();
}
Item {
anchors {
verticalCenter: parent.verticalCenter
}
implicitWidth: closeFab.implicitWidth
implicitHeight: closeFab.implicitHeight
StyledRectangularShadow {
target: closeFab
radius: closeFab.buttonRadius
}
FloatingActionButton {
id: closeFab
baseSize: 48
iconText: "close"
onClicked: root.dismiss();
StyledToolTip {
text: Translation.tr("Close")
}
colBackground: Appearance.colors.colTertiaryContainer
colBackgroundHover: Appearance.colors.colTertiaryContainerHover
colRipple: Appearance.colors.colTertiaryContainerActive
colOnBackground: Appearance.colors.colOnTertiaryContainer
}
}
}
// Controls
onPressed: (mouse) => {
root.dragStartX = mouse.x;
root.dragStartY = mouse.y;
root.draggingX = mouse.x;
root.draggingY = mouse.y;
root.dragging = true;
root.mouseButton = mouse.button;
}
onReleased: (mouse) => {
// Detect if it was a click -> Try to select targeted region
if (root.draggingX === root.dragStartX && root.draggingY === root.dragStartY) {
if (root.targetedRegionValid()) {
root.setRegionToTargeted();
}
}
// Circle dragging?
else if (root.selectionMode === RegionSelection.SelectionMode.Circle) {
const padding = Config.options.regionSelector.circle.padding + Config.options.regionSelector.circle.strokeWidth / 2;
const dragPoints = (root.points.length > 0) ? root.points : [{ x: mouseArea.mouseX, y: mouseArea.mouseY }];
const maxX = Math.max(...dragPoints.map(p => p.x));
const minX = Math.min(...dragPoints.map(p => p.x));
const maxY = Math.max(...dragPoints.map(p => p.y));
const minY = Math.min(...dragPoints.map(p => p.y));
root.regionX = minX - padding;
root.regionY = minY - padding;
root.regionWidth = maxX - minX + padding * 2;
root.regionHeight = maxY - minY + padding * 2;
}
root.snip();
}
onPositionChanged: (mouse) => {
root.updateTargetedRegion(mouse.x, mouse.y);
if (!root.dragging) return;
root.draggingX = mouse.x;
root.draggingY = mouse.y;
root.dragDiffX = mouse.x - root.dragStartX;
root.dragDiffY = mouse.y - root.dragStartY;
root.points.push({ x: mouse.x, y: mouse.y });
}
Loader {
z: 2
anchors.fill: parent
active: root.selectionMode === RegionSelection.SelectionMode.RectCorners
sourceComponent: RectCornersSelectionDetails {
regionX: root.regionX
regionY: root.regionY
regionWidth: root.regionWidth
regionHeight: root.regionHeight
mouseX: mouseArea.mouseX
mouseY: mouseArea.mouseY
color: root.selectionBorderColor
overlayColor: root.overlayColor
breathingBorderOnly: root.phase === RegionSelection.Phase.Post
}
}
Loader {
z: 2
anchors.fill: parent
active: root.selectionMode === RegionSelection.SelectionMode.Circle
sourceComponent: CircleSelectionDetails {
color: root.selectionBorderColor
overlayColor: root.overlayColor
points: root.points
}
}
// The thing to the bottom-right with an icon
CursorGuide {
z: 9999
visible: root.phase === RegionSelection.Phase.Select
x: root.dragging ? root.regionX + root.regionWidth : mouseArea.mouseX
y: root.dragging ? root.regionY + root.regionHeight : mouseArea.mouseY
action: root.action
selectionMode: root.selectionMode
}
// Window regions
Repeater {
model: ScriptModel {
values: {
if (root.phase === RegionSelection.Phase.Select && root.enableWindowRegions) {
return root.windowRegions
} else {
return []
}
}
}
delegate: TargetRegion {
z: 2
required property var modelData
clientDimensions: modelData
showIcon: true
targeted: !root.draggedAway && //
(root.targetedRegionX === modelData.at[0] //
&& root.targetedRegionY === modelData.at[1] //
&& root.targetedRegionWidth === modelData.size[0] //
&& root.targetedRegionHeight === modelData.size[1])
opacity: root.draggedAway ? 0 : root.targetRegionOpacity
borderColor: root.windowBorderColor
fillColor: targeted ? root.windowFillColor : "transparent"
text: `${modelData.class}`
radius: Appearance.rounding.windowRounding
}
}
// Layer regions
Repeater {
model: ScriptModel {
values: {
if (root.phase === RegionSelection.Phase.Select && root.enableLayerRegions) {
return root.layerRegions
} else {
return []
}
}
}
delegate: TargetRegion {
z: 3
required property var modelData
clientDimensions: modelData
targeted: !root.draggedAway &&
(root.targetedRegionX === modelData.at[0]
&& root.targetedRegionY === modelData.at[1]
&& root.targetedRegionWidth === modelData.size[0]
&& root.targetedRegionHeight === modelData.size[1])
opacity: root.draggedAway ? 0 : root.targetRegionOpacity
borderColor: root.windowBorderColor
fillColor: targeted ? root.windowFillColor : "transparent"
text: `${modelData.namespace}`
radius: Appearance.rounding.windowRounding
}
}
// Content regions
Repeater {
model: ScriptModel {
values: {
if (root.phase === RegionSelection.Phase.Select && root.enableContentRegions) {
return root.imageRegions
} else {
return []
}
}
}
delegate: TargetRegion {
z: 4
required property var modelData
clientDimensions: modelData
targeted: !root.draggedAway &&
(root.targetedRegionX === modelData.at[0]
&& root.targetedRegionY === modelData.at[1]
&& root.targetedRegionWidth === modelData.size[0]
&& root.targetedRegionHeight === modelData.size[1])
opacity: root.draggedAway ? 0 : root.contentRegionOpacity
borderColor: root.imageBorderColor
fillColor: targeted ? root.imageFillColor : "transparent"
text: Translation.tr("Content region")
}
}
// Controls
Row {
id: regionSelectionControls
z: 10
visible: root.phase === RegionSelection.Phase.Select
anchors {
horizontalCenter: parent.horizontalCenter
bottom: parent.bottom
bottomMargin: -height
}
opacity: 0
Connections {
target: root
function onVisibleChanged() {
if (!visible) return;
regionSelectionControls.anchors.bottomMargin = 8;
regionSelectionControls.opacity = 1;
}
}
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on anchors.bottomMargin {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
spacing: 6
OptionsToolbar {
Synchronizer on action {
property alias source: root.action
}
Synchronizer on selectionMode {
property alias source: root.selectionMode
}
onDismiss: root.dismiss();
}
Item {
anchors {
verticalCenter: parent.verticalCenter
}
implicitWidth: closeFab.implicitWidth
implicitHeight: closeFab.implicitHeight
StyledRectangularShadow {
target: closeFab
radius: closeFab.buttonRadius
}
FloatingActionButton {
id: closeFab
baseSize: 48
iconText: "close"
onClicked: root.dismiss();
StyledToolTip {
text: Translation.tr("Close")
}
colBackground: Appearance.colors.colTertiaryContainer
colBackgroundHover: Appearance.colors.colTertiaryContainerHover
colRipple: Appearance.colors.colTertiaryContainerActive
colOnBackground: Appearance.colors.colOnTertiaryContainer
}
}
}
}
}
@@ -65,12 +65,16 @@ Scope {
function record() {
root.action = RegionSelection.SnipAction.Record
root.selectionMode = RegionSelection.SelectionMode.RectCorners
// If already open then re-trigger to stop recording
if (GlobalStates.regionSelectorOpen) GlobalStates.regionSelectorOpen = false
GlobalStates.regionSelectorOpen = true
}
function recordWithSound() {
root.action = RegionSelection.SnipAction.RecordWithSound
root.selectionMode = RegionSelection.SelectionMode.RectCorners
// If already open then re-trigger to stop recording
if (GlobalStates.regionSelectorOpen) GlobalStates.regionSelectorOpen = false
GlobalStates.regionSelectorOpen = true
}