diff --git a/quickshell/Modules/Plugins/PluginSettings.qml b/quickshell/Modules/Plugins/PluginSettings.qml index cfa991ac..2ba5d05f 100644 --- a/quickshell/Modules/Plugins/PluginSettings.qml +++ b/quickshell/Modules/Plugins/PluginSettings.qml @@ -147,6 +147,24 @@ Item { return defaultValue; } + function saveState(key, value) { + if (!pluginService) + return; + if (pluginService.savePluginState) + pluginService.savePluginState(pluginId, key, value); + } + + function loadState(key, defaultValue) { + if (pluginService && pluginService.loadPluginState) + return pluginService.loadPluginState(pluginId, key, defaultValue); + return defaultValue; + } + + function clearState() { + if (pluginService && pluginService.clearPluginState) + pluginService.clearPluginState(pluginId); + } + function findFlickable(item) { var current = item?.parent; while (current) { diff --git a/quickshell/PLUGINS/QuickNotesExample/QuickNotesLauncher.qml b/quickshell/PLUGINS/QuickNotesExample/QuickNotesLauncher.qml new file mode 100644 index 00000000..42e40010 --- /dev/null +++ b/quickshell/PLUGINS/QuickNotesExample/QuickNotesLauncher.qml @@ -0,0 +1,174 @@ +import QtQuick +import Quickshell +import qs.Services + +Item { + id: root + + property var pluginService: null + property string trigger: "n" + + signal itemsChanged + + property var notes: [] + property int maxNotes: 50 + + Component.onCompleted: { + if (!pluginService) + return; + trigger = pluginService.loadPluginData("quickNotesExample", "trigger", "n"); + maxNotes = pluginService.loadPluginData("quickNotesExample", "maxNotes", 50); + + // Load notes from plugin STATE (persistent across sessions, separate file) + notes = pluginService.loadPluginState("quickNotesExample", "notes", []); + } + + function getItems(query) { + const items = []; + + if (query && query.trim().length > 0) { + const text = query.trim(); + items.push({ + name: "Save note: " + text, + icon: "material:note_add", + comment: "Save as a new note", + action: "add:" + text, + categories: ["Quick Notes"] + }); + + items.push({ + name: "Copy: " + text, + icon: "material:content_copy", + comment: "Copy text to clipboard", + action: "copy:" + text, + categories: ["Quick Notes"] + }); + } + + const filteredNotes = query ? notes.filter(n => n.text.toLowerCase().includes(query.toLowerCase())) : notes; + + for (let i = 0; i < Math.min(20, filteredNotes.length); i++) { + const note = filteredNotes[i]; + const age = _formatAge(note.timestamp); + items.push({ + name: note.text, + icon: "material:sticky_note_2", + comment: age + " — select to copy, hold for options", + action: "copy:" + note.text, + categories: ["Quick Notes"] + }); + } + + if (notes.length > 0 && !query) { + items.push({ + name: "Clear all notes (" + notes.length + ")", + icon: "material:delete_sweep", + comment: "Remove all saved notes", + action: "clear:", + categories: ["Quick Notes"] + }); + } + + return items; + } + + function executeItem(item) { + if (!item?.action) + return; + + const colonIdx = item.action.indexOf(":"); + const actionType = item.action.substring(0, colonIdx); + const actionData = item.action.substring(colonIdx + 1); + + switch (actionType) { + case "add": + addNote(actionData); + break; + case "copy": + copyToClipboard(actionData); + break; + case "remove": + removeNote(actionData); + break; + case "clear": + clearAllNotes(); + break; + default: + showToast("Unknown action: " + actionType); + } + } + + function addNote(text) { + if (!text) + return; + + const existing = notes.findIndex(n => n.text === text); + if (existing !== -1) + notes.splice(existing, 1); + + notes.unshift({ + text: text, + timestamp: Date.now() + }); + + if (notes.length > maxNotes) + notes = notes.slice(0, maxNotes); + + _saveNotes(); + showToast("Note saved"); + } + + function removeNote(text) { + notes = notes.filter(n => n.text !== text); + _saveNotes(); + showToast("Note removed"); + } + + function clearAllNotes() { + notes = []; + pluginService.clearPluginState("quickNotesExample"); + showToast("All notes cleared"); + itemsChanged(); + } + + function _saveNotes() { + if (!pluginService) + return; + // Save to plugin STATE — writes to quickNotesExample_state.json + // This is separate from plugin SETTINGS (plugin_settings.json) + pluginService.savePluginState("quickNotesExample", "notes", notes); + itemsChanged(); + } + + function copyToClipboard(text) { + Quickshell.execDetached(["dms", "cl", "copy", text]); + showToast("Copied to clipboard"); + } + + function showToast(message) { + if (typeof ToastService !== "undefined") + ToastService.showInfo("Quick Notes", message); + } + + function _formatAge(timestamp) { + if (!timestamp) + return ""; + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) + return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) + return minutes + "m ago"; + const hours = Math.floor(minutes / 60); + if (hours < 24) + return hours + "h ago"; + const days = Math.floor(hours / 24); + return days + "d ago"; + } + + onTriggerChanged: { + if (!pluginService) + return; + pluginService.savePluginData("quickNotesExample", "trigger", trigger); + } +} diff --git a/quickshell/PLUGINS/QuickNotesExample/QuickNotesSettings.qml b/quickshell/PLUGINS/QuickNotesExample/QuickNotesSettings.qml new file mode 100644 index 00000000..96596146 --- /dev/null +++ b/quickshell/PLUGINS/QuickNotesExample/QuickNotesSettings.qml @@ -0,0 +1,263 @@ +import QtQuick +import qs.Widgets + +FocusScope { + id: root + + property var pluginService: null + + implicitHeight: settingsColumn.implicitHeight + height: implicitHeight + + Column { + id: settingsColumn + anchors.fill: parent + anchors.margins: 16 + spacing: 16 + + Text { + text: "Quick Notes Settings" + font.pixelSize: 18 + font.weight: Font.Bold + color: "#FFFFFF" + } + + Text { + text: "Demonstrates the plugin state API — notes are stored in a separate state file (quickNotesExample_state.json) rather than plugin_settings.json." + font.pixelSize: 14 + color: "#CCFFFFFF" + wrapMode: Text.WordWrap + width: parent.width - 32 + } + + Rectangle { + width: parent.width - 32 + height: 1 + color: "#30FFFFFF" + } + + Column { + spacing: 12 + width: parent.width - 32 + + Text { + text: "Trigger Configuration" + font.pixelSize: 16 + font.weight: Font.Medium + color: "#FFFFFF" + } + + Row { + spacing: 12 + anchors.left: parent.left + anchors.right: parent.right + + Text { + text: "Trigger:" + font.pixelSize: 14 + color: "#FFFFFF" + anchors.verticalCenter: parent.verticalCenter + } + + DankTextField { + id: triggerField + width: 100 + height: 40 + text: loadSettings("trigger", "n") + placeholderText: "n" + backgroundColor: "#30FFFFFF" + textColor: "#FFFFFF" + + onTextEdited: { + saveSettings("trigger", text.trim() || "n"); + } + } + } + } + + Rectangle { + width: parent.width - 32 + height: 1 + color: "#30FFFFFF" + } + + Column { + spacing: 12 + width: parent.width - 32 + + Text { + text: "Storage" + font.pixelSize: 16 + font.weight: Font.Medium + color: "#FFFFFF" + } + + Row { + spacing: 12 + + Text { + text: "Max notes:" + font.pixelSize: 14 + color: "#FFFFFF" + anchors.verticalCenter: parent.verticalCenter + } + + DankTextField { + id: maxNotesField + width: 80 + height: 40 + text: loadSettings("maxNotes", 50).toString() + placeholderText: "50" + backgroundColor: "#30FFFFFF" + textColor: "#FFFFFF" + + onTextEdited: { + const val = parseInt(text); + if (!isNaN(val) && val > 0) + saveSettings("maxNotes", val); + } + } + } + + Text { + text: { + const count = loadState("notes", []).length; + return "Currently storing " + count + " note(s)"; + } + font.pixelSize: 12 + color: "#AAFFFFFF" + } + + Rectangle { + width: clearRow.implicitWidth + 24 + height: clearRow.implicitHeight + 16 + radius: 8 + color: clearMouseArea.containsMouse ? "#40FF5252" : "#30FF5252" + + Row { + id: clearRow + anchors.centerIn: parent + spacing: 8 + + Text { + text: "🗑" + font.pixelSize: 14 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "Clear all notes" + font.pixelSize: 14 + color: "#FF5252" + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: clearMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (pluginService) { + pluginService.clearPluginState("quickNotesExample"); + } + } + } + } + } + + Rectangle { + width: parent.width - 32 + height: 1 + color: "#30FFFFFF" + } + + Column { + spacing: 8 + width: parent.width - 32 + + Text { + text: "API Usage (for plugin developers):" + font.pixelSize: 14 + font.weight: Font.Medium + color: "#FFFFFF" + } + + Column { + spacing: 4 + leftPadding: 16 + bottomPadding: 24 + + Text { + text: "• pluginService.savePluginState(id, key, value)" + font.pixelSize: 12 + color: "#CCFFFFFF" + font.family: "monospace" + } + + Text { + text: " Writes to ~/.local/state/.../id_state.json" + font.pixelSize: 11 + color: "#AAFFFFFF" + } + + Text { + text: "• pluginService.loadPluginState(id, key, default)" + font.pixelSize: 12 + color: "#CCFFFFFF" + font.family: "monospace" + } + + Text { + text: " Reads from the per-plugin state file" + font.pixelSize: 11 + color: "#AAFFFFFF" + } + + Text { + text: "• pluginService.clearPluginState(id)" + font.pixelSize: 12 + color: "#CCFFFFFF" + font.family: "monospace" + } + + Text { + text: " Clears all state for a plugin" + font.pixelSize: 11 + color: "#AAFFFFFF" + } + + Text { + text: "• pluginService.removePluginStateKey(id, key)" + font.pixelSize: 12 + color: "#CCFFFFFF" + font.family: "monospace" + } + + Text { + text: " Removes a single key from plugin state" + font.pixelSize: 11 + color: "#AAFFFFFF" + } + } + } + } + + function saveSettings(key, value) { + if (pluginService) + pluginService.savePluginData("quickNotesExample", key, value); + } + + function loadSettings(key, defaultValue) { + if (pluginService) + return pluginService.loadPluginData("quickNotesExample", key, defaultValue); + return defaultValue; + } + + function loadState(key, defaultValue) { + if (pluginService) + return pluginService.loadPluginState("quickNotesExample", key, defaultValue); + return defaultValue; + } +} diff --git a/quickshell/PLUGINS/QuickNotesExample/plugin.json b/quickshell/PLUGINS/QuickNotesExample/plugin.json new file mode 100644 index 00000000..fcf34589 --- /dev/null +++ b/quickshell/PLUGINS/QuickNotesExample/plugin.json @@ -0,0 +1,17 @@ +{ + "id": "quickNotesExample", + "name": "Quick Notes", + "description": "Example launcher plugin demonstrating the plugin state API for persistent data like history and notes", + "version": "1.0.0", + "author": "DankMaterialShell", + "icon": "sticky_note_2", + "type": "launcher", + "capabilities": ["clipboard"], + "component": "./QuickNotesLauncher.qml", + "settings": "./QuickNotesSettings.qml", + "trigger": "n", + "permissions": [ + "settings_read", + "settings_write" + ] +} diff --git a/quickshell/Services/PluginService.qml b/quickshell/Services/PluginService.qml index 156017a9..03f9be64 100644 --- a/quickshell/Services/PluginService.qml +++ b/quickshell/Services/PluginService.qml @@ -33,10 +33,17 @@ Singleton { property var pluginInstances: ({}) property var globalVars: ({}) + property var _stateCache: ({}) + property var _stateLoaded: ({}) + property var _stateWriters: ({}) + property var _stateDirtyPlugins: ({}) + property bool _stateDirCreated: false + signal pluginLoaded(string pluginId) signal pluginUnloaded(string pluginId) signal pluginLoadFailed(string pluginId, string error) signal pluginDataChanged(string pluginId) + signal pluginStateChanged(string pluginId) signal pluginListUpdated signal globalVarChanged(string pluginId, string varName) signal requestLauncherUpdate(string pluginId) @@ -48,6 +55,13 @@ Singleton { onTriggered: resyncAll() } + Timer { + id: _stateWriteTimer + interval: 150 + repeat: false + onTriggered: root._flushDirtyStates() + } + Component.onCompleted: { userWatcher.folder = Paths.toFileUrl(root.pluginDirectory); systemWatcher.folder = Paths.toFileUrl(root.systemPluginDirectory); @@ -374,6 +388,7 @@ Singleton { delete newLoaded[pluginId]; loadedPlugins = newLoaded; + _cleanupPluginStateWriter(pluginId); pluginUnloaded(pluginId); return true; } catch (error) { @@ -603,6 +618,111 @@ Singleton { SettingsData.savePluginSettings(); } + function getPluginStatePath(pluginId) { + return Paths.strip(Paths.state) + "/plugins/" + pluginId + "_state.json"; + } + + function loadPluginState(pluginId, key, defaultValue) { + if (!_stateLoaded[pluginId]) + _loadStateFromDisk(pluginId); + const state = _stateCache[pluginId]; + if (!state) + return defaultValue; + return state[key] !== undefined ? state[key] : defaultValue; + } + + function savePluginState(pluginId, key, value) { + if (!_stateLoaded[pluginId]) + _loadStateFromDisk(pluginId); + if (!_stateCache[pluginId]) + _stateCache[pluginId] = {}; + _stateCache[pluginId][key] = value; + _stateDirtyPlugins[pluginId] = true; + _stateWriteTimer.restart(); + pluginStateChanged(pluginId); + } + + function clearPluginState(pluginId) { + _stateCache[pluginId] = {}; + _stateLoaded[pluginId] = true; + _flushStateToDisk(pluginId); + pluginStateChanged(pluginId); + } + + function removePluginStateKey(pluginId, key) { + if (!_stateCache[pluginId]) + return; + delete _stateCache[pluginId][key]; + _stateDirtyPlugins[pluginId] = true; + _stateWriteTimer.restart(); + pluginStateChanged(pluginId); + } + + function _ensureStateDir() { + if (_stateDirCreated) + return; + _stateDirCreated = true; + Paths.mkdir(Paths.state + "/plugins"); + } + + function _loadStateFromDisk(pluginId) { + _stateLoaded[pluginId] = true; + _ensureStateDir(); + const path = getPluginStatePath(pluginId); + const escapedPath = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + try { + const qml = 'import QtQuick; import Quickshell.Io; FileView { path: "' + escapedPath + '"; blockLoading: true; blockWrites: true; atomicWrites: true }'; + const fv = Qt.createQmlObject(qml, root, "sf_" + pluginId); + const raw = fv.text(); + if (raw && raw.trim()) { + _stateCache[pluginId] = JSON.parse(raw); + } else { + _stateCache[pluginId] = {}; + } + _stateWriters[pluginId] = fv; + } catch (e) { + _stateCache[pluginId] = {}; + } + } + + function _flushStateToDisk(pluginId) { + _ensureStateDir(); + const content = JSON.stringify(_stateCache[pluginId] || {}, null, 2); + if (_stateWriters[pluginId]) { + _stateWriters[pluginId].setText(content); + return; + } + const path = getPluginStatePath(pluginId); + const escapedPath = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + try { + const qml = 'import QtQuick; import Quickshell.Io; FileView { path: "' + escapedPath + '"; blockWrites: true; atomicWrites: true }'; + const fv = Qt.createQmlObject(qml, root, "sw_" + pluginId); + _stateWriters[pluginId] = fv; + fv.loaded.connect(function () { + fv.setText(content); + }); + fv.loadFailed.connect(function () { + fv.setText(content); + }); + } catch (e) { + console.warn("PluginService: Failed to write state for", pluginId, e.message); + } + } + + function _flushDirtyStates() { + const dirty = _stateDirtyPlugins; + _stateDirtyPlugins = {}; + for (const pluginId in dirty) + _flushStateToDisk(pluginId); + } + + function _cleanupPluginStateWriter(pluginId) { + if (!_stateWriters[pluginId]) + return; + _stateWriters[pluginId].destroy(); + delete _stateWriters[pluginId]; + } + function scanPlugins() { const userUrl = Paths.toFileUrl(root.pluginDirectory); const systemUrl = Paths.toFileUrl(root.systemPluginDirectory);