Merge branch 'launcher-evolved'

This commit is contained in:
LemmyCook 2025-09-03 11:23:15 -04:00
commit 1e81a89a1a
10 changed files with 1051 additions and 1159 deletions

View file

@ -1,151 +0,0 @@
import QtQuick
import Quickshell
import qs.Commons
import "../../Helpers/AdvancedMath.js" as AdvancedMath
QtObject {
id: calculator
// Function to evaluate mathematical expressions
function evaluate(expression) {
if (!expression || expression.trim() === "") {
return {
"isValid": false,
"result": "",
"displayResult": "",
"error": "Empty expression"
}
}
try {
// Try advanced math first
if (typeof AdvancedMath !== 'undefined') {
const result = AdvancedMath.evaluate(expression.trim())
const displayResult = AdvancedMath.formatResult(result)
return {
"isValid": true,
"result": result,
"displayResult": displayResult,
"expression": expression,
"error": ""
}
} else {
// Fallback to basic evaluation
Logger.warn("Calculator", "AdvancedMath not available, using basic eval")
// Basic preprocessing for common functions
var processed = expression.trim(
).replace(/\bpi\b/gi,
Math.PI).replace(/\be\b/gi,
Math.E).replace(/\bsqrt\s*\(/g,
'Math.sqrt(').replace(/\bsin\s*\(/g,
'Math.sin(').replace(/\bcos\s*\(/g,
'Math.cos(').replace(/\btan\s*\(/g, 'Math.tan(').replace(/\blog\s*\(/g, 'Math.log10(').replace(/\bln\s*\(/g, 'Math.log(').replace(/\bexp\s*\(/g, 'Math.exp(').replace(/\bpow\s*\(/g, 'Math.pow(').replace(/\babs\s*\(/g, 'Math.abs(')
// Sanitize and evaluate
if (!/^[0-9+\-*/().\s\w,]+$/.test(processed)) {
throw new Error("Invalid characters in expression")
}
const result = eval(processed)
if (!isFinite(result) || isNaN(result)) {
throw new Error("Invalid result")
}
const displayResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/, '')
return {
"isValid": true,
"result": result,
"displayResult": displayResult,
"expression": expression,
"error": ""
}
}
} catch (error) {
return {
"isValid": false,
"result": "",
"displayResult": "",
"error": error.message || error.toString()
}
}
}
// Generate calculator entry for display
function createEntry(expression, searchContext = "") {
const evaluation = evaluate(expression)
if (!evaluation.isValid) {
return {
"isCalculator": true,
"name": "Invalid expression",
"content": evaluation.error,
"icon": "error",
"execute": function () {// Do nothing for invalid expressions
}
}
}
const displayName = searchContext
=== "calc" ? `${expression} = ${evaluation.displayResult}` : `${expression} = ${evaluation.displayResult}`
return {
"isCalculator": true,
"name": displayName,
"result": evaluation.result,
"expr": expression,
"displayResult": evaluation.displayResult,
"icon": "calculate",
"execute": function () {
Quickshell.clipboardText = evaluation.displayResult
// Also copy using shell command for better compatibility
Quickshell.execDetached(
["sh", "-lc", `printf %s ${evaluation.displayResult} | wl-copy -t text/plain;charset=utf-8`])
Quickshell.execDetached(
["notify-send", "Calculator", `${expression} = ${evaluation.displayResult} (copied to clipboard)`])
}
}
}
// Create placeholder entry for empty calculator mode
function createPlaceholderEntry() {
return {
"isCalculator": true,
"name": "Calculator",
"content": "Try: sqrt(16), sin(1), cos(0), pi*2, exp(1), pow(2,8), abs(-5)",
"icon": "calculate",
"execute": function () {// Do nothing for placeholder
}
}
}
// Process calculator queries
function processQuery(query, searchContext = "") {
const results = []
if (searchContext === "calc") {
// Handle ">calc" mode
const expr = query.slice(5).trim()
if (expr && expr !== "") {
results.push(createEntry(expr, "calc"))
} else {
results.push(createPlaceholderEntry())
}
} else if (query.startsWith(">") && query.length > 1 && !query.startsWith(">clip") && !query.startsWith(">calc")) {
// Handle direct math expressions after ">"
const mathExpr = query.slice(1).trim()
const evaluation = evaluate(mathExpr)
if (evaluation.isValid) {
results.push(createEntry(mathExpr, "direct"))
}
// If invalid, don't add anything - let it fall through to regular search
}
return results
}
}

View file

@ -1,108 +0,0 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services
QtObject {
id: clipboardHistory
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 formatTextPreview(preview) {
const normalized = (preview || "").replace(/\s+/g, ' ').trim()
const lines = normalized.split(/\n+/)
const title = (lines[0] || "Text").slice(0, 60)
const subtitle = (lines.length > 1) ? lines[1].slice(0, 80) : ""
return {
"title": title,
"subtitle": subtitle
}
}
function createClipboardEntry(item) {
if (item.isImage) {
const meta = parseImageMeta(item.preview)
const title = meta ? `Image ${meta.w}×${meta.h}` : "Image"
const subtitle = ""
return {
"isClipboard": true,
"name": title,
"content": subtitle,
"icon": "image",
"type": 'image',
"id": item.id,
"mime": item.mime
}
} else {
const parts = formatTextPreview(item.preview)
return {
"isClipboard": true,
"name": parts.title,
"content": "",
"icon": "content_paste",
"type": 'text',
"id": item.id
}
}
}
function createEmptyEntry() {
return {
"isClipboard": true,
"name": "No clipboard history",
"content": "No matching clipboard entries found",
"icon": "content_paste_off",
"execute": function () {}
}
}
function processQuery(query, items) {
const results = []
if (!query.startsWith(">clip")) {
return results
}
const searchTerm = query.slice(5).trim().toLowerCase()
// Dependency hook without side effects
const _rev = CliphistService.revision
const source = items || CliphistService.items
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)
}
})
if (results.length === 0) {
results.push(createEmptyEntry())
}
return results
}
function refresh() {
CliphistService.list(100)
}
function clearAll() {
CliphistService.wipeAll()
}
}

View file

@ -1,415 +1,284 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
import "../../Helpers/FuzzySort.js" as Fuzzysort
NPanel {
id: root
panelWidth: Math.min(700 * scaling, screen?.width * 0.75)
panelHeight: Math.min(550 * scaling, screen?.height * 0.8)
// Positioning derives from Settings.data.bar.position for vertical (top/bottom)
// and from Settings.data.appLauncher.position for horizontal vs center.
// Options: center, top_left, top_right, bottom_left, bottom_right, bottom_center, top_center
readonly property string launcherPosition: Settings.data.appLauncher.position
panelAnchorHorizontalCenter: launcherPosition === "center" || (launcherPosition.endsWith("_center"))
panelAnchorVerticalCenter: launcherPosition === "center"
panelAnchorLeft: launcherPosition !== "center" && (launcherPosition.endsWith("_left"))
panelAnchorRight: launcherPosition !== "center" && (launcherPosition.endsWith("_right"))
panelAnchorBottom: launcherPosition.startsWith("bottom_")
panelAnchorTop: launcherPosition.startsWith("top_")
// Panel configuration
panelWidth: {
var w = Math.round(Math.max(screen?.width * 0.3, 500) * scaling)
w = Math.min(w, screen?.width - Style.marginL * 2)
return w
}
panelHeight: {
var h = Math.round(Math.max(screen?.height * 0.5, 600) * scaling)
h = Math.min(h, screen?.height - Style.barHeight * scaling - Style.marginL * 2)
return h
}
// Enable keyboard focus for launcher (needed for search)
panelKeyboardFocus: true
// Background opacity following bar's approach
panelBackgroundColor: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b,
Settings.data.appLauncher.backgroundOpacity)
// Properties
property string searchText: ""
property bool shouldResetCursor: false
// Positioning
readonly property string launcherPosition: Settings.data.appLauncher.position
panelAnchorHorizontalCenter: launcherPosition === "center" || launcherPosition.endsWith("_center")
panelAnchorVerticalCenter: launcherPosition === "center"
panelAnchorLeft: launcherPosition !== "center" && launcherPosition.endsWith("_left")
panelAnchorRight: launcherPosition !== "center" && launcherPosition.endsWith("_right")
panelAnchorBottom: launcherPosition.startsWith("bottom_")
panelAnchorTop: launcherPosition.startsWith("top_")
// Add function to set search text programmatically
// Core state
property string searchText: ""
property int selectedIndex: 0
property var results: []
property var plugins: []
property var activePlugin: null
// Public API for plugins
function setSearchText(text) {
searchText = text
// The searchInput will automatically update via the text binding
// Focus and cursor position will be handled by the TextField's Component.onCompleted
}
onOpened: {
// Reset state when panel opens to avoid sticky modes
if (searchText === "") {
searchText = ""
selectedIndex = 0
// Plugin registration
function registerPlugin(plugin) {
plugins.push(plugin)
plugin.launcher = root
if (plugin.init)
plugin.init()
}
// Search handling
function updateResults() {
results = []
activePlugin = null
// Check for command mode
if (searchText.startsWith(">")) {
// Find plugin that handles this command
for (let plugin of plugins) {
if (plugin.handleCommand && plugin.handleCommand(searchText)) {
activePlugin = plugin
results = plugin.getResults(searchText)
break
}
}
// Show available commands if just ">"
if (searchText === ">" && !activePlugin) {
for (let plugin of plugins) {
if (plugin.commands) {
results = results.concat(plugin.commands())
}
}
}
} else {
// Regular search - let plugins contribute results
for (let plugin of plugins) {
if (plugin.handleSearch) {
const pluginResults = plugin.getResults(searchText)
results = results.concat(pluginResults)
}
}
}
// Focus search input on open and place cursor at end
Qt.callLater(() => {
if (searchInputBox && searchInputBox.inputItem) {
searchInputBox.inputItem.forceActiveFocus()
if (searchText && searchText.length > 0) {
searchInputBox.inputItem.cursorPosition = searchText.length
} else {
searchInputBox.inputItem.cursorPosition = 0
}
}
})
selectedIndex = 0
}
onSearchTextChanged: updateResults()
// Lifecycle
onOpened: {
// Notify plugins
for (let plugin of plugins) {
if (plugin.onOpened)
plugin.onOpened()
}
updateResults()
}
onClosed: {
// Reset search bar when launcher is closed
searchText = ""
selectedIndex = 0
shouldResetCursor = true
}
// Import modular components
Calculator {
id: calculator
}
ClipboardHistory {
id: clipboardHistory
}
// Poll cliphist while in clipboard mode to keep entries fresh
Timer {
id: clipRefreshTimer
interval: 2000
repeat: true
running: Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")
onTriggered: clipboardHistory.refresh()
}
// Properties
property var desktopEntries: DesktopEntries.applications.values
property int selectedIndex: 0
// Refresh clipboard when user starts typing clipboard commands
onSearchTextChanged: {
if (Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")) {
clipboardHistory.refresh()
// Notify plugins
for (let plugin of plugins) {
if (plugin.onClosed)
plugin.onClosed()
}
}
// Main filtering logic
property var filteredEntries: {
// Explicit dependency so changes to items/decoded images retrigger this binding
const _clipItems = Settings.data.appLauncher.enableClipboardHistory ? CliphistService.items : []
const _clipRev = Settings.data.appLauncher.enableClipboardHistory ? CliphistService.revision : 0
var query = searchText ? searchText.toLowerCase() : ""
if (Settings.data.appLauncher.enableClipboardHistory && query.startsWith(">clip")) {
return clipboardHistory.processQuery(query, _clipItems)
}
if (!desktopEntries || desktopEntries.length === 0) {
return []
}
// Filter out entries that shouldn't be displayed
var visibleEntries = desktopEntries.filter(entry => {
if (!entry || entry.noDisplay) {
return false
}
return true
})
var results = []
// Handle special commands
if (query === ">") {
results.push({
"isCommand": true,
"name": ">calc",
"content": "Calculator - evaluate mathematical expressions",
"icon": "calculate",
"execute": executeCalcCommand
})
if (Settings.data.appLauncher.enableClipboardHistory) {
results.push({
"isCommand": true,
"name": ">clip",
"content": "Clipboard history - browse and restore clipboard items",
"icon": "content_paste",
"execute": executeClipCommand
})
}
return results
}
// Handle calculator
if (query.startsWith(">calc")) {
return calculator.processQuery(query, "calc")
}
// Handle direct math expressions after ">"
if (query.startsWith(">") && query.length > 1 && (!Settings.data.appLauncher.enableClipboardHistory
|| !query.startsWith(">clip")) && !query.startsWith(">calc")) {
const mathResults = calculator.processQuery(query, "direct")
if (mathResults.length > 0) {
return mathResults
}
// If math evaluation fails, fall through to regular search
}
// Regular app search
if (!query) {
results = results.concat(visibleEntries.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase())
}))
} else {
var fuzzyResults = Fuzzysort.go(query, visibleEntries, {
"keys": ["name", "comment", "genericName"]
})
results = results.concat(fuzzyResults.map(function (r) {
return r.obj
}))
}
return results
}
// Command execution functions
function executeCalcCommand() {
setSearchText(">calc ")
}
function executeClipCommand() {
setSearchText(">clip ")
}
// Navigation functions
// Navigation
function selectNext() {
if (filteredEntries.length > 0) {
selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1)
if (results.length > 0) {
// Clamp the index to not exceed the last item
selectedIndex = Math.min(selectedIndex + 1, results.length - 1)
}
}
function selectPrev() {
if (filteredEntries.length > 0) {
if (results.length > 0) {
// Clamp the index to not go below the first item (0)
selectedIndex = Math.max(selectedIndex - 1, 0)
}
}
function selectNextPage() {
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) {
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() {
if (filteredEntries.length === 0)
return
var modelData = filteredEntries[selectedIndex]
if (modelData && modelData.execute) {
if (modelData.isCommand) {
modelData.execute()
return
} else {
modelData.execute()
function activate() {
if (results.length > 0 && results[selectedIndex]) {
const item = results[selectedIndex]
if (item.onActivate) {
item.onActivate()
}
root.close()
}
}
// Load plugins
Component.onCompleted: {
Logger.log("Launcher", "Component completed")
Logger.log("Launcher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined')
if (typeof DesktopEntries !== 'undefined') {
Logger.log("Launcher", "DesktopEntries.entries:",
DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined')
// Load applications plugin
const appsPlugin = Qt.createComponent("Plugins/ApplicationsPlugin.qml").createObject(this)
if (appsPlugin) {
registerPlugin(appsPlugin)
Logger.log("Launcher", "Registered: ApplicationsPlugin")
} else {
Logger.error("Launcher", "Failed to load ApplicationsPlugin")
}
// Start clipboard refresh immediately on open if enabled
if (Settings.data.appLauncher.enableClipboardHistory) {
clipboardHistory.refresh()
// Load calculator plugin
const calcPlugin = Qt.createComponent("Plugins/CalculatorPlugin.qml").createObject(this)
if (calcPlugin) {
registerPlugin(calcPlugin)
Logger.log("Launcher", "Registered: CalculatorPlugin")
} else {
Logger.error("Launcher", "Failed to load CalculatorPlugin")
}
// Load clipboard history plugin
const clipboardPlugin = Qt.createComponent("Plugins/ClipboardPlugin.qml").createObject(this)
if (clipboardPlugin) {
registerPlugin(clipboardPlugin)
Logger.log("Launcher", "Registered: clipboardPlugin")
} else {
Logger.error("Launcher", "Failed to load clipboardPlugin")
}
}
// Main content container
// UI
panelContent: Rectangle {
color: Color.transparent
Component.onCompleted: {
searchText = ""
selectedIndex = 0
if (searchInput?.forceActiveFocus) {
searchInput.forceActiveFocus()
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
RowLayout {
FocusScope {
id: searchInputWrap
Layout.fillWidth: true
Layout.preferredHeight: Math.round(Style.barHeight * scaling)
Layout.bottomMargin: Style.marginM * scaling
// Wrapper ensures the input stretches to full width under RowLayout
Item {
id: searchInputWrap
Layout.fillWidth: true
Layout.preferredHeight: Math.round(Style.barHeight * scaling)
// This FocusScope should get focus when panel opens
focus: true
NTextInput {
id: searchInputBox
anchors.fill: parent
placeholderText: "Search applications... (use > to view commands)"
text: searchText
inputMaxWidth: 100000
// Tune vertical centering on inner input
Component.onCompleted: {
searchInputBox.inputItem.font.pointSize = Style.fontSizeL * scaling
searchInputBox.inputItem.verticalAlignment = TextInput.AlignVCenter
// Ensure focus when launcher first appears
Qt.callLater(() => {
searchInputBox.inputItem.forceActiveFocus()
if (searchText && searchText.length > 0) {
searchInputBox.inputItem.cursorPosition = searchText.length
} else {
searchInputBox.inputItem.cursorPosition = 0
}
})
NTextInput {
id: searchInput
anchors.fill: parent
// The input should have focus within the scope
focus: true
placeholderText: "Search entries... or use > for commands"
text: searchText
inputMaxWidth: Number.MAX_SAFE_INTEGER
function forceActiveFocus() {
// First ensure the scope has focus
searchInputWrap.forceActiveFocus()
// Then focus the actual input
if (inputItem && inputItem.visible) {
inputItem.forceActiveFocus()
}
onTextChanged: {
if (searchText !== text) {
searchText = text
}
Qt.callLater(() => selectedIndex = 0)
if (shouldResetCursor && text === "") {
searchInputBox.inputItem.cursorPosition = 0
shouldResetCursor = false
}
}
// Forward key navigation to behave like before
Keys.onDownPressed: selectNext()
Keys.onUpPressed: selectPrev()
Keys.onEnterPressed: activateSelected()
Keys.onReturnPressed: activateSelected()
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
}
}
}
}
}
// Clear-all action to the right of the input
NIconButton {
Layout.alignment: Qt.AlignVCenter
visible: searchText.startsWith(">clip")
icon: "delete_sweep"
tooltipText: "Clear clipboard history"
onClicked: CliphistService.wipeAll()
Component.onCompleted: {
if (inputItem) {
inputItem.font.pointSize = Style.fontSizeL * scaling
inputItem.verticalAlignment = TextInput.AlignVCenter
}
}
onTextChanged: searchText = text
Keys.onDownPressed: root.selectNext()
Keys.onUpPressed: root.selectPrev()
Keys.onReturnPressed: root.activate()
Keys.onEscapePressed: root.close()
}
}
// Applications list
// Results list
ListView {
id: appsList
id: resultsList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: Style.marginXXS * scaling
model: filteredEntries
model: results
currentIndex: selectedIndex
boundsBehavior: Flickable.StopAtBounds
maximumFlickVelocity: 2500
flickDeceleration: 2000
clip: true
cacheBuffer: resultsList.height * 2
onCurrentIndexChanged: {
cancelFlick()
if (currentIndex >= 0) {
positionViewAtIndex(currentIndex, ListView.Contain)
}
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
// Keep viewport anchored to the selected item when the clipboard model refreshes
Connections {
target: CliphistService
function onRevisionChanged() {
if (Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")) {
// Clamp selection in case the list shrank
if (selectedIndex >= filteredEntries.length) {
selectedIndex = Math.max(0, filteredEntries.length - 1)
}
Qt.callLater(() => {
appsList.positionViewAtIndex(selectedIndex, ListView.Contain)
})
delegate: Rectangle {
id: entry
property bool isSelected: mouseArea.containsMouse || (index === selectedIndex)
property int badgeSize: Style.baseWidgetSize * 1.6 * scaling
// Property to reliably track the current item's ID.
// This changes whenever the delegate is recycled for a new item.
property var currentClipboardId: modelData.isImage ? modelData.clipboardId : ""
// When this delegate is assigned a new image item, trigger the decode.
onCurrentClipboardIdChanged: {
// Check if it's a valid ID and if the data isn't already cached.
if (currentClipboardId && !ClipboardService.getImageData(currentClipboardId)) {
ClipboardService.decodeToDataUrl(currentClipboardId, modelData.mime, null)
}
}
}
delegate: Rectangle {
width: appsList.width - Style.marginS * scaling
height: 65 * scaling
width: resultsList.width - Style.marginS * scaling
height: badgeSize + Style.marginM * 2 * scaling
radius: Style.radiusM * scaling
property bool isSelected: index === selectedIndex
color: (appCardArea.containsMouse || isSelected) ? Color.mSecondary : Color.mSurface
color: entry.isSelected ? Color.mTertiary : Color.mSurface
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
@ -418,97 +287,129 @@ NPanel {
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
// App/clipboard icon with background
// Icon badge or Image preview
Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
radius: Style.radiusS * scaling
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurfaceVariant
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")
Layout.preferredWidth: badgeSize
Layout.preferredHeight: badgeSize
radius: Style.radiusM * scaling
color: Color.mSurfaceVariant
clip: true
// Decode image thumbnails on demand
Component.onCompleted: {
if (modelData && modelData.type === 'image' && !CliphistService.imageDataById[modelData.id]) {
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {})
// Image preview for clipboard images
NImageRounded {
id: imagePreview
anchors.fill: parent
visible: modelData.isImage
imageRadius: Style.radiusM * scaling
// This property creates a dependency on the service's revision counter
readonly property int _rev: ClipboardService.revision
// Fetches from the service's cache.
// The dependency on `_rev` ensures this binding is re-evaluated when the cache is updated.
imagePath: {
_rev
return ClipboardService.getImageData(modelData.clipboardId) || ""
}
}
onVisibleChanged: {
if (visible && modelData && modelData.type === 'image'
&& !CliphistService.imageDataById[modelData.id]) {
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {})
// Loading indicator
Rectangle {
anchors.fill: parent
visible: parent.status === Image.Loading
color: Color.mSurfaceVariant
BusyIndicator {
anchors.centerIn: parent
running: true
width: Style.baseWidgetSize * 0.5 * scaling
height: width
}
}
// Error fallback
onStatusChanged: status => {
if (status === Image.Error) {
iconLoader.visible = true
imagePreview.visible = false
}
}
}
// Clipboard image display (pull from cache)
Image {
id: clipboardImage
// Icon fallback
Loader {
id: iconLoader
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
visible: modelData.type === 'image'
source: modelData.type === 'image' ? (CliphistService.imageDataById[modelData.id] || "") : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
}
IconImage {
id: iconImg
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
asynchronous: true
source: modelData.isCalculator ? "" : modelData.isClipboard ? "" : modelData.isCommand ? modelData.icon : Icons.iconFromName(
modelData.icon,
"application-x-executable")
visible: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand || parent.iconLoaded)
&& modelData.type !== 'image'
}
Rectangle {
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
radius: Style.radiusXS * scaling
color: Color.mPrimary
opacity: Style.opacityMedium
visible: !parent.iconLoaded
visible: !modelData.isImage || imagePreview.status === Image.Error
active: visible
sourceComponent: Component {
IconImage {
anchors.fill: parent
source: modelData.icon ? Icons.iconFromName(modelData.icon, "application-x-executable") : ""
visible: modelData.icon && source !== ""
asynchronous: true
}
}
}
// Fallback text if no icon and no image
NText {
anchors.centerIn: parent
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
visible: !imagePreview.visible && !iconLoader.visible
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
color: Color.mOnPrimary
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
// Image type indicator overlay
Rectangle {
visible: modelData.isImage && imagePreview.visible
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2 * scaling
width: formatLabel.width + 6 * scaling
height: formatLabel.height + 2 * scaling
radius: Style.radiusM * scaling
color: Color.mSurfaceVariant
NText {
id: formatLabel
anchors.centerIn: parent
text: {
if (!modelData.isImage)
return ""
const desc = modelData.description || ""
const parts = desc.split(" • ")
return parts[0] || "IMG"
}
font.pointSize: Style.fontSizeXXS * scaling
color: Color.mPrimary
}
}
}
// App info
// Text content
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS * scaling
spacing: 0 * scaling
NText {
text: modelData.name || "Unknown"
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
NText {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : modelData.isClipboard ? modelData.content : modelData.isCommand ? modelData.content : (modelData.genericName || modelData.comment || "")
font.pointSize: Style.fontSizeM * scaling
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
text: modelData.description || ""
font.pointSize: Style.fontSizeS * scaling
color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurfaceVariant
elide: Text.ElideRight
Layout.fillWidth: true
visible: text !== ""
@ -517,41 +418,34 @@ NPanel {
}
MouseArea {
id: appCardArea
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
selectedIndex = index
activateSelected()
root.activate()
}
}
}
}
// No results message
NText {
text: searchText.trim() !== "" ? "No applications found" : "No applications available"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
NDivider {
Layout.fillWidth: true
visible: filteredEntries.length === 0
}
// Results count
// Status
NText {
text: searchText.startsWith(
">clip") ? (Settings.data.appLauncher.enableClipboardHistory ? `${filteredEntries.length} clipboard item${filteredEntries.length !== 1 ? 's' : ''}` : `Clipboard history is disabled`) : searchText.startsWith(
">calc") ? `${filteredEntries.length} result${filteredEntries.length
!== 1 ? 's' : ''}` : `${filteredEntries.length} application${filteredEntries.length
!== 1 ? 's' : ''}`
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: searchText.trim() !== ""
text: {
if (results.length === 0)
return searchText ? "No results" : ""
const prefix = activePlugin?.name ? `${activePlugin.name}: ` : ""
return prefix + `${results.length} result${results.length !== 1 ? 's' : ''}`
}
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignCenter
}
}
}

View file

@ -0,0 +1,96 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
import "../../../Helpers/FuzzySort.js" as Fuzzysort
Item {
property var launcher: null
property string name: "Applications"
property bool handleSearch: true
property var entries: []
function init() {
loadApplications()
}
function onOpened() {
// Refresh apps when launcher opens
loadApplications()
}
function loadApplications() {
if (typeof DesktopEntries === 'undefined') {
Logger.warn("ApplicationsPlugin", "DesktopEntries service not available")
return
}
const allApps = DesktopEntries.applications.values || []
entries = allApps.filter(app => app && !app.noDisplay)
Logger.log("ApplicationsPlugin", `Loaded ${entries.length} applications`)
}
function getResults(query) {
if (!entries || entries.length === 0)
return []
if (!query || query.trim() === "") {
// Return all apps alphabetically
return entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())).slice(
0, 50) // Limit to 50 for performance
.map(app => createResultEntry(app))
}
// Use fuzzy search if available, fallback to simple search
if (typeof Fuzzysort !== 'undefined') {
const fuzzyResults = Fuzzysort.go(query, entries, {
"keys": ["name", "comment", "genericName"],
"threshold": -1000,
"limit": 20
})
return fuzzyResults.map(result => createResultEntry(result.obj))
} else {
// Fallback to simple search
const searchTerm = query.toLowerCase()
return entries.filter(app => {
const name = (app.name || "").toLowerCase()
const comment = (app.comment || "").toLowerCase()
const generic = (app.genericName || "").toLowerCase()
return name.includes(searchTerm) || comment.includes(searchTerm) || generic.includes(
searchTerm)
}).sort((a, b) => {
// Prioritize name matches
const aName = a.name.toLowerCase()
const bName = b.name.toLowerCase()
const aStarts = aName.startsWith(searchTerm)
const bStarts = bName.startsWith(searchTerm)
if (aStarts && !bStarts)
return -1
if (!aStarts && bStarts)
return 1
return aName.localeCompare(bName)
}).slice(0, 20).map(app => createResultEntry(app))
}
}
function createResultEntry(app) {
return {
"name": app.name || "Unknown",
"description": app.genericName || app.comment || "",
"icon": app.icon || "application-x-executable",
"isImage": false,
"onActivate": function () {
Logger.log("ApplicationsPlugin", `Launching: ${app.name}`)
if (app.execute) {
app.execute()
} else if (app.exec) {
// Fallback to manual execution
Process.execute(app.exec)
}
launcher.close()
}
}
}
}

View file

@ -0,0 +1,106 @@
import QtQuick
import qs.Services
import "../../../Helpers/AdvancedMath.js" as AdvancedMath
Item {
property var launcher: null
property string name: "Calculator"
function handleCommand(query) {
// Handle >calc command or direct math expressions after >
return query.startsWith(">calc") || (query.startsWith(">") && query.length > 1 && isMathExpression(
query.substring(1)))
}
function commands() {
return [{
"name": ">calc",
"description": "Calculator - evaluate mathematical expressions",
"icon": "accessories-calculator",
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">calc ")
}
}]
}
function getResults(query) {
let expression = ""
if (query.startsWith(">calc")) {
expression = query.substring(5).trim()
} else if (query.startsWith(">")) {
expression = query.substring(1).trim()
} else {
return []
}
if (!expression) {
return [{
"name": "Calculator",
"description": "Enter a mathematical expression",
"icon": "accessories-calculator",
"isImage": false,
"onActivate": function () {}
}]
}
try {
let result = AdvancedMath.evaluate(expression.trim())
return [{
"name": AdvancedMath.formatResult(result),
"description": `${expression} = ${result}`,
"icon": "accessories-calculator",
"isImage": false,
"onActivate": function () {
// TODO: copy entry to clipboard via ClipHist
launcher.close()
}
}]
} catch (error) {
return [{
"name": "Error",
"description": error.message || "Invalid expression",
"icon": "dialog-error",
"isImage": false,
"onActivate": function () {}
}]
}
}
function evaluateExpression(expr) {
// Sanitize input - only allow safe characters
const sanitized = expr.replace(/[^0-9\+\-\*\/\(\)\.\s\%]/g, '')
if (sanitized !== expr) {
throw new Error("Invalid characters in expression")
}
// Don't allow empty expressions
if (!sanitized.trim()) {
throw new Error("Empty expression")
}
try {
// Use Function constructor for safe evaluation
// This is safer than eval() but still evaluate math
const result = Function('"use strict"; return (' + sanitized + ')')()
// Check for valid result
if (!isFinite(result)) {
throw new Error("Result is not a finite number")
}
// Round to reasonable precision to avoid floating point issues
return Math.round(result * 1000000000) / 1000000000
} catch (e) {
throw new Error("Invalid mathematical expression")
}
}
function isMathExpression(expr) {
// Check if string looks like a math expression
// Allow digits, operators, parentheses, decimal points, and whitespace
return /^[\d\s\+\-\*\/\(\)\.\%]+$/.test(expr)
}
}

View file

@ -0,0 +1,265 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services
Item {
id: root
// Plugin metadata
property string name: "Clipboard History"
property var launcher: null
// Plugin capabilities
property bool handleSearch: false // Don't handle regular search
// Internal state
property bool isWaitingForData: false
property bool gotResults: false
property string lastSearchText: ""
// Listen for clipboard data updates
Connections {
target: ClipboardService
function onListCompleted() {
if (gotResults && (lastSearchText === searchText)) {
// Do not update results after the first fetch.
// This will avoid the list resetting every 2seconds when the service updates.
return
}
// Refresh results if we're waiting for data or if clipboard plugin is active
if (isWaitingForData || (launcher && launcher.searchText.startsWith(">clip"))) {
isWaitingForData = false
gotResults = true
if (launcher) {
launcher.updateResults()
}
}
}
}
// Initialize plugin
function init() {
Logger.log("ClipboardPlugin", "Initialized")
// Pre-load clipboard data if service is active
if (ClipboardService.active) {
ClipboardService.list(100)
}
}
// Called when launcher opens
function onOpened() {
// Refresh clipboard history when launcher opens
if (ClipboardService.active) {
isWaitingForData = true
ClipboardService.list(100)
}
}
// Check if this plugin handles the command
function handleCommand(searchText) {
return searchText.startsWith(">clip")
}
// Return available commands when user types ">"
function commands() {
return [{
"name": ">clip",
"description": "Search clipboard history",
"icon": "text-x-generic",
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">clip ")
}
}, {
"name": ">clip clear",
"description": "Clear all clipboard history",
"icon": "text-x-generic",
"isImage": false,
"onActivate": function () {
ClipboardService.wipeAll()
launcher.close()
}
}]
}
// Get search results
function getResults(searchText) {
if (!searchText.startsWith(">clip")) {
return []
}
lastSearchText = searchText
const results = []
const query = searchText.slice(5).trim()
// Check if clipboard service is not active
if (!ClipboardService.active) {
return [{
"name": "Clipboard History Disabled",
"description": "Enable clipboard history in settings or install cliphist",
"icon": "view-refresh",
"isImage": false,
"onActivate": function () {}
}]
}
// Special command: clear
if (query === "clear") {
return [{
"name": "Clear Clipboard History",
"description": "Remove all items from clipboard history",
"icon": "delete_sweep",
"isImage": false,
"onActivate": function () {
ClipboardService.wipeAll()
launcher.close()
}
}]
}
// Show loading state if data is being loaded
if (ClipboardService.loading || isWaitingForData) {
return [{
"name": "Loading clipboard history...",
"description": "Please wait",
"icon": "view-refresh",
"isImage": false,
"onActivate": function () {}
}]
}
// Get clipboard items
const items = ClipboardService.items || []
// If no items and we haven't tried loading yet, trigger a load
if (items.length === 0 && !ClipboardService.loading) {
isWaitingForData = true
ClipboardService.list(100)
return [{
"name": "Loading clipboard history...",
"description": "Please wait",
"icon": "view-refresh",
"isImage": false,
"onActivate": function () {}
}]
}
// Search clipboard items
const searchTerm = query.toLowerCase()
// Filter and format results
items.forEach(function (item) {
const preview = (item.preview || "").toLowerCase()
// Skip if search term doesn't match
if (searchTerm && preview.indexOf(searchTerm) === -1) {
return
}
// Format the result based on type
let entry
if (item.isImage) {
entry = formatImageEntry(item)
} else {
entry = formatTextEntry(item)
}
// Add activation handler
entry.onActivate = function () {
ClipboardService.copyToClipboard(item.id)
launcher.close()
}
results.push(entry)
})
// Show empty state if no results
if (results.length === 0) {
results.push({
"name": searchTerm ? "No matching clipboard items" : "Clipboard is empty",
"description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here",
"icon": "text-x-generic",
"isImage": false,
"onActivate": function () {// Do nothing
}
})
}
//Logger.log("ClipboardPlugin", `Returning ${results.length} results for query: "${query}"`)
return results
}
// Helper: Format image clipboard entry
function formatImageEntry(item) {
const meta = parseImageMeta(item.preview)
// The launcher's delegate will now be responsible for fetching the image data.
// This function's role is to provide the necessary metadata for that request.
return {
"name": meta ? `Image ${meta.w}×${meta.h}` : "Image",
"description": meta ? `${meta.fmt} ${meta.size}` : item.mime || "Image data",
"icon": "image",
"isImage": true,
"imageWidth": meta ? meta.w : 0,
"imageHeight": meta ? meta.h : 0,
"clipboardId": item.id,
"mime": item.mime
}
}
// Helper: Format text clipboard entry with preview
function formatTextEntry(item) {
const preview = (item.preview || "").trim()
const lines = preview.split('\n').filter(l => l.trim())
// Use first line as title, limit length
let title = lines[0] || "Empty text"
if (title.length > 60) {
title = title.substring(0, 57) + "..."
}
// Use second line or character count as description
let description = ""
if (lines.length > 1) {
description = lines[1]
if (description.length > 80) {
description = description.substring(0, 77) + "..."
}
} else {
const chars = preview.length
const words = preview.split(/\s+/).length
description = `${chars} characters, ${words} word${words !== 1 ? 's' : ''}`
}
return {
"name": title,
"description": description,
"icon": "text-x-generic",
"isImage": false
}
}
// Helper: Parse image metadata from preview string
function parseImageMeta(preview) {
const re = /\[\[\s*binary data\s+([\d\.]+\s*(?:KiB|MiB|GiB|B))\s+(\w+)\s+(\d+)x(\d+)\s*\]\]/i
const match = (preview || "").match(re)
if (!match) {
return null
}
return {
"size": match[1],
"fmt": (match[2] || "").toUpperCase(),
"w": Number(match[3]),
"h": Number(match[4])
}
}
// Public method to get image data for a clipboard item
// This can be called by the launcher when rendering
function getImageForItem(clipboardId) {
return ClipboardService.getImageData ? ClipboardService.getImageData(clipboardId) : null
}
}

View file

@ -4,225 +4,333 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
// Thin wrapper around the cliphist CLI
Singleton {
id: root
property var history: []
property bool initialized: false
property int maxHistory: 50 // Limit clipboard history entries
// Public API
property bool active: Settings.isLoaded && Settings.data.appLauncher.enableClipboardHistory && cliphistAvailable
property bool loading: false
property var items: [] // [{id, preview, mime, isImage}]
// Internal state
property bool _enabled: true
// Check if cliphist is available on the system
property bool cliphistAvailable: false
property bool dependencyChecked: false
// Cached history file path
property string historyFile: Quickshell.env("NOCTALIA_CLIPBOARD_HISTORY_FILE")
|| (Settings.cacheDir + "clipboard.json")
// Optional automatic watchers to feed cliphist DB
property bool autoWatch: true
property bool watchersStarted: false
// Persisted storage for clipboard history
property FileView historyFileView: FileView {
id: historyFileView
objectName: "clipboardHistoryFileView"
path: historyFile
watchChanges: false // We don't need to watch changes for clipboard
onAdapterUpdated: writeAdapter()
Component.onCompleted: reload()
onLoaded: loadFromHistory()
onLoadFailed: function (error) {
// Create file on first use
if (error.toString().includes("No such file") || error === 2) {
writeAdapter()
}
}
// Expose decoded thumbnails by id and a revision to notify bindings
property var imageDataById: ({})
property int revision: 0
JsonAdapter {
id: historyAdapter
property var history: []
property double timestamp: 0
}
// Approximate first-seen timestamps for entries this session (seconds)
property var firstSeenById: ({})
// 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: ""
signal listCompleted
// Check if cliphist is available
Component.onCompleted: {
checkCliphistAvailability()
}
Timer {
interval: 2000
repeat: true
running: root._enabled
onTriggered: root.refresh()
// Check dependency availability
function checkCliphistAvailability() {
if (dependencyChecked)
return
dependencyCheckProcess.command = ["which", "cliphist"]
dependencyCheckProcess.running = true
}
// Detect current clipboard types (text/image)
// Process to check if cliphist is available
Process {
id: typeProcess
property bool isLoading: false
property var currentTypes: []
id: dependencyCheckProcess
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
root.dependencyChecked = true
if (exitCode === 0) {
currentTypes = String(stdout.text).trim().split('\n').filter(t => t)
// Always check for text first
textProcess.command = ["wl-paste", "-n", "--type", "text/plain"]
textProcess.isLoading = true
textProcess.running = true
// Also check for images if available
const imageType = currentTypes.find(t => t.startsWith('image/'))
if (imageType) {
imageProcess.mimeType = imageType
imageProcess.command = ["sh", "-c", `wl-paste -n -t "${imageType}" | base64 -w 0`]
imageProcess.running = true
root.cliphistAvailable = true
// Start watchers if feature is enabled
if (root.active) {
startWatchers()
}
} else {
typeProcess.isLoading = false
}
}
stdout: StdioCollector {}
}
// Read image data
Process {
id: imageProcess
property string mimeType: ""
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
const base64 = stdout.text.trim()
if (base64) {
const entry = {
"type": 'image',
"mimeType": mimeType,
"data": `data:${mimeType};base64,${base64}`,
"timestamp": new Date().getTime()
}
// Check if this exact image already exists
const exists = root.history.find(item => item.type === 'image' && item.data === entry.data)
if (!exists) {
// Normalize existing history and add the new image
const normalizedHistory = root.history.map(item => {
if (typeof item === 'string') {
return {
"type": 'text',
"content": item,
"timestamp": new Date().getTime(
) - 1000 // Make it slightly older
}
}
return item
})
root.history = [entry, ...normalizedHistory].slice(0, maxHistory)
saveHistory()
}
root.cliphistAvailable = false
// Show toast notification if feature is enabled but cliphist is missing
if (Settings.data.appLauncher.enableClipboardHistory) {
ToastService.showWarning(
"Clipboard History Unavailable",
"The 'cliphist' application is not installed. Please install it to use clipboard history features.",
false, 6000)
}
}
// Always mark as initialized when done
if (!textProcess.isLoading) {
root.initialized = true
typeProcess.isLoading = false
}
}
stdout: StdioCollector {}
}
// Read text data
// Start/stop watchers when enabled changes
onActiveChanged: {
if (root.active) {
startWatchers()
} else {
stopWatchers()
loading = false
// Optional: clear items to avoid stale UI
items = []
}
}
// Fallback: periodically refresh list so UI updates even if not in clip mode
Timer {
interval: 5000
repeat: true
running: root.active
onTriggered: list()
}
// Internal process objects
Process {
id: textProcess
property bool isLoading: false
id: listProc
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
textProcess.isLoading = false
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/*"
}
// Record first seen time for new ids (approximate copy time)
if (!root.firstSeenById[id]) {
root.firstSeenById[id] = Time.timestamp
}
return {
"id": id,
"preview": preview,
"isImage": isImage,
"mime": mime
}
})
items = parsed
loading = false
if (exitCode === 0) {
const content = String(stdout.text).trim()
if (content && content.length > 0) {
const entry = {
"type": 'text',
"content": content,
"timestamp": new Date().getTime()
}
// Emit the signal for subscribers
root.listCompleted()
}
}
// Check if this exact text content already exists
const exists = root.history.find(item => {
if (item.type === 'text') {
return item.content === content
}
return item === content
})
if (!exists) {
// Normalize existing history entries
const normalizedHistory = root.history.map(item => {
if (typeof item === 'string') {
return {
"type": 'text',
"content": item,
"timestamp": new Date().getTime(
) - 1000 // Make it slightly older
}
}
return item
})
root.history = [entry, ...normalizedHistory].slice(0, maxHistory)
saveHistory()
}
Process {
id: decodeProc
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
const out = String(stdout.text)
if (root._decodeCallback) {
try {
root._decodeCallback(out)
} finally {
root._decodeCallback = null
}
}
// Mark as initialized and clean up loading states
root.initialized = true
if (!imageProcess.running) {
typeProcess.isLoading = false
}
}
}
Process {
id: copyProc
stdout: StdioCollector {}
}
function refresh() {
if (!typeProcess.isLoading && !textProcess.isLoading && !imageProcess.running) {
typeProcess.isLoading = true
typeProcess.command = ["wl-paste", "-l"]
typeProcess.running = true
// 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)
} catch (e) {
}
}
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)
}
}
function loadFromHistory() {
// Populate in-memory history from cached file
try {
const items = historyAdapter.history || []
root.history = items.slice(0, maxHistory) // Apply limit when loading
Logger.log("Clipboard", "Loaded", root.history.length, "entries from cache")
} catch (e) {
Logger.error("Clipboard", "Failed to load history:", e)
root.history = []
// 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 saveHistory() {
try {
// Ensure we don't exceed the maximum history limit
const limitedHistory = root.history.slice(0, maxHistory)
function startWatchers() {
if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
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
}
historyAdapter.history = limitedHistory
historyAdapter.timestamp = Time.timestamp
function stopWatchers() {
if (!watchersStarted)
return
watchText.running = false
watchImage.running = false
watchersStarted = false
}
// Ensure cache directory exists
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir])
function list(maxPreviewWidth) {
if (!root.active || !root.cliphistAvailable) {
return
}
if (listProc.running)
return
loading = true
const width = maxPreviewWidth || 100
listProc.command = ["cliphist", "list", "-preview-width", String(width)]
listProc.running = true
}
Qt.callLater(function () {
historyFileView.writeAdapter()
})
} catch (e) {
Logger.error("Clipboard", "Failed to save history:", e)
function decode(id, cb) {
if (!root.cliphistAvailable) {
if (cb)
cb("")
return
}
root._decodeCallback = cb
decodeProc.command = ["cliphist", "decode", id]
decodeProc.running = true
}
function decodeToDataUrl(id, mime, cb) {
if (!root.cliphistAvailable) {
if (cb)
cb("")
return
}
// 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": id,
"mime": mime || "image/*",
"cb": cb
})
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
_startNextB64()
}
}
function clearHistory() {
root.history = []
saveHistory()
function getImageData(id) {
if (id === undefined) {
return null
}
return root.imageDataById[id]
}
function _startNextB64() {
if (root._b64Queue.length === 0 || !root.cliphistAvailable)
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) {
if (!root.cliphistAvailable) {
return
}
// 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) {
if (!root.cliphistAvailable) {
return
}
Quickshell.execDetached(["cliphist", "delete", id])
revision++
Qt.callLater(() => list())
}
function wipeAll() {
if (!root.cliphistAvailable) {
return
}
Quickshell.execDetached(["cliphist", "wipe"])
revision++
Qt.callLater(() => list())
}
}

View file

@ -1,322 +0,0 @@
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
// Active only when feature is enabled, settings have finished initial load, and cliphist is available
property bool active: Settings.data.appLauncher.enableClipboardHistory && Settings.isLoaded && cliphistAvailable
// Check if cliphist is available on the system
property bool cliphistAvailable: false
property bool dependencyChecked: 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
// Approximate first-seen timestamps for entries this session (seconds)
property var firstSeenById: ({})
// 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: ""
// Check if cliphist is available
Component.onCompleted: {
checkCliphistAvailability()
}
// Check dependency availability
function checkCliphistAvailability() {
if (dependencyChecked)
return
dependencyCheckProcess.command = ["which", "cliphist"]
dependencyCheckProcess.running = true
}
// Process to check if cliphist is available
Process {
id: dependencyCheckProcess
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
root.dependencyChecked = true
if (exitCode === 0) {
root.cliphistAvailable = true
// Start watchers if feature is enabled
if (root.active) {
startWatchers()
}
} else {
root.cliphistAvailable = false
// Show toast notification if feature is enabled but cliphist is missing
if (Settings.data.appLauncher.enableClipboardHistory) {
ToastService.showWarning(
"Clipboard History Unavailable",
"The 'cliphist' application is not installed. Please install it to use clipboard history features.",
false, 6000)
}
}
}
}
// Start/stop watchers when enabled changes
onActiveChanged: {
if (root.active && root.cliphistAvailable) {
startWatchers()
} else {
stopWatchers()
loading = false
// Optional: clear items to avoid stale UI
items = []
}
}
// Fallback: periodically refresh list so UI updates even if not in clip mode
Timer {
interval: 5000
repeat: true
running: root.active && root.cliphistAvailable
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/*"
}
// Record first seen time for new ids (approximate copy time)
if (!root.firstSeenById[id]) {
root.firstSeenById[id] = Time.timestamp
}
return {
"id": id,
"preview": preview,
"isImage": isImage,
"mime": 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 (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
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 stopWatchers() {
if (!watchersStarted)
return
watchText.running = false
watchImage.running = false
watchersStarted = false
}
function list(maxPreviewWidth) {
if (!root.active || !root.cliphistAvailable) {
return
}
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) {
if (!root.cliphistAvailable) {
if (cb)
cb("")
return
}
root._decodeCallback = cb
decodeProc.command = ["cliphist", "decode", id]
decodeProc.running = true
}
function decodeToDataUrl(id, mime, cb) {
if (!root.cliphistAvailable) {
if (cb)
cb("")
return
}
// 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": id,
"mime": mime || "image/*",
"cb": cb
})
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
_startNextB64()
}
}
function _startNextB64() {
if (root._b64Queue.length === 0 || !root.cliphistAvailable)
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) {
if (!root.cliphistAvailable) {
return
}
// 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) {
if (!root.cliphistAvailable) {
return
}
Quickshell.execDetached(["cliphist", "delete", id])
Qt.callLater(() => list())
}
function wipeAll() {
if (!root.cliphistAvailable) {
return
}
Quickshell.execDetached(["cliphist", "wipe"])
Qt.callLater(() => list())
}
}

View file

@ -7,7 +7,7 @@ import qs.Commons
import qs.Services
import Quickshell.Services.Notifications
QtObject {
Singleton {
id: root
// Notification server instance

View file

@ -16,6 +16,8 @@ Rectangle {
property real scaledRadius: imageRadius * Settings.data.general.radiusRatio
signal statusChanged(int status)
color: Color.transparent
radius: scaledRadius
anchors.margins: Style.marginXXS * scaling
@ -34,6 +36,8 @@ Rectangle {
asynchronous: true
antialiasing: true
fillMode: Image.PreserveAspectCrop
onStatusChanged: root.statusChanged(status)
}
ShaderEffect {