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
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets import Quickshell.Widgets
import qs.Commons import qs.Commons
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import "../../Helpers/FuzzySort.js" as Fuzzysort
NPanel { NPanel {
id: root 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")) // Panel configuration
panelAnchorVerticalCenter: launcherPosition === "center" panelWidth: {
panelAnchorLeft: launcherPosition !== "center" && (launcherPosition.endsWith("_left")) var w = Math.round(Math.max(screen?.width * 0.3, 500) * scaling)
panelAnchorRight: launcherPosition !== "center" && (launcherPosition.endsWith("_right")) w = Math.min(w, screen?.width - Style.marginL * 2)
panelAnchorBottom: launcherPosition.startsWith("bottom_") return w
panelAnchorTop: launcherPosition.startsWith("top_") }
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 panelKeyboardFocus: true
// Background opacity following bar's approach
panelBackgroundColor: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b, panelBackgroundColor: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b,
Settings.data.appLauncher.backgroundOpacity) Settings.data.appLauncher.backgroundOpacity)
// Properties // Positioning
property string searchText: "" readonly property string launcherPosition: Settings.data.appLauncher.position
property bool shouldResetCursor: false 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) { function setSearchText(text) {
searchText = 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: { // Plugin registration
// Reset state when panel opens to avoid sticky modes function registerPlugin(plugin) {
if (searchText === "") { plugins.push(plugin)
searchText = "" plugin.launcher = root
selectedIndex = 0 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(() => { selectedIndex = 0
if (searchInputBox && searchInputBox.inputItem) { }
searchInputBox.inputItem.forceActiveFocus()
if (searchText && searchText.length > 0) { onSearchTextChanged: updateResults()
searchInputBox.inputItem.cursorPosition = searchText.length
} else { // Lifecycle
searchInputBox.inputItem.cursorPosition = 0 onOpened: {
} // Notify plugins
} for (let plugin of plugins) {
}) if (plugin.onOpened)
plugin.onOpened()
}
updateResults()
} }
onClosed: { onClosed: {
// Reset search bar when launcher is closed // Notify plugins
searchText = "" for (let plugin of plugins) {
selectedIndex = 0 if (plugin.onClosed)
shouldResetCursor = true plugin.onClosed()
}
// 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()
} }
} }
// Main filtering logic // Navigation
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
function selectNext() { function selectNext() {
if (filteredEntries.length > 0) { if (results.length > 0) {
selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1) // Clamp the index to not exceed the last item
selectedIndex = Math.min(selectedIndex + 1, results.length - 1)
} }
} }
function selectPrev() { 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) selectedIndex = Math.max(selectedIndex - 1, 0)
} }
} }
function selectNextPage() { function activate() {
if (filteredEntries.length > 0) { if (results.length > 0 && results[selectedIndex]) {
const delegateHeight = 65 * scaling + (Style.marginXXS * scaling) const item = results[selectedIndex]
const page = Math.max(1, Math.floor(appsList.height / delegateHeight)) if (item.onActivate) {
selectedIndex = Math.min(selectedIndex + page, filteredEntries.length - 1) item.onActivate()
}
}
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()
} }
root.close()
} }
} }
// Load plugins
Component.onCompleted: { Component.onCompleted: {
Logger.log("Launcher", "Component completed") // Load applications plugin
Logger.log("Launcher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined') const appsPlugin = Qt.createComponent("Plugins/ApplicationsPlugin.qml").createObject(this)
if (typeof DesktopEntries !== 'undefined') { if (appsPlugin) {
Logger.log("Launcher", "DesktopEntries.entries:", registerPlugin(appsPlugin)
DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined') 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) { // Load calculator plugin
clipboardHistory.refresh() 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 { panelContent: Rectangle {
color: Color.transparent color: Color.transparent
Component.onCompleted: {
searchText = ""
selectedIndex = 0
if (searchInput?.forceActiveFocus) {
searchInput.forceActiveFocus()
}
}
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: Style.marginL * scaling anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling spacing: Style.marginM * scaling
RowLayout { FocusScope {
id: searchInputWrap
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Math.round(Style.barHeight * scaling) Layout.preferredHeight: Math.round(Style.barHeight * scaling)
Layout.bottomMargin: Style.marginM * scaling
// Wrapper ensures the input stretches to full width under RowLayout // This FocusScope should get focus when panel opens
Item { focus: true
id: searchInputWrap
Layout.fillWidth: true
Layout.preferredHeight: Math.round(Style.barHeight * scaling)
NTextInput { NTextInput {
id: searchInputBox id: searchInput
anchors.fill: parent anchors.fill: parent
placeholderText: "Search applications... (use > to view commands)"
text: searchText // The input should have focus within the scope
inputMaxWidth: 100000 focus: true
// Tune vertical centering on inner input
Component.onCompleted: { placeholderText: "Search entries... or use > for commands"
searchInputBox.inputItem.font.pointSize = Style.fontSizeL * scaling text: searchText
searchInputBox.inputItem.verticalAlignment = TextInput.AlignVCenter inputMaxWidth: Number.MAX_SAFE_INTEGER
// Ensure focus when launcher first appears
Qt.callLater(() => { function forceActiveFocus() {
searchInputBox.inputItem.forceActiveFocus() // First ensure the scope has focus
if (searchText && searchText.length > 0) { searchInputWrap.forceActiveFocus()
searchInputBox.inputItem.cursorPosition = searchText.length // Then focus the actual input
} else { if (inputItem && inputItem.visible) {
searchInputBox.inputItem.cursorPosition = 0 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 Component.onCompleted: {
NIconButton { if (inputItem) {
Layout.alignment: Qt.AlignVCenter inputItem.font.pointSize = Style.fontSizeL * scaling
visible: searchText.startsWith(">clip") inputItem.verticalAlignment = TextInput.AlignVCenter
icon: "delete_sweep" }
tooltipText: "Clear clipboard history" }
onClicked: CliphistService.wipeAll()
onTextChanged: searchText = text
Keys.onDownPressed: root.selectNext()
Keys.onUpPressed: root.selectPrev()
Keys.onReturnPressed: root.activate()
Keys.onEscapePressed: root.close()
} }
} }
// Applications list // Results list
ListView { ListView {
id: appsList id: resultsList
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
clip: true
spacing: Style.marginXXS * scaling spacing: Style.marginXXS * scaling
model: filteredEntries
model: results
currentIndex: selectedIndex currentIndex: selectedIndex
boundsBehavior: Flickable.StopAtBounds
maximumFlickVelocity: 2500 clip: true
flickDeceleration: 2000 cacheBuffer: resultsList.height * 2
onCurrentIndexChanged: { onCurrentIndexChanged: {
cancelFlick() cancelFlick()
if (currentIndex >= 0) { if (currentIndex >= 0) {
positionViewAtIndex(currentIndex, ListView.Contain) positionViewAtIndex(currentIndex, ListView.Contain)
} }
} }
ScrollBar.vertical: ScrollBar { ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded policy: ScrollBar.AsNeeded
} }
// Keep viewport anchored to the selected item when the clipboard model refreshes delegate: Rectangle {
Connections { id: entry
target: CliphistService
function onRevisionChanged() { property bool isSelected: mouseArea.containsMouse || (index === selectedIndex)
if (Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")) { property int badgeSize: Style.baseWidgetSize * 1.6 * scaling
// Clamp selection in case the list shrank
if (selectedIndex >= filteredEntries.length) { // Property to reliably track the current item's ID.
selectedIndex = Math.max(0, filteredEntries.length - 1) // This changes whenever the delegate is recycled for a new item.
} property var currentClipboardId: modelData.isImage ? modelData.clipboardId : ""
Qt.callLater(() => {
appsList.positionViewAtIndex(selectedIndex, ListView.Contain) // 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: resultsList.width - Style.marginS * scaling
width: appsList.width - Style.marginS * scaling height: badgeSize + Style.marginM * 2 * scaling
height: 65 * scaling
radius: Style.radiusM * scaling radius: Style.radiusM * scaling
property bool isSelected: index === selectedIndex color: entry.isSelected ? Color.mTertiary : Color.mSurface
color: (appCardArea.containsMouse || isSelected) ? Color.mSecondary : Color.mSurface
Behavior on color { Behavior on color {
ColorAnimation { ColorAnimation {
duration: Style.animationFast duration: Style.animationFast
} easing.type: Easing.OutCirc
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
} }
} }
@ -418,97 +287,129 @@ NPanel {
anchors.margins: Style.marginM * scaling anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling spacing: Style.marginM * scaling
// App/clipboard icon with background // Icon badge or Image preview
Rectangle { Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling Layout.preferredWidth: badgeSize
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling Layout.preferredHeight: badgeSize
radius: Style.radiusS * scaling radius: Style.radiusM * scaling
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurfaceVariant color: Color.mSurfaceVariant
property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand) clip: true
|| (iconImg.status === Image.Ready && iconImg.source !== ""
&& iconImg.status !== Image.Error && iconImg.source !== "")
visible: !searchText.startsWith(">calc")
// Decode image thumbnails on demand
Component.onCompleted: { // Image preview for clipboard images
if (modelData && modelData.type === 'image' && !CliphistService.imageDataById[modelData.id]) { NImageRounded {
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {}) 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: { // Loading indicator
if (visible && modelData && modelData.type === 'image' Rectangle {
&& !CliphistService.imageDataById[modelData.id]) { anchors.fill: parent
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {}) 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) // Icon fallback
Image { Loader {
id: clipboardImage id: iconLoader
anchors.fill: parent anchors.fill: parent
anchors.margins: Style.marginXS * scaling anchors.margins: Style.marginXS * scaling
visible: modelData.type === 'image'
source: modelData.type === 'image' ? (CliphistService.imageDataById[modelData.id] || "") : "" visible: !modelData.isImage || imagePreview.status === Image.Error
fillMode: Image.PreserveAspectCrop active: visible
asynchronous: true
cache: true sourceComponent: Component {
} IconImage {
anchors.fill: parent
IconImage { source: modelData.icon ? Icons.iconFromName(modelData.icon, "application-x-executable") : ""
id: iconImg visible: modelData.icon && source !== ""
anchors.fill: parent asynchronous: true
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
} }
// Fallback text if no icon and no image
NText { NText {
anchors.centerIn: parent 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() : "?" 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.mOnPrimary
} }
Behavior on color { // Image type indicator overlay
ColorAnimation { Rectangle {
duration: Style.animationFast 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 { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Style.marginXXS * scaling spacing: 0 * scaling
NText { NText {
text: modelData.name || "Unknown" text: modelData.name || "Unknown"
font.pointSize: Style.fontSizeL * scaling font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold font.weight: Style.fontWeightBold
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurface
elide: Text.ElideRight elide: Text.ElideRight
Layout.fillWidth: true Layout.fillWidth: true
} }
NText { NText {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : modelData.isClipboard ? modelData.content : modelData.isCommand ? modelData.content : (modelData.genericName || modelData.comment || "") text: modelData.description || ""
font.pointSize: Style.fontSizeM * scaling font.pointSize: Style.fontSizeS * scaling
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurfaceVariant
elide: Text.ElideRight elide: Text.ElideRight
Layout.fillWidth: true Layout.fillWidth: true
visible: text !== "" visible: text !== ""
@ -517,41 +418,34 @@ NPanel {
} }
MouseArea { MouseArea {
id: appCardArea id: mouseArea
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
selectedIndex = index selectedIndex = index
activateSelected() root.activate()
} }
} }
} }
} }
// No results message NDivider {
NText {
text: searchText.trim() !== "" ? "No applications found" : "No applications available"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true Layout.fillWidth: true
visible: filteredEntries.length === 0
} }
// Results count // Status
NText { 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 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
import Quickshell.Io import Quickshell.Io
import qs.Commons import qs.Commons
import qs.Services
// Thin wrapper around the cliphist CLI
Singleton { Singleton {
id: root id: root
property var history: [] // Public API
property bool initialized: false property bool active: Settings.isLoaded && Settings.data.appLauncher.enableClipboardHistory && cliphistAvailable
property int maxHistory: 50 // Limit clipboard history entries property bool loading: false
property var items: [] // [{id, preview, mime, isImage}]
// Internal state // Check if cliphist is available on the system
property bool _enabled: true property bool cliphistAvailable: false
property bool dependencyChecked: false
// Cached history file path // Optional automatic watchers to feed cliphist DB
property string historyFile: Quickshell.env("NOCTALIA_CLIPBOARD_HISTORY_FILE") property bool autoWatch: true
|| (Settings.cacheDir + "clipboard.json") property bool watchersStarted: false
// Persisted storage for clipboard history // Expose decoded thumbnails by id and a revision to notify bindings
property FileView historyFileView: FileView { property var imageDataById: ({})
id: historyFileView property int revision: 0
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()
}
}
JsonAdapter { // Approximate first-seen timestamps for entries this session (seconds)
id: historyAdapter property var firstSeenById: ({})
property var history: []
property double timestamp: 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: ""
signal listCompleted
// Check if cliphist is available
Component.onCompleted: {
checkCliphistAvailability()
} }
Timer { // Check dependency availability
interval: 2000 function checkCliphistAvailability() {
repeat: true if (dependencyChecked)
running: root._enabled return
onTriggered: root.refresh()
dependencyCheckProcess.command = ["which", "cliphist"]
dependencyCheckProcess.running = true
} }
// Detect current clipboard types (text/image) // Process to check if cliphist is available
Process { Process {
id: typeProcess id: dependencyCheckProcess
property bool isLoading: false stdout: StdioCollector {}
property var currentTypes: []
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
root.dependencyChecked = true
if (exitCode === 0) { if (exitCode === 0) {
currentTypes = String(stdout.text).trim().split('\n').filter(t => t) root.cliphistAvailable = true
// Start watchers if feature is enabled
// Always check for text first if (root.active) {
textProcess.command = ["wl-paste", "-n", "--type", "text/plain"] startWatchers()
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
} }
} else { } else {
typeProcess.isLoading = false root.cliphistAvailable = false
} // Show toast notification if feature is enabled but cliphist is missing
} if (Settings.data.appLauncher.enableClipboardHistory) {
ToastService.showWarning(
stdout: StdioCollector {} "Clipboard History Unavailable",
} "The 'cliphist' application is not installed. Please install it to use clipboard history features.",
false, 6000)
// 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()
}
} }
} }
// 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 { Process {
id: textProcess id: listProc
property bool isLoading: false stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => { 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) { // Emit the signal for subscribers
const content = String(stdout.text).trim() root.listCompleted()
if (content && content.length > 0) { }
const entry = { }
"type": 'text',
"content": content,
"timestamp": new Date().getTime()
}
// Check if this exact text content already exists Process {
const exists = root.history.find(item => { id: decodeProc
if (item.type === 'text') { stdout: StdioCollector {}
return item.content === content onExited: (exitCode, exitStatus) => {
} const out = String(stdout.text)
return item === content if (root._decodeCallback) {
}) try {
root._decodeCallback(out)
if (!exists) { } finally {
// Normalize existing history entries root._decodeCallback = null
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()
}
} }
} }
// Mark as initialized and clean up loading states
root.initialized = true
if (!imageProcess.running) {
typeProcess.isLoading = false
}
} }
}
Process {
id: copyProc
stdout: StdioCollector {} stdout: StdioCollector {}
} }
function refresh() { // Base64 decode pipeline (queued)
if (!typeProcess.isLoading && !textProcess.isLoading && !imageProcess.running) { Process {
typeProcess.isLoading = true id: decodeB64Proc
typeProcess.command = ["wl-paste", "-l"] stdout: StdioCollector {}
typeProcess.running = true 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() { // Long-running watchers to store new clipboard contents
// Populate in-memory history from cached file Process {
try { id: watchText
const items = historyAdapter.history || [] stdout: StdioCollector {}
root.history = items.slice(0, maxHistory) // Apply limit when loading onExited: (exitCode, exitStatus) => {
Logger.log("Clipboard", "Loaded", root.history.length, "entries from cache") // Auto-restart if watcher dies
} catch (e) { if (root.autoWatch)
Logger.error("Clipboard", "Failed to load history:", e) Qt.callLater(() => {
root.history = [] running = true
})
}
}
Process {
id: watchImage
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
if (root.autoWatch)
Qt.callLater(() => {
running = true
})
} }
} }
function saveHistory() { function startWatchers() {
try { if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
// Ensure we don't exceed the maximum history limit return
const limitedHistory = root.history.slice(0, maxHistory) 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 function stopWatchers() {
historyAdapter.timestamp = Time.timestamp if (!watchersStarted)
return
watchText.running = false
watchImage.running = false
watchersStarted = false
}
// Ensure cache directory exists function list(maxPreviewWidth) {
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]) 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 () { function decode(id, cb) {
historyFileView.writeAdapter() if (!root.cliphistAvailable) {
}) if (cb)
} catch (e) { cb("")
Logger.error("Clipboard", "Failed to save history:", e) 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() { function getImageData(id) {
root.history = [] if (id === undefined) {
saveHistory() 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 qs.Services
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
QtObject { Singleton {
id: root id: root
// Notification server instance // Notification server instance

View file

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