-
Notifications
You must be signed in to change notification settings - Fork 224
feat: added launcher-cycle plugin to cycle launcher modes #329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| # Changelog | ||
|
|
||
| All notable changes to this plugin are documented in this file. | ||
|
|
||
| ## [1.0.0] - 2026-03-05 | ||
|
|
||
| - Initial release. | ||
| - Added IPC commands to cycle launcher command modes forward and backward. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import QtQuick | ||
| import Quickshell.Io | ||
| import qs.Services.UI | ||
|
|
||
| Item { | ||
| id: root | ||
|
|
||
| property var pluginApi: null | ||
| property var cfg: pluginApi?.pluginSettings || ({}) | ||
| property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) | ||
|
|
||
| property var preferredBuiltInModes: ["file", "cmd", "win", "settings", "emoji", "clip"] | ||
|
|
||
| function detectMode(searchText) { | ||
| const text = searchText || ""; | ||
| const match = text.match(/^>(\S+)/); | ||
| if (match && match[1]) | ||
| return match[1]; | ||
| return ""; | ||
| } | ||
|
|
||
| function modeIndex(modeId, modes) { | ||
| for (var i = 0; i < modes.length; i++) { | ||
| if (modes[i] === modeId) | ||
| return i; | ||
| } | ||
| return -1; | ||
| } | ||
|
|
||
| function normalizePrefix(prefix) { | ||
| if (!prefix) | ||
| return ""; | ||
|
|
||
| var normalized = ("" + prefix).trim(); | ||
| if (normalized.startsWith(">")) | ||
| normalized = normalized.slice(1); | ||
| if (!normalized) | ||
| return ""; | ||
| if (/\s/.test(normalized)) | ||
| return ""; | ||
| return normalized; | ||
| } | ||
|
|
||
| function parseModeList(value) { | ||
| var input = value; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason why you create a variable called input here? Why not use the value variable from the parameters? |
||
| if (input === undefined || input === null) | ||
| return []; | ||
|
|
||
| var items = []; | ||
| if (Array.isArray(input)) { | ||
| items = input; | ||
| } else if (typeof input === "string") { | ||
| items = input.split(","); | ||
| } else { | ||
| return []; | ||
| } | ||
|
|
||
| var result = []; | ||
| var seen = {}; | ||
| for (var i = 0; i < items.length; i++) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm guessing this part is for removing duplicates, there's an easier way to do it in Javascript and it's by utilizing a set, you can create a Set from the array and the convert the Set back to an array. |
||
| var mode = normalizePrefix(items[i]); | ||
| if (!mode || seen[mode]) | ||
| continue; | ||
| seen[mode] = true; | ||
| result.push(mode); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| function getAdditionalModes() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Basically as stated above, instead of having a getter function you can define all the variables up at the start to skip the extra code. |
||
| var value = cfg.additionalModes; | ||
| if (value === undefined) | ||
| value = defaults.additionalModes; | ||
| return parseModeList(value); | ||
| } | ||
|
|
||
| function getExcludedModes() { | ||
| var value = cfg.excludeModes; | ||
| if (value === undefined) | ||
| value = defaults.excludeModes; | ||
| return parseModeList(value); | ||
| } | ||
|
|
||
| function getAvailableModes() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Btw another quick tip is that javascript code, can be used in the definition of a variable. For example: readonly property var variable: {
if(...) {
return 1;
}
return 0;
}So the following function can be included in the variable definition. |
||
| var result = []; | ||
| var seen = {}; | ||
|
|
||
| function addMode(prefix) { | ||
| var mode = normalizePrefix(prefix); | ||
| if (!mode) | ||
| return; | ||
| if (seen[mode]) | ||
| return; | ||
| seen[mode] = true; | ||
| result.push(mode); | ||
| } | ||
|
|
||
| for (var i = 0; i < preferredBuiltInModes.length; i++) { | ||
| addMode(preferredBuiltInModes[i]); | ||
| } | ||
|
|
||
| var pluginModes = []; | ||
| var providerIds = LauncherProviderRegistry.getPluginProviders() || []; | ||
| for (var j = 0; j < providerIds.length; j++) { | ||
| var providerId = providerIds[j]; | ||
| var metadata = LauncherProviderRegistry.getProviderMetadata(providerId) || {}; | ||
| var pluginId = providerId.startsWith("plugin:") ? providerId.slice(7) : providerId; | ||
| var prefix = metadata.commandPrefix || pluginId; | ||
| prefix = normalizePrefix(prefix); | ||
| if (prefix) | ||
| pluginModes.push(prefix); | ||
| } | ||
|
|
||
| pluginModes.sort(); | ||
| for (var k = 0; k < pluginModes.length; k++) { | ||
| addMode(pluginModes[k]); | ||
| } | ||
|
|
||
| var additionalModes = getAdditionalModes(); | ||
| for (var m = 0; m < additionalModes.length; m++) { | ||
| addMode(additionalModes[m]); | ||
| } | ||
|
|
||
| var excludedModes = getExcludedModes(); | ||
| if (excludedModes.length === 0) | ||
| return result; | ||
|
|
||
| var excluded = {}; | ||
| for (var n = 0; n < excludedModes.length; n++) { | ||
| excluded[excludedModes[n]] = true; | ||
| } | ||
|
|
||
| return result.filter(function (mode) { | ||
| return !excluded[mode]; | ||
| }); | ||
| } | ||
|
|
||
| function cycle(step) { | ||
| if (!pluginApi) | ||
| return; | ||
|
|
||
| pluginApi.withCurrentScreen(function (screen) { | ||
| if (!screen) | ||
| return; | ||
|
|
||
| const isOpen = PanelService.isLauncherOpen(screen); | ||
| const currentSearch = PanelService.getLauncherSearchText(screen) || ""; | ||
| const currentMode = detectMode(currentSearch); | ||
| const modeOrder = getAvailableModes(); | ||
| const count = modeOrder.length; | ||
| if (count === 0) | ||
| return; | ||
|
|
||
| var nextIndex = 0; | ||
| if (isOpen) { | ||
| const currentIndex = modeIndex(currentMode, modeOrder); | ||
| if (currentIndex >= 0) | ||
| nextIndex = ((currentIndex + step) % count + count) % count; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit confused what this calculation does, do you need to add count and then modulo with count two times? |
||
| } | ||
|
|
||
| const nextSearch = ">" + modeOrder[nextIndex] + " "; | ||
|
|
||
| if (isOpen) | ||
| PanelService.setLauncherSearchText(screen, nextSearch); | ||
| else | ||
| PanelService.openLauncherWithSearch(screen, nextSearch); | ||
| }); | ||
| } | ||
|
|
||
| IpcHandler { | ||
| target: "plugin:launcher-cycle" | ||
|
|
||
| function next() { | ||
| root.cycle(1); | ||
| } | ||
|
|
||
| function previous() { | ||
| root.cycle(-1); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| # Launcher Cycle | ||
|
|
||
| Cycle Noctalia launcher command modes with IPC commands, so one keybind can step through common launcher prefixes. | ||
|
|
||
|  | ||
|  | ||
|
|
||
| ## What it does | ||
|
|
||
| - If the launcher is closed, `next` opens it with the first available mode. | ||
| - If the launcher is open, `next` cycles forward through modes. | ||
| - `previous` cycles backward through modes. | ||
|
|
||
| Mode order is built dynamically from top-level prefixes (`>prefix`) and cycles only those modes (subcommands like `>clip clear` are ignored). | ||
|
|
||
| Default base order: | ||
|
|
||
| 1. `>file` | ||
| 2. `>cmd` | ||
| 3. `>win` | ||
| 4. `>settings` | ||
| 5. `>emoji` | ||
| 6. `>clip` | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a command called clip in noctalia?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| Then plugin launcher providers are appended using each provider's `metadata.commandPrefix` (or plugin ID if no prefix is set), with duplicates removed. | ||
|
|
||
| ## Settings | ||
|
|
||
| In plugin settings, you can configure: | ||
|
|
||
| - `Additional modes`: extra top-level prefixes to append to the cycle list (for example `>todo`, `>notes`). | ||
| - `Exclude modes`: top-level prefixes to remove from cycling (for example `>cmd`, `>clip`). | ||
|
|
||
| Both fields accept comma-separated values and ignore subcommands. | ||
|
|
||
| ## IPC commands | ||
|
|
||
| Target: | ||
|
|
||
| `plugin:launcher-cycle` | ||
|
|
||
| Commands: | ||
|
|
||
| - `next` | ||
| - `previous` | ||
|
|
||
| Examples: | ||
|
|
||
| ```bash | ||
| qs -c noctalia-shell ipc call plugin:launcher-cycle next | ||
| qs -c noctalia-shell ipc call plugin:launcher-cycle previous | ||
| ``` | ||
|
|
||
| ## Keybind example | ||
|
|
||
| ```json | ||
| { | ||
| "keybinds": { | ||
| "Super+Space": "qs -c noctalia-shell ipc call plugin:launcher-cycle next", | ||
| "Super+Shift+Space": "qs -c noctalia-shell ipc call plugin:launcher-cycle previous" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Compatibility | ||
|
|
||
| - `minNoctaliaVersion`: `4.5.0` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| import QtQuick | ||
| import QtQuick.Layouts | ||
| import qs.Commons | ||
| import qs.Widgets | ||
|
|
||
| ColumnLayout { | ||
| id: root | ||
|
|
||
| property var pluginApi: null | ||
| property var cfg: pluginApi?.pluginSettings || ({}) | ||
| property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) | ||
|
|
||
| property string editAdditionalModesText: modeListToText(cfg.additionalModes !== undefined ? cfg.additionalModes : defaults.additionalModes) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When choosing either the pluginSettings variable, or the defaultSettings variable it's always good to provide a fallback, for example ({}). If I'm not wrong it's because without a fallback there's a chance that a lot of warning errors can be printed, and that's not something we want in the logs, unnecessary warnings. |
||
| property string editExcludeModesText: modeListToText(cfg.excludeModes !== undefined ? cfg.excludeModes : defaults.excludeModes) | ||
|
|
||
| spacing: Style.marginL | ||
|
|
||
| function normalizePrefix(prefix) { | ||
| if (!prefix) | ||
| return ""; | ||
|
|
||
| var normalized = ("" + prefix).trim(); | ||
| if (normalized.startsWith(">")) | ||
| normalized = normalized.slice(1); | ||
| if (!normalized) | ||
| return ""; | ||
| if (/\s/.test(normalized)) | ||
| return ""; | ||
| return normalized; | ||
| } | ||
|
|
||
| function parseModeInput(value) { | ||
| var input = value; | ||
| if (input === undefined || input === null) | ||
| return []; | ||
|
|
||
| var items = []; | ||
| if (Array.isArray(input)) { | ||
| items = input; | ||
| } else if (typeof input === "string") { | ||
| items = input.split(","); | ||
| } else { | ||
| return []; | ||
| } | ||
|
|
||
| var result = []; | ||
| var seen = {}; | ||
| for (var i = 0; i < items.length; i++) { | ||
| var mode = normalizePrefix(items[i]); | ||
| if (!mode || seen[mode]) | ||
| continue; | ||
| seen[mode] = true; | ||
| result.push(mode); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| function modeListToText(value) { | ||
| return parseModeInput(value).map(function (mode) { | ||
| return ">" + mode; | ||
| }).join(", "); | ||
| } | ||
|
|
||
| NTextInput { | ||
| Layout.fillWidth: true | ||
| label: "Additional modes" | ||
| description: "Extra top-level modes to append to cycling (comma-separated, e.g. >todo, >git)" | ||
| placeholderText: ">todo, >calc" | ||
| text: root.editAdditionalModesText | ||
| onTextChanged: root.editAdditionalModesText = text | ||
| } | ||
|
|
||
| NDivider { | ||
| Layout.fillWidth: true | ||
| } | ||
|
|
||
| NTextInput { | ||
| Layout.fillWidth: true | ||
| label: "Exclude modes" | ||
| description: "Top-level modes to remove from cycling (comma-separated, e.g. >cmd, >clip)" | ||
| placeholderText: ">cmd, >clip" | ||
| text: root.editExcludeModesText | ||
| onTextChanged: root.editExcludeModesText = text | ||
| } | ||
|
|
||
| function saveSettings() { | ||
| if (!pluginApi) { | ||
| Logger.e("LauncherCycle", "Cannot save settings: pluginApi is null"); | ||
| return; | ||
| } | ||
|
|
||
| pluginApi.pluginSettings.additionalModes = parseModeInput(root.editAdditionalModesText); | ||
| pluginApi.pluginSettings.excludeModes = parseModeInput(root.editExcludeModesText); | ||
| pluginApi.saveSettings(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| { | ||
| "id": "launcher-cycle", | ||
| "name": "Launcher Cycle", | ||
| "version": "1.0.0", | ||
| "minNoctaliaVersion": "4.5.0", | ||
| "author": "Jakob Stender Guldberg", | ||
| "license": "MIT", | ||
| "repository": "https://github.com/noctalia-dev/noctalia-plugins", | ||
| "description": "Cycle launcher command modes with one keybind", | ||
| "tags": [ | ||
| "Utility", | ||
| "Productivity" | ||
| ], | ||
| "entryPoints": { | ||
| "main": "Main.qml", | ||
| "settings": "Settings.qml" | ||
| }, | ||
| "dependencies": { | ||
| "plugins": [] | ||
| }, | ||
| "metadata": { | ||
| "defaultSettings": { | ||
| "additionalModes": [], | ||
| "excludeModes": [] | ||
| } | ||
| } | ||
| } |
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason this recording also exists? You have the preview.gif |

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since variables in qml are reactive (as far as I know), I would suggest you define all the variables that you're going to use up here, and then use them in the code. For example:
Note: Its better to use ?? instead of || since the latter can create false values if you have for example: || true, which will always be true, basically its better to get used to using ??.