Build the Panama Hyprland desktop
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
// The floating control panel at the bottom of the capture overlay.
|
||||
//
|
||||
// Layout is a deliberate copy of GNOME 42's screenshot UI: the area segmented
|
||||
// control on top, and below it the shutter dead-centre with "Show Pointer" on
|
||||
// the left and the screenshot/screencast switch on the right.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal captureRequested
|
||||
|
||||
// Set by the overlay so the shutter can grey out when Window mode has
|
||||
// nothing pickable (no Hyprland, or an empty workspace).
|
||||
property bool captureEnabled: true
|
||||
|
||||
implicitWidth: 760
|
||||
implicitHeight: col.implicitHeight + 2 * 20
|
||||
radius: 28
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
|
||||
Column {
|
||||
id: col
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 20
|
||||
}
|
||||
spacing: 18
|
||||
|
||||
// ── Area mode ───────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
implicitWidth: modes.implicitWidth + 8
|
||||
implicitHeight: modes.implicitHeight + 8
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
Row {
|
||||
id: modes
|
||||
anchors.centerIn: parent
|
||||
spacing: 2
|
||||
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Screen"
|
||||
active: Capture.mode === "screen"
|
||||
onClicked: Capture.mode = "screen"
|
||||
}
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Window"
|
||||
active: Capture.mode === "window"
|
||||
onClicked: Capture.mode = "window"
|
||||
}
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Selection"
|
||||
active: Capture.mode === "selection"
|
||||
onClicked: Capture.mode = "selection"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pointer toggle · shutter · capture type ─────────────────────────
|
||||
Item {
|
||||
width: col.width
|
||||
height: 68
|
||||
|
||||
// "Show Pointer". wf-recorder has no cursor switch in this build,
|
||||
// so it is disabled while the screencast type is selected instead
|
||||
// of silently lying about what it does.
|
||||
Rectangle {
|
||||
id: pointerToggle
|
||||
anchors {
|
||||
left: parent.left
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
implicitWidth: pointerRow.implicitWidth + 26
|
||||
implicitHeight: 40
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
opacity: Capture.recordMode || Capture.intelligenceMode ? 0.4 : 1.0
|
||||
color: pointerMouse.containsMouse && !Capture.recordMode && !Capture.intelligenceMode ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: pointerRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Capture.showPointer ? "" : ""
|
||||
color: Capture.showPointer ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Show Pointer"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: pointerMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !Capture.recordMode && !Capture.intelligenceMode
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Capture.showPointer = !Capture.showPointer
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shutter ─────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
id: shutter
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: 64
|
||||
implicitHeight: 64
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
opacity: root.captureEnabled ? 1.0 : 0.45
|
||||
color: shutterMouse.containsMouse && root.captureEnabled ? Theme.alpha(Theme.fg, 0.3) : Theme.alpha(Theme.fg, 0.18)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: shutterMouse.pressed ? 40 : 46
|
||||
height: width
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: Capture.recordMode ? Theme.danger : (Capture.intelligenceMode ? Theme.accent : Theme.fg)
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: Capture.intelligenceMode
|
||||
text: ""
|
||||
color: Theme.bgDark
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 21
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: shutterMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: root.captureEnabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.captureRequested()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screenshot / screencast ─────────────────────────────────────
|
||||
Rectangle {
|
||||
anchors {
|
||||
right: parent.right
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
implicitWidth: types.implicitWidth + 8
|
||||
implicitHeight: types.implicitHeight + 8
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
Row {
|
||||
id: types
|
||||
anchors.centerIn: parent
|
||||
spacing: 2
|
||||
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Shot"
|
||||
active: !Capture.recordMode && !Capture.intelligenceMode
|
||||
onClicked: Capture.selectScreenshot()
|
||||
}
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Record"
|
||||
active: Capture.recordMode
|
||||
onClicked: Capture.selectRecording()
|
||||
}
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Read"
|
||||
active: Capture.intelligenceMode
|
||||
onClicked: Capture.selectIntelligence()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// The GNOME 42 screenshot UI.
|
||||
//
|
||||
// Print freezes the screen, this overlay paints that frozen frame full-bleed
|
||||
// and puts the picker on top of it. Because the frame is a still, nothing under
|
||||
// the overlay animates while the user aims, and the overlay can never end up in
|
||||
// its own screenshot.
|
||||
//
|
||||
// The grab happens in services/Capture.qml *before* ShellState flips to
|
||||
// "capture", so by the time this window maps the backdrop is already on disk.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: ShellState.captureOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
// Fullscreen overlays must not reserve space, or every window on the
|
||||
// workspace gets resized as the picker opens and closes.
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
|
||||
onVisibleChanged: {
|
||||
if (!win.visible)
|
||||
return;
|
||||
// Hyprland reports window geometry in layout-global coordinates; the
|
||||
// picker works in window-local ones. On a single monitor this is 0,0.
|
||||
Capture.originX = win.screen ? win.screen.x : 0;
|
||||
Capture.originY = win.screen ? win.screen.y : 0;
|
||||
keys.forceActiveFocus();
|
||||
}
|
||||
|
||||
// Fire whatever the current mode has selected.
|
||||
function capture(): void {
|
||||
if (Capture.mode === "screen") {
|
||||
Capture.commit(null);
|
||||
return;
|
||||
}
|
||||
if (Capture.mode === "window") {
|
||||
const r = picker.selectedRect;
|
||||
if (r)
|
||||
Capture.commit(r);
|
||||
return;
|
||||
}
|
||||
if (!selection.hasSelection)
|
||||
return;
|
||||
const s = selection.selection;
|
||||
Capture.commit(Qt.rect(s.x + Capture.originX, s.y + Capture.originY, s.width, s.height));
|
||||
}
|
||||
|
||||
function cycleMode(step: int): void {
|
||||
const order = ["screen", "window", "selection"];
|
||||
const i = order.indexOf(Capture.mode);
|
||||
Capture.mode = order[(i + step + order.length) % order.length];
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: keys
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onPressed: event => {
|
||||
switch (event.key) {
|
||||
case Qt.Key_Escape:
|
||||
Capture.close();
|
||||
break;
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
case Qt.Key_Space:
|
||||
win.capture();
|
||||
break;
|
||||
case Qt.Key_Left:
|
||||
win.cycleMode(-1);
|
||||
break;
|
||||
case Qt.Key_Right:
|
||||
win.cycleMode(1);
|
||||
break;
|
||||
case Qt.Key_1:
|
||||
Capture.mode = "screen";
|
||||
break;
|
||||
case Qt.Key_2:
|
||||
Capture.mode = "window";
|
||||
break;
|
||||
case Qt.Key_3:
|
||||
Capture.mode = "selection";
|
||||
break;
|
||||
case Qt.Key_P:
|
||||
Capture.showPointer = !Capture.showPointer;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
// Painted under the frozen frame so a failed grab (no wlr-screencopy)
|
||||
// still gives the picker something opaque to sit on.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.bgDark
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
// The frozen screen. grim -o pins the grab to this output, so filling
|
||||
// the window is an exact 1:1 mapping; the source is 4500x3000 device
|
||||
// pixels drawn into 3000x2000 logical ones, i.e. full sharpness.
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: Capture.freezeUrl
|
||||
fillMode: Image.Stretch
|
||||
// Unique filename per grab plus cache:false — otherwise QML would
|
||||
// happily repaint a previous session's frame.
|
||||
cache: false
|
||||
// Synchronous so the overlay never flashes black on the way in; a
|
||||
// raw PPM decode is a parse, not a decompress.
|
||||
asynchronous: false
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
// ── Screen mode ─────────────────────────────────────────────────────
|
||||
// Nothing to pick, so just outline the output the way GNOME does.
|
||||
Frame {
|
||||
anchors.fill: parent
|
||||
visible: Capture.mode === "screen"
|
||||
strokeWidth: 3
|
||||
strokeColor: Theme.accent
|
||||
}
|
||||
|
||||
// ── Window mode ─────────────────────────────────────────────────────
|
||||
WindowPicker {
|
||||
id: picker
|
||||
anchors.fill: parent
|
||||
visible: Capture.mode === "window"
|
||||
originX: Capture.originX
|
||||
originY: Capture.originY
|
||||
onPicked: win.capture()
|
||||
}
|
||||
|
||||
// ── Selection mode ──────────────────────────────────────────────────
|
||||
SelectionLayer {
|
||||
id: selection
|
||||
anchors.fill: parent
|
||||
visible: Capture.mode === "selection"
|
||||
onCommitted: win.capture()
|
||||
}
|
||||
|
||||
CaptureBar {
|
||||
anchors {
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
bottom: parent.bottom
|
||||
bottomMargin: 56
|
||||
}
|
||||
captureEnabled: {
|
||||
if (Capture.mode === "window")
|
||||
return picker.selectedRect !== null;
|
||||
if (Capture.mode === "selection")
|
||||
return selection.hasSelection;
|
||||
return true;
|
||||
}
|
||||
onCaptureRequested: win.capture()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// An outline drawn as four opaque strips rather than a Rectangle border.
|
||||
//
|
||||
// A rounded Rectangle with a translucent fill and a border renders holes at the
|
||||
// corners on this Qt build (QTBUG-137166), and every outline in the capture
|
||||
// overlay sits on top of a photo where that would be very visible. Four plain
|
||||
// strips have no corners to get wrong.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property color strokeColor: Theme.accent
|
||||
property int strokeWidth: 2
|
||||
// Optional wash over the enclosed area, drawn under the strips.
|
||||
property color fillColor: "transparent"
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.fillColor
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
height: root.strokeWidth
|
||||
color: root.strokeColor
|
||||
border.width: 0
|
||||
}
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
|
||||
height: root.strokeWidth
|
||||
color: root.strokeColor
|
||||
border.width: 0
|
||||
}
|
||||
Rectangle {
|
||||
anchors { top: parent.top; bottom: parent.bottom; left: parent.left }
|
||||
width: root.strokeWidth
|
||||
color: root.strokeColor
|
||||
border.width: 0
|
||||
}
|
||||
Rectangle {
|
||||
anchors { top: parent.top; bottom: parent.bottom; right: parent.right }
|
||||
width: root.strokeWidth
|
||||
color: root.strokeColor
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// Focused result sheet for local OCR and code recognition. It is intentionally
|
||||
// a transient overlay: the user selects pixels, acts on the result, and leaves
|
||||
// without creating another application window or workspace tile.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: ScreenIntelligence.visible
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "qs-screen-intelligence"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible)
|
||||
keyboardScope.forceActiveFocus();
|
||||
else
|
||||
copied = false;
|
||||
}
|
||||
|
||||
property bool copied: false
|
||||
|
||||
Timer {
|
||||
id: copiedTimer
|
||||
interval: 1500
|
||||
onTriggered: win.copied = false
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: keyboardScope
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
ScreenIntelligence.close();
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, 0.46)
|
||||
border.width: 0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: ScreenIntelligence.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: sheet
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 80, 840)
|
||||
height: Math.min(parent.height - 120, ScreenIntelligence.phase === "ready" ? 680 : 390)
|
||||
radius: 24
|
||||
color: Theme.alpha(Theme.bgPopover, 0.965)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
clip: true
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: sheet.radius
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: mouse => mouse.accepted = true
|
||||
}
|
||||
|
||||
Column {
|
||||
id: heading
|
||||
anchors.left: parent.left
|
||||
anchors.right: closeButton.left
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 28
|
||||
anchors.topMargin: 25
|
||||
anchors.rightMargin: 18
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: "Screen Intelligence"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
text: {
|
||||
if (ScreenIntelligence.phase === "capturing")
|
||||
return "Capturing the selected pixels";
|
||||
if (ScreenIntelligence.phase === "reading")
|
||||
return "Reading locally on this computer";
|
||||
if (ScreenIntelligence.phase === "error")
|
||||
return "Recognition needs attention";
|
||||
return "Local text and code recognition";
|
||||
}
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: closeButton
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: 20
|
||||
anchors.rightMargin: 20
|
||||
width: 34
|
||||
height: 34
|
||||
radius: 11
|
||||
color: closeMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "×"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 20
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: ScreenIntelligence.close()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: body
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: heading.bottom
|
||||
anchors.bottom: actions.top
|
||||
anchors.leftMargin: 28
|
||||
anchors.rightMargin: 28
|
||||
anchors.topMargin: 24
|
||||
anchors.bottomMargin: 18
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width, 520)
|
||||
spacing: 13
|
||||
visible: ScreenIntelligence.busy
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: ""
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 40
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: ScreenIntelligence.phase === "capturing" ? "Capturing…" : "Reading text and codes…"
|
||||
color: Theme.fg
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Pixels stay on this computer. No network request is being made."
|
||||
color: Theme.fgDim
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width, 560)
|
||||
spacing: 14
|
||||
visible: ScreenIntelligence.phase === "error"
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: ""
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 38
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: ScreenIntelligence.error
|
||||
color: Theme.fg
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: commandText.implicitWidth + 28
|
||||
height: 38
|
||||
radius: 10
|
||||
visible: !ScreenIntelligence.ocrReady
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: commandText
|
||||
anchors.centerIn: parent
|
||||
text: "sudo dnf install -y tesseract zbar"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 12
|
||||
visible: ScreenIntelligence.phase === "ready"
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: ScreenIntelligence.codeValue === "" ? 0 : 62
|
||||
visible: ScreenIntelligence.codeValue !== ""
|
||||
radius: 13
|
||||
color: Theme.alpha(Theme.accent, 0.09)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.2)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: ""
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 22
|
||||
}
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 52
|
||||
anchors.right: codeAction.left
|
||||
anchors.rightMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text { text: ScreenIntelligence.codeType || "Detected code"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSizeSmall; font.weight: Font.DemiBold }
|
||||
Text { width: parent.width; text: ScreenIntelligence.codeValue; color: Theme.fgDim; elide: Text.ElideMiddle; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
}
|
||||
ResultAction {
|
||||
id: codeAction
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: ScreenIntelligence.primaryUrl !== "" ? "Open" : "Copy"
|
||||
onClicked: {
|
||||
if (ScreenIntelligence.primaryUrl !== "")
|
||||
ScreenIntelligence.openDetected();
|
||||
else
|
||||
ScreenIntelligence.copyCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: parent.height - (ScreenIntelligence.codeValue === "" ? 0 : 74)
|
||||
radius: 14
|
||||
color: Theme.alpha(Theme.bgDark, 0.72)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.075)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 60
|
||||
visible: ScreenIntelligence.text === "" && ScreenIntelligence.codeValue === ""
|
||||
text: "No readable text or code was found. Try a tighter selection with larger, higher-contrast text."
|
||||
color: Theme.fgDim
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 17
|
||||
visible: ScreenIntelligence.text !== ""
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: resultText.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
TextEdit {
|
||||
id: resultText
|
||||
width: parent.width
|
||||
text: ScreenIntelligence.text
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.accent
|
||||
selectedTextColor: Theme.bgDark
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actions
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: 28
|
||||
anchors.bottomMargin: 24
|
||||
spacing: 8
|
||||
visible: ScreenIntelligence.phase === "ready" && (ScreenIntelligence.text !== "" || ScreenIntelligence.codeValue !== "")
|
||||
|
||||
ResultAction {
|
||||
label: "Open link"
|
||||
visible: ScreenIntelligence.primaryUrl !== "" && ScreenIntelligence.codeValue === ""
|
||||
onClicked: ScreenIntelligence.openDetected()
|
||||
}
|
||||
ResultAction {
|
||||
label: "Translate"
|
||||
enabled: ScreenIntelligence.text !== ""
|
||||
onClicked: ScreenIntelligence.translate()
|
||||
}
|
||||
ResultAction {
|
||||
label: "Search"
|
||||
enabled: ScreenIntelligence.text !== ""
|
||||
onClicked: ScreenIntelligence.search()
|
||||
}
|
||||
ResultAction {
|
||||
label: win.copied ? "Copied" : "Copy text"
|
||||
primary: true
|
||||
enabled: ScreenIntelligence.text !== ""
|
||||
onClicked: {
|
||||
ScreenIntelligence.copyText();
|
||||
win.copied = true;
|
||||
copiedTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component ResultAction: Rectangle {
|
||||
id: action
|
||||
property string label: ""
|
||||
property bool primary: false
|
||||
property bool enabled: true
|
||||
signal clicked
|
||||
|
||||
implicitWidth: actionLabel.implicitWidth + 26
|
||||
implicitHeight: 34
|
||||
radius: 10
|
||||
opacity: enabled ? 1 : 0.4
|
||||
color: primary
|
||||
? (actionMouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.1) : Theme.accent)
|
||||
: (actionMouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075))
|
||||
border.width: primary ? 0 : 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: actionLabel
|
||||
anchors.centerIn: parent
|
||||
text: action.label
|
||||
color: action.primary ? Theme.bgDark : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: actionMouse
|
||||
anchors.fill: parent
|
||||
enabled: action.enabled
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: action.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// One segment of the Screen / Window / Selection control.
|
||||
//
|
||||
// Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the
|
||||
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolour
|
||||
// them, so they'd be invisible on this dark overlay.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string glyph: ""
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
|
||||
signal clicked
|
||||
|
||||
implicitWidth: content.implicitWidth + 30
|
||||
implicitHeight: 40
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
|
||||
color: {
|
||||
if (root.active)
|
||||
return mouse.containsMouse ? Qt.lighter(Theme.accent, 1.1) : Theme.accent;
|
||||
if (mouse.pressed)
|
||||
return Theme.alpha(Theme.fg, Theme.activeAlpha);
|
||||
if (mouse.containsMouse)
|
||||
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color contentColor: root.active ? Theme.bgDark : Theme.fg
|
||||
|
||||
Row {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.glyph
|
||||
color: root.contentColor
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.label
|
||||
color: root.contentColor
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// The "you are being recorded" pill.
|
||||
//
|
||||
// GNOME puts a red dot and a timer in the top bar while a screencast runs and
|
||||
// clicking it stops the recording. The bar is another module's window, so this
|
||||
// is its own tiny layer surface parked just below it.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: Capture.recording
|
||||
color: "transparent"
|
||||
|
||||
// Top only: with neither left nor right anchored, layer-shell centres the
|
||||
// surface horizontally.
|
||||
anchors.top: true
|
||||
margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap
|
||||
|
||||
implicitWidth: pill.implicitWidth
|
||||
implicitHeight: pill.implicitHeight
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-recording"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
function elapsed(): string {
|
||||
const t = Capture.recordingSeconds;
|
||||
const s = ("0" + (t % 60)).slice(-2);
|
||||
const m = Math.floor(t / 60) % 60;
|
||||
const h = Math.floor(t / 3600);
|
||||
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
implicitWidth: row.implicitWidth + 12
|
||||
implicitHeight: 34
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.redDeep
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
// Static dot, not a blinking one: nothing in this shell repaints
|
||||
// while idle.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
border.width: 0
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: win.elapsed()
|
||||
color: Theme.fg
|
||||
// Tabular figures: the timer must not shuffle as it counts.
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: stop
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 26
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: ""
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: stopMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// SIGINT, so wf-recorder finalises the container.
|
||||
onClicked: Capture.stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Dims everything except one rectangle, GNOME-style.
|
||||
//
|
||||
// Four dimming rectangles laid around the hole, because QML has no way to
|
||||
// subtract a shape from another without an OpacityMask shader — and a shader
|
||||
// here would mean a full-screen 4500x3000 offscreen texture on every pointer
|
||||
// move.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// The region left undimmed, in this item's coordinates.
|
||||
property rect hole: Qt.rect(0, 0, 0, 0)
|
||||
// When false the whole item dims uniformly (nothing selected yet).
|
||||
property bool holeVisible: false
|
||||
property color dimColor: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
|
||||
readonly property real _l: Math.max(0, Math.min(root.width, root.hole.x))
|
||||
readonly property real _t: Math.max(0, Math.min(root.height, root.hole.y))
|
||||
readonly property real _r: Math.max(root._l, Math.min(root.width, root.hole.x + root.hole.width))
|
||||
readonly property real _b: Math.max(root._t, Math.min(root.height, root.hole.y + root.hole.height))
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: !root.holeVisible
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
// Above the hole
|
||||
Rectangle {
|
||||
visible: root.holeVisible
|
||||
x: 0
|
||||
y: 0
|
||||
width: root.width
|
||||
height: root._t
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
// Below
|
||||
Rectangle {
|
||||
visible: root.holeVisible
|
||||
x: 0
|
||||
y: root._b
|
||||
width: root.width
|
||||
height: Math.max(0, root.height - root._b)
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
// Left of the hole, between top and bottom bands
|
||||
Rectangle {
|
||||
visible: root.holeVisible
|
||||
x: 0
|
||||
y: root._t
|
||||
width: root._l
|
||||
height: Math.max(0, root._b - root._t)
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
// Right
|
||||
Rectangle {
|
||||
visible: root.holeVisible
|
||||
x: root._r
|
||||
y: root._t
|
||||
width: Math.max(0, root.width - root._r)
|
||||
height: Math.max(0, root._b - root._t)
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Window mode: outline every window on the focused workspace, highlight the one
|
||||
// under the pointer and dim the rest, click to capture it.
|
||||
//
|
||||
// Geometry comes from `hyprctl -j clients` via services/Capture.qml, because
|
||||
// Quickshell's HyprlandToplevel exposes no position or size.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Layout-global origin of the output this overlay covers.
|
||||
property real originX: 0
|
||||
property real originY: 0
|
||||
|
||||
signal picked
|
||||
|
||||
readonly property var windows: Capture.windows
|
||||
|
||||
// Front-to-back hit test: Capture.windows is already sorted by
|
||||
// focusHistoryID, so the first hit is the topmost window.
|
||||
readonly property int hoveredIndex: {
|
||||
if (!area.containsMouse)
|
||||
return -1;
|
||||
const mx = area.mouseX;
|
||||
const my = area.mouseY;
|
||||
for (let i = 0; i < root.windows.length; i++) {
|
||||
const w = root.windows[i];
|
||||
const x = w.x - root.originX;
|
||||
const y = w.y - root.originY;
|
||||
if (mx >= x && mx <= x + w.w && my >= y && my <= y + w.h)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Once a window has been hovered it stays highlighted after the pointer
|
||||
// moves down onto the control bar, so the shutter button always has
|
||||
// something to shoot. Index 0 is the most recently focused window, which is
|
||||
// what GNOME preselects.
|
||||
property int stickyIndex: 0
|
||||
onHoveredIndexChanged: {
|
||||
if (root.hoveredIndex >= 0)
|
||||
root.stickyIndex = root.hoveredIndex;
|
||||
}
|
||||
onWindowsChanged: root.stickyIndex = 0
|
||||
|
||||
readonly property int activeIndex: root.hoveredIndex >= 0 ? root.hoveredIndex : root.stickyIndex
|
||||
readonly property var hovered: (root.activeIndex >= 0 && root.activeIndex < root.windows.length) ? root.windows[root.activeIndex] : null
|
||||
|
||||
// Layout-global rect of the hovered window, or null. The overlay hands this
|
||||
// straight to Capture.commit().
|
||||
readonly property var selectedRect: root.hovered ? Qt.rect(root.hovered.x, root.hovered.y, root.hovered.w, root.hovered.h) : null
|
||||
|
||||
Spotlight {
|
||||
anchors.fill: parent
|
||||
holeVisible: root.hovered !== null
|
||||
hole: root.hovered ? Qt.rect(root.hovered.x - root.originX, root.hovered.y - root.originY, root.hovered.w, root.hovered.h) : Qt.rect(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
// Faint outline on every candidate so the user can see what is pickable
|
||||
// before moving the pointer, the way GNOME's window shots do.
|
||||
Repeater {
|
||||
model: root.windows
|
||||
|
||||
Frame {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
x: modelData.x - root.originX
|
||||
y: modelData.y - root.originY
|
||||
width: modelData.w
|
||||
height: modelData.h
|
||||
visible: index !== root.activeIndex
|
||||
strokeWidth: 1
|
||||
strokeColor: Theme.alpha(Theme.fg, 0.35)
|
||||
}
|
||||
}
|
||||
|
||||
Frame {
|
||||
visible: root.hovered !== null
|
||||
x: root.hovered ? root.hovered.x - root.originX : 0
|
||||
y: root.hovered ? root.hovered.y - root.originY : 0
|
||||
width: root.hovered ? root.hovered.w : 0
|
||||
height: root.hovered ? root.hovered.h : 0
|
||||
strokeWidth: 3
|
||||
strokeColor: Theme.accent
|
||||
fillColor: Theme.alpha(Theme.accent, 0.08)
|
||||
}
|
||||
|
||||
// Title chip on the highlighted window — stacked windows are otherwise very
|
||||
// hard to tell apart once the rest of the screen is dimmed.
|
||||
Rectangle {
|
||||
id: chip
|
||||
visible: root.hovered !== null
|
||||
x: (root.hovered ? root.hovered.x - root.originX : 0) + 12
|
||||
y: (root.hovered ? root.hovered.y - root.originY : 0) + 12
|
||||
implicitWidth: Math.min(chipText.implicitWidth + 24, root.hovered ? root.hovered.w - 24 : 0)
|
||||
implicitHeight: 32
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
width: chip.width - 24
|
||||
text: root.hovered ? (root.hovered.title || root.hovered.appId) : ""
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
// Shown when hyprctl gave us nothing — a blank dimmed screen with no
|
||||
// explanation would just look broken.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.windows.length === 0
|
||||
text: "No windows to capture on this workspace"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: area
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.hovered ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.hovered)
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user