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

View file

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

View file

@ -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.

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