Files
clipboard-history/PreviewPane.qml
T

914 lines
27 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}