import QtQuick import "popoutCatalog.js" as PopoutCatalog import "diagnostics.js" as DiagnosticData Item { id: root property int operationId: 0 property bool popoutOpen: false property bool popoutClosing: false property string popoutPhase: "closed" property string popoutName: "" property string popoutOutput: "" property string activeLoaderName: "" property string pendingLoaderName: "" property string removingLoaderName: "" property var previousContent: ({}) property string lastInteraction: "" property string lastCloseReason: "" property rect popoutAnchor: Qt.rect(0, 0, 0, 0) property real popoutProgress: 0 property int screenWidth: 0 property int screenHeight: 0 property int barHeight: 34 property string orientation: "horizontal" property var powerService property var mediaService property var trayService property Niri niriService property var returnWindowId: null readonly property bool vertical: orientation === "vertical" property var anchorRegistry: ({}) // Ordinary view state lives for the shell session, never on disk. property var panelStates: ({ "clock": { "scrollY": 0 }, "system": { "scrollY": 0 }, "notifications": { "scrollY": 0 }, "noko": { "scrollY": 0 }, "connectivity": { "scrollY": 0, "expandedSsid": "" }, "launcher": { "query": "", "selectedId": "", "scrollY": 0 }, "clipboard": { "query": "", "selectedId": "", "scrollY": 0 }, "audio": { "scrollY": 0, "expandedSink": "", "expandedSource": "" }, "brightness": { "scrollY": 0 }, "media": { "scrollY": 0 }, "tray": { "scrollY": 0 }, "power": { "scrollY": 0 } }) signal dismissing(string name) readonly property int closeRetentionMs: 0 function parseRect(text) { const parts = String(text).split(/[, x]+/).filter(p => { return p.length > 0; }).map(Number); if (parts.length !== 4 || parts.some(n => { return !Number.isFinite(n); })) return Qt.rect(0, 0, 0, 0); return Qt.rect(parts[0], parts[1], parts[2], parts[3]); } function dismissTask() { dismissing(popoutName); if (popoutName === "power" && powerService) powerService.cancelAction(); } function rectObject(rect) { return { "x": Math.round(rect.x), "y": Math.round(rect.y), "width": Math.round(rect.width), "height": Math.round(rect.height) }; } function emptyPanel(name) { const service = name === "media" ? root.mediaService : (name === "tray" ? root.trayService : null); if (!service) return false; const state = JSON.parse(service.stateJson()); return (name === "media" ? (state.players || []) : (state.items || [])).length === 0; } function popoutWidth(name) { const entry = Object.prototype.hasOwnProperty.call(PopoutCatalog.entries, String(name)) ? PopoutCatalog.entries[String(name)] : null; const wanted = emptyPanel(name) ? 368 : (entry ? entry.width : 520); const along = vertical ? screenWidth - barHeight : screenWidth; return along > 0 ? Math.min(wanted, Math.max(1, along - 16)) : wanted; } function heightBudget(wanted, minimum = 220) { const reserve = vertical ? 48 : barHeight + 48; const maxHeight = screenHeight > 0 ? Math.max(260, screenHeight - reserve) : 680; return Math.min(maxHeight, Math.max(minimum, Number(wanted))); } function powerContentSize() { const width = popoutWidth("power"); const state = powerService ? JSON.parse(powerService.stateJson()) : ({ "battery": {}, "externalDevices": [] }); const hasBattery = !!(state.battery && state.battery.present); const deviceCount = (state.externalDevices || []).length; const entry = PopoutCatalog.entries.power; const deviceHeight = deviceCount > 0 ? Math.ceil(deviceCount / 2) * 40 + 4 : 0; const confirmationHeight = state.pendingConfirmation ? 116 : 0; return { "width": width, "height": heightBudget((hasBattery ? entry.batteryHeight : entry.height) + deviceHeight + confirmationHeight, entry.height) }; } function popoutContentSize(name) { const key = String(name); if (key === "power") return powerContentSize(); const entry = Object.prototype.hasOwnProperty.call(PopoutCatalog.entries, key) ? PopoutCatalog.entries[key] : null; return { "width": popoutWidth(key), "height": emptyPanel(key) ? heightBudget(104, 104) : heightBudget(entry ? entry.height : PopoutCatalog.fallbackHeight) }; } function popupWindowAnchorRect() { if (vertical) return { "x": Math.max(1, Math.round(barHeight)), "y": 0, "width": 1, "height": screenHeight > 0 ? screenHeight : popoutContentSize(popoutName).height }; return { "x": 0, "y": Math.max(1, Math.round(barHeight)), "width": screenWidth > 0 ? screenWidth : popoutContentSize(popoutName).width, "height": 1 }; } function popupCardFrame(progress) { const t = Math.max(0, Math.min(1, Number(progress))); const contentSize = popoutContentSize(popoutName); if (vertical) { const surfaceHeight = screenHeight > 0 ? screenHeight : contentSize.height; return { "source": "left-bar-slide-fade", "progress": t, "currentRect": { "x": Math.round(barHeight - 18 + 26 * t), "y": Math.max(8, Math.round((surfaceHeight - contentSize.height) / 2) + 8), "width": Math.max(1, Math.round(contentSize.width - 16)), "height": Math.max(1, Math.round(contentSize.height - 16)) }, "opacity": t }; } const surfaceWidth = screenWidth > 0 ? screenWidth : contentSize.width; return { "source": "simple-center-slide-fade", "progress": t, "currentRect": { "x": Math.max(8, Math.round((surfaceWidth - contentSize.width) / 2) + 8), "y": Math.round(barHeight - 18 + 26 * t), "width": Math.max(1, Math.round(contentSize.width - 16)), "height": Math.max(1, Math.round(contentSize.height - 16)) }, "opacity": t }; } function popoutContentCatalog() { return PopoutCatalog.catalogJson(); } function registerAnchor(name, anchorText) { const next = Object.assign({}, anchorRegistry); next[String(name)] = rectObject(parseRect(anchorText)); anchorRegistry = next; return registeredAnchors(); } function registeredAnchors() { return JSON.stringify(anchorRegistry); } function setPopoutScreen(width, height, topBarThickness, orient) { screenWidth = Math.max(0, Number(width)); screenHeight = Math.max(0, Number(height)); barHeight = Math.max(1, Number(topBarThickness || barHeight)); if (orient === "horizontal" || orient === "vertical") orientation = orient; return popoutState(); } function clampedTarget(origin, contentSize) { const edges = []; if (vertical) { let y = Math.round(origin.y - contentSize.height / 2); if (y < 0) { y = 0; edges.push("top"); } if (screenHeight > 0 && y + contentSize.height > screenHeight) { y = Math.max(0, screenHeight - contentSize.height); edges.length = 0; edges.push("bottom"); } return { "rect": { "x": Math.max(0, Math.round(barHeight)), "y": y, "width": contentSize.width, "height": contentSize.height }, "edges": edges }; } let x = Math.round(origin.x - contentSize.width / 2); if (x < 0) { x = 0; edges.push("left"); } if (screenWidth > 0 && x + contentSize.width > screenWidth) { x = Math.max(0, screenWidth - contentSize.width); edges.length = 0; edges.push("right"); } return { "rect": { "x": x, "y": origin.y, "width": contentSize.width, "height": contentSize.height }, "edges": edges }; } function openPopout(name, anchorText, outputName) { const nextName = String(name); const wasOpen = popoutOpen && !popoutClosing; if (wasOpen && popoutName !== nextName) dismissTask(); if (!wasOpen) returnWindowId = niriService ? niriService.focusedWindow : null; operationId = DiagnosticData.nextOperationId(operationId); unloadTimer.stop(); openTimer.stop(); popoutClosing = false; lastInteraction = ""; lastCloseReason = ""; popoutName = nextName; popoutOutput = String(outputName || ""); activeLoaderName = nextName; pendingLoaderName = ""; removingLoaderName = ""; previousContent = ({}); popoutAnchor = parseRect(anchorText); popoutOpen = true; if (wasOpen) { popoutProgress = 1; popoutPhase = "open"; } else { popoutPhase = "opening"; popoutProgress = 0; openTimer.restart(); } console.info(JSON.stringify(DiagnosticData.transition(wasOpen ? "switched" : "opening", root))); return popoutState(); } function openRegisteredPopout(name, outputName) { const anchor = anchorRegistry[String(name)] || { "x": 0, "y": 0, "width": 0, "height": 0 }; return openPopout(name, `${anchor.x},${anchor.y},${anchor.width},${anchor.height}`, outputName); } function hoverRegisteredPopout(name) { // Hover is discovery, never navigation away from an active task. return popoutState(); } function closePopout(reason) { if (!popoutOpen && !popoutClosing) return popoutState(); dismissTask(); operationId = DiagnosticData.nextOperationId(operationId); openTimer.stop(); if (reason && String(reason).length > 0) lastCloseReason = String(reason); popoutOpen = false; popoutClosing = false; popoutProgress = 0; popoutPhase = "closed"; popoutName = ""; popoutOutput = ""; activeLoaderName = ""; pendingLoaderName = ""; removingLoaderName = ""; previousContent = ({}); console.info(JSON.stringify(DiagnosticData.transition("closed", root))); return popoutState(); } function popoutInteraction(kind) { lastInteraction = String(kind); if (lastInteraction === "inside") return popoutState(); if (lastInteraction === "outside" || lastInteraction === "escape" || lastInteraction === "focus-grab-outside" || lastInteraction === "escape-key") return closePopout(lastInteraction); return popoutState(); } function popoutState() { const contentSize = popoutContentSize(popoutName); const origin = vertical ? { "x": Math.round(popoutAnchor.x + popoutAnchor.width), "y": Math.round(popoutAnchor.y + popoutAnchor.height / 2) } : { "x": Math.round(popoutAnchor.x + popoutAnchor.width / 2), "y": Math.round(popoutAnchor.y + popoutAnchor.height) }; const target = clampedTarget(origin, contentSize); return JSON.stringify({ "open": popoutOpen, "closing": popoutClosing, "phase": popoutPhase, "name": popoutName, "output": popoutOutput, "lastInteraction": lastInteraction, "lastCloseReason": lastCloseReason, "anchor": rectObject(popoutAnchor), "origin": origin, "contentSize": contentSize, "popupAnchorRect": popupWindowAnchorRect(), "popupCardFrame": popupCardFrame(popoutProgress), "activeLoaderName": activeLoaderName, "pendingLoaderName": pendingLoaderName, "removingLoaderName": removingLoaderName, "previousContent": previousContent, "screen": { "width": screenWidth, "height": screenHeight, "barHeight": barHeight }, "progress": popoutProgress, "transition": { "host": "bar", "animation": vertical ? "left-bar-slide-fade" : "simple-center-slide-fade", "direction": vertical ? (popoutClosing ? "left-out" : "right-in") : (popoutClosing ? "up-out" : "down-in"), "loaderActive": popoutOpen, "targetRect": popupWindowAnchorRect() } }); } function completeOpen() { if (!popoutOpen || popoutClosing || popoutPhase !== "opening") return; popoutProgress = 1; popoutPhase = "open"; console.info(JSON.stringify(DiagnosticData.transition("opened", root))); } Timer { id: openTimer interval: root.closeRetentionMs repeat: false onTriggered: root.completeOpen() } Timer { id: unloadTimer interval: root.closeRetentionMs repeat: false onTriggered: { root.popoutClosing = false; root.popoutPhase = "closed"; root.popoutName = ""; root.popoutOutput = ""; root.activeLoaderName = ""; root.pendingLoaderName = ""; root.removingLoaderName = ""; root.previousContent = ({}); } } }