Clipboard history picker: Omarchy shell plugin (tank.clipboard) with fuzzy search, rich previews, capture daemon
This commit is contained in:
@@ -0,0 +1,821 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Store.js" as Store
|
||||
import "Fuzzy.js" as Fuzzy
|
||||
import "Classify.js" as Classify
|
||||
|
||||
// Clipboard history picker — Raycast-style: fuzzy search bar, result list,
|
||||
// and a per-type preview pane. Clone of omarchy.clipboard with richer
|
||||
// capture metadata (app, size, dims, pins, usage) and full-text fuzzy search.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool opened: false
|
||||
property string filterText: ""
|
||||
property string typeFilter: "" // chip filter, "" = all; combined into the query
|
||||
property int selectedIndex: 0
|
||||
property bool cursorActive: false
|
||||
property bool cursorVisible: true
|
||||
property bool clearConfirmOpen: false
|
||||
property var history: []
|
||||
property var results: [] // [{ row, score, positions }]
|
||||
|
||||
property string pluginDir: Qt.resolvedUrl(".").toString().replace(/^file:\/\//, "").replace(/\/$/, "")
|
||||
property string statePath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history-rich.json"
|
||||
property int historyLimit: 1500
|
||||
property int displayLimit: 200
|
||||
property var typeCache: ({}) // id → derived type, memoized
|
||||
|
||||
// Theme surface tokens (menu) — tracks the active Omarchy theme.
|
||||
property color background: Color.menu.background
|
||||
property color foreground: Color.menu.text
|
||||
property color border: Color.menu.border
|
||||
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
|
||||
property color scrim: Color.menu.scrim
|
||||
property color selectedBackground: Color.menu.selectedBackground
|
||||
property color selectedText: Color.menu.selectedText
|
||||
property color accent: Color.accent
|
||||
property color mutedFg: Util.alpha(foreground, 0.55)
|
||||
property color chipBg: Util.alpha(foreground, 0.07)
|
||||
property color lineColor: Util.alpha(foreground, 0.14)
|
||||
readonly property int cornerRadius: Style.cornerRadius
|
||||
readonly property string fontFamily: Style.font.menuFamily
|
||||
readonly property int contentMargin: Style.spacing.panelPadding
|
||||
readonly property int headerHeight: Math.max(Style.space(40), Style.font.heading + Style.spacing.controlPaddingY * 2)
|
||||
readonly property int cardWidth: Math.min(Style.space(980), panel.width - Style.gapsOut * 2)
|
||||
readonly property int cardHeight: Math.min(Style.space(680), panel.height - Style.gapsOut * 2)
|
||||
readonly property int rowHeight: Style.space(52)
|
||||
readonly property int listWidth: Math.round(card.width * 0.46)
|
||||
|
||||
readonly property var currentResult: results.length > 0 && selectedIndex >= 0 && selectedIndex < results.length ? results[selectedIndex] : null
|
||||
|
||||
// ------------------------------------------------------------ lifecycle
|
||||
|
||||
function open() {
|
||||
root.opened = true
|
||||
root.filterText = ""
|
||||
root.typeFilter = ""
|
||||
root.selectedIndex = 0
|
||||
root.cursorActive = true
|
||||
root.disarmPointer()
|
||||
root.rebuild()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
function close() {
|
||||
root.cancelClearHistory()
|
||||
root.opened = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.opened) root.close()
|
||||
else root.open()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ store
|
||||
|
||||
function loadHistory(raw) {
|
||||
root.history = Store.parseHistory(raw, Math.floor(Date.now() / 1000))
|
||||
root.typeCache = {}
|
||||
if (root.opened) root.rebuild()
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
var pruned = Store.prune(root.history, root.historyLimit)
|
||||
root.history = pruned.entries
|
||||
historyFile.setText(JSON.stringify(root.history, null, 1) + "\n")
|
||||
if (pruned.droppedImagePaths.length > 0) {
|
||||
var cmd = ["rm", "-f"].concat(pruned.droppedImagePaths)
|
||||
gcProc.command = cmd
|
||||
gcProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
function addClipboardJson(line) {
|
||||
var entry = null
|
||||
try { entry = JSON.parse(String(line || "")) } catch (e) { return }
|
||||
if (!entry) return
|
||||
root.history = Store.addEntry(root.history, entry, root.historyLimit, Math.floor(Date.now() / 1000))
|
||||
root.saveHistory()
|
||||
if (root.opened) root.rebuild()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ search
|
||||
|
||||
function effectiveQuery() {
|
||||
var q = root.filterText
|
||||
if (root.typeFilter === "pinned") return q + (q ? " " : "") + "is:pinned"
|
||||
if (root.typeFilter) return "type:" + root.typeFilter + (q ? " " + q : "")
|
||||
return q
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
var now = Math.floor(Date.now() / 1000)
|
||||
var rows = []
|
||||
for (var i = 0; i < root.history.length; i++) {
|
||||
var entry = root.history[i]
|
||||
var derived = root.typeCache[entry.id]
|
||||
if (derived === undefined) {
|
||||
derived = Classify.deriveType(entry)
|
||||
root.typeCache[entry.id] = derived
|
||||
}
|
||||
rows.push(Store.buildRow(entry, derived, now))
|
||||
}
|
||||
root.results = Fuzzy.searchRows(rows, root.effectiveQuery(), now, root.displayLimit)
|
||||
|
||||
if (root.results.length === 0) root.selectedIndex = 0
|
||||
else if (root.selectedIndex >= root.results.length) root.selectedIndex = root.results.length - 1
|
||||
|
||||
Qt.callLater(function() {
|
||||
if (root.results.length > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
||||
})
|
||||
}
|
||||
|
||||
function setFilter(next) {
|
||||
root.filterText = next
|
||||
root.selectedIndex = 0
|
||||
root.cursorActive = true
|
||||
root.disarmPointer()
|
||||
root.rebuild()
|
||||
}
|
||||
|
||||
function setTypeFilter(next) {
|
||||
root.typeFilter = next
|
||||
root.selectedIndex = 0
|
||||
root.disarmPointer()
|
||||
root.rebuild()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ navigation
|
||||
|
||||
function select(delta) {
|
||||
if (root.results.length === 0) return
|
||||
root.disarmPointer()
|
||||
if (!root.cursorActive) {
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = delta < 0 ? root.results.length - 1 : 0
|
||||
} else {
|
||||
root.selectedIndex = (root.selectedIndex + delta + root.results.length) % root.results.length
|
||||
}
|
||||
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
||||
}
|
||||
|
||||
function selectAbsolute(index) {
|
||||
if (root.results.length === 0) return
|
||||
root.disarmPointer()
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = Math.max(0, Math.min(index, root.results.length - 1))
|
||||
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
||||
}
|
||||
|
||||
function disarmPointer() {
|
||||
pointerGate.reset()
|
||||
}
|
||||
|
||||
function selectFromPointer(index, item, mouse) {
|
||||
if (!pointerGate.moved(item, mouse)) return
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = index
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ actions
|
||||
|
||||
function pasteResult(result) {
|
||||
if (!result) return
|
||||
root.close()
|
||||
root.history = Store.touch(root.history, result.row.entry.id, Math.floor(Date.now() / 1000))
|
||||
root.saveHistory()
|
||||
Quickshell.execDetached([root.pluginDir + "/paste-entry.sh", result.row.entry.id])
|
||||
}
|
||||
|
||||
function copyResult(result) {
|
||||
if (!result) return
|
||||
root.close()
|
||||
Quickshell.execDetached([root.pluginDir + "/paste-entry.sh", result.row.entry.id, "--copy-only"])
|
||||
}
|
||||
|
||||
function openResult(result) {
|
||||
if (!result) return
|
||||
root.close()
|
||||
Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id])
|
||||
}
|
||||
|
||||
function removeIndex(index) {
|
||||
if (index < 0 || index >= root.results.length) return
|
||||
var entry = root.results[index].row.entry
|
||||
root.history = Store.removeById(root.history, entry.id)
|
||||
delete root.typeCache[entry.id]
|
||||
root.saveHistory()
|
||||
if (root.results.length <= 1) root.selectedIndex = 0
|
||||
else if (root.selectedIndex >= root.results.length - 1) root.selectedIndex = root.results.length - 2
|
||||
root.rebuild()
|
||||
}
|
||||
|
||||
function togglePinIndex(index) {
|
||||
if (index < 0 || index >= root.results.length) return
|
||||
root.history = Store.togglePin(root.history, root.results[index].row.entry.id)
|
||||
root.saveHistory()
|
||||
root.rebuild()
|
||||
}
|
||||
|
||||
function requestClearHistory() {
|
||||
if (root.history.length === 0) return
|
||||
clearConfirm.selectedIndex = 1
|
||||
root.clearConfirmOpen = true
|
||||
}
|
||||
|
||||
function cancelClearHistory() {
|
||||
root.clearConfirmOpen = false
|
||||
root.disarmPointer()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
function confirmClearHistory() {
|
||||
var dropped = []
|
||||
for (var i = 0; i < root.history.length; i++) {
|
||||
if (root.history[i].type === "image" && root.history[i].path) dropped.push(root.history[i].path)
|
||||
}
|
||||
root.history = []
|
||||
root.typeCache = {}
|
||||
root.saveHistory()
|
||||
if (dropped.length > 0) {
|
||||
gcProc.command = ["rm", "-f"].concat(dropped)
|
||||
gcProc.running = true
|
||||
}
|
||||
root.selectedIndex = 0
|
||||
root.clearConfirmOpen = false
|
||||
root.rebuild()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
Component.onCompleted: initProc.running = true
|
||||
|
||||
// ------------------------------------------------------------ capture
|
||||
|
||||
ListModel { id: displayModel }
|
||||
|
||||
PointerMoveGate { id: pointerGate; referenceItem: card }
|
||||
|
||||
FileView {
|
||||
id: historyFile
|
||||
path: root.statePath
|
||||
watchChanges: true
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
onLoaded: root.loadHistory(text())
|
||||
onLoadFailed: root.loadHistory("[]")
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
Process { id: gcProc }
|
||||
|
||||
// Reap watchers left behind by a previous shell instance, then start our
|
||||
// own. pdeathsig kills them whenever the shell exits.
|
||||
Process {
|
||||
id: initProc
|
||||
command: ["pkill", "-f", "wl-paste .*--watch .*capture\\.py"]
|
||||
onExited: {
|
||||
snapshotProc.running = true
|
||||
watchProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: snapshotProc
|
||||
command: ["python3", root.pluginDir + "/capture.py"]
|
||||
stdout: SplitParser {
|
||||
onRead: function(data) { root.addClipboardJson(data) }
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: watchProc
|
||||
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "python3", root.pluginDir + "/capture.py", "watch"]
|
||||
onExited: watchRestartTimer.restart()
|
||||
stdout: SplitParser {
|
||||
onRead: function(data) { root.addClipboardJson(data) }
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: watchRestartTimer
|
||||
interval: 1000
|
||||
repeat: false
|
||||
onTriggered: if (!watchProc.running) watchProc.running = true
|
||||
}
|
||||
|
||||
// Cursor blink
|
||||
Timer {
|
||||
running: root.opened
|
||||
interval: 530
|
||||
repeat: true
|
||||
onTriggered: root.cursorVisible = !root.cursorVisible
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ window
|
||||
|
||||
PanelWindow {
|
||||
id: panel
|
||||
visible: root.opened
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
WlrLayershell.namespace: "tank-clipboard"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.scrim
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
BorderSurface {
|
||||
id: card
|
||||
width: root.cardWidth
|
||||
height: root.cardHeight
|
||||
radius: root.cornerRadius
|
||||
anchors.centerIn: parent
|
||||
color: root.background
|
||||
borderSpec: root.borderSpec
|
||||
padding: root.contentMargin
|
||||
|
||||
MouseArea { anchors.fill: parent; onClicked: function() {} }
|
||||
|
||||
Item {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
z: root.clearConfirmOpen ? 20 : 0
|
||||
focus: true
|
||||
|
||||
Keys.priority: Keys.BeforeItem
|
||||
Keys.onPressed: function(event) {
|
||||
if (root.clearConfirmOpen) {
|
||||
if (clearConfirm.handleKey(event)) event.accepted = true
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
if (root.filterText) root.setFilter("")
|
||||
else if (root.typeFilter) root.setTypeFilter("")
|
||||
else root.close()
|
||||
event.accepted = true
|
||||
} else if (Util.editsFilter(event, root.filterText)) {
|
||||
root.setFilter(Util.editedFilter(event, root.filterText))
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Delete) {
|
||||
if (event.modifiers & Qt.ShiftModifier) root.requestClearHistory()
|
||||
else root.removeIndex(root.selectedIndex)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up || (event.key === Qt.Key_P && (event.modifiers & Qt.ControlModifier))) {
|
||||
root.select(-1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Down || (event.key === Qt.Key_N && (event.modifiers & Qt.ControlModifier))) {
|
||||
root.select(1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageUp) {
|
||||
root.select(-8)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageDown) {
|
||||
root.select(8)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Home) {
|
||||
root.selectAbsolute(0)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_End) {
|
||||
root.selectAbsolute(root.results.length - 1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
root.togglePinIndex(root.selectedIndex)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
|
||||
root.openResult(root.currentResult)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
if (event.modifiers & Qt.ShiftModifier) root.copyResult(root.currentResult)
|
||||
else if (event.modifiers & Qt.AltModifier) root.openResult(root.currentResult)
|
||||
else root.pasteResult(root.currentResult)
|
||||
event.accepted = true
|
||||
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
|
||||
root.setFilter(root.filterText + event.text)
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmDialog {
|
||||
id: clearConfirm
|
||||
anchors.fill: parent
|
||||
opened: root.clearConfirmOpen
|
||||
z: 10
|
||||
message: "Delete entire clipboard history?"
|
||||
confirmText: "Delete"
|
||||
background: root.background
|
||||
foreground: root.foreground
|
||||
scrim: root.scrim
|
||||
selectedBackground: root.selectedBackground
|
||||
selectedText: root.selectedText
|
||||
fontFamily: root.fontFamily
|
||||
cornerRadius: root.cornerRadius
|
||||
onCanceled: root.cancelClearHistory()
|
||||
onConfirmed: root.confirmClearHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- layout
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: card.contentTopInset
|
||||
anchors.rightMargin: card.contentRightInset
|
||||
anchors.bottomMargin: card.contentBottomInset
|
||||
anchors.leftMargin: card.contentLeftInset
|
||||
spacing: Style.space(10)
|
||||
|
||||
// ---- search bar
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.headerHeight
|
||||
|
||||
Text {
|
||||
id: searchIcon
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: ""
|
||||
color: root.accent
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: searchIcon.right
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.right: countLabel.left
|
||||
anchors.rightMargin: Style.space(10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(2)
|
||||
|
||||
Text {
|
||||
id: searchText
|
||||
width: Math.min(implicitWidth, parent.width - Style.space(6))
|
||||
text: root.filterText || "Search clipboard\u2026 (type:image app:firefox <2h is:pinned)"
|
||||
color: root.foreground
|
||||
opacity: root.filterText.length > 0 ? 1 : 0.4
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(1, Style.space(2))
|
||||
height: Style.font.heading
|
||||
color: root.accent
|
||||
visible: root.cursorVisible && root.cursorActive
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: countLabel
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
var shown = root.results.length
|
||||
var total = root.history.length
|
||||
if (shown === total) return total + " items"
|
||||
return shown + " of " + total
|
||||
}
|
||||
color: root.mutedFg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
// ---- type chips
|
||||
Row {
|
||||
id: chipsRow
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ key: "", label: "All" },
|
||||
{ key: "text", label: " Text" },
|
||||
{ key: "link", label: " Links" },
|
||||
{ key: "image", label: " Images" },
|
||||
{ key: "files", label: " Files" },
|
||||
{ key: "code", label: " Code" },
|
||||
{ key: "json", label: " JSON" },
|
||||
{ key: "color", label: " Colors" },
|
||||
{ key: "pinned", label: "★ Pinned" }
|
||||
]
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
property string chipKey: modelData.key
|
||||
property string chipLabel: modelData.label
|
||||
property bool active: root.typeFilter === chipKey
|
||||
|
||||
radius: height / 2
|
||||
color: active ? Util.alpha(root.accent, 0.18) : root.chipBg
|
||||
width: chipLabel_.implicitWidth + Style.space(16)
|
||||
height: Style.space(22)
|
||||
|
||||
Text {
|
||||
id: chipLabel_
|
||||
anchors.centerIn: parent
|
||||
text: modelData.label
|
||||
color: parent.active ? root.accent : root.mutedFg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.setTypeFilter(parent.chipKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- list + preview
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - root.headerHeight - chipsRow.height - footer.height - Style.space(30)
|
||||
|
||||
ListView {
|
||||
id: resultList
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
width: root.listWidth
|
||||
model: displayModel
|
||||
clip: true
|
||||
spacing: Style.space(3)
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: Rectangle {
|
||||
id: row
|
||||
required property int index
|
||||
required property var row_ // results[i]: { row, score, positions }
|
||||
required property string derived
|
||||
required property string titleHtml
|
||||
required property string subtitle
|
||||
|
||||
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
|
||||
readonly property var entry: row_ ? row_.row.entry : null
|
||||
|
||||
width: resultList.width
|
||||
height: root.rowHeight
|
||||
radius: root.cornerRadius
|
||||
color: hasCursor ? root.selectedBackground : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.rightMargin: Style.space(10)
|
||||
spacing: Style.space(10)
|
||||
|
||||
// thumbnail for images, glyph tile otherwise
|
||||
Rectangle {
|
||||
width: Style.space(36)
|
||||
height: Style.space(36)
|
||||
radius: Style.space(6)
|
||||
color: root.chipBg
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
visible: row.derived === "image"
|
||||
source: row.entry && row.entry.path ? Util.fileUrl(row.entry.path) : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
sourceSize.width: 72
|
||||
sourceSize.height: 72
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: row.derived !== "image"
|
||||
text: Classify.typeIcon(row.derived)
|
||||
color: root.foreground
|
||||
opacity: 0.85
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.iconLarge
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - Style.space(46) - pinMark.width
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(2)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.titleHtml
|
||||
textFormat: Text.StyledText
|
||||
color: row.hasCursor ? root.selectedText : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.subtitle
|
||||
color: root.mutedFg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: pinMark
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "★"
|
||||
visible: row.entry && row.entry.pinned ? true : false
|
||||
color: root.accent
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPositionChanged: function(mouse) { root.selectFromPointer(row.index, row, mouse) }
|
||||
onClicked: {
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = row.index
|
||||
root.pasteResult(root.currentResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// divider between list and preview
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: root.listWidth + Style.space(6)
|
||||
width: 1
|
||||
color: root.lineColor
|
||||
}
|
||||
|
||||
PreviewPane {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: root.listWidth + Style.space(14)
|
||||
result: root.currentResult
|
||||
visible: root.currentResult !== null
|
||||
}
|
||||
|
||||
// empty state
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.space(8)
|
||||
visible: displayModel.count === 0
|
||||
|
||||
Text {
|
||||
text: ""
|
||||
color: root.selectedText
|
||||
opacity: 0.8
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.displayLarge
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.history.length === 0
|
||||
? "Clipboard is empty — copy something first"
|
||||
: "No matches for “" + root.filterText + "”"
|
||||
color: root.foreground
|
||||
opacity: 0.7
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- footer key hints
|
||||
Row {
|
||||
id: footer
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ keys: "ctrl+n/p", hint: "navigate" },
|
||||
{ keys: "enter", hint: "paste" },
|
||||
{ keys: "shift+enter", hint: "copy" },
|
||||
{ keys: "ctrl+o", hint: "open" },
|
||||
{ keys: "tab", hint: "pin" },
|
||||
{ keys: "del", hint: "remove" },
|
||||
{ keys: "esc", hint: "close" }
|
||||
]
|
||||
|
||||
delegate: Row {
|
||||
required property var modelData
|
||||
spacing: Style.space(4)
|
||||
|
||||
Rectangle {
|
||||
radius: Style.space(3)
|
||||
color: root.chipBg
|
||||
width: keyText.implicitWidth + Style.space(10)
|
||||
height: Style.space(18)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
id: keyText
|
||||
anchors.centerIn: parent
|
||||
text: modelData.keys
|
||||
color: root.mutedFg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: modelData.hint
|
||||
color: root.mutedFg
|
||||
opacity: 0.7
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ display model
|
||||
|
||||
// results → displayModel rows. Wrapped in function so it can be called from
|
||||
// rebuild(); title/subtitle strings precomputed here, not in delegates.
|
||||
function syncDisplayModel() {
|
||||
displayModel.clear()
|
||||
for (var i = 0; i < root.results.length; i++) {
|
||||
var r = root.results[i]
|
||||
var e = r.row.entry
|
||||
var derived = r.row.type
|
||||
var titleHtml = ""
|
||||
var subtitleParts = []
|
||||
|
||||
if (e.type === "image") {
|
||||
titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "")
|
||||
if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h
|
||||
subtitleParts.push(Classify.formatBytes(r.row.bytes))
|
||||
} else if (e.type === "files") {
|
||||
var base = Classify.fileBase(e.paths[0])
|
||||
titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base)
|
||||
subtitleParts.push(Classify.fileDir(e.paths[0]))
|
||||
} else {
|
||||
titleHtml = Fuzzy.highlightFirstLine(e.text, r.positions, "<b><font color=\"" + root.accentHex() + "\">", "</font></b>")
|
||||
var st = Classify.textStats(e.text)
|
||||
if (st.words > 0) subtitleParts.push(Classify.plural(st.words, "word"))
|
||||
}
|
||||
|
||||
subtitleParts.push(Classify.typeLabel(derived))
|
||||
if (r.row.app) subtitleParts.push(Classify.prettyApp(r.row.app))
|
||||
if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000)))
|
||||
|
||||
displayModel.append({
|
||||
row_: r,
|
||||
derived: derived,
|
||||
titleHtml: titleHtml,
|
||||
subtitle: subtitleParts.join(" · ")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 6-digit hex for rich-text <font color> tags, whatever toString() returns.
|
||||
function accentHex() {
|
||||
var s = root.accent.toString()
|
||||
return "#" + s.slice(-6)
|
||||
}
|
||||
|
||||
onResultsChanged: syncDisplayModel()
|
||||
}
|
||||
Reference in New Issue
Block a user