Use Cliphist, fix AppLauncher navigation

This commit is contained in:
Ly-sec 2025-08-21 12:32:08 +02:00
parent 257220e20b
commit 02e246ab8d
4 changed files with 325 additions and 144 deletions

View file

@ -18,6 +18,12 @@ NPanel {
panelHeight: Math.min(550 * scaling, screen?.height * 0.8) panelHeight: Math.min(550 * scaling, screen?.height * 0.8)
panelAnchorCentered: true panelAnchorCentered: true
onOpened: {
// Reset state when panel opens to avoid sticky modes
searchText = ""
selectedIndex = 0
}
// Import modular components // Import modular components
Calculator { Calculator {
id: calculator id: calculator
@ -27,6 +33,15 @@ NPanel {
id: clipboardHistory 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 // Properties
property var desktopEntries: DesktopEntries.applications.values property var desktopEntries: DesktopEntries.applications.values
property string searchText: "" property string searchText: ""
@ -41,9 +56,16 @@ NPanel {
// Main filtering logic // Main filtering logic
property var filteredEntries: { 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) { if (!desktopEntries || desktopEntries.length === 0) {
Logger.log("AppLauncher", "No desktop entries available")
return [] return []
} }
@ -55,9 +77,6 @@ NPanel {
return true return true
}) })
Logger.log("AppLauncher", "Visible entries:", visibleEntries.length)
var query = searchText ? searchText.toLowerCase() : ""
var results = [] var results = []
// Handle special commands // Handle special commands
@ -81,11 +100,6 @@ NPanel {
return results return results
} }
// Handle clipboard history
if (query.startsWith(">clip")) {
return clipboardHistory.processQuery(query)
}
// Handle calculator // Handle calculator
if (query.startsWith(">calc")) { if (query.startsWith(">calc")) {
return calculator.processQuery(query, "calc") return calculator.processQuery(query, "calc")
@ -114,18 +128,19 @@ NPanel {
})) }))
} }
Logger.log("AppLauncher", "Filtered entries:", results.length)
return results return results
} }
// Command execution functions // Command execution functions
function executeCalcCommand() { function executeCalcCommand() {
searchText = ">calc " searchText = ">calc "
searchInput.text = searchText
searchInput.cursorPosition = searchText.length searchInput.cursorPosition = searchText.length
} }
function executeClipCommand() { function executeClipCommand() {
searchText = ">clip " searchText = ">clip "
searchInput.text = searchText
searchInput.cursorPosition = searchText.length searchInput.cursorPosition = searchText.length
} }
@ -143,12 +158,18 @@ NPanel {
} }
function selectNextPage() { function selectNextPage() {
if (filteredEntries.length > 0) if (filteredEntries.length > 0) {
selectedIndex = Math.min(selectedIndex + 10, filteredEntries.length - 1) 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() { function selectPrevPage() {
if (filteredEntries.length > 0) if (filteredEntries.length > 0) {
selectedIndex = Math.max(selectedIndex - 10, 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() { function activateSelected() {
@ -259,19 +280,33 @@ NPanel {
Keys.onEscapePressed: root.close() Keys.onEscapePressed: root.close()
Keys.onPressed: event => { Keys.onPressed: event => {
if (event.key === Qt.Key_PageDown) { if (event.key === Qt.Key_PageDown) {
appsList.cancelFlick()
root.selectNextPage() root.selectNextPage()
event.accepted = true event.accepted = true
} else if (event.key === Qt.Key_PageUp) { } else if (event.key === Qt.Key_PageUp) {
appsList.cancelFlick()
root.selectPrevPage() root.selectPrevPage()
event.accepted = true 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) { if (event.modifiers & Qt.ControlModifier) {
switch (event.key) { switch (event.key) {
case Qt.Key_J: case Qt.Key_J:
appsList.cancelFlick()
root.selectNext() root.selectNext()
event.accepted = true event.accepted = true
break break
case Qt.Key_K: case Qt.Key_K:
appsList.cancelFlick()
root.selectPrev() root.selectPrev()
event.accepted = true event.accepted = true
break break
@ -295,21 +330,26 @@ NPanel {
} }
// Applications list // Applications list
ScrollView { ListView {
id: appsList
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
clip: true clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff spacing: Style.marginXXS * scaling
ScrollBar.vertical.policy: ScrollBar.AsNeeded 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 { delegate: Rectangle {
id: appsList
anchors.fill: parent
spacing: Style.marginXXS * scaling
model: filteredEntries
currentIndex: selectedIndex
delegate: Rectangle {
width: appsList.width - Style.marginS * scaling width: appsList.width - Style.marginS * scaling
height: 65 * scaling height: 65 * scaling
radius: Style.radiusM * scaling radius: Style.radiusM * scaling
@ -341,7 +381,7 @@ NPanel {
anchors.margins: Style.marginM * scaling anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling spacing: Style.marginM * scaling
// App icon with background // App/clipboard icon with background
Rectangle { Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
Layout.preferredHeight: 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) property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|| (iconImg.status === Image.Ready && iconImg.source !== "" || (iconImg.status === Image.Ready && iconImg.source !== ""
&& iconImg.status !== Image.Error && 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 { Image {
id: clipboardImage id: clipboardImage
anchors.fill: parent anchors.fill: parent
anchors.margins: Style.marginXS * scaling anchors.margins: Style.marginXS * scaling
visible: modelData.type === 'image' visible: modelData.type === 'image'
source: modelData.data || "" source: modelData.type === 'image' ? (CliphistService.imageDataById[modelData.id] || "") : ""
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
asynchronous: true asynchronous: true
cache: true cache: true
@ -374,7 +414,6 @@ NPanel {
&& modelData.type !== 'image' && modelData.type !== 'image'
} }
// Fallback icon container
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
anchors.margins: Style.marginXS * scaling anchors.margins: Style.marginXS * scaling
@ -386,19 +425,14 @@ NPanel {
NText { NText {
anchors.centerIn: parent anchors.centerIn: parent
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|| modelData.isCommand)
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?" text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
font.pointSize: Style.fontSizeXXL * scaling font.pointSize: Style.fontSizeXXL * scaling
font.weight: Style.fontWeightBold font.weight: Style.fontWeightBold
color: Color.mPrimary color: Color.mPrimary
} }
Behavior on color { Behavior on color { ColorAnimation { duration: Style.animationFast } }
ColorAnimation {
duration: Style.animationFast
}
}
} }
// App info // App info
@ -437,7 +471,6 @@ NPanel {
activateSelected() activateSelected()
} }
} }
}
} }
} }

View file

@ -6,108 +6,87 @@ import qs.Services
QtObject { QtObject {
id: clipboardHistory id: clipboardHistory
// Copy helpers for different content types function parseImageMeta(preview) {
function copyImageBase64(mime, base64) { const re = /\[\[\s*binary data\s+([\d\.]+\s*(?:KiB|MiB|GiB|B))\s+(\w+)\s+(\d+)x(\d+)\s*\]\]/i
Quickshell.execDetached(["sh", "-lc", `printf %s ${base64} | base64 -d | wl-copy -t '${mime}'`]) 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) { function formatTextPreview(preview) {
// Use printf with proper quoting to handle special characters const normalized = (preview || "").replace(/\s+/g, ' ').trim()
Quickshell.execDetached(["sh", "-c", `printf '%s' ${JSON.stringify(text)} | wl-copy -t text/plain`]) 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(item) {
function createClipboardEntry(clip, index) { if (item.isImage) {
if (clip.type === 'image') { const meta = parseImageMeta(item.preview)
const title = meta ? `Image ${meta.w}×${meta.h}` : "Image"
const subtitle = meta ? `${meta.size} · ${meta.fmt}` : (item.preview || "")
return { return {
"isClipboard": true, "isClipboard": true,
"name": "Image from " + new Date(clip.timestamp).toLocaleTimeString(), "name": title,
"content": "Image: " + clip.mimeType, "content": subtitle,
"icon": "image", "icon": "image",
"type": 'image', "type": 'image',
"data": clip.data, "id": item.id,
"timestamp": clip.timestamp, "mime": item.mime
"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])
}
} }
} else { } else {
// Handle text content const parts = formatTextPreview(item.preview)
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) + "..."
}
return { return {
"isClipboard": true, "isClipboard": true,
"name": displayContent, "name": parts.title,
"content": previewContent || textContent, "content": parts.subtitle,
"icon": "content_paste", "icon": "content_paste",
"type": 'text', "type": 'text',
"timestamp": clip.timestamp, "id": item.id
"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])
}
} }
} }
} }
// Create empty state entry
function createEmptyEntry() { function createEmptyEntry() {
return { return {
"isClipboard": true, "isClipboard": true,
"name": "No clipboard history", "name": "No clipboard history",
"content": "No matching clipboard entries found", "content": "No matching clipboard entries found",
"icon": "content_paste_off", "icon": "content_paste_off",
"execute": function () {// Do nothing for empty state "execute": function () {}
}
} }
} }
// Process clipboard queries function processQuery(query, items) {
function processQuery(query) {
const results = [] const results = []
if (!query.startsWith(">clip")) { if (!query.startsWith(">clip")) {
return results return results
} }
// Extract search term after ">clip " const searchTerm = query.slice(5).trim().toLowerCase()
const searchTerm = query.slice(5).trim()
// 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 source.forEach(function (item) {
ClipboardService.history.forEach(function (clip, index) { const hay = (item.preview || "").toLowerCase()
let searchContent = clip.type === 'image' ? clip.mimeType : clip.content || clip if (!searchTerm || hay.indexOf(searchTerm) !== -1) {
const entry = createClipboardEntry(item)
// Apply search filter if provided // Attach execute at this level to avoid duplicating functions
if (!searchTerm || searchContent.toLowerCase().includes(searchTerm.toLowerCase())) { entry.execute = function () {
const entry = createClipboardEntry(clip, index) CliphistService.copyToClipboard(item.id)
}
results.push(entry) results.push(entry)
} }
}) })
// Show empty state if no results
if (results.length === 0) { if (results.length === 0) {
results.push(createEmptyEntry()) results.push(createEmptyEntry())
} }
@ -115,43 +94,11 @@ QtObject {
return results 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() { 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() { function clearAll() {
ClipboardService.clearHistory() CliphistService.wipeAll()
} }
} }

View file

@ -68,6 +68,7 @@ A sleek, minimal, and thoughtfully crafted desktop shell for Wayland using **Qui
### Optional ### Optional
- `cliphist` - For clipboard history support
- `swww` - Wallpaper animations and effects - `swww` - Wallpaper animations and effects
- `matugen` - Material You color scheme generation - `matugen` - Material You color scheme generation
- `cava` - Audio visualizer component - `cava` - Audio visualizer component
@ -85,8 +86,8 @@ A sleek, minimal, and thoughtfully crafted desktop shell for Wayland using **Qui
# Install Quickshell # Install Quickshell
yay -S quickshell-git yay -S quickshell-git
# Download and install Noctalia # Download and install Noctalia (latest release)
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 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 ### Usage
@ -215,11 +216,10 @@ Noctalia/
### Contributing ### Contributing
1. All Pull requests should be based on the "dev" branch 1. Follow the existing code style and patterns
2. Follow the existing code style and patterns 2. Use the modular architecture for new features
3. Use the modular architecture for new features 3. Implement proper error handling and logging
4. Implement proper error handling and logging 4. Test with both Hyprland and Niri compositors (if applicable)
5. 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. 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.

View 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())
}
}