diff --git a/Clipboard.qml b/Clipboard.qml index 1c8ea06..4e786f3 100644 --- a/Clipboard.qml +++ b/Clipboard.qml @@ -947,6 +947,7 @@ Item { result: root.currentResult openAction: function() { root.openResult(root.currentResult) } copyTextAction: function(text) { root.copyText(text) } + pluginDir: root.pluginDir visible: root.currentResult !== null } diff --git a/PreviewPane.qml b/PreviewPane.qml index 5e0485f..9f4b04d 100644 --- a/PreviewPane.qml +++ b/PreviewPane.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell.Io import qs.Commons import qs.Ui import "Classify.js" as Classify @@ -16,6 +17,55 @@ Item { 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 @@ -79,7 +129,7 @@ Item { chips.push(Classify.plural(st.words, "word")) chips.push(Classify.plural(st.lines, "line")) } - if (r.bytes > 0) chips.push(Classify.formatBytes(r.bytes)) + 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") @@ -625,8 +675,10 @@ Item { } } - // files body - Column { + // 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 @@ -634,57 +686,158 @@ Item { anchors.topMargin: Style.space(10) anchors.bottomMargin: Style.space(10) visible: root.derived === "files" - spacing: Style.space(6) clip: true - - Repeater { - model: { - if (!root.entry || root.entry.type !== "files") return [] - return (root.entry.paths || []).slice(0, 10) + 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 } - - delegate: Row { - required property var modelData - width: parent ? parent.width : 0 + } + Column { + id: fileContent + width: filesBody.width + spacing: Style.space(8) + Row { + width: parent.width spacing: Style.space(8) - - Text { - text: "󰈚" - color: Color.accent - font.family: root.font_ - font.pixelSize: Style.font.body + 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: Classify.fileBase(modelData) - color: root.fg + text: (root.fileIndex + 1) + " / " + root.filePaths.length + color: root.mutedFg font.family: root.font_ - font.pixelSize: Style.font.body - elide: Text.ElideMiddle - width: parent.width - Style.space(24) - maximumLineCount: 1 + font.pixelSize: Style.font.caption } } - } - - Text { - visible: !!(root.entry && root.entry.paths && root.entry.paths.length > 10) - text: root.entry && root.entry.paths ? "… and " + (root.entry.paths.length - 10) + " more" : "" - color: root.mutedFg - font.family: root.font_ - font.pixelSize: Style.font.caption - } - - Text { - text: root.entry && root.entry.paths && root.entry.paths.length > 0 - ? "in " + Classify.fileDir(root.entry.paths[0]) - : "" - color: root.mutedFg - font.family: root.font_ - font.pixelSize: Style.font.caption - elide: Text.ElideMiddle - width: parent.width - maximumLineCount: 1 + 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 + } } } diff --git a/README.md b/README.md index e5238f7..d91fcce 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,20 @@ That's the whole install — `omarchy plugin add` clones the repo, validates the manifest, and enables it. It replaces the built-in `omarchy.clipboard` (restore it later with `omarchy plugin disable alanfortlink.clipboard`). +### File previews + +Copied files show their name, type, size, modification time, and path. Image +and video files show a thumbnail and resolution; videos and audio show duration, +and audio also shows codec, sample rate, and channels. PDFs show a first-page +thumbnail and page information. Text and code files show the first 8 KiB. +Folders and other files retain basic information. Use Previous/Next to inspect +multiple copied files. Details load on selection, including for existing history. + +Media previews use optional `ffmpeg` (`ffprobe` included); PDF previews use +optional `poppler` (`pdfinfo` and `pdftoppm`). Without them, basic file information +still works. Preview commands have time and output limits, and media thumbnails +are skipped above 40 megapixels. Missing or moved files show an error. + ### Dependencies All are regular Arch packages; nothing is downloaded or run at install time. diff --git a/scripts/file-preview.py b/scripts/file-preview.py new file mode 100644 index 0000000..c14bd6a --- /dev/null +++ b/scripts/file-preview.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Bounded, on-demand metadata for one local copied file; never modifies it.""" +import base64 +import json +import mimetypes +import os +from pathlib import Path +import stat +import sys +import time + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from capture import read_bounded + + +def preview(path): + result = {'path': path} + deadline = time.monotonic() + 6 + + def run(args, limit=128 * 1024): + remaining = deadline - time.monotonic() + return read_bounded(args, limit, timeout=min(2, remaining)) if remaining > 0 else None + + try: + info = os.stat(path) + result.update(bytes=info.st_size, modified=int(info.st_mtime)) + if stat.S_ISDIR(info.st_mode): + result['kind'] = 'Folder' + return result + if not stat.S_ISREG(info.st_mode): + result['kind'] = 'Special file' + return result + mime = mimetypes.guess_type(path)[0] or 'application/octet-stream' + result['mime'] = mime + result['kind'] = mime + # Do not let a media playlist cause ffmpeg to open referenced resources. + media = Path(path).suffix.lower() in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.mp3', '.flac', '.wav', '.ogg', '.opus', '.m4a', '.aac'} + if media: + options = ['-v', 'error', '-max_alloc', '67108864', '-protocol_whitelist', 'file', '-probesize', '1048576', '-analyzeduration', '1000000'] + data = run(['ffprobe'] + options + ['-show_entries', 'format=duration:stream=codec_type,codec_name,width,height,sample_rate,channels', '-of', 'json', path]) + if data: + parsed = json.loads(data) + duration = float(parsed.get('format', {}).get('duration', 0)) + if 0 < duration < 1e9: + result['duration'] = duration + streams = parsed.get('streams', []) + visual = next((s for s in streams if s.get('codec_type') == 'video'), {}) + audio = next((s for s in streams if s.get('codec_type') == 'audio'), {}) + for key in ('width', 'height'): + if visual.get(key): + result[key] = int(visual[key]) + if audio: + result['audio'] = ' · '.join(str(audio[k]) + suffix for k, suffix in [('codec_name', ''), ('sample_rate', ' Hz'), ('channels', ' channels')] if audio.get(k)) + if 0 < result.get('width', 0) * result.get('height', 0) <= 40000000: + thumb = run(['ffmpeg'] + options + ['-nostdin', '-threads', '1', '-i', path, '-frames:v', '1', '-vf', 'scale=640:360:force_original_aspect_ratio=decrease', '-threads', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-protocol_whitelist', 'pipe', 'pipe:1'], 512 * 1024) + if thumb: + result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii') + elif mime == 'application/pdf': + thumb = run(['pdftoppm', '-f', '1', '-singlefile', '-scale-to', '640', '-jpeg', path], 512 * 1024) + if thumb: + result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii') + data = run(['pdfinfo', path]) + if data: + result['details'] = '\n'.join(line.strip() for line in data.decode('utf-8', 'replace').splitlines() if line.startswith(('Pages:', 'Page size:', 'Title:', 'Author:'))) + elif mime.startswith('text/') or Path(path).suffix.lower() in {'.json', '.yaml', '.yml', '.toml', '.md', '.py', '.js', '.ts', '.qml', '.sh', '.rs', '.go', '.log', '.csv'}: + # Nonblocking open avoids hanging on a file replaced with a FIFO. + fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as source: + if stat.S_ISREG(os.fstat(source.fileno()).st_mode): + data = source.read(8193) + if b'\0' not in data: + result['text'] = data[:8192].decode('utf-8', 'replace') + result['truncated'] = len(data) > 8192 + except (OSError, ValueError, TypeError) as error: + result['error'] = str(error) + return result + + +if __name__ == '__main__': + print(json.dumps({'request': sys.argv[1], 'file': preview(os.path.abspath(sys.argv[2]))})) diff --git a/tests/run.sh b/tests/run.sh index 82bbb97..a715780 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -2,5 +2,5 @@ # Run all plugin logic tests. Requires node and python3. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." -python3 -m unittest discover -s tests -p 'test_capture.py' +python3 -m unittest discover -s tests -p 'test_*.py' exec node --test 'tests/*.mjs' diff --git a/tests/test_file_preview.py b/tests/test_file_preview.py new file mode 100644 index 0000000..4425f28 --- /dev/null +++ b/tests/test_file_preview.py @@ -0,0 +1,69 @@ +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('file_preview', Path(__file__).resolve().parents[1] / 'scripts/file-preview.py') +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class FilePreviewTests(unittest.TestCase): + def test_text_is_bounded_and_path_preserved(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'a " quoted.txt' + path.write_text('a' * 10000) + data = module.preview(str(path)) + self.assertEqual(data['bytes'], 10000) + self.assertEqual(len(data['text']), 8192) + self.assertTrue(data['truncated']) + self.assertEqual(data['path'], str(path)) + + def test_missing_file_and_folder(self): + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(module.preview(directory)['kind'], 'Folder') + self.assertIn('error', module.preview(directory + '/missing')) + + def test_special_file_is_not_read(self): + with tempfile.TemporaryDirectory() as directory: + path = directory + '/fifo.txt' + os.mkfifo(path) + self.assertEqual(module.preview(path)['kind'], 'Special file') + + def test_missing_media_tools_preserve_basic_metadata(self): + with tempfile.NamedTemporaryFile(suffix='.mp4') as source: + with patch.object(module, 'read_bounded', return_value=None): + data = module.preview(source.name) + self.assertEqual(data['mime'], 'video/mp4') + self.assertIn('bytes', data) + self.assertNotIn('thumbnail', data) + + @unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional') + def test_video_resolution_duration_and_thumbnail(self): + with tempfile.TemporaryDirectory() as directory: + path = directory + '/clip.mkv' + subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=160x90:d=1', '-c:v', 'ffv1', path], check=True, timeout=10) + data = module.preview(path) + self.assertEqual((data['width'], data['height']), (160, 90)) + self.assertAlmostEqual(data['duration'], 1, places=1) + self.assertTrue(data['thumbnail'].startswith('data:image/jpeg;base64,')) + + @unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional') + def test_audio_details(self): + with tempfile.TemporaryDirectory() as directory: + path = directory + '/audio.wav' + subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'sine=duration=1', path], check=True, timeout=10) + data = module.preview(path) + self.assertAlmostEqual(data['duration'], 1, places=1) + self.assertIn('44100 Hz', data['audio']) + self.assertNotIn('thumbnail', data) + + def test_generic_binary_not_shown_as_text(self): + with tempfile.NamedTemporaryFile(suffix='.txt') as source: + source.write(b'abc\x00def') + source.flush() + self.assertNotIn('text', module.preview(source.name))