Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions launcher-cycle/CHANGELOG.md
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.
181 changes: 181 additions & 0 deletions launcher-cycle/Main.qml
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 || ({})
Copy link
Copy Markdown
Collaborator

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:

// Readonly so that the property changes depending on the values.
readonly property var additionalModes: pluginApi?.pluginSettings?.additionalModes ?? pluginApi?.manifest?.metadata?.defaultSettings?.additionalModes ?? ({})

// Or if you want to use the variables defined here
readonly property var cfg: pluginApi?.pluginSettings ?? ({})
readonly property var defaults: pluginApi?.manifest?.metadata?.defaultSettings ?? ({})
readonly property var additionalModes: cfg?.additionalModes ?? defaults?.additionalModes ?? ({})

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 ??.

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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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++) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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);
}
}
}
67 changes: 67 additions & 0 deletions launcher-cycle/README.md
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.

![Launcher cycle demo](./preview.gif)
![Launcher settings](./preview.png)

## 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`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a command called clip in noctalia?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup

image

Works with multiple clipboard managers


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`
96 changes: 96 additions & 0 deletions launcher-cycle/Settings.qml
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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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();
}
}
27 changes: 27 additions & 0 deletions launcher-cycle/manifest.json
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": []
}
}
}
Binary file added launcher-cycle/preview.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added launcher-cycle/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added launcher-cycle/recording_20260305_074027.gif
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.