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