import QtQuick Item { id: root property Niri niriService property Osd osdService property string lastColor: "" property var recent: [] property var pending: [] property int requestSerial: 0 property var lastAction: ({}) property var selection: ({ "model": "normalized-cross-screen-rect", "phase": "idle", "press": { "x": 0, "y": 0 }, "current": { "x": 0, "y": 0 }, "rect": { "x": 0, "y": 0, "width": 0, "height": 0 }, "cursor": "default", "completionFlash": false }) property var lastOsd: ({}) readonly property bool nativeActionRoute: !!niriService && !!niriService.available readonly property var nativeActions: ["screenshot-screen", "screenshot-window", "screenshot", "pick-color"] readonly property var boundedActions: ["focused-screen", "focused-window", "interactive", "select-region", "pick-color", "copy", "save", "open-record", "clear"] readonly property var toolbarActions: ["copy", "save", "cancel", "color"] function rectFromPoints(start, end) { const x = Math.min(start.x, end.x); const y = Math.min(start.y, end.y); return { "x": x, "y": y, "width": Math.abs(end.x - start.x), "height": Math.abs(end.y - start.y) }; } function record(id, kind, path) { const next = [ { "id": id, "kind": kind, "path": path, "source": "niri-native", "saved": true } ]; for (let i = 0; i < recent.length; i++) { if (recent[i].id !== id) next.push(recent[i]); } recent = next.slice(0, 12); } // Niri 26.04, revision 8ed0da44d974c32c6877d2f4630c314da0717ecb: // https://github.com/niri-wm/niri/blob/8ed0da44d974c32c6877d2f4630c314da0717ecb/src/niri.rs#L5643-L5745 // Only a non-null completion path proves a save. Null covers clipboard-only, // failed writes, and paths not representable as UTF-8. Encoding/render failures // and native cancellation emit no completion. Submission is not save success. // There is no request ID or cancellation event. Pending means unconfirmed, // not necessarily still running, and clear() forgets it without cancelling Niri. // Exact paths are the only correlation: same-path requests consume one match // FIFO, but concurrent/external saves to that path cannot be distinguished. function acceptCaptured(path) { if (typeof path !== "string" || path.length === 0) return; const index = pending.findIndex(request => request.path === path); if (index < 0) return; const request = pending[index]; pending = pending.filter((_, i) => i !== index); record(request.id, request.kind, path); if (lastAction.id === request.id) { lastAction = Object.assign({}, lastAction, { "status": "saved", "saved": true, "completed": true }); } if (selection.requestId === request.id) { selection = Object.assign({}, selection, { "phase": "completed", "cursor": "default", "completionFlash": true }); } } function cornerHandles(rect) { const size = rect.width > 0 && rect.height > 0 ? 14 : 0; return [ { "id": "top-left", "x": rect.x, "y": rect.y, "size": size, "role": "ui.accent" }, { "id": "top-right", "x": rect.x + rect.width, "y": rect.y, "size": size, "role": "ui.accent" }, { "id": "bottom-left", "x": rect.x, "y": rect.y + rect.height, "size": size, "role": "ui.accent" }, { "id": "bottom-right", "x": rect.x + rect.width, "y": rect.y + rect.height, "size": size, "role": "ui.accent" } ]; } function selectionVisuals(selectionState) { const rect = selectionState.rect || { "x": 0, "y": 0, "width": 0, "height": 0 }; const active = selectionState.phase === "dragging" || selectionState.phase === "pending" || selectionState.phase === "completed"; return { "source": "Nuguland screenSnip plus Nuguland Geom.qml", "parts": ["dimmed-mask", "clear-cutout", "corner-handles", "dimensions", "toolbar-actions"], "dimmedMask": { "kind": "dimmed-mask", "visible": active, "opacity": active ? 0.42 : 0, "role": "ui.backdrop" }, "clearCutout": { "kind": "clear-cutout", "rect": rect }, "cornerHandles": { "kind": "corner-handles", "handles": cornerHandles(rect) }, "dimensions": { "kind": "dimensions", "text": `${Math.round(rect.width)}×${Math.round(rect.height)}`, "width": Math.round(rect.width), "height": Math.round(rect.height) }, "toolbarActions": { "kind": "toolbar-actions", "actions": toolbarActions, "anchor": rect.height >= 48 ? "selection-top" : "screen-top" }, "nugulandCornerAccents": { "bounded": true, "freePhysicsLoop": false, "anchors": cornerHandles(rect).map(handle => { return ({ "id": handle.id, "x": handle.x, "y": handle.y }); }) } }; } function screenshotsPanelContract() { return { "source": "Nuguland AreaPicker.qml plus Nuguland RegionSelector.qml WRegionSelectionPanel.qml", "ownedBy": "PopoutHost.qml inline ScreenshotsPanel", "niriCapturePanel": true, "captureActionGrid": true, "regionSelectionPreview": true, "recentCaptureRows": true, "colorPickSwatch": true, "toolbarActionRail": true, "boundedCornerAccents": true, "serviceActionsOnly": true, "captureBackend": "niri-native", "panelActions": ["focused-screen", "focused-window", "select-region", "pick-color"], "selectionParts": ["dimmed-mask", "clear-cutout", "corner-handles", "dimensions", "toolbar-actions"] }; } function currentState() { return { "backend": "Screenshots.qml", "usesNiriNative": true, "nativeActions": nativeActions, "nativeActionRoute": nativeActionRoute, "helperProcesses": [], "serviceOwnedActions": true, "boundedActions": boundedActions, "screenshotsPanelContract": screenshotsPanelContract(), "selection": selection, "selectionVisuals": selectionVisuals(selection), "recent": recent, "recentCount": recent.length, "pending": pending, "pendingCount": pending.length, "lastColor": lastColor, "lastAction": lastAction, "osd": lastOsd }; } function stateJson() { return JSON.stringify(currentState()); } function beginSelection(x, y) { const point = { "x": x, "y": y }; selection = { "model": "normalized-cross-screen-rect", "phase": "dragging", "press": point, "current": point, "rect": { "x": x, "y": y, "width": 0, "height": 0 }, "cursor": "crosshair", "completionFlash": false }; lastAction = { "kind": "select-region-begin", "point": point }; return stateJson(); } function updateSelection(x, y) { const start = selection.press || { "x": 0, "y": 0 }; const current = { "x": x, "y": y }; selection = Object.assign({}, selection, { "phase": "dragging", "current": current, "rect": rectFromPoints(start, current), "cursor": "crosshair", "completionFlash": false, "requestId": null }); lastAction = { "kind": "select-region-update", "point": current }; return stateJson(); } function completeSelection(path) { return capture("select-region", path); } function cancelSelection() { selection = Object.assign({}, selection, { "phase": "idle", "cursor": "default", "completionFlash": false, "requestId": null }); // This only dismisses our preview, not Niri's native selection UI. lastAction = { "kind": "cancel" }; return stateJson(); } function capture(kind, path) { const actionKind = String(kind); const target = typeof path === "string" ? path : ""; const interactive = actionKind === "interactive" || actionKind === "select-region"; const nativeAction = actionKind === "focused-screen" ? "screenshot-screen" : actionKind === "focused-window" ? "screenshot-window" : interactive ? "screenshot" : ""; const status = !nativeAction ? "unsupported" : !target ? "invalid-path" : !nativeActionRoute ? "unavailable" : "pending"; // These IDs belong to local history only; Niri never receives them. const id = status === "pending" ? `capture-${actionKind}-${++requestSerial}` : null; lastAction = { "id": id, "kind": actionKind, "nativeAction": nativeAction, "path": target, "nativeActionRoute": status === "pending", "interactive": interactive, "status": status, "saved": false, "completed": false }; if (actionKind === "select-region") { selection = Object.assign({}, selection, { "phase": status === "pending" ? "pending" : "idle", "cursor": "default", "completionFlash": false, "requestId": id }); } if (status === "pending") { pending = pending.concat([lastAction]); if (actionKind === "focused-screen") niriService.screenshotScreen(target); else if (actionKind === "focused-window") niriService.screenshotWindow(target); else niriService.screenshot(target); } return stateJson(); } function acceptPickedColor(color) { const value = String(color || "").toLowerCase(); if (!/^#[0-9a-f]{6}$/.test(value)) return; lastColor = value; lastAction = Object.assign({}, lastAction, { "color": lastColor, "completed": true }); if (osdService) { try { lastOsd = JSON.parse(osdService.trigger("color", lastColor)); } catch (error) { lastOsd = { "event": "color", "value": lastColor }; } } else { lastOsd = { "event": "color", "value": lastColor }; } } function pickColor() { lastColor = ""; if (nativeActionRoute) niriService.pickColor(); lastAction = { "kind": "pick-color", "nativeAction": "pick-color", "color": lastColor, "nativeActionRoute": nativeActionRoute, "interactive": true, "completed": false }; lastOsd = { "event": "color", "value": "picking" }; return stateJson(); } function recordAction(kind, id) { lastAction = { "kind": String(kind), "id": String(id), "nativeActionRoute": false }; return stateJson(); } function clear() { recent = []; pending = []; if (selection.phase === "pending") cancelSelection(); lastAction = { "kind": "clear" }; return stateJson(); } Connections { function onScreenshotCaptured(path) { root.acceptCaptured(path); } function onLastPickedColorChanged() { root.acceptPickedColor(root.niriService.lastPickedColor); } target: root.niriService } }