quickshell and hyprland additions

This commit is contained in:
2026-03-15 13:56:00 +02:00
parent c9c27d1554
commit 1ad06b82a6
509 changed files with 68371 additions and 19 deletions

View File

@@ -0,0 +1,606 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell.Wayland
import qs.config
import qs.modules.components
Scope {
id: root
property bool active: false
property rect selectedRegion: Qt.rect(0, 0, 0, 0)
property string tempScreenshot: ""
IpcHandler {
target: "screen"
function capture() {
if (root.active) {
console.info("screencap", "already active");
return;
}
console.info("screencap", "starting capture");
root.active = true;
}
}
LazyLoader {
active: root.active
component: PanelWindow {
id: win
property bool closing: false
property bool ready: false
property bool processing: false
property bool windowMode: false
property string savedPath: ""
property bool savedSuccess: false
color: Appearance.m3colors.m3surface
anchors { top: true; left: true; right: true; bottom: true }
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "nucleus:screencapture"
Component.onCompleted: {
var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss");
root.tempScreenshot = "/tmp/screenshot_" + ts + ".png";
}
function close() {
if (closing) return;
closing = true;
closeAnim.start();
}
function saveFullscreen() {
console.info("screencap", "saveFullscreen started");
win.processing = true;
screencopy.grabToImage(function(result) {
console.info("screencap", "fullscreen grabbed");
var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss");
win.savedPath = Quickshell.env("HOME") + "/Pictures/Screenshots/screenshot_" + ts + ".png";
console.info("screencap", "saving to: " + win.savedPath);
if (result.saveToFile(win.savedPath)) {
console.info("screencap", "saved, copying");
Quickshell.execDetached({
command: ["sh", "-c", "cat '" + win.savedPath + "' | wl-copy --type image/png"]
});
win.savedSuccess = true;
} else {
console.info("screencap", "save failed");
win.savedSuccess = false;
}
win.processing = false;
console.info("screencap", "closing window");
win.close();
});
}
Component {
id: ffmpegProc
Process {
property string outputPath
property bool success: false
onExited: (code) => {
console.info("screencap", "ffmpeg exited: " + code);
success = code === 0;
if (success) {
console.info("screencap", "copying to clipboard");
Quickshell.execDetached({
command: ["sh", "-c", "cat '" + outputPath + "' | wl-copy --type image/png"]
});
}
Quickshell.execDetached({ command: ["rm", root.tempScreenshot] });
win.savedSuccess = success;
win.processing = false;
console.info("screencap", "done, closing");
win.close();
destroy();
}
}
}
function saveRegion(rect, suffix) {
console.info("screencap", "saveRegion started: " + rect.x + "," + rect.y + " " + rect.width + "x" + rect.height);
screencopy.grabToImage(function(result) {
console.info("screencap", "full screenshot grabbed for cropping");
if (!result.saveToFile(root.tempScreenshot)) {
console.info("screencap", "ERROR: failed to save temp screenshot");
win.savedSuccess = false;
win.processing = false;
win.close();
return;
}
console.info("screencap", "temp saved, cropping with ffmpeg");
var ts = Qt.formatDateTime(new Date(), "yyyy-MM-dd_hh-mm-ss");
win.savedPath = Quickshell.env("HOME") + "/Pictures/Screenshots/screenshot_" + ts + suffix + ".png";
ffmpegProc.createObject(win, {
command: ["ffmpeg", "-i", root.tempScreenshot, "-vf", "crop=" + Math.floor(rect.width) + ":" + Math.floor(rect.height) + ":" + Math.floor(rect.x) + ":" + Math.floor(rect.y), "-y", win.savedPath],
outputPath: win.savedPath,
running: true
});
});
}
function captureFullscreen() {
win.processing = true;
saveFullscreen();
}
function captureWindow(rect) {
win.processing = true;
saveRegion(rect, "_window");
}
function captureRegion() {
if (!ready || !selection.hasSelection) return;
win.processing = true;
saveRegion(root.selectedRegion, "_region");
}
ScreencopyView {
id: screencopy
anchors.fill: parent
captureSource: win.screen
z: -999
live: false
onHasContentChanged: {
console.info("screencap", "hasContent: " + hasContent);
if (hasContent) {
console.info("screencap", "grabbing for preview");
grabToImage(function(result) {
console.info("screencap", "preview grabbed: " + result.url);
frozen.source = result.url;
readyTimer.start();
});
}
}
}
Timer {
id: readyTimer
interval: Metrics.chronoDuration("normal") + 50
onTriggered: {
console.info("screencap", "UI ready");
win.ready = true;
}
}
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: win.close()
Keys.onPressed: event => {
if (event.key === Qt.Key_F) {
win.captureFullscreen();
event.accepted = true;
} else if (event.key === Qt.Key_W) {
win.windowMode = !win.windowMode;
event.accepted = true;
}
}
Image {
id: bg
anchors.fill: parent
source: Config.runtime.appearance.background.path
fillMode: Image.PreserveAspectCrop
opacity: 0
scale: 1
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 1.0
blurMax: 64
brightness: -0.1
}
onStatusChanged: {
if (status === Image.Ready) fadeIn.start();
}
NumberAnimation on opacity {
id: fadeIn
to: 1
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
}
Item {
id: container
anchors.centerIn: parent
width: win.width
height: win.height
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowOpacity: 1
shadowColor: Appearance.m3colors.m3shadow
}
Image {
id: frozen
anchors.fill: parent
fillMode: Image.PreserveAspectFit
smooth: true
cache: false
}
Item {
id: darkOverlay
anchors.fill: parent
visible: (selection.hasSelection || selection.selecting) && !win.windowMode
Rectangle {
y: 0
width: parent.width
height: selection.sy
color: "black"
opacity: 0.5
}
Rectangle {
y: selection.sy + selection.h
width: parent.width
height: parent.height - (selection.sy + selection.h)
color: "black"
opacity: 0.5
}
Rectangle {
x: 0
y: selection.sy
width: selection.sx
height: selection.h
color: "black"
opacity: 0.5
}
Rectangle {
x: selection.sx + selection.w
y: selection.sy
width: parent.width - (selection.sx + selection.w)
height: selection.h
color: "black"
opacity: 0.5
}
Rectangle {
x: selection.sx
y: selection.sy
width: selection.w
height: selection.h
color: "black"
opacity: win.processing ? 0.6 : 0
Behavior on opacity {
NumberAnimation { duration: 200 }
}
LoadingIcon {
anchors.centerIn: parent
visible: win.processing
}
}
}
Rectangle {
id: outline
x: selection.sx
y: selection.sy
width: selection.w
height: selection.h
color: "transparent"
border.color: Appearance.m3colors.m3primary
border.width: 2
visible: (selection.selecting || selection.hasSelection) && !win.windowMode
}
Rectangle {
visible: selection.selecting
anchors.top: outline.bottom
anchors.topMargin: Metrics.margin(10)
anchors.horizontalCenter: outline.horizontalCenter
width: coords.width + 10
height: coords.height + 10
color: Appearance.m3colors.m3surface
radius: Metrics.radius(20)
StyledText {
id: coords
anchors.centerIn: parent
font.pixelSize: Metrics.fontSize(14)
animate: false
color: Appearance.m3colors.m3onSurface
property real scaleX: container.width / win.width
property real scaleY: container.height / win.height
text: Math.floor(selection.sx/scaleX) + "," + Math.floor(selection.sy/scaleY) + " " + Math.floor(selection.w/scaleX) + "x" + Math.floor(selection.h/scaleY)
}
}
MouseArea {
id: selection
anchors.fill: parent
enabled: win.ready && !win.windowMode
property real x1: 0
property real y1: 0
property real x2: 0
property real y2: 0
property bool selecting: false
property bool hasSelection: false
property real xp: 0
property real yp: 0
property real wp: 0
property real hp: 0
property real sx: xp * parent.width
property real sy: yp * parent.height
property real w: wp * parent.width
property real h: hp * parent.height
onPressed: mouse => {
if (!win.ready) return;
x1 = Math.max(0, Math.min(mouse.x, width));
y1 = Math.max(0, Math.min(mouse.y, height));
x2 = x1;
y2 = y1;
selecting = true;
hasSelection = false;
}
onPositionChanged: mouse => {
if (selecting) {
x2 = Math.max(0, Math.min(mouse.x, width));
y2 = Math.max(0, Math.min(mouse.y, height));
xp = Math.min(x1, x2) / width;
yp = Math.min(y1, y2) / height;
wp = Math.abs(x2 - x1) / width;
hp = Math.abs(y2 - y1) / height;
}
}
onReleased: mouse => {
if (!selecting) return;
x2 = Math.max(0, Math.min(mouse.x, width));
y2 = Math.max(0, Math.min(mouse.y, height));
selecting = false;
hasSelection = Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5;
if (hasSelection) {
xp = Math.min(x1, x2) / width;
yp = Math.min(y1, y2) / height;
wp = Math.abs(x2 - x1) / width;
hp = Math.abs(y2 - y1) / height;
root.selectedRegion = Qt.rect(
Math.min(x1, x2) * win.screen.width / width,
Math.min(y1, y2) * win.screen.height / height,
Math.abs(x2 - x1) * win.screen.width / width,
Math.abs(y2 - y1) * win.screen.height / height
);
win.captureRegion();
} else {
win.close();
}
}
}
Repeater {
model: {
if (!win.windowMode || !win.ready) return [];
var ws = Hyprland.focusedMonitor?.activeWorkspace;
return ws?.toplevels ? ws.toplevels.values : [];
}
delegate: Item {
required property var modelData
property var w: modelData?.lastIpcObject
visible: w?.at && w?.size
property real barX: 0
property real barY: 0
property real sx: container.width / (win.screen.width - barX)
property real sy: container.height / (win.screen.height - barY)
x: visible ? (w.at[0] - barX) * sx : 0
y: visible ? (w.at[1] - barY) * sy : 0
width: visible ? w.size[0] * sx : 0
height: visible ? w.size[1] * sy : 0
z: w?.floating ? (hover.containsMouse ? 1000 : 100) : (hover.containsMouse ? 50 : 0)
Rectangle {
anchors.fill: parent
color: "transparent"
border.color: Appearance.m3colors.m3primary
border.width: hover.containsMouse ? 3 : 0
radius: Metrics.radius(8)
Behavior on border.width {
NumberAnimation { duration: Metrics.chronoDuration(150) }
}
}
Rectangle {
anchors.fill: parent
color: Appearance.m3colors.m3primary
opacity: hover.containsMouse ? 0.15 : 0
radius: Metrics.radius(8)
Behavior on opacity {
NumberAnimation { duration: Metrics.chronoDuration(150) }
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
win.captureWindow(Qt.rect(w.at[0], w.at[1], w.size[0], w.size[1]));
}
}
}
}
ParallelAnimation {
running: win.visible && !win.closing && frozen.source != ""
NumberAnimation {
target: bg
property: "scale"
to: bg.scale + 0.05
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: container
property: "width"
to: win.width * 0.8
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: container
property: "height"
to: win.height * 0.8
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
}
ParallelAnimation {
id: closeAnim
NumberAnimation {
target: bg
property: "scale"
to: bg.scale - 0.05
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: container
property: "width"
to: win.width
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: container
property: "height"
to: win.height
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: darkOverlay
property: "opacity"
to: 0
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
NumberAnimation {
target: outline
property: "opacity"
to: 0
duration: Metrics.chronoDuration("normal")
easing.type: Appearance.animation.easing
}
onFinished: {
root.active = false;
if (win.savedSuccess) {
Quickshell.execDetached({
command: ["notify-send", "Screenshot saved", win.savedPath.split("/").pop() + " (copied)"]
});
} else if (win.savedPath !== "") {
Quickshell.execDetached({
command: ["notify-send", "Screenshot failed", "Could not save"]
});
}
}
}
}
Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Metrics.margin(30)
width: row.width + 20
height: row.height + 20
visible: true
Rectangle {
anchors.fill: parent
color: Appearance.m3colors.m3surface
radius: Metrics.radius("large")
}
RowLayout {
id: row
anchors.centerIn: parent
StyledButton {
icon: "fullscreen"
text: "Full screen"
tooltipText: "Capture the whole screen [F]"
onClicked: win.captureFullscreen()
}
Rectangle {
Layout.fillHeight: true
width: 2
color: Appearance.m3colors.m3onSurfaceVariant
opacity: 0.2
}
StyledButton {
icon: "window"
checkable: true
checked: win.windowMode
text: "Window"
tooltipText: "Hover and click a window [W]"
onClicked: win.windowMode = !win.windowMode
}
StyledButton {
secondary: true
icon: "close"
tooltipText: "Exit [Escape]"
onClicked: win.close()
}
}
}
}
HyprlandFocusGrab {
id: grab
windows: [win]
}
onVisibleChanged: {
if (visible) grab.active = true
}
Connections {
target: grab
function onActiveChanged() {
if (!grab.active && !win.closing) win.close();
}
}
}
}
}