Use Cliphist, fix AppLauncher navigation
This commit is contained in:
parent
257220e20b
commit
02e246ab8d
4 changed files with 325 additions and 144 deletions
|
|
@ -18,6 +18,12 @@ NPanel {
|
|||
panelHeight: Math.min(550 * scaling, screen?.height * 0.8)
|
||||
panelAnchorCentered: true
|
||||
|
||||
onOpened: {
|
||||
// Reset state when panel opens to avoid sticky modes
|
||||
searchText = ""
|
||||
selectedIndex = 0
|
||||
}
|
||||
|
||||
// Import modular components
|
||||
Calculator {
|
||||
id: calculator
|
||||
|
|
@ -27,6 +33,15 @@ NPanel {
|
|||
id: clipboardHistory
|
||||
}
|
||||
|
||||
// Poll cliphist while in clipboard mode to keep entries fresh
|
||||
Timer {
|
||||
id: clipRefreshTimer
|
||||
interval: 2000
|
||||
repeat: true
|
||||
running: searchText.startsWith(">clip")
|
||||
onTriggered: clipboardHistory.refresh()
|
||||
}
|
||||
|
||||
// Properties
|
||||
property var desktopEntries: DesktopEntries.applications.values
|
||||
property string searchText: ""
|
||||
|
|
@ -41,9 +56,16 @@ NPanel {
|
|||
|
||||
// Main filtering logic
|
||||
property var filteredEntries: {
|
||||
Logger.log("AppLauncher", "Total desktop entries:", desktopEntries ? desktopEntries.length : 0)
|
||||
// Explicit dependency so changes to items/decoded images retrigger this binding
|
||||
const _clipItems = CliphistService.items
|
||||
const _clipRev = CliphistService.revision
|
||||
|
||||
var query = searchText ? searchText.toLowerCase() : ""
|
||||
if (query.startsWith(">clip")) {
|
||||
return clipboardHistory.processQuery(query, _clipItems)
|
||||
}
|
||||
|
||||
if (!desktopEntries || desktopEntries.length === 0) {
|
||||
Logger.log("AppLauncher", "No desktop entries available")
|
||||
return []
|
||||
}
|
||||
|
||||
|
|
@ -55,9 +77,6 @@ NPanel {
|
|||
return true
|
||||
})
|
||||
|
||||
Logger.log("AppLauncher", "Visible entries:", visibleEntries.length)
|
||||
|
||||
var query = searchText ? searchText.toLowerCase() : ""
|
||||
var results = []
|
||||
|
||||
// Handle special commands
|
||||
|
|
@ -81,11 +100,6 @@ NPanel {
|
|||
return results
|
||||
}
|
||||
|
||||
// Handle clipboard history
|
||||
if (query.startsWith(">clip")) {
|
||||
return clipboardHistory.processQuery(query)
|
||||
}
|
||||
|
||||
// Handle calculator
|
||||
if (query.startsWith(">calc")) {
|
||||
return calculator.processQuery(query, "calc")
|
||||
|
|
@ -114,18 +128,19 @@ NPanel {
|
|||
}))
|
||||
}
|
||||
|
||||
Logger.log("AppLauncher", "Filtered entries:", results.length)
|
||||
return results
|
||||
}
|
||||
|
||||
// Command execution functions
|
||||
function executeCalcCommand() {
|
||||
searchText = ">calc "
|
||||
searchInput.text = searchText
|
||||
searchInput.cursorPosition = searchText.length
|
||||
}
|
||||
|
||||
function executeClipCommand() {
|
||||
searchText = ">clip "
|
||||
searchInput.text = searchText
|
||||
searchInput.cursorPosition = searchText.length
|
||||
}
|
||||
|
||||
|
|
@ -143,12 +158,18 @@ NPanel {
|
|||
}
|
||||
|
||||
function selectNextPage() {
|
||||
if (filteredEntries.length > 0)
|
||||
selectedIndex = Math.min(selectedIndex + 10, filteredEntries.length - 1)
|
||||
if (filteredEntries.length > 0) {
|
||||
const delegateHeight = 65 * scaling + (Style.marginXXS * scaling)
|
||||
const page = Math.max(1, Math.floor(appsList.height / delegateHeight))
|
||||
selectedIndex = Math.min(selectedIndex + page, filteredEntries.length - 1)
|
||||
}
|
||||
}
|
||||
function selectPrevPage() {
|
||||
if (filteredEntries.length > 0)
|
||||
selectedIndex = Math.max(selectedIndex - 10, 0)
|
||||
if (filteredEntries.length > 0) {
|
||||
const delegateHeight = 65 * scaling + (Style.marginXXS * scaling)
|
||||
const page = Math.max(1, Math.floor(appsList.height / delegateHeight))
|
||||
selectedIndex = Math.max(selectedIndex - page, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function activateSelected() {
|
||||
|
|
@ -259,19 +280,33 @@ NPanel {
|
|||
Keys.onEscapePressed: root.close()
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_PageDown) {
|
||||
appsList.cancelFlick()
|
||||
root.selectNextPage()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageUp) {
|
||||
appsList.cancelFlick()
|
||||
root.selectPrevPage()
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Home) {
|
||||
appsList.cancelFlick()
|
||||
selectedIndex = 0
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_End) {
|
||||
appsList.cancelFlick()
|
||||
if (filteredEntries.length > 0) {
|
||||
selectedIndex = filteredEntries.length - 1
|
||||
}
|
||||
event.accepted = true
|
||||
}
|
||||
if (event.modifiers & Qt.ControlModifier) {
|
||||
switch (event.key) {
|
||||
case Qt.Key_J:
|
||||
appsList.cancelFlick()
|
||||
root.selectNext()
|
||||
event.accepted = true
|
||||
break
|
||||
case Qt.Key_K:
|
||||
appsList.cancelFlick()
|
||||
root.selectPrev()
|
||||
event.accepted = true
|
||||
break
|
||||
|
|
@ -295,21 +330,26 @@ NPanel {
|
|||
}
|
||||
|
||||
// Applications list
|
||||
ScrollView {
|
||||
ListView {
|
||||
id: appsList
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
spacing: Style.marginXXS * scaling
|
||||
model: filteredEntries
|
||||
currentIndex: selectedIndex
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
maximumFlickVelocity: 2500
|
||||
flickDeceleration: 2000
|
||||
onCurrentIndexChanged: {
|
||||
cancelFlick()
|
||||
if (currentIndex >= 0) {
|
||||
positionViewAtIndex(currentIndex, ListView.Contain)
|
||||
}
|
||||
}
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
|
||||
ListView {
|
||||
id: appsList
|
||||
anchors.fill: parent
|
||||
spacing: Style.marginXXS * scaling
|
||||
model: filteredEntries
|
||||
currentIndex: selectedIndex
|
||||
|
||||
delegate: Rectangle {
|
||||
delegate: Rectangle {
|
||||
width: appsList.width - Style.marginS * scaling
|
||||
height: 65 * scaling
|
||||
radius: Style.radiusM * scaling
|
||||
|
|
@ -341,7 +381,7 @@ NPanel {
|
|||
anchors.margins: Style.marginM * scaling
|
||||
spacing: Style.marginM * scaling
|
||||
|
||||
// App icon with background
|
||||
// App/clipboard icon with background
|
||||
Rectangle {
|
||||
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
|
||||
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
|
||||
|
|
@ -350,15 +390,15 @@ NPanel {
|
|||
property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|
||||
|| (iconImg.status === Image.Ready && iconImg.source !== ""
|
||||
&& iconImg.status !== Image.Error && iconImg.source !== "")
|
||||
visible: !searchText.startsWith(">calc") // Hide icon when in calculator mode
|
||||
visible: !searchText.startsWith(">calc")
|
||||
|
||||
// Clipboard image display
|
||||
// Clipboard image display (pull from cache)
|
||||
Image {
|
||||
id: clipboardImage
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginXS * scaling
|
||||
visible: modelData.type === 'image'
|
||||
source: modelData.data || ""
|
||||
source: modelData.type === 'image' ? (CliphistService.imageDataById[modelData.id] || "") : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: true
|
||||
|
|
@ -374,7 +414,6 @@ NPanel {
|
|||
&& modelData.type !== 'image'
|
||||
}
|
||||
|
||||
// Fallback icon container
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginXS * scaling
|
||||
|
|
@ -386,19 +425,14 @@ NPanel {
|
|||
|
||||
NText {
|
||||
anchors.centerIn: parent
|
||||
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard
|
||||
|| modelData.isCommand)
|
||||
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|
||||
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
|
||||
font.pointSize: Style.fontSizeXXL * scaling
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mPrimary
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
Behavior on color { ColorAnimation { duration: Style.animationFast } }
|
||||
}
|
||||
|
||||
// App info
|
||||
|
|
@ -437,7 +471,6 @@ NPanel {
|
|||
activateSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,108 +6,87 @@ import qs.Services
|
|||
QtObject {
|
||||
id: clipboardHistory
|
||||
|
||||
// Copy helpers for different content types
|
||||
function copyImageBase64(mime, base64) {
|
||||
Quickshell.execDetached(["sh", "-lc", `printf %s ${base64} | base64 -d | wl-copy -t '${mime}'`])
|
||||
function parseImageMeta(preview) {
|
||||
const re = /\[\[\s*binary data\s+([\d\.]+\s*(?:KiB|MiB|GiB|B))\s+(\w+)\s+(\d+)x(\d+)\s*\]\]/i
|
||||
const m = (preview || "").match(re)
|
||||
if (!m) return null
|
||||
return { size: m[1], fmt: (m[2] || "").toUpperCase(), w: Number(m[3]), h: Number(m[4]) }
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
// Use printf with proper quoting to handle special characters
|
||||
Quickshell.execDetached(["sh", "-c", `printf '%s' ${JSON.stringify(text)} | wl-copy -t text/plain`])
|
||||
function formatTextPreview(preview) {
|
||||
const normalized = (preview || "").replace(/\s+/g, ' ').trim()
|
||||
const lines = normalized.split(/\n+/)
|
||||
const title = (lines[0] || "Text").slice(0, 60)
|
||||
let subtitle = ""
|
||||
if (lines.length > 1) {
|
||||
subtitle = lines[1].slice(0, 80)
|
||||
} else {
|
||||
subtitle = `${normalized.length} chars`
|
||||
}
|
||||
return { title, subtitle }
|
||||
}
|
||||
|
||||
// Create clipboard entry for display
|
||||
function createClipboardEntry(clip, index) {
|
||||
if (clip.type === 'image') {
|
||||
function createClipboardEntry(item) {
|
||||
if (item.isImage) {
|
||||
const meta = parseImageMeta(item.preview)
|
||||
const title = meta ? `Image ${meta.w}×${meta.h}` : "Image"
|
||||
const subtitle = meta ? `${meta.size} · ${meta.fmt}` : (item.preview || "")
|
||||
return {
|
||||
"isClipboard": true,
|
||||
"name": "Image from " + new Date(clip.timestamp).toLocaleTimeString(),
|
||||
"content": "Image: " + clip.mimeType,
|
||||
"name": title,
|
||||
"content": subtitle,
|
||||
"icon": "image",
|
||||
"type": 'image',
|
||||
"data": clip.data,
|
||||
"timestamp": clip.timestamp,
|
||||
"index": index,
|
||||
"execute": function () {
|
||||
const dataParts = clip.data.split(',')
|
||||
const base64Data = dataParts.length > 1 ? dataParts[1] : clip.data
|
||||
copyImageBase64(clip.mimeType, base64Data)
|
||||
Quickshell.execDetached(["notify-send", "Clipboard", "Image copied: " + clip.mimeType])
|
||||
}
|
||||
"id": item.id,
|
||||
"mime": item.mime
|
||||
}
|
||||
} else {
|
||||
// Handle text content
|
||||
const textContent = clip.content || clip
|
||||
let displayContent = textContent
|
||||
let previewContent = ""
|
||||
|
||||
// Normalize whitespace for display
|
||||
displayContent = displayContent.replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Create preview for long content
|
||||
if (displayContent.length > 50) {
|
||||
previewContent = displayContent
|
||||
displayContent = displayContent.split('\n')[0].substring(0, 50) + "..."
|
||||
}
|
||||
|
||||
const parts = formatTextPreview(item.preview)
|
||||
return {
|
||||
"isClipboard": true,
|
||||
"name": displayContent,
|
||||
"content": previewContent || textContent,
|
||||
"name": parts.title,
|
||||
"content": parts.subtitle,
|
||||
"icon": "content_paste",
|
||||
"type": 'text',
|
||||
"timestamp": clip.timestamp,
|
||||
"index": index,
|
||||
"textData": textContent,
|
||||
"execute"// Store the text data for the execute function
|
||||
: function () {
|
||||
const text = this.textData || clip.content || clip
|
||||
Quickshell.clipboardText = String(text)
|
||||
copyText(String(text))
|
||||
var preview = (text.length > 50) ? text.slice(0, 50) + "…" : text
|
||||
Quickshell.execDetached(["notify-send", "Clipboard", "Text copied: " + preview])
|
||||
}
|
||||
"id": item.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create empty state entry
|
||||
function createEmptyEntry() {
|
||||
return {
|
||||
"isClipboard": true,
|
||||
"name": "No clipboard history",
|
||||
"content": "No matching clipboard entries found",
|
||||
"icon": "content_paste_off",
|
||||
"execute": function () {// Do nothing for empty state
|
||||
}
|
||||
"execute": function () {}
|
||||
}
|
||||
}
|
||||
|
||||
// Process clipboard queries
|
||||
function processQuery(query) {
|
||||
function processQuery(query, items) {
|
||||
const results = []
|
||||
|
||||
if (!query.startsWith(">clip")) {
|
||||
return results
|
||||
}
|
||||
|
||||
// Extract search term after ">clip "
|
||||
const searchTerm = query.slice(5).trim()
|
||||
const searchTerm = query.slice(5).trim().toLowerCase()
|
||||
|
||||
// Note: Clipboard refresh should be handled externally to avoid binding loops
|
||||
// Dependency hook without side effects
|
||||
const _rev = CliphistService.revision
|
||||
const source = items || CliphistService.items
|
||||
|
||||
// Process each clipboard item
|
||||
ClipboardService.history.forEach(function (clip, index) {
|
||||
let searchContent = clip.type === 'image' ? clip.mimeType : clip.content || clip
|
||||
|
||||
// Apply search filter if provided
|
||||
if (!searchTerm || searchContent.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
const entry = createClipboardEntry(clip, index)
|
||||
source.forEach(function (item) {
|
||||
const hay = (item.preview || "").toLowerCase()
|
||||
if (!searchTerm || hay.indexOf(searchTerm) !== -1) {
|
||||
const entry = createClipboardEntry(item)
|
||||
// Attach execute at this level to avoid duplicating functions
|
||||
entry.execute = function () {
|
||||
CliphistService.copyToClipboard(item.id)
|
||||
}
|
||||
results.push(entry)
|
||||
}
|
||||
})
|
||||
|
||||
// Show empty state if no results
|
||||
if (results.length === 0) {
|
||||
results.push(createEmptyEntry())
|
||||
}
|
||||
|
|
@ -115,43 +94,11 @@ QtObject {
|
|||
return results
|
||||
}
|
||||
|
||||
// Create command entry for clipboard mode (deprecated - use direct creation in parent)
|
||||
function createCommandEntry() {
|
||||
return {
|
||||
"isCommand": true,
|
||||
"name": ">clip",
|
||||
"content": "Clipboard history - browse and restore clipboard items",
|
||||
"icon": "content_paste",
|
||||
"execute": function () {// This should be handled by the parent component
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to refresh clipboard
|
||||
function refresh() {
|
||||
ClipboardService.refresh()
|
||||
CliphistService.list(100)
|
||||
}
|
||||
|
||||
// Get clipboard history count
|
||||
function getHistoryCount() {
|
||||
return ClipboardService.history ? ClipboardService.history.length : 0
|
||||
}
|
||||
|
||||
// Get formatted timestamp for display
|
||||
function formatTimestamp(timestamp) {
|
||||
return new Date(timestamp).toLocaleTimeString()
|
||||
}
|
||||
|
||||
// Get clipboard entry by index
|
||||
function getEntryByIndex(index) {
|
||||
if (ClipboardService.history && index >= 0 && index < ClipboardService.history.length) {
|
||||
return ClipboardService.history[index]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Clear all clipboard history
|
||||
function clearAll() {
|
||||
ClipboardService.clearHistory()
|
||||
CliphistService.wipeAll()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -68,6 +68,7 @@ A sleek, minimal, and thoughtfully crafted desktop shell for Wayland using **Qui
|
|||
|
||||
### Optional
|
||||
|
||||
- `cliphist` - For clipboard history support
|
||||
- `swww` - Wallpaper animations and effects
|
||||
- `matugen` - Material You color scheme generation
|
||||
- `cava` - Audio visualizer component
|
||||
|
|
@ -85,8 +86,8 @@ A sleek, minimal, and thoughtfully crafted desktop shell for Wayland using **Qui
|
|||
# Install Quickshell
|
||||
yay -S quickshell-git
|
||||
|
||||
# Download and install Noctalia
|
||||
mkdir -p ~/.config/quickshell && curl -sL https://github.com/noctalia-dev/noctalia-shell/archive/refs/heads/main.tar.gz | tar -xz --strip-components=1 -C ~/.config/quickshell
|
||||
# Download and install Noctalia (latest release)
|
||||
mkdir -p ~/.config/quickshell && curl -sL https://github.com/noctalia-dev/noctalia-shell/releases/latest/download/noctalia-latest.tar.gz | tar -xz --strip-components=1 -C ~/.config/quickshell
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
|
@ -215,11 +216,10 @@ Noctalia/
|
|||
|
||||
### Contributing
|
||||
|
||||
1. All Pull requests should be based on the "dev" branch
|
||||
2. Follow the existing code style and patterns
|
||||
3. Use the modular architecture for new features
|
||||
4. Implement proper error handling and logging
|
||||
5. Test with both Hyprland and Niri compositors (if applicable)
|
||||
1. Follow the existing code style and patterns
|
||||
2. Use the modular architecture for new features
|
||||
3. Implement proper error handling and logging
|
||||
4. Test with both Hyprland and Niri compositors (if applicable)
|
||||
|
||||
Contributions are welcome! Don't worry about being perfect - every contribution helps! Whether it's fixing a small bug, adding a new feature, or improving documentation, we welcome all contributions. Feel free to open an issue to discuss ideas or ask questions before diving in. For feature requests and ideas, you can also use our discussions page.
|
||||
|
||||
|
|
|
|||
201
Services/CliphistService.qml
Normal file
201
Services/CliphistService.qml
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
|
||||
// Thin wrapper around the cliphist CLI
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Public API
|
||||
property var items: [] // [{id, preview, mime, isImage}]
|
||||
property bool loading: false
|
||||
|
||||
// Optional automatic watchers to feed cliphist DB
|
||||
property bool autoWatch: true
|
||||
property bool watchersStarted: false
|
||||
|
||||
// Expose decoded thumbnails by id and a revision to notify bindings
|
||||
property var imageDataById: ({})
|
||||
property int revision: 0
|
||||
|
||||
// Internal: store callback for decode
|
||||
property var _decodeCallback: null
|
||||
|
||||
// Queue for base64 decodes
|
||||
property var _b64Queue: []
|
||||
property var _b64CurrentCb: null
|
||||
property string _b64CurrentMime: ""
|
||||
property string _b64CurrentId: ""
|
||||
|
||||
// Start watchers when the singleton loads
|
||||
Component.onCompleted: startWatchers()
|
||||
|
||||
// Fallback: periodically refresh list so UI updates even if not in clip mode
|
||||
Timer {
|
||||
interval: 5000
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: list()
|
||||
}
|
||||
|
||||
// Internal process objects
|
||||
Process {
|
||||
id: listProc
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
const out = String(stdout.text)
|
||||
const lines = out.split('\n').filter(l => l.length > 0)
|
||||
// cliphist list default format: "<id> <preview>" or "<id>\t<preview>"
|
||||
const parsed = lines.map(l => {
|
||||
let id = ""
|
||||
let preview = ""
|
||||
const m = l.match(/^(\d+)\s+(.+)$/)
|
||||
if (m) {
|
||||
id = m[1]
|
||||
preview = m[2]
|
||||
} else {
|
||||
const tab = l.indexOf('\t')
|
||||
id = tab > -1 ? l.slice(0, tab) : l
|
||||
preview = tab > -1 ? l.slice(tab + 1) : ""
|
||||
}
|
||||
const lower = preview.toLowerCase()
|
||||
const isImage = lower.startsWith("[image]") || lower.includes(" binary data ")
|
||||
// Best-effort mime guess from preview
|
||||
var mime = "text/plain"
|
||||
if (isImage) {
|
||||
if (lower.includes(" png")) mime = "image/png"
|
||||
else if (lower.includes(" jpg") || lower.includes(" jpeg")) mime = "image/jpeg"
|
||||
else if (lower.includes(" webp")) mime = "image/webp"
|
||||
else if (lower.includes(" gif")) mime = "image/gif"
|
||||
else mime = "image/*"
|
||||
}
|
||||
return { id, preview, isImage, mime }
|
||||
})
|
||||
items = parsed
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: decodeProc
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
const out = String(stdout.text)
|
||||
if (root._decodeCallback) {
|
||||
try { root._decodeCallback(out) } finally { root._decodeCallback = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: copyProc
|
||||
stdout: StdioCollector {}
|
||||
}
|
||||
|
||||
// Base64 decode pipeline (queued)
|
||||
Process {
|
||||
id: decodeB64Proc
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
const b64 = String(stdout.text).trim()
|
||||
if (root._b64CurrentCb) {
|
||||
const url = `data:${root._b64CurrentMime};base64,${b64}`
|
||||
try { root._b64CurrentCb(url) } finally { /* noop */ }
|
||||
}
|
||||
if (root._b64CurrentId !== "") {
|
||||
root.imageDataById[root._b64CurrentId] = `data:${root._b64CurrentMime};base64,${b64}`
|
||||
root.revision += 1
|
||||
}
|
||||
root._b64CurrentCb = null
|
||||
root._b64CurrentMime = ""
|
||||
root._b64CurrentId = ""
|
||||
Qt.callLater(root._startNextB64)
|
||||
}
|
||||
}
|
||||
|
||||
// Long-running watchers to store new clipboard contents
|
||||
Process {
|
||||
id: watchText
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// Auto-restart if watcher dies
|
||||
if (root.autoWatch) Qt.callLater(() => { running = true })
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: watchImage
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (root.autoWatch) Qt.callLater(() => { running = true })
|
||||
}
|
||||
}
|
||||
|
||||
function startWatchers() {
|
||||
if (!autoWatch || watchersStarted) return
|
||||
watchersStarted = true
|
||||
// Start text watcher
|
||||
watchText.command = ["wl-paste", "--type", "text", "--watch", "cliphist", "store"]
|
||||
watchText.running = true
|
||||
// Start image watcher
|
||||
watchImage.command = ["wl-paste", "--type", "image", "--watch", "cliphist", "store"]
|
||||
watchImage.running = true
|
||||
}
|
||||
|
||||
function list(maxPreviewWidth) {
|
||||
if (listProc.running) return
|
||||
loading = true
|
||||
const width = maxPreviewWidth || 100
|
||||
listProc.command = ["cliphist", "list", "-preview-width", String(width)]
|
||||
listProc.running = true
|
||||
}
|
||||
|
||||
function decode(id, cb) {
|
||||
root._decodeCallback = cb
|
||||
decodeProc.command = ["cliphist", "decode", id]
|
||||
decodeProc.running = true
|
||||
}
|
||||
|
||||
function decodeToDataUrl(id, mime, cb) {
|
||||
// If cached, return immediately
|
||||
if (root.imageDataById[id]) {
|
||||
if (cb) cb(root.imageDataById[id])
|
||||
return
|
||||
}
|
||||
// Queue request; ensures single process handles sequentially
|
||||
root._b64Queue.push({ id, mime: mime || "image/*", cb })
|
||||
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
|
||||
_startNextB64()
|
||||
}
|
||||
}
|
||||
|
||||
function _startNextB64() {
|
||||
if (root._b64Queue.length === 0) return
|
||||
const job = root._b64Queue.shift()
|
||||
root._b64CurrentCb = job.cb
|
||||
root._b64CurrentMime = job.mime
|
||||
root._b64CurrentId = job.id
|
||||
decodeB64Proc.command = ["sh", "-lc", `cliphist decode ${job.id} | base64 -w 0`]
|
||||
decodeB64Proc.running = true
|
||||
}
|
||||
|
||||
function copyToClipboard(id) {
|
||||
// decode and pipe to wl-copy; implement via shell to preserve binary
|
||||
copyProc.command = ["sh", "-lc", `cliphist decode ${id} | wl-copy`]
|
||||
copyProc.running = true
|
||||
}
|
||||
|
||||
function deleteById(id) {
|
||||
Quickshell.execDetached(["cliphist", "delete", id])
|
||||
Qt.callLater(() => list())
|
||||
}
|
||||
|
||||
function wipeAll() {
|
||||
Quickshell.execDetached(["cliphist", "wipe"])
|
||||
Qt.callLater(() => list())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue