From 4fe12bbabfe2bb4c468c66348afedb34aee0b783 Mon Sep 17 00:00:00 2001 From: littleblack111 Date: Sat, 16 Aug 2025 16:23:20 +0800 Subject: feat(Clip): metadatas(app it's taken from, timestamp) Persistent and Directories are partially copied from end4-dots which is GPL Licensed --- launcher/components/Clip.qml | 26 ++++++++++++--- services/Clip.qml | 78 ++++++++++++++++++++++++++++++++++++++++++-- services/Persistent.qml | 27 +++++++++++++++ services/TimeDate.qml | 48 +++++++++++++++++++++++++++ utils/Directories.qml | 60 ++++++++++++++++++++++++++++++++++ 5 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 services/Persistent.qml create mode 100644 utils/Directories.qml diff --git a/launcher/components/Clip.qml b/launcher/components/Clip.qml index 568d508..29b5959 100644 --- a/launcher/components/Clip.qml +++ b/launcher/components/Clip.qml @@ -94,11 +94,29 @@ IComponent { anchors.margins: Launcher.innerMargin * 2 spacing: Launcher.innerMargin * 2 - IText { - text: modelData?.isImage ? "Image" : "Text" || "" + IconImage { + source: modelData?.appIcon + implicitSize: parent.height } - IText { - text: modelData?.data || "" + + Column { + IText { + text: modelData?.data || "" + font.pixelSize: Style.font.size.largerr + } + Row { + spacing: Launcher.innerMargin + IText { + text: modelData.isImage ? "Image" : "Text" + color: Colors.foreground3 + font.pixelSize: Style.font.size.normal + } + IText { + text: TimeDate.sinceWhen(modelData?.timestamp) || "" + color: Colors.foreground3 + font.pixelSize: Style.font.size.normal + } + } } } diff --git a/services/Clip.qml b/services/Clip.qml index d03976c..59c460b 100644 --- a/services/Clip.qml +++ b/services/Clip.qml @@ -2,8 +2,11 @@ pragma Singleton import Quickshell import Quickshell.Io -import QtQuick +import Quickshell.Wayland + import qs.components +import qs.utils +import qs.services Searchable { id: root @@ -15,6 +18,8 @@ Searchable { property list _clipImg: [] property list _clipImgIds: _clipImg.map(p => p.split("/").pop().split(".").shift()) + property var _clipMetadata: ({}) + list: { if (!_clipHist?.length) return []; @@ -41,16 +46,45 @@ Searchable { const idStr = idNum >= 0 ? "" + idNum : ""; const imgPath = idStr ? imgMap[idStr] || "" : ""; const isImg = !!imgPath; + const metadata = _clipMetadata[idStr] || {}; return { index: idNum, isImage: isImg, data: isImg ? imgPath : payload, - raw: raw + raw: raw, + timestamp: metadata.timestamp || "", + appId: metadata.appId || "", + appTitle: metadata.appTitle || "", + appIcon: Quickshell.iconPath(AppSearch.guessIcon(metadata.appId), "image-missing") }; }); } + Persistent { + id: clipMetaStore + filePath: Directories.clipHistMetaDataPath + adapter: JsonAdapter { + property var metadata: ({}) + } + fileView.onLoaded: { + const meta = adapter.metadata; + for (const k in meta) { + if (meta[k].timestamp && typeof meta[k].timestamp === "string") { + meta[k].timestamp = new Date(meta[k].timestamp); + } + } + root._clipMetadata = meta; + } + // fileView.onAdapterUpdated: { + // root._clipMetadata = adapter.metadata; + // } + } + + on_ClipMetadataChanged: { + clipMetaStore.adapter.metadata = _clipMetadata; + } + function transformSearch(search) { return search.toLowerCase(); } @@ -68,18 +102,57 @@ Searchable { imgProc.running = true; } + function _captureMetadata() { + const now = new Date(); + const activeToplevel = ToplevelManager.activeToplevel; + + Qt.callLater(() => { + clipProc.running = true; + clipProc.stdout.onceFinished = () => { + const lines = clipProc.stdout.text.split(/\r?\n/); + const firstLine = lines[0] || ""; + const tab = firstLine.indexOf("\t"); + + if (tab > 0) { + const idStr = firstLine.slice(0, tab); + const n = parseInt(idStr, 10); + if (!Number.isNaN(n)) { + var newMetadata = {}; + for (var k in _clipMetadata) { + if (_clipMetadata.hasOwnProperty(k)) + newMetadata[k] = _clipMetadata[k]; + } + + newMetadata[idStr] = { + timestamp: now, + appId: activeToplevel ? activeToplevel.appId : "", + appTitle: activeToplevel ? activeToplevel.title : "" + }; + _clipMetadata = newMetadata; + } + } + }; + }); + } + Process { id: clipProc running: true command: ["cliphist", "-preview-width", 9 ** 9, "-max-items", 9 ** 9, "list"] stdout: StdioCollector { property bool pending: false + property var onceFinished: null + onStreamFinished: () => { pending = true; Qt.callLater(() => { if (pending) { pending = false; _clipHist = this.text.split(/\r?\n/); + if (onceFinished) { + onceFinished(); + onceFinished = null; + } } }); } @@ -113,6 +186,7 @@ Searchable { stdout: SplitParser { onRead: line => { + _captureMetadata(); _update(); } } diff --git a/services/Persistent.qml b/services/Persistent.qml new file mode 100644 index 0000000..3a911bf --- /dev/null +++ b/services/Persistent.qml @@ -0,0 +1,27 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.utils + +Scope { + id: root + property alias adapter: fileView.adapter + property alias fileView: fileView + property string fileDir: Directories.state + property string fileName: "states.json" + property string filePath: `${root.fileDir}/${root.fileName}` + + FileView { + id: fileView + path: root.filePath + watchChanges: true + onFileChanged: reload() + onAdapterUpdated: writeAdapter() + onLoadFailed: error => { + console.log("Failed to load persistent states file:", error); + if (error == FileViewError.FileNotFound) { + writeAdapter(); + } + } + } +} diff --git a/services/TimeDate.qml b/services/TimeDate.qml index fab2beb..f066f46 100644 --- a/services/TimeDate.qml +++ b/services/TimeDate.qml @@ -27,6 +27,54 @@ Singleton { readonly property string date: Qt.formatDateTime(clock.date, "dddd dd/MM") + function sinceWhen(d) { + if (typeof d === "string") + return; + + var now = clock.date; + + function startOfDay(x) { + return new Date(x.getFullYear(), x.getMonth(), x.getDate(), 0, 0, 0, 0); + } + + function startOfWeek(x) { + var dow = x.getDay(); + var mondayOffset = (dow + 6) % 7; + var sod = startOfDay(x); + sod.setDate(sod.getDate() - mondayOffset); + return sod; + } + + function fmt12h(dateObj) { + var h24 = dateObj.getHours(); + var m = dateObj.getMinutes(); + var h12 = h24 % 12; + if (h12 === 0) + h12 = 12; + var ampm = h24 < 12 ? "AM" : "PM"; + return ((h12 < 10 ? "0" : "") + h12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm); + } + + function weekdayName(dateObj) { + return Qt.formatDateTime(dateObj, "dddd"); + } + + var nowSOD = startOfDay(now).getTime(); + var dSOD = startOfDay(d).getTime(); + if (nowSOD === dSOD) { + return fmt12h(d); + } + + var nowSOW = startOfWeek(now).getTime(); + var dSOW = startOfWeek(d).getTime(); + if (nowSOW === dSOW) { + return weekdayName(d) + " " + fmt12h(d); + } + + var ymd = Qt.formatDateTime(d, "yyyy-MM-dd"); + return ymd + " at " + fmt12h(d); + } + SystemClock { id: clock precision: SystemClock.Seconds diff --git a/utils/Directories.qml b/utils/Directories.qml new file mode 100644 index 0000000..f643a78 --- /dev/null +++ b/utils/Directories.qml @@ -0,0 +1,60 @@ +pragma Singleton +// pragma ComponentBehavior: Bound + +import Qt.labs.platform +import QtQuick +import Quickshell +import Quickshell.Hyprland + +Singleton { + /** + * Trims the File protocol off the input string + * @param {string} str + * @returns {string} + */ + function trimFileProtocol(str) { + return str.startsWith("file://") ? str.slice(7) : str; + } + + /** + * Extracts the file name from a file path + * @param {string} str + * @returns {string} + */ + function fileNameForPath(str) { + if (typeof str !== "string") + return ""; + const trimmed = trimFileProtocol(str); + return trimmed.split(/[\\/]/).pop(); + } + + /** + * Removes the file extension from a file path or name + * @param {string} str + * @returns {string} + */ + function trimFileExt(str) { + if (typeof str !== "string") + return ""; + const trimmed = trimFileProtocol(str); + const lastDot = trimmed.lastIndexOf("."); + if (lastDot > -1 && lastDot > trimmed.lastIndexOf("/")) { + return trimmed.slice(0, lastDot); + } + return trimmed; + } + + // XDG Dirs, with "file://" + readonly property string config: StandardPaths.standardLocations(StandardPaths.ConfigLocation)[0] + readonly property string state: StandardPaths.standardLocations(StandardPaths.StateLocation)[0] + readonly property string cache: StandardPaths.standardLocations(StandardPaths.CacheLocation)[0] + readonly property string pictures: StandardPaths.standardLocations(StandardPaths.PicturesLocation)[0] + readonly property string downloads: StandardPaths.standardLocations(StandardPaths.DownloadLocation)[0] + + property string clipHistMetaDataPath: `${state}/cliphist.json` + + // Cleanup on init + Component.onCompleted: { + Quickshell.execDetached(["touch", trimFileProtocol(clipHistMetaDataPath)]); + } +} -- cgit v1.2.3