Launcher: first refactoring pass

This commit is contained in:
LemmyCook 2025-09-02 22:20:01 -04:00
parent 80a2e69eaa
commit 742a600e38
5 changed files with 383 additions and 666 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,245 @@
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
// Panel configuration
panelWidth: Math.min(700 * scaling, screen?.width * 0.75) panelWidth: Math.min(700 * scaling, screen?.width * 0.75)
panelHeight: Math.min(550 * scaling, screen?.height * 0.8) panelHeight: Math.min(600 * 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_")
// 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")
} }
} }
// 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 { // Wrapper ensures the input stretches to full width under RowLayout
Item {
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 // Search input
Item { NTextInput {
id: searchInputWrap id: searchInput
Layout.fillWidth: true anchors.fill: parent // The NTextInput fills the wrapper
Layout.preferredHeight: Math.round(Style.barHeight * scaling) Layout.preferredHeight: Style.barHeight * scaling
NTextInput { placeholderText: "Search entries... or use > for commands"
id: searchInputBox text: searchText
anchors.fill: parent inputMaxWidth: Number.MAX_SAFE_INTEGER
placeholderText: "Search applications... (use > to view commands)"
text: searchText function forceActiveFocus() {
inputMaxWidth: 100000 inputItem.forceActiveFocus()
// 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
}
})
}
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 { inputItem.font.pointSize = Style.fontSizeL * scaling
Layout.alignment: Qt.AlignVCenter inputItem.verticalAlignment = TextInput.AlignVCenter
visible: searchText.startsWith(">clip") }
icon: "delete_sweep"
tooltipText: "Clear clipboard history" onTextChanged: searchText = text
onClicked: CliphistService.wipeAll()
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
//boundsBehavior: Flickable.StopAtBounds
// maximumFlickVelocity: 2500
// flickDeceleration: 2000
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
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 { delegate: Rectangle {
width: appsList.width - Style.marginS * scaling id: entry
property bool isSelected: mouseArea.containsMouse || (index === selectedIndex)
width: resultsList.width - Style.marginS * scaling
height: 65 * 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 +248,50 @@ 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
Rectangle { Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
radius: Style.radiusS * scaling radius: Style.radiusS * scaling
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurfaceVariant color: 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")
// Decode image thumbnails on demand
Component.onCompleted: {
if (modelData && modelData.type === 'image' && !CliphistService.imageDataById[modelData.id]) {
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {})
}
}
onVisibleChanged: {
if (visible && modelData && modelData.type === 'image'
&& !CliphistService.imageDataById[modelData.id]) {
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {})
}
}
// Clipboard image display (pull from cache)
Image {
id: clipboardImage
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 { IconImage {
id: iconImg
anchors.fill: parent anchors.fill: parent
anchors.margins: Style.marginXS * scaling anchors.margins: Style.marginXS * scaling
source: modelData.icon ? Icons.iconFromName(modelData.icon, "application-x-executable") : ""
visible: modelData.icon && source !== ""
asynchronous: true 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 if no icon
NText { NText {
anchors.centerIn: parent anchors.centerIn: parent
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard || modelData.isCommand) visible: !modelData.icon || parent.children[0].source === ""
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 {
ColorAnimation {
duration: Style.animationFast
}
} }
} }
// App info // Text
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 +300,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,95 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
import "../../../Helpers/FuzzySort.js" as Fuzzysort
QtObject {
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",
"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,105 @@
import QtQuick
import qs.Services
import "../../../Helpers/AdvancedMath.js" as AdvancedMath
QtObject {
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",
"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",
"onActivate": function () {}
}]
}
try {
let result = AdvancedMath.evaluate(expression.trim())
return [{
"name": AdvancedMath.formatResult(result),
"description": `${expression} = ${result}`,
"icon": "accessories-calculator",
"onActivate": function () {
// Copy result to clipboard if service available
// if (typeof ClipboardService !== 'undefined') {
// ClipboardService.copy(result.toString())
// }
launcher.close()
}
}]
} catch (error) {
return [{
"name": "Error",
"description": error.message || "Invalid expression",
"icon": "dialog-error",
"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)
}
}