mirror of
https://github.com/end-4/dots-hyprland.git
synced 2026-06-28 19:47:29 -05:00
qs: move panels into modules/ii
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import Quickshell
|
||||
|
||||
Item {
|
||||
id: root
|
||||
required property color color
|
||||
required property color overlayColor
|
||||
required property list<point> points
|
||||
property int strokeWidth: Config.options.regionSelector.circle.strokeWidth
|
||||
|
||||
function updatePoints() {
|
||||
if (!root.dragging) return;
|
||||
root.points.push({ x: root.mouseX, y: root.mouseY });
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: darkenOverlay
|
||||
z: 1
|
||||
anchors.fill: parent
|
||||
color: root.overlayColor
|
||||
}
|
||||
|
||||
Shape {
|
||||
id: shape
|
||||
z: 2
|
||||
anchors.fill: parent
|
||||
layer.enabled: true
|
||||
layer.smooth: true
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
|
||||
ShapePath {
|
||||
id: shapePath
|
||||
strokeWidth: root.strokeWidth
|
||||
pathHints: ShapePath.PathLinear
|
||||
fillColor: "transparent"
|
||||
strokeColor: root.color
|
||||
capStyle: ShapePath.RoundCap
|
||||
joinStyle: ShapePath.RoundJoin
|
||||
|
||||
PathPolyline {
|
||||
path: root.points
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
import qs
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
|
||||
// Options toolbar
|
||||
Toolbar {
|
||||
id: root
|
||||
|
||||
// Use a synchronizer on these
|
||||
property var action
|
||||
property var selectionMode
|
||||
// Signals
|
||||
signal dismiss()
|
||||
|
||||
MaterialShape {
|
||||
Layout.fillHeight: true
|
||||
Layout.leftMargin: 2
|
||||
Layout.rightMargin: 2
|
||||
implicitSize: 36 // Intentionally smaller because this one is brighter than others
|
||||
shape: switch (root.action) {
|
||||
case RegionSelection.SnipAction.Copy:
|
||||
case RegionSelection.SnipAction.Edit:
|
||||
return MaterialShape.Shape.Cookie4Sided;
|
||||
case RegionSelection.SnipAction.Search:
|
||||
return MaterialShape.Shape.Pentagon;
|
||||
case RegionSelection.SnipAction.CharRecognition:
|
||||
return MaterialShape.Shape.Sunny;
|
||||
case RegionSelection.SnipAction.Record:
|
||||
case RegionSelection.SnipAction.RecordWithSound:
|
||||
return MaterialShape.Shape.Gem;
|
||||
default:
|
||||
return MaterialShape.Shape.Cookie12Sided;
|
||||
}
|
||||
color: Appearance.colors.colPrimary
|
||||
MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
iconSize: 22
|
||||
color: Appearance.colors.colOnPrimary
|
||||
animateChange: true
|
||||
text: switch (root.action) {
|
||||
case RegionSelection.SnipAction.Copy:
|
||||
case RegionSelection.SnipAction.Edit:
|
||||
return "content_cut";
|
||||
case RegionSelection.SnipAction.Search:
|
||||
return "image_search";
|
||||
case RegionSelection.SnipAction.CharRecognition:
|
||||
return "document_scanner";
|
||||
case RegionSelection.SnipAction.Record:
|
||||
case RegionSelection.SnipAction.RecordWithSound:
|
||||
return "videocam";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarTabBar {
|
||||
id: tabBar
|
||||
tabButtonList: [
|
||||
{"icon": "activity_zone", "name": Translation.tr("Rect")},
|
||||
{"icon": "gesture", "name": Translation.tr("Circle")}
|
||||
]
|
||||
currentIndex: root.selectionMode === RegionSelection.SelectionMode.RectCorners ? 0 : 1
|
||||
onCurrentIndexChanged: {
|
||||
root.selectionMode = currentIndex === 0 ? RegionSelection.SelectionMode.RectCorners : RegionSelection.SelectionMode.Circle;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
id: root
|
||||
required property real regionX
|
||||
required property real regionY
|
||||
required property real regionWidth
|
||||
required property real regionHeight
|
||||
required property real mouseX
|
||||
required property real mouseY
|
||||
required property color color
|
||||
required property color overlayColor
|
||||
property bool showAimLines: Config.options.regionSelector.rect.showAimLines
|
||||
|
||||
// Overlay to darken screen
|
||||
// Base dark overlay around region
|
||||
Rectangle {
|
||||
id: darkenOverlay
|
||||
z: 1
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
leftMargin: root.regionX - darkenOverlay.border.width
|
||||
topMargin: root.regionY - darkenOverlay.border.width
|
||||
}
|
||||
width: root.regionWidth + darkenOverlay.border.width * 2
|
||||
height: root.regionHeight + darkenOverlay.border.width * 2
|
||||
color: "transparent"
|
||||
border.color: root.overlayColor
|
||||
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
|
||||
}
|
||||
|
||||
StyledText {
|
||||
z: 2
|
||||
anchors {
|
||||
top: selectionBorder.bottom
|
||||
right: selectionBorder.right
|
||||
margins: 8
|
||||
}
|
||||
color: root.color
|
||||
text: `${Math.round(root.regionWidth)} x ${Math.round(root.regionHeight)}`
|
||||
}
|
||||
|
||||
// Coord lines
|
||||
Rectangle { // Vertical
|
||||
visible: root.showAimLines
|
||||
opacity: 0.2
|
||||
z: 2
|
||||
x: root.mouseX
|
||||
anchors {
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: 1
|
||||
color: root.color
|
||||
}
|
||||
Rectangle { // Horizontal
|
||||
visible: root.showAimLines
|
||||
opacity: 0.2
|
||||
z: 2
|
||||
y: root.mouseY
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
}
|
||||
height: 1
|
||||
color: root.color
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
pragma Singleton
|
||||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function intersectionOverUnion(regionA, regionB) {
|
||||
// region: { at: [x, y], size: [w, h] }
|
||||
const ax1 = regionA.at[0], ay1 = regionA.at[1];
|
||||
const ax2 = ax1 + regionA.size[0], ay2 = ay1 + regionA.size[1];
|
||||
const bx1 = regionB.at[0], by1 = regionB.at[1];
|
||||
const bx2 = bx1 + regionB.size[0], by2 = by1 + regionB.size[1];
|
||||
|
||||
const interX1 = Math.max(ax1, bx1);
|
||||
const interY1 = Math.max(ay1, by1);
|
||||
const interX2 = Math.min(ax2, bx2);
|
||||
const interY2 = Math.min(ay2, by2);
|
||||
|
||||
const interArea = Math.max(0, interX2 - interX1) * Math.max(0, interY2 - interY1);
|
||||
const areaA = (ax2 - ax1) * (ay2 - ay1);
|
||||
const areaB = (bx2 - bx1) * (by2 - by1);
|
||||
const unionArea = areaA + areaB - interArea;
|
||||
|
||||
return unionArea > 0 ? interArea / unionArea : 0;
|
||||
}
|
||||
|
||||
function filterOverlappingImageRegions(regions) {
|
||||
let keep = [];
|
||||
let removed = new Set();
|
||||
for (let i = 0; i < regions.length; ++i) {
|
||||
if (removed.has(i)) continue;
|
||||
let regionA = regions[i];
|
||||
for (let j = i + 1; j < regions.length; ++j) {
|
||||
if (removed.has(j)) continue;
|
||||
let regionB = regions[j];
|
||||
if (intersectionOverUnion(regionA, regionB) > 0) {
|
||||
// Compare areas
|
||||
let areaA = regionA.size[0] * regionA.size[1];
|
||||
let areaB = regionB.size[0] * regionB.size[1];
|
||||
if (areaA <= areaB) {
|
||||
removed.add(j);
|
||||
} else {
|
||||
removed.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < regions.length; ++i) {
|
||||
if (!removed.has(i)) keep.push(regions[i]);
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
function filterWindowRegionsByLayers(windowRegions, layerRegions) {
|
||||
return windowRegions.filter(windowRegion => {
|
||||
for (let i = 0; i < layerRegions.length; ++i) {
|
||||
if (intersectionOverUnion(windowRegion, layerRegions[i]) > 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function filterImageRegions(regions, windowRegions, threshold = 0.1) {
|
||||
// Remove image regions that overlap too much with any window region
|
||||
let filtered = regions.filter(region => {
|
||||
for (let i = 0; i < windowRegions.length; ++i) {
|
||||
if (intersectionOverUnion(region, windowRegions[i]) > threshold)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Remove overlapping image regions, keep only the smaller one
|
||||
return filterOverlappingImageRegions(filtered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Qt.labs.synchronizer
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
visible: false
|
||||
color: "transparent"
|
||||
WlrLayershell.namespace: "quickshell:regionSelector"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
anchors {
|
||||
left: true
|
||||
right: true
|
||||
top: true
|
||||
bottom: true
|
||||
}
|
||||
|
||||
// TODO: Ask: sidebar AI; Ocr: tesseract
|
||||
enum SnipAction { Copy, Edit, Search, CharRecognition, Record, RecordWithSound }
|
||||
enum SelectionMode { RectCorners, Circle }
|
||||
property var action: RegionSelection.SnipAction.Copy
|
||||
property var selectionMode: RegionSelection.SelectionMode.RectCorners
|
||||
signal dismiss()
|
||||
|
||||
property string saveScreenshotDir: Config.options.screenSnip.savePath !== ""
|
||||
? Config.options.screenSnip.savePath
|
||||
: ""
|
||||
|
||||
property string screenshotDir: Directories.screenshotTemp
|
||||
property string imageSearchEngineBaseUrl: Config.options.search.imageSearch.imageSearchEngineBaseUrl
|
||||
property string fileUploadApiEndpoint: "https://uguu.se/upload"
|
||||
property color overlayColor: "#88111111"
|
||||
property color brightText: Appearance.m3colors.darkmode ? Appearance.colors.colOnLayer0 : Appearance.colors.colLayer0
|
||||
property color brightSecondary: Appearance.m3colors.darkmode ? Appearance.colors.colSecondary : Appearance.colors.colOnSecondary
|
||||
property color brightTertiary: Appearance.m3colors.darkmode ? Appearance.colors.colTertiary : Qt.lighter(Appearance.colors.colPrimary)
|
||||
property color selectionBorderColor: ColorUtils.mix(brightText, brightSecondary, 0.5)
|
||||
property color selectionFillColor: "#33ffffff"
|
||||
property color windowBorderColor: brightSecondary
|
||||
property color windowFillColor: ColorUtils.transparentize(windowBorderColor, 0.85)
|
||||
property color imageBorderColor: brightTertiary
|
||||
property color imageFillColor: ColorUtils.transparentize(imageBorderColor, 0.85)
|
||||
property color onBorderColor: "#ff000000"
|
||||
readonly property var windows: [...HyprlandData.windowList].sort((a, b) => {
|
||||
// Sort floating=true windows before others
|
||||
if (a.floating === b.floating) return 0;
|
||||
return a.floating ? -1 : 1;
|
||||
})
|
||||
readonly property var layers: HyprlandData.layers
|
||||
readonly property real falsePositivePreventionRatio: 0.5
|
||||
|
||||
readonly property HyprlandMonitor hyprlandMonitor: Hyprland.monitorFor(screen)
|
||||
readonly property real monitorScale: hyprlandMonitor.scale
|
||||
readonly property real monitorOffsetX: hyprlandMonitor.x
|
||||
readonly property real monitorOffsetY: hyprlandMonitor.y
|
||||
property int activeWorkspaceId: hyprlandMonitor.activeWorkspace?.id ?? 0
|
||||
property string screenshotPath: `${root.screenshotDir}/image-${screen.name}`
|
||||
property real dragStartX: 0
|
||||
property real dragStartY: 0
|
||||
property real draggingX: 0
|
||||
property real draggingY: 0
|
||||
property real dragDiffX: 0
|
||||
property real dragDiffY: 0
|
||||
property bool draggedAway: (dragDiffX !== 0 || dragDiffY !== 0)
|
||||
property bool dragging: false
|
||||
property list<point> points: []
|
||||
property var mouseButton: null
|
||||
property var imageRegions: []
|
||||
readonly property list<var> windowRegions: RegionFunctions.filterWindowRegionsByLayers(
|
||||
root.windows.filter(w => w.workspace.id === root.activeWorkspaceId),
|
||||
root.layerRegions
|
||||
).map(window => {
|
||||
return {
|
||||
at: [window.at[0] - root.monitorOffsetX, window.at[1] - root.monitorOffsetY],
|
||||
size: [window.size[0], window.size[1]],
|
||||
class: window.class,
|
||||
title: window.title,
|
||||
}
|
||||
})
|
||||
readonly property list<var> layerRegions: {
|
||||
const layersOfThisMonitor = root.layers[root.hyprlandMonitor.name]
|
||||
const topLayers = layersOfThisMonitor?.levels["2"]
|
||||
if (!topLayers) return [];
|
||||
const nonBarTopLayers = topLayers
|
||||
.filter(layer => !(layer.namespace.includes(":bar") || layer.namespace.includes(":verticalBar") || layer.namespace.includes(":dock")))
|
||||
.map(layer => {
|
||||
return {
|
||||
at: [layer.x, layer.y],
|
||||
size: [layer.w, layer.h],
|
||||
namespace: layer.namespace,
|
||||
}
|
||||
})
|
||||
const offsetAdjustedLayers = nonBarTopLayers.map(layer => {
|
||||
return {
|
||||
at: [layer.at[0] - root.monitorOffsetX, layer.at[1] - root.monitorOffsetY],
|
||||
size: layer.size,
|
||||
namespace: layer.namespace,
|
||||
}
|
||||
});
|
||||
return offsetAdjustedLayers;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
property real targetedRegionX: -1
|
||||
property real targetedRegionY: -1
|
||||
property real targetedRegionWidth: 0
|
||||
property real targetedRegionHeight: 0
|
||||
function targetedRegionValid() {
|
||||
return (root.targetedRegionX >= 0 && root.targetedRegionY >= 0)
|
||||
}
|
||||
function setRegionToTargeted() {
|
||||
const padding = Config.options.regionSelector.targetRegions.selectionPadding; // Make borders not cut off n stuff
|
||||
root.regionX = root.targetedRegionX - padding;
|
||||
root.regionY = root.targetedRegionY - padding;
|
||||
root.regionWidth = root.targetedRegionWidth + padding * 2;
|
||||
root.regionHeight = root.targetedRegionHeight + padding * 2;
|
||||
}
|
||||
|
||||
function updateTargetedRegion(x, y) {
|
||||
// Image regions
|
||||
const clickedRegion = root.imageRegions.find(region => {
|
||||
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
||||
});
|
||||
if (clickedRegion) {
|
||||
root.targetedRegionX = clickedRegion.at[0];
|
||||
root.targetedRegionY = clickedRegion.at[1];
|
||||
root.targetedRegionWidth = clickedRegion.size[0];
|
||||
root.targetedRegionHeight = clickedRegion.size[1];
|
||||
return;
|
||||
}
|
||||
|
||||
// Layer regions
|
||||
const clickedLayer = root.layerRegions.find(region => {
|
||||
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
||||
});
|
||||
if (clickedLayer) {
|
||||
root.targetedRegionX = clickedLayer.at[0];
|
||||
root.targetedRegionY = clickedLayer.at[1];
|
||||
root.targetedRegionWidth = clickedLayer.size[0];
|
||||
root.targetedRegionHeight = clickedLayer.size[1];
|
||||
return;
|
||||
}
|
||||
|
||||
// Window regions
|
||||
const clickedWindow = root.windowRegions.find(region => {
|
||||
return region.at[0] <= x && x <= region.at[0] + region.size[0] && region.at[1] <= y && y <= region.at[1] + region.size[1];
|
||||
});
|
||||
if (clickedWindow) {
|
||||
root.targetedRegionX = clickedWindow.at[0];
|
||||
root.targetedRegionY = clickedWindow.at[1];
|
||||
root.targetedRegionWidth = clickedWindow.size[0];
|
||||
root.targetedRegionHeight = clickedWindow.size[1];
|
||||
return;
|
||||
}
|
||||
|
||||
root.targetedRegionX = -1;
|
||||
root.targetedRegionY = -1;
|
||||
root.targetedRegionWidth = 0;
|
||||
root.targetedRegionHeight = 0;
|
||||
}
|
||||
|
||||
property real regionWidth: Math.abs(draggingX - dragStartX)
|
||||
property real regionHeight: Math.abs(draggingY - dragStartY)
|
||||
property real regionX: Math.min(dragStartX, draggingX)
|
||||
property real regionY: Math.min(dragStartY, draggingY)
|
||||
|
||||
Process {
|
||||
id: screenshotProc
|
||||
running: true
|
||||
command: ["bash", "-c", `mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}' && grim -o '${StringUtils.shellSingleQuoteEscape(root.screen.name)}' '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'`]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (root.enableContentRegions) imageDetectionProcess.running = true;
|
||||
root.preparationDone = !checkRecordingProc.running;
|
||||
}
|
||||
}
|
||||
property bool isRecording: root.action === RegionSelection.SnipAction.Record || root.action === RegionSelection.SnipAction.RecordWithSound
|
||||
property bool recordingShouldStop: false
|
||||
Process {
|
||||
id: checkRecordingProc
|
||||
running: isRecording
|
||||
command: ["pidof", "wf-recorder"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.preparationDone = !screenshotProc.running
|
||||
root.recordingShouldStop = (exitCode === 0);
|
||||
}
|
||||
}
|
||||
property bool preparationDone: false
|
||||
onPreparationDoneChanged: {
|
||||
if (!preparationDone) return;
|
||||
if (root.isRecording && root.recordingShouldStop) {
|
||||
Quickshell.execDetached([Directories.recordScriptPath]);
|
||||
root.dismiss();
|
||||
return;
|
||||
}
|
||||
root.visible = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: imageDetectionProcess
|
||||
command: ["bash", "-c", `${Directories.scriptPath}/images/find-regions-venv.sh `
|
||||
+ `--hyprctl `
|
||||
+ `--image '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}' `
|
||||
+ `--max-width ${Math.round(root.screen.width * root.falsePositivePreventionRatio)} `
|
||||
+ `--max-height ${Math.round(root.screen.height * root.falsePositivePreventionRatio)} `]
|
||||
stdout: StdioCollector {
|
||||
id: imageDimensionCollector
|
||||
onStreamFinished: {
|
||||
imageRegions = RegionFunctions.filterImageRegions(
|
||||
JSON.parse(imageDimensionCollector.text),
|
||||
root.windowRegions
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function snip() {
|
||||
// Validity check
|
||||
if (root.regionWidth <= 0 || root.regionHeight <= 0) {
|
||||
console.warn("[Region Selector] Invalid region size, skipping snip.");
|
||||
root.dismiss();
|
||||
}
|
||||
|
||||
// Clamp region to screen bounds
|
||||
root.regionX = Math.max(0, Math.min(root.regionX, root.screen.width - root.regionWidth));
|
||||
root.regionY = Math.max(0, Math.min(root.regionY, root.screen.height - root.regionHeight));
|
||||
root.regionWidth = Math.max(0, Math.min(root.regionWidth, root.screen.width - root.regionX));
|
||||
root.regionHeight = Math.max(0, Math.min(root.regionHeight, root.screen.height - root.regionY));
|
||||
|
||||
// Adjust action
|
||||
if (root.action === RegionSelection.SnipAction.Copy || root.action === RegionSelection.SnipAction.Edit) {
|
||||
root.action = root.mouseButton === Qt.RightButton ? RegionSelection.SnipAction.Edit : RegionSelection.SnipAction.Copy;
|
||||
}
|
||||
|
||||
// Set command for action
|
||||
const rx = Math.round(root.regionX * root.monitorScale);
|
||||
const ry = Math.round(root.regionY * root.monitorScale);
|
||||
const rw = Math.round(root.regionWidth * root.monitorScale);
|
||||
const rh = Math.round(root.regionHeight * root.monitorScale);
|
||||
const cropBase = `magick ${StringUtils.shellSingleQuoteEscape(root.screenshotPath)} `
|
||||
+ `-crop ${rw}x${rh}+${rx}+${ry}`
|
||||
const cropToStdout = `${cropBase} -`
|
||||
const cropInPlace = `${cropBase} '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'`
|
||||
const cleanup = `rm '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}'`
|
||||
const slurpRegion = `${rx},${ry} ${rw}x${rh}`
|
||||
const uploadAndGetUrl = (filePath) => {
|
||||
return `curl -sF files[]=@'${StringUtils.shellSingleQuoteEscape(filePath)}' ${root.fileUploadApiEndpoint} | jq -r '.files[0].url'`
|
||||
}
|
||||
switch (root.action) {
|
||||
case RegionSelection.SnipAction.Copy:
|
||||
if (saveScreenshotDir === "") {
|
||||
// not saving the screenshot, just copy to clipboard
|
||||
snipProc.command = ["bash", "-c", `${cropToStdout} | wl-copy && ${cleanup}`]
|
||||
break;
|
||||
}
|
||||
|
||||
const savePathBase = root.saveScreenshotDir
|
||||
|
||||
snipProc.command = [
|
||||
"bash", "-c",
|
||||
`mkdir -p '${StringUtils.shellSingleQuoteEscape(savePathBase)}' && \
|
||||
saveFileName="screenshot-$(date '+%Y-%m-%d_%H.%M.%S').png" && \
|
||||
savePath="${savePathBase}/$saveFileName" && \
|
||||
${cropToStdout} | tee >(wl-copy) > "$savePath" && \
|
||||
${cleanup}`
|
||||
]
|
||||
|
||||
break;
|
||||
case RegionSelection.SnipAction.Edit:
|
||||
snipProc.command = ["bash", "-c", `${cropToStdout} | swappy -f - && ${cleanup}`]
|
||||
break;
|
||||
case RegionSelection.SnipAction.Search:
|
||||
snipProc.command = ["bash", "-c", `${cropInPlace} && xdg-open "${root.imageSearchEngineBaseUrl}$(${uploadAndGetUrl(root.screenshotPath)})" && ${cleanup}`]
|
||||
break;
|
||||
case RegionSelection.SnipAction.CharRecognition:
|
||||
snipProc.command = ["bash", "-c", `${cropInPlace} && tesseract '${StringUtils.shellSingleQuoteEscape(root.screenshotPath)}' stdout -l $(tesseract --list-langs | awk 'NR>1{print $1}' | tr '\\n' '+' | sed 's/\\+$/\\n/') | wl-copy && ${cleanup}`]
|
||||
break;
|
||||
case RegionSelection.SnipAction.Record:
|
||||
snipProc.command = ["bash", "-c", `${Directories.recordScriptPath} --region '${slurpRegion}'`]
|
||||
break;
|
||||
case RegionSelection.SnipAction.RecordWithSound:
|
||||
snipProc.command = ["bash", "-c", `${Directories.recordScriptPath} --region '${slurpRegion}' --sound`]
|
||||
break;
|
||||
default:
|
||||
console.warn("[Region Selector] Unknown snip action, skipping snip.");
|
||||
root.dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
// Image post-processing
|
||||
snipProc.startDetached();
|
||||
root.dismiss();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: snipProc
|
||||
}
|
||||
|
||||
ScreencopyView {
|
||||
anchors.fill: parent
|
||||
live: false
|
||||
captureSource: root.screen
|
||||
|
||||
focus: root.visible
|
||||
Keys.onPressed: (event) => { // Esc to close
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
root.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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: 9999
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
import qs
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Qt5Compat.GraphicalEffects
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
function dismiss() {
|
||||
GlobalStates.regionSelectorOpen = false
|
||||
}
|
||||
|
||||
property var action: RegionSelection.SnipAction.Copy
|
||||
property var selectionMode: RegionSelection.SelectionMode.RectCorners
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
delegate: Loader {
|
||||
id: regionSelectorLoader
|
||||
required property var modelData
|
||||
active: GlobalStates.regionSelectorOpen
|
||||
|
||||
sourceComponent: RegionSelection {
|
||||
screen: regionSelectorLoader.modelData
|
||||
onDismiss: root.dismiss()
|
||||
action: root.action
|
||||
selectionMode: root.selectionMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function screenshot() {
|
||||
root.action = RegionSelection.SnipAction.Copy
|
||||
root.selectionMode = RegionSelection.SelectionMode.RectCorners
|
||||
GlobalStates.regionSelectorOpen = true
|
||||
}
|
||||
|
||||
function search() {
|
||||
root.action = RegionSelection.SnipAction.Search
|
||||
if (Config.options.search.imageSearch.useCircleSelection) {
|
||||
root.selectionMode = RegionSelection.SelectionMode.Circle
|
||||
} else {
|
||||
root.selectionMode = RegionSelection.SelectionMode.RectCorners
|
||||
}
|
||||
GlobalStates.regionSelectorOpen = true
|
||||
}
|
||||
|
||||
function ocr() {
|
||||
root.action = RegionSelection.SnipAction.CharRecognition
|
||||
root.selectionMode = RegionSelection.SelectionMode.RectCorners
|
||||
GlobalStates.regionSelectorOpen = true
|
||||
}
|
||||
|
||||
function record() {
|
||||
root.action = RegionSelection.SnipAction.Record
|
||||
root.selectionMode = RegionSelection.SelectionMode.RectCorners
|
||||
GlobalStates.regionSelectorOpen = true
|
||||
}
|
||||
|
||||
function recordWithSound() {
|
||||
root.action = RegionSelection.SnipAction.RecordWithSound
|
||||
root.selectionMode = RegionSelection.SelectionMode.RectCorners
|
||||
GlobalStates.regionSelectorOpen = true
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "region"
|
||||
|
||||
function screenshot() {
|
||||
root.screenshot()
|
||||
}
|
||||
function search() {
|
||||
root.search()
|
||||
}
|
||||
function ocr() {
|
||||
root.ocr()
|
||||
}
|
||||
function record() {
|
||||
root.record()
|
||||
}
|
||||
function recordWithSound() {
|
||||
root.recordWithSound()
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "regionScreenshot"
|
||||
description: "Takes a screenshot of the selected region"
|
||||
onPressed: root.screenshot()
|
||||
}
|
||||
GlobalShortcut {
|
||||
name: "regionSearch"
|
||||
description: "Searches the selected region"
|
||||
onPressed: root.search()
|
||||
}
|
||||
GlobalShortcut {
|
||||
name: "regionOcr"
|
||||
description: "Recognizes text in the selected region"
|
||||
onPressed: root.ocr()
|
||||
}
|
||||
GlobalShortcut {
|
||||
name: "regionRecord"
|
||||
description: "Records the selected region"
|
||||
onPressed: root.record()
|
||||
}
|
||||
GlobalShortcut {
|
||||
name: "regionRecordWithSound"
|
||||
description: "Records the selected region with sound"
|
||||
onPressed: root.recordWithSound()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import qs.services
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
required property var clientDimensions
|
||||
|
||||
property color colBackground: Qt.alpha("#88111111", 0.9)
|
||||
property color colForeground: "#ddffffff"
|
||||
property bool showLabel: Config.options.regionSelector.targetRegions.showLabel
|
||||
property bool showIcon: false
|
||||
property bool targeted: false
|
||||
property color borderColor
|
||||
property color fillColor: "transparent"
|
||||
property string text: ""
|
||||
property real textPadding: 10
|
||||
z: 2
|
||||
color: fillColor
|
||||
border.color: borderColor
|
||||
border.width: targeted ? 4 : 2
|
||||
radius: 4
|
||||
|
||||
visible: opacity > 0
|
||||
Behavior on opacity {
|
||||
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
||||
}
|
||||
x: clientDimensions.at[0]
|
||||
y: clientDimensions.at[1]
|
||||
width: clientDimensions.size[0]
|
||||
height: clientDimensions.size[1]
|
||||
|
||||
Loader {
|
||||
anchors {
|
||||
top: parent.top
|
||||
left: parent.left
|
||||
topMargin: root.textPadding
|
||||
leftMargin: root.textPadding
|
||||
}
|
||||
|
||||
active: root.showLabel
|
||||
sourceComponent: Rectangle {
|
||||
property real verticalPadding: 5
|
||||
property real horizontalPadding: 10
|
||||
radius: 10
|
||||
color: root.colBackground
|
||||
border.width: 1
|
||||
border.color: Appearance.m3colors.m3outlineVariant
|
||||
implicitWidth: regionInfoRow.implicitWidth + horizontalPadding * 2
|
||||
implicitHeight: regionInfoRow.implicitHeight + verticalPadding * 2
|
||||
|
||||
Row {
|
||||
id: regionInfoRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
|
||||
Loader {
|
||||
id: regionIconLoader
|
||||
active: root.showIcon
|
||||
visible: active
|
||||
sourceComponent: IconImage {
|
||||
implicitSize: Appearance.font.pixelSize.larger
|
||||
source: Quickshell.iconPath(AppSearch.guessIcon(root.text), "image-missing")
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: regionText
|
||||
text: root.text
|
||||
color: root.colForeground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user