import QtQuick import Quickshell.Io import qs.Commons import qs.Ui import "Classify.js" as Classify // Right-hand preview pane for the clipboard picker. // Renders a full preview plus metadata chips, per derived type. // `result` is a Fuzzy search result: { row: {entry, content, app, type, ...}, positions } Item { id: root property var result: null property var entry: result ? result.row.entry : null property string derived: result ? result.row.type : "" // Wired by the picker: opens the current result (browser for links). property var openAction: function() {} // Wired by the picker: copies a string (OCR text, QR payload) to the clipboard. property var copyTextAction: function(text) {} property string pluginDir: "" property int fileIndex: 0 readonly property var filePaths: entry && entry.type === "files" ? entry.paths || [] : [] readonly property string selectedFile: filePaths.length ? filePaths[Math.min(fileIndex, filePaths.length - 1)] : "" property var fileInfo: ({}) property int fileRequest: 0 onFilePathsChanged: fileIndex = 0 onSelectedFileChanged: { fileRequest++ fileInfo = ({}) fileProbe.running = false fileDelay.restart() } Timer { id: fileDelay interval: 120 onTriggered: { if (!root.selectedFile || !root.pluginDir) return fileProbe.command = ["python3", root.pluginDir + "/scripts/file-preview.py", String(root.fileRequest), root.selectedFile] fileProbe.running = true } } Process { id: fileProbe stdout: StdioCollector { waitForEnd: true onStreamFinished: { try { var data = JSON.parse(text) if (String(data.request) === String(root.fileRequest)) root.fileInfo = data.file } catch (e) {} } } } function fileDetails() { var f = fileInfo var parts = [] if (f.kind) parts.push(f.kind) if (f.bytes !== undefined && f.kind !== "Folder") parts.push(Classify.formatBytes(f.bytes)) if (f.width && f.height) parts.push(f.width + "×" + f.height) if (f.duration) { var seconds = Math.floor(f.duration) parts.push(Math.floor(seconds / 60) + ":" + (seconds % 60 < 10 ? "0" : "") + seconds % 60) } if (f.audio) parts.push(f.audio) if (f.modified) parts.push("Modified " + new Date(f.modified * 1000).toLocaleString()) return parts.join(" · ") } readonly property string font_: Style.font.menuFamily readonly property color fg: Color.menu.text readonly property color mutedFg: Util.alpha(fg, 0.55) readonly property color chipBg: Util.alpha(fg, 0.07) readonly property color lineColor: Util.alpha(fg, 0.16) // Textual types render in the scrollable body; every other type has its // own block below. Derived as bindings (not set from onResultChanged) so // the body text and the type can never disagree mid-update, which used to // leave a stale text body visible underneath an image preview. readonly property bool textual: derived === "text" || derived === "code" || derived === "email" || derived === "number" || derived === "json" || derived === "html" readonly property string bodyText: { if (!entry || !textual) return "" var raw = String(entry.text || "") if (derived === "json") return Classify.prettyJson(raw, 200000) || raw if (derived === "html") return Classify.stripHtml(raw) || raw return raw } function rawSafe() { return entry ? String(entry.text || "").trim() : "" } function rgbLine() { var rgb = Classify.colorToRgb(rawSafe()) return rgb ? "rgb(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ")" : "" } function hslLine() { var hsl = Classify.colorToHsl(rawSafe()) return hsl ? "hsl(" + hsl[0] + ", " + hsl[1] + "%, " + hsl[2] + "%)" : "" } // URL inside the decoded QR payload ("" when the payload is not a link). function qrUrl() { if (!entry || !entry.qr) return "" var url = Classify.extractUrl(entry.qr) if (url) return url var trimmed = String(entry.qr).trim() return root.bareDomainRe.test(trimmed) ? trimmed : "" } readonly property var bareDomainRe: /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?:\/[^\s]*)?$/i function metaChips() { if (!result) return [] var r = result.row var e = r.entry var chips = [] if (r.app) chips.push(Classify.prettyApp(r.app)) if (e.qr) chips.push("QR: " + Classify.firstLine(e.qr, 40)) if (e.ocr) { var ow = Classify.textStats(e.ocr).words chips.push("OCR: " + Classify.plural(ow, "word")) } if (e.type === "text" && e.text) { var st = Classify.textStats(e.text) chips.push(Classify.plural(st.words, "word")) chips.push(Classify.plural(st.lines, "line")) } if (r.bytes > 0 && e.type !== "files") chips.push(Classify.formatBytes(r.bytes)) if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h) if (e.type === "image") chips.push(e.mime || "image") if (e.pinned) chips.push("★ pinned") if (r.uses > 0) chips.push("pasted " + r.uses + "×") return chips } // ------------------------------------------------------------- header Row { id: headerRow anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right spacing: Style.space(10) Rectangle { width: Style.space(38) height: width radius: Style.cornerRadius color: root.chipBg anchors.verticalCenter: parent.verticalCenter Text { anchors.centerIn: parent text: Classify.typeIcon(root.derived) color: root.fg font.family: root.font_ font.pixelSize: Style.font.heading } } Column { width: parent.width - Style.space(48) - badge.width - Style.space(10) anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) Text { width: parent.width text: { if (!root.entry) return "" var e = root.entry if (e.type === "image") return e.qr ? Classify.firstLine(e.qr, 120) : Classify.fileBase(e.path) if (e.type === "files") { var base = Classify.fileBase(e.paths[0]) return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base } return Classify.firstLine(e.text, 200) } color: root.fg font.family: root.font_ font.pixelSize: Style.font.title font.bold: true textFormat: Text.PlainText elide: Text.ElideRight maximumLineCount: 1 } Text { width: parent.width text: { if (!root.result) return "" var r = root.result.row var parts = [] if (r.ts) parts.push(Classify.formatFullDate(r.ts)) if (r.bytes) parts.push(Classify.formatBytes(r.bytes)) return parts.join(" · ") } color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption elide: Text.ElideRight maximumLineCount: 1 } } Rectangle { id: badge anchors.verticalCenter: parent.verticalCenter radius: height / 2 color: root.chipBg width: badgeLabel.implicitWidth + Style.space(14) height: Style.space(20) Text { id: badgeLabel anchors.centerIn: parent text: Classify.typeIcon(root.derived) + " " + Classify.typeLabel(root.derived) color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } } Rectangle { id: divider anchors.top: headerRow.bottom anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.space(10) height: Style.normalBorderWidth color: root.lineColor } // ------------------------------------------------------------- body // text-ish body (text / code / json / html / email / number) Flickable { id: textBody anchors.top: divider.bottom anchors.bottom: metaRow.top anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.space(10) anchors.bottomMargin: Style.space(10) visible: root.textual && root.bodyText !== "" clip: true contentWidth: width contentHeight: bodyEdit.implicitHeight boundsBehavior: Flickable.StopAtBounds WheelHandler { acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: function(ev) { if (ev.angleDelta.y < 0) textBody.flick(0, -240) else textBody.flick(0, 240) ev.accepted = true } } TextEdit { id: bodyEdit width: parent.width readOnly: true activeFocusOnPress: false text: root.bodyText textFormat: TextEdit.PlainText color: root.fg font.family: root.font_ font.pixelSize: Style.font.body wrapMode: TextEdit.Wrap selectionColor: Util.alpha(Color.accent, 0.4) } } // color body Column { anchors.top: divider.bottom anchors.bottom: metaRow.top anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.space(10) anchors.bottomMargin: Style.space(10) visible: root.derived === "color" spacing: Style.space(14) Rectangle { width: Math.min(Style.space(240), parent.width) height: Style.space(120) radius: Style.cornerRadius color: root.hexRe.test(root.rawSafe()) ? root.rawSafe() : "transparent" border.width: Style.normalBorderWidth border.color: root.lineColor } Text { text: root.rawSafe() color: root.fg font.family: root.font_ font.pixelSize: Style.font.heading font.bold: true } Text { visible: root.rgbLine().length > 0 text: root.rgbLine() color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.body } Text { visible: root.hslLine().length > 0 text: root.hslLine() color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.body } } // link body Column { anchors.top: divider.bottom anchors.bottom: metaRow.top anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.space(10) anchors.bottomMargin: Style.space(10) visible: root.derived === "link" spacing: Style.space(10) Text { text: Classify.urlDomain(root.entry ? String(root.entry.text || "") : "") color: Color.accent font.family: root.font_ font.pixelSize: Style.font.display font.bold: true elide: Text.ElideRight width: parent.width maximumLineCount: 1 } Text { text: root.entry ? Classify.firstLine(String(root.entry.text || ""), 400) : "" color: root.fg font.family: root.font_ font.pixelSize: Style.font.body wrapMode: Text.WrapAnywhere width: parent.width } Text { text: "Ctrl+O opens this link in your browser" color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } Rectangle { radius: height / 2 color: Util.alpha(Color.accent, 0.15) width: linkOpenLabel.implicitWidth + Style.space(16) height: Style.space(22) Row { anchors.centerIn: parent spacing: Style.space(4) Text { anchors.verticalCenter: parent.verticalCenter text: "󰌹" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.caption } Text { id: linkOpenLabel anchors.verticalCenter: parent.verticalCenter text: "Open link" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.caption font.bold: true } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.openAction() } } } // image body Column { anchors.top: divider.bottom anchors.bottom: metaRow.top anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.space(10) anchors.bottomMargin: Style.space(10) visible: root.derived === "image" spacing: Style.space(8) // Decoded QR payload sits above the image so it is immediately readable. Rectangle { visible: !!(root.entry && root.entry.qr) width: parent.width height: qrContent.height + Style.space(12) radius: Style.cornerRadius color: root.chipBg Column { id: qrContent anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: Style.space(6) anchors.topMargin: Style.space(6) anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) spacing: Style.space(4) Row { spacing: Style.space(6) Text { text: "󰐲" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.body anchors.verticalCenter: parent.verticalCenter } Text { text: "QR code content" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.caption font.bold: true anchors.verticalCenter: parent.verticalCenter } Rectangle { radius: height / 2 color: Util.alpha(Color.accent, 0.15) width: qrCopyLabel.implicitWidth + Style.space(16) height: Style.space(20) anchors.verticalCenter: parent.verticalCenter Text { id: qrCopyLabel anchors.centerIn: parent text: "󰆏 Copy" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.caption font.bold: true } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.copyTextAction(root.entry ? String(root.entry.qr || "") : "") } } } TextEdit { id: qrValue width: parent.width readOnly: true activeFocusOnPress: false text: root.entry && root.entry.qr ? root.entry.qr : "" textFormat: TextEdit.PlainText color: root.fg font.family: root.font_ font.pixelSize: Style.font.body font.bold: true wrapMode: TextEdit.WrapAnywhere selectionColor: Util.alpha(Color.accent, 0.4) } // QR payloads frequently encode links — offer the open affordance. Rectangle { visible: root.qrUrl().length > 0 radius: height / 2 color: Util.alpha(Color.accent, 0.15) width: qrOpenLabel.implicitWidth + Style.space(16) height: Style.space(20) Row { anchors.centerIn: parent spacing: Style.space(4) Text { anchors.verticalCenter: parent.verticalCenter text: "󰌹" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.caption } Text { id: qrOpenLabel anchors.verticalCenter: parent.verticalCenter text: "Open link" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.caption font.bold: true } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.openAction() } } } } // OCR text recovered from the image (searchable like any text clip). Rectangle { id: ocrPanel visible: !!(root.entry && root.entry.ocr) width: parent.width // Never taller than the cap: the body scrolls instead of overflowing // the panel (which used to spill over the image below it). readonly property int maxHeight: Math.max(Style.space(80), Math.floor(parent.height * 0.4)) height: Math.min(maxHeight, ocrHeader.height + Style.space(4) + ocrText.implicitHeight + Style.space(12)) radius: Style.cornerRadius color: root.chipBg clip: true Column { id: ocrContent anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.topMargin: Style.space(6) anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) spacing: Style.space(4) Row { id: ocrHeader width: parent.width spacing: Style.space(8) Text { text: "󰐦 Recognized text (OCR)" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.caption font.bold: true anchors.verticalCenter: parent.verticalCenter } Rectangle { radius: height / 2 color: Util.alpha(Color.accent, 0.15) width: ocrCopyLabel.implicitWidth + Style.space(16) height: Style.space(20) anchors.verticalCenter: parent.verticalCenter Text { id: ocrCopyLabel anchors.centerIn: parent text: "󰆏 Copy text" color: Color.accent font.family: root.font_ font.pixelSize: Style.font.caption font.bold: true } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.copyTextAction(root.entry ? String(root.entry.ocr || "") : "") } } } Flickable { id: ocrFlick width: parent.width height: ocrPanel.height - ocrHeader.height - Style.space(4) - Style.space(12) clip: true contentWidth: width contentHeight: ocrText.implicitHeight boundsBehavior: Flickable.StopAtBounds WheelHandler { acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: function(ev) { if (ev.angleDelta.y < 0) ocrFlick.flick(0, -240) else ocrFlick.flick(0, 240) ev.accepted = true } } TextEdit { id: ocrText width: parent.width readOnly: true activeFocusOnPress: false text: root.entry && root.entry.ocr ? root.entry.ocr : "" textFormat: TextEdit.PlainText color: root.fg font.family: root.font_ font.pixelSize: Style.font.caption wrapMode: TextEdit.Wrap selectionColor: Util.alpha(Color.accent, 0.4) } } } } Item { width: parent.width height: Math.max(0, parent.height - (root.entry && root.entry.qr ? qrContent.height + Style.space(12) + Style.space(8) : 0) - (root.entry && root.entry.ocr ? ocrPanel.height + Style.space(8) : 0) - infoLabel.height - Style.space(12)) Image { id: img anchors.centerIn: parent width: parent.width height: parent.height source: root.entry && root.entry.path ? Util.fileUrl(root.entry.path) : "" fillMode: Image.PreserveAspectFit asynchronous: true smooth: true cache: false } Rectangle { anchors.fill: img color: "transparent" border.width: Style.normalBorderWidth border.color: root.lineColor radius: Style.space(4) visible: img.status === Image.Ready } Text { anchors.centerIn: parent visible: img.status === Image.Error || img.status === Image.Null text: "Preview unavailable" color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.body } } Text { id: infoLabel text: { if (!root.entry) return "" var e = root.entry var dims = e.w && e.h ? e.w + " × " + e.h + " px · " : "" return dims + (e.mime || "image") + (e.bytes ? " · " + Classify.formatBytes(e.bytes) : "") } color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } // File content is bounded by a scrollable viewport, including long paths // and text snippets. Selecting another file loads its details on demand. Flickable { id: filesBody anchors.top: divider.bottom anchors.bottom: metaRow.top anchors.left: parent.left anchors.right: parent.right anchors.topMargin: Style.space(10) anchors.bottomMargin: Style.space(10) visible: root.derived === "files" clip: true contentWidth: width contentHeight: fileContent.height boundsBehavior: Flickable.StopAtBounds Connections { target: root function onSelectedFileChanged() { filesBody.contentY = 0 } } WheelHandler { acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: function(ev) { filesBody.flick(0, ev.angleDelta.y < 0 ? -240 : 240) ev.accepted = true } } Column { id: fileContent width: filesBody.width spacing: Style.space(8) Row { width: parent.width spacing: Style.space(8) visible: root.filePaths.length > 1 Repeater { model: ["‹ Previous", "Next ›"] delegate: Rectangle { required property int index required property string modelData width: navLabel.implicitWidth + Style.space(16) height: Style.space(24) color: root.chipBg radius: Style.cornerRadius Text { id: navLabel anchors.centerIn: parent text: modelData color: root.fg font.family: root.font_ font.pixelSize: Style.font.caption } MouseArea { anchors.fill: parent onClicked: root.fileIndex = (root.fileIndex + (index === 0 ? -1 : 1) + root.filePaths.length) % root.filePaths.length } } } Text { text: (root.fileIndex + 1) + " / " + root.filePaths.length color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } Text { width: parent.width text: Classify.fileBase(root.selectedFile) textFormat: Text.PlainText wrapMode: Text.WrapAnywhere color: root.fg font.family: root.font_ font.pixelSize: Style.font.body font.bold: true } Rectangle { id: thumbnailPanel readonly property bool loading: fileDelay.running || fileProbe.running || fileThumbnail.status === Image.Loading width: parent.width height: visible ? Math.min(Style.space(240), filesBody.height * 0.55) : 0 visible: loading || !!root.fileInfo.thumbnail color: root.chipBg radius: Style.cornerRadius clip: true Image { id: fileThumbnail anchors.fill: parent visible: status === Image.Ready && !thumbnailPanel.loading source: root.fileInfo.thumbnail || "" fillMode: Image.PreserveAspectFit asynchronous: true sourceSize.width: 640 sourceSize.height: 360 } Column { anchors.centerIn: parent spacing: Style.space(8) visible: thumbnailPanel.loading Text { anchors.horizontalCenter: parent.horizontalCenter text: "◌" color: Color.accent font.pixelSize: Style.space(28) NumberAnimation on rotation { from: 0 to: 360 duration: 1000 loops: Animation.Infinite running: thumbnailPanel.loading && filesBody.visible } } Text { text: "Loading preview…" color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } Text { anchors.centerIn: parent visible: !thumbnailPanel.loading && fileThumbnail.status === Image.Error text: "Thumbnail unavailable" color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } Text { width: parent.width text: root.fileInfo.error || root.fileDetails() || (thumbnailPanel.loading ? "Loading file information…" : "File information unavailable") textFormat: Text.PlainText wrapMode: Text.WrapAnywhere color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } Text { width: parent.width text: root.selectedFile textFormat: Text.PlainText wrapMode: Text.WrapAnywhere color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } TextEdit { width: parent.width visible: text.length > 0 text: root.fileInfo.text || root.fileInfo.details || "" textFormat: TextEdit.PlainText readOnly: true activeFocusOnPress: false wrapMode: TextEdit.WrapAnywhere color: root.fg font.family: root.font_ font.pixelSize: Style.font.body } Text { visible: !!root.fileInfo.truncated text: "Preview limited to the first 8 KiB" color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } } // ------------------------------------------------------------- meta chips Row { id: metaRow anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right spacing: Style.space(6) Rectangle { width: Style.space(20) height: Style.space(20) radius: height / 2 color: root.chipBg anchors.verticalCenter: parent.verticalCenter Text { anchors.centerIn: parent text: Classify.typeIcon(root.derived) color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } Rectangle { radius: height / 2 color: root.chipBg width: typeChipText.implicitWidth + Style.space(14) height: Style.space(20) anchors.verticalCenter: parent.verticalCenter Text { id: typeChipText anchors.centerIn: parent text: Classify.typeLabel(root.derived) color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } Repeater { model: root.metaChips() delegate: Rectangle { required property var modelData property string chipText: String(modelData) radius: height / 2 color: root.chipBg width: chipLabel.implicitWidth + Style.space(14) height: Style.space(20) anchors.verticalCenter: parent.verticalCenter Text { id: chipLabel anchors.centerIn: parent text: parent.chipText textFormat: Text.PlainText color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption } } } } readonly property var hexRe: /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i }