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

View file

@ -0,0 +1,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)
}
}