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)
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
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