Launcher: first refactoring pass
This commit is contained in:
parent
80a2e69eaa
commit
742a600e38
5 changed files with 383 additions and 666 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
Modules/Launcher/Plugins/ApplicationsPlugin.qml
Normal file
95
Modules/Launcher/Plugins/ApplicationsPlugin.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
Modules/Launcher/Plugins/CalculatorPlugin.qml
Normal file
105
Modules/Launcher/Plugins/CalculatorPlugin.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue