Skip to content

Latest commit

 

History

History
1004 lines (836 loc) · 46.8 KB

PLUGINS.md

File metadata and controls

1004 lines (836 loc) · 46.8 KB

Creating a plugin

k4 loads plugins from two places: the repository's (plugins/) and the user's in ~/.config/k4/plugins/<id>/. This guide is for the latter: you do not need to touch the repository to write one.

The complete example for this guide lives in ejemplos/hola/ and can be copied as is:

cp -r ejemplos/hola ~/.config/k4/plugins/
quickshell ipc -p ~/.config/quickshell/k4/shell.qml call k4 pluginEnable hola
quickshell ipc -p ~/.config/quickshell/k4/shell.qml call k4.hola toggle

You may not need to read this. k4 installs a skill for coding agents (agentes/skills/k4/, linked by ./install), so Claude Code, Codex and anything else that reads ~/.claude/skills/ or ~/.config/agents/skills/ already know everything on this page. Asking for the plugin you want is a legitimate way to get one — this guide is here for when you want to know what it is doing, or when you are doing it yourself.

Native markdown

Use K4.MarkdownView for rich, selectable text instead of implementing a markdown parser in a plugin. It sizes vertically to its content, so give it a width and place it in your scrolling surface. Enable streaming for a live reply. Connect copyRequested(text) to your clipboard action and linkActivated(url) to your link handler. selectionStarted() lets a conversation suspend automatic scroll. The native renderer includes code highlighting/copy controls, horizontal code and table scrolling, task lists, nested lists, quotes and standard inline markup. See Native markdown for defaults and limitations.

System telemetry

Use K4.SystemMonitor for CPU, memory, temperatures and network rates. It shares the host sampler with the built-in System card. Acquire a uniquely named lease with K4.SystemMonitor.sample("my-plugin.summary", true, false) while visible; release it with both flags false on disable/destruction. Handle available changing to true if your component is created before the host bridge is ready. Use true for the third argument while showing its view Component in a Loader. Detailed sampling adds supported GPU readings, filesystem capacity and process activity. Missing or warming-up percentages/rates are -1, not zero; memory capacities are GiB, and rate formatters use binary units. See System telemetry for the complete contract.

0 · Installing one that already exists

To see what is published in the public registry:

python3 tools/plugins.py --search
python3 tools/plugins.py --search snake

Each entry prints its exact install command, with the commit already in it. To publish yours, open a PR adding an entry to plugins/registro.json: id, title, description, git repo, commit (the full 40-character SHA you want published) and carpeta if the plugin does not live at the repo root.

A registry entry publishes a commit, not a branch. A branch moves after it is reviewed, so what someone installs next month would not be what was looked at — the point of pinning is that those two are the same thing. To publish a new version, open another submission with the new SHA.

The easier route is the Publish a plugin issue form. Opening it (or editing it afterwards) makes a bot fetch that exact commit, validate it with tools/plugins.py — without running any of your plugin — and comment with what it found: what the manifest declares, which permissions it asks for, which surfaces it occupies. It then labels the issue validado, or revision-de-seguridad if the plugin asks for permissions, or necesita-arreglos with the reason.

The bot never publishes. Only a maintainer applying the publicado label does, and even then the entry is written only if the commit still matches the one that was reviewed — otherwise approving would mean approving "that repository", which is a promise nobody can keep.

Named rules

Beyond permissions — which say which k4 API a plugin touches — the checker looks for patterns that make the code you end up running not the code someone reviewed. Each one carries why it matters and how to fix it, because a warning that doesn't say what to do gets ignored:

Rule What it looks for
descarga-y-ejecuta downloading something and piping it to a shell
sudo-sin-contrasena NOPASSWD, sudo -n, pkexec
clon-sin-commit git clone of a remote without a pinned SHA
qml-desde-texto Qt.createQmlObject, eval, new Function
borra-a-lo-ancho recursive deletes with wildcards

Only the first two block publication, and that split is deliberate. They are unambiguous: whatever the URL serves, or whatever runs as root, was never part of the commit anyone looked at. The rest can be perfectly reasonable in context, so they mark the submission for a person to read rather than stopping it — and even a maintainer's label cannot publish past a blocking rule.

Blocking applies to publishing, never to installing. If you bring your own plugin to your own machine, the bar tells you what fired and why, and the button says "Install anyway". Taking the choice away without explaining it would be worse than letting you make it having read the reason.

None of this is a security audit and the report says so. Static checks on one commit are what it is: a plugin runs inside the bar and can do whatever the bar can do.

And if someone handed you a repository directly:

python3 tools/plugins.py --install https://github.com/quien/su-plugin
python3 tools/plugins.py --install https://github.com/quien/su-plugin \
                         --commit 4f1c2ab...   # that exact one

Without --commit you get the tip of the default branch, which is fine for your own code and worth thinking about for someone else's. Either way the commit that landed is written down, so you can always answer "what exactly do I have installed?".

It clones to a temporary directory, validates the whole thing there, and only then shows what it claims to be and which permissions it declares, and asks. Nothing reaches your plugin directory without passing the same exam the installed ones pass: there is no "half installed and broken". If the QML uses something the manifest does not declare, it is rejected before touching the disk.

It arrives off. Turning it on is a separate decision, made in Settings, looking at those same permissions.

python3 tools/plugins.py --installed        # what you have, from where, at which commit
python3 tools/plugins.py --check         # what no longer matches the registry
python3 tools/plugins.py --update snake  # reinstall from its origin (tip)
python3 tools/plugins.py --update snake --commit 4f1c2ab...   # or that one
python3 tools/plugins.py --remove snake      # uninstall (--con-estado also
                                             # deletes what it saved)

--check answers the question the old installer could not: it compares the commit each installed plugin actually came from against what the registry publishes today, and tells you which ones have something new upstream — and which ones were installed before any of this existed, so their commit is simply unknown.

With the bar running, k4 pluginRefresh makes it re-read the catalog: what you just installed appears and what you removed disappears without restarting anything. And after updating an enabled one, k4 pluginReload <id> swaps the running code — the old one stays alive in the bar until you say so.

0b · Starting one

Do not start from an empty directory:

tools/plugins.py --new mi-plugin

That writes a manifest and a plugin that already opens, and tells you the three commands that come next. The rest of this page explains what it wrote.

1 · The directory and the manifest

~/.config/k4/plugins/hola/
├── plugin.json
├── HolaPlugin.qml
└── HolaView.qml

plugin.json is the manifest:

{
  "id": "hola",
  "entry": "HolaPlugin.qml",
  "version": "1.0.0",
  "title": "Hello",
  "description": "What it does, in one sentence — shown in Settings",
  "host": ">=1.1.0",
  "permissions": [],
  "surfaces": ["island"]
}

The keys above are the vocabulary of record. The Spanish keys a manifest could use until the rename (permisos, superficies, icono, aplicacion, requiere) are still read as aliases, so a plugin written yesterday keeps validating today — but write English.

  • surfaces: what your plugin occupies, as opposed to what it touches. island (it has a view), pildora, ventana, ipc, centro (a block in the control centre). It is optional — a manifest without it still validates — but if you declare it, the validator checks it against what your QML actually does, the same way it already does with permissions. Declaring lets Settings describe your plugin without loading it.

  • id: lowercase, no spaces, and it must match the directory name. If it collides with one of the bar's plugins, yours loses.

  • entry: the file that inherits from K4.Plugin, inside the directory itself.

  • host: the minimum bar version you need (>=x.y.z).

  • icon: your icon, of one of these two kinds:

    • a Nerd Font codepoint as text, "0xF011A" — find it with python3 tools/glyphs.py <word>. It inherits the theme's color, so it looks like the rest of the bar and dims and tints along with it.
    • your own image, "icono.png": a PNG or SVG file from your directory (no paths: your icon is yours). A PNG must be at least 64×64 — below that it looks blurry exactly where people look the most, and a pixelated icon makes a good plugin look bad — and weigh less than 1 MB. SVG has no minimum, that is what scaling is for.

    It is validated at install time: an icon that does not exist, is too small or comes in a strange format is an installation error, not an empty little square in the application center. It shows up in Settings, in the application center and in the control center's shortcuts.

  • application: true if yours is something that is opened and used — a game, a tool — and not an indicator or a service. With it you appear in the application center (SUPER+SHIFT+Space) and you can be pinned to the control center strip — where shortcuts are also reordered by dragging. You also appear when typing in the launcher (SUPER+Space), which remains a different drawer — the desktop applications' one — but finds both.

  • permissions: which capabilities you use — see below. Empty if you only paint.

2 · The plugin and the view

The plugin is the state: it lives always, island or no island. The view only paints, and only exists while the host presents the plugin.

One summoned view at a time deploys from the bar, and the one just opened is the one on it: the host retires the previous summoned view through its close() — whatever that verb must do on the way out still happens — while its view fades out under the arriving one and the island glides to the newcomer. priority still arbitrates everything else: the pill, the hover views, the transients. The view deploys on the edge and at the point its placement says (Settings → Popups & Layout). The ends of an edge are corners: a placement flush at either end attaches the view to two walls — flush along its edge and the side wall. The bar can live in a corner itself (Settings → Island → alignment), where the rim carries its material into the turn. The island keeps the pill, the hover views and the transitions between them, and stays alive above the dim the host paints behind any summoned view that is out; hover views dim nothing. A deployed view closes through its own close() — Escape, the outside-click, its own cross and verbs work as always.

The card also carries a copy button — the IPC command that opens the surface, ready to paste in a terminal or hang on a key. It reads summonCommand, because target and verb are the plugin's to say: the convention is k4.<id> + toggle, and the exceptions (the terminal's k4.term island) are exactly why it is declared and not guessed. A colocable surface with no summonCommand shows no button.

Open independently without replacing the main island

Set independentIsland: true on K4.Plugin to have the host present your existing view in its own island. Keep your usual active, viewLoaded, size, keyboard flags and close() implementation; no custom window is required. The main island stays in place and existing views remain open. This property defaults to false; Hyprland Submap enables it by default.

For a colocable surface, Settings → Popups & Layout → Separate popup lets the user enable or disable this behavior regardless of your default. The override survives restarts and position changes, including Follow bar. Transients may opt in through the API without receiving a popup settings card.

The configured edge and alignment are the preferred position. If occupied, the host tries corners clockwise (top-left → top-right → bottom-right → bottom-left), starting at the next corner along a non-corner edge. It avoids both the main island and other independent islands, including notification popups, on that monitor. With all corners occupied, it chooses the least overlap. Existing independent windows get first choice; free allocated positions stay stable until obstructed or their placement/size changes. Repositioning keeps the view alive.

Independent views are excluded from the main island's priority and replacement rules. They open on the requested or focused monitor, do not dim the desktop, and retain hover-exit, keyboard and cross-monitor dismissal behavior. Only the newest eligible independent view grabs exclusive keyboard focus. Outside-click catchers leave other islands clickable. K4.Isla still reports the main island: use your view item's own dimensions, hover and focus for independent content. See the presentation contract for details.

// HolaPlugin.qml
import QtQuick
import K4 as K4

K4.Plugin {
    id: self
    name: "hola"                 // The same ID as the manifest.
    priority: 65
    active: abierto              // Request presentation right now.
    islandWidth: 360
    islandHeight: 100

    property bool abierto: false

    view: Component { HolaView { plugin: self } }

    K4.Ipc {
        target: "k4.hola"
        function toggle(): void { self.abierto = !self.abierto }
        function close(): void { self.abierto = false }
    }
}
// HolaView.qml
import QtQuick
import K4 as K4

Item {
    required property var plugin
    K4.Etiqueta {
        anchors.centerIn: parent
        text: "Hello from an external plugin"
    }
}

Pick a priority above 50, or the native clock eats your panel

priority decides who gets the island when more than one surface wants it, and the number is not decoration. The bar's native resting views sit at 50 and 55 — the clock and the player — and they activate on Island.hovered.

So a panel below 50 cannot be closed. To reach your close button the user puts the pointer on the island; that turns the clock on; the clock outranks you; and your panel becomes the clock exactly as they were reaching for it. It is not a rare case: it is what happens every single time, and with the bar in Hidden space mode it happens sooner, because entering with the pointer is the first thing anyone does.

The map, so you can place yourself:

0 native pill at rest
40 native volume HUD
50 · 55 native clock · player — the hover views. Be above these.
59 native notification toast
60 · 61 · 63 · 66 native control center · native sound · tray · settings
72 · 80 app center · launcher

Above the resting views so you survive being touched; below the things the user opens on purpose if yours can open itself. A module that appears over what someone was already doing is rude, and a plugin that announces something is announcing it, not demanding the screen.

Between summoned views the ladder no longer chooses: opening one supersedes whoever holds the island — the host closes the previous one — so your priority only places you against the resting views and the transients. Be findable in the map anyway; the number still says what you are.

Requested and user-configured size

You ask for islandWidth and islandHeight, and you can change them live: a plugin can be a 200×150 strip and turn into a big screen depending on what it is doing.

islandWidth:  modo === "mini" ? 200 : 980
islandHeight: modo === "mini" ? 150 : K4.Isla.altoMaximo

For colocable surfaces, Settings → Popups & Layout offers width and height overrides independently. Auto follows the corresponding live request; a custom pixel value takes precedence in both the main island and separate-popup hosts without changing the plugin's property bindings. Overrides persist by surface ID in the host's popupSizes setting. Each card provides size and position resets; cards start collapsed and retain their disclosure state while navigating the current Settings session.

The host constrains the actual view to the available screen. Lay out against the view's actual width and height rather than assuming the requested size was granted. K4.Isla.altoMaximo remains a useful height budget. If content can grow without limit, put it in a K4.Rodillo.

Rules the bar enforces:

  • A plugin imports QtQuick and K4. Nothing else. From your directory there is no path to the internal services, and that is on purpose: the public API is the contract that does not break under your feet on update.

  • The root id should be self: a view with required property var plugin can shadow an id with the same name. If you declare yourself application, the bar will open you by calling abrir(). By default it uses your toggle(), which is what almost all of them already have; redefine it if you need something else — for example always opening instead of toggling.

  • Processes, timers and IPC go as children of the K4.Plugin, not of the view: the view is destroyed every time you lose the island.

An animation nobody sees still runs

In Qt Quick an animation does not stop because its item stopped being visible. It keeps running, and while it runs the whole scene keeps repainting — at the refresh rate, on every monitor, for as long as its condition holds.

That is not a warning from the manual. k4's own pill once had a five-pixel dot pulsing whenever there was something waiting to be opened:

SequentialAnimation on opacity {
    running: hayPendientes            // ← data, not visibility
    loops: Animation.Infinite
    NumberAnimation { to: 0.3; duration: 900 }
    NumberAnimation { to: 1;   duration: 900 }
}

The condition asks about data, never about being seen. With 61 items sitting there it had been true for weeks, so the bar rendered 122 frames per second while collapsed with nothing open — around 6% of a core, day and night, for a dot nobody was looking at. Fixing that one line took the bar to 0 fps and 0.0% at rest.

So an animation with loops: Animation.Infinite asks whether anyone can see it:

running: hayAlgoQueContar && K4.Isla.aLaVista

K4.Isla.aLaVista is false while the island is retracted (the Hidden mode in Settings), while a system dialog has it out of the way, and on a monitor whose bar is not showing. It is published per screen, so with two monitors the answer is "yes" when any of them shows it. With no bar behind it — a --test run — it answers "yes", because an animation too many is easier to notice than one that never starts.

Your view is destroyed when you lose the island, so an animation that lives inside it goes away with it and costs nothing. The trap is everything drawn where the view survives: what you contribute to the pill, your own K4.Ventana, anything you hang in a place that is not yours.

The same reasoning covers anything that repeats without an end: a Timer with repeat: true, a Canvas that repaints itself, an AnimatedImage. If it runs forever, it needs a reason to be running now.

Your own files

Your plugin knows where it lives. carpeta is its directory on disk, filled in by the host when it creates you, and fichero(...) builds a path inside it:

K4.Process {
    command: ["python3", fichero("tools/mine.py")]
}

That is what lets you ship your own things — a script, a binary, a model, a data file. Qt.resolvedUrl("assets/x.png") was already enough to paint an image, but a Process wants a path, not a URL, and K4.Paths.raiz is the bar's directory, not yours.

3 · What the API gives you

Type What for
K4.Plugin the contract: island, priority, keyboard, view
K4.Tema the palette (tinta, superficie, apagado…) and the fonts
K4.Etiqueta text with the bar's defaults
K4.Glifo a Nerd Font icon (find them with tools/glyphs.py)
K4.Icono a desktop-theme icon, by name
K4.Miniatura the live thumbnail of an open window, by its address
K4.Interruptor the bar's switch; it notifies, it does not flip itself
K4.Deslizador filled slider with label and value; 60 px labelled, 32 px unlabelled
K4.Medidor a bar that measures and is not touched: volume, progress, how much of a quota is gone
K4.Baldosa the control center's pressable card
K4.Boton round one-glyph button
K4.ActionButton compact text action; text, controlled selected, inherited enabled, clicked()
K4.TextField shared single-line editor; caller owns labels, validation and commits
K4.Rodillo a scrolling area that actually obeys the wheel, house scroll bar included
K4.Desplazador the house scroll bar — thin, fades away; attach it to your own lists
K4.Estela the house caret, with the trail k4term leaves — use it as cursorDelegate
K4.Aparicion enters with a fade instead of popping
K4.FocoInicial moves the cursor to your text field on open
K4.Guardado Existing cargado/guardar contract; settings are shared, other state is local
K4.PluginSettings Shareable preference values in config.json; loaded/save
K4.PluginState Owner-local named state files; loaded/save, recoverable saveSections
K4.Credential Memory-only credential value backed by the system keyring
K4.Ipc your IPC target (k4.<id>)
K4.Process external processes — requires the procesos permission
K4.Terminal the house terminal: run a script where it best fits, open a window, or register an island provider through its host adapter — process launches require procesos; see the provider reference
K4.Sonido a short sound — requires the sound permission
K4.Feedback Standard UI cues: click() for activation, rate-limited tick() for adjustment; host settings govern playback
K4.Fichero reading and writing files — requires ficheros
K4.Pildora an indicator on the folded pill
K4.Capsule the capsule growing toward a screen edge with your text
K4.Submaps the submap Hyprland is in right now ("" for none). Names follow the parseable standard — Title: (k)Label (command) — which the house hyprland-submap plugin turns into its key-chip island; anything else shows as plain text
K4.Paths Asset/helper paths and the display-only config location
K4.IconoPlugin a plugin's icon: its image if it brings one, its glyph if not

For live numeric pill indicators, pass an optional seventh slots argument to K4.Pildora.registrar. Each slot supplies { text, samples, prefix }, with prefix optional: a percentage can reserve samples: ["100%"], while network download and upload use separate slots with stationary arrows and left-aligned values directly beside them and a compact 3 px gap between slots. Reserve the four-character outputs of K4.SystemMonitor.compactRate for network values. The host's Island → Indicator icon size setting scales every status glyph together (8–20 px, default 14 px), including agent indicators, without resizing text. Slots without a prefix stay right-aligned. Update texto and the complete slots array through actualizar, retaining the same samples and prefixes. This keeps the folded, clock, and player views steady while values change. See pill indicators for sizing and rate samples.

Use the shared controls for consistent keyboard focus and disabled states. Give icon buttons, cards and switches an Accessible.name. Switches and choice buttons report activation; keep the displayed value bound to its owner. K4.Deslizador supports arrows and Home/End, exposes read-only dragging, and uses a compact 32 px layout when etiqueta is empty (supply an accessible name). K4.Rodillo brings focused descendants into view during Tab traversal. Text fields need a persistent visible label and an explicit commit/cancel policy; do not persist password drafts. See the control reference in API.md.

Shared buttons, switches and pressable cards already provide click sounds; sliders provide quieter, throttled ticks for user changes. Do not duplicate those calls in your handlers. Custom controls may call K4.Feedback.click() or K4.Feedback.tick() for user interaction, without an additional permission. These fixed cues obey Settings → Island → Interaction sounds, system mute, and the default output device. Background changes and hover stay silent. Arbitrary sound playback still uses K4.Sonido and its declared permission. See interaction sounds.

And what you need when yours grows past the island:

Type What for
K4.Ventana a window of your own, apart from the island: a full-screen picker, something worth real space
K4.PorPantalla one copy of yours per monitor — with two screens almost nothing wants to exist once
K4.Cargador loads the expensive part only when needed and drops it when not
K4.Apps the installed applications: name, icon, how to launch them
K4.Sistema the loose ends: launching things, reading the environment, finding icons
K4.MenuBandeja the menu a tray icon publishes
K4.BloqueoSesion locking the session for real (ext-session-lock)
K4.SuperficieBloqueo what is drawn while locked, one per monitor
K4.Autenticacion checking that whoever is in front is who they claim, via PAM

The last four are for replacing pieces of the bar — a lock screen of your own, another tray — more than for an ordinary plugin. They are here because the built-in modules use them and the rule is the same for everyone: if a repository plugin can, an outside one can too. (K4.MenuBandeja is the one exception to the first half: the built-in tray is native now — services/TrayIsland.qml, always on — and the type stays so a replacement tray can still read an application's menu.)

(K4.Puente also exists and is not for you: it is how the bar hands the API what it needs. Only someone implementing the API on another host touches it.)

And the live system data:

Type What it gives Writing requires
K4.Audio volume, mute audio
K4.Medios what is playing: title, artist, artwork, position medios
K4.Notificaciones the ones that arrive, with the llego() signal notificaciones
K4.Red Wi‑Fi and Bluetooth — read-only, no exceptions —
K4.Escritorios which ones exist and which one you are on —
K4.Portapapeles the history — just reading already requires portapapeles portapapeles
K4.Reloj the time, from the bar's single clock —

The line is drawn by the effect, not the module: looking at the volume does nothing to anyone, raising it does. The clipboard goes the other way because it holds passwords and tokens — there, the delicate part is reading. And connecting to a network or pairing a device does not open even with a permission: a mistake there costs your connectivity or hands your laptop to a stranger, and no plugin idea makes up for that.

K4.Medios.posicion only advances if someone is watching: call seguirPosicion() when your view mounts and dejarPosicion() when you drop it, or the timer does not run.

With those pieces your plugin has the SAME face as the bar without drawing a rectangle: ejemplos/piezas/ is the showcase, copyable and runnable. And a warning that saves you an afternoon: if you put your list in a plain Flickable and its rows have hover or click, the wheel will not work — a MouseArea accepts the wheel whether it handles it or not — and no error will be raised. That is why K4.Rodillo exists.

For a game: K4.PluginState is the local save and the high score, tecladoAlPasar: true gives you the keys while the pointer is over the island and hands them back when it leaves, and a Timer is the tick. Use grabKeyboard only for something you open, look at and close: a game stays open, and exclusive focus would leave the whole desktop unable to type. Do NOT use tecladoOpcional for keys you expect to just work — on-demand means the compositor only grants them if you CLICK the surface, so a game opened by shortcut never sees a keystroke.

And the second half of the keyboard, which is a separate problem: the layer having the keys does not mean your view receives one. The island's root also claims focus — that is where ESC lives — so claim it back with K4.FocoInicial, and not only on open: with tecladoAlPasar the keys arrive when the pointer enters, and by then FocoInicial has already given up.

property var foco: K4.FocoInicial { objetivo: raiz }
Component.onCompleted: foco.reclamar()
HoverHandler { onHoveredChanged: if (hovered) raiz.foco.reclamar() }

Four more things every game needs, and none of them raises an error when missing:

  • function close() — the host closes the active module on ESC by calling it. Without it the key does nothing and your view becomes a trap.
  • handlesBackgroundTap: true with an empty onBackgroundTapped — or a click on any gap in your view opens the control center.
  • closeOnHoverExit: false — nothing must vanish when the pointer leaves mid-use.
  • Anything you put in services/ is NOT hot-reloaded: pluginReload reloads your plugin directory only, so rule changes need the bar restarted.

The repo's own examples (ejemplos/) demonstrate these capabilities. Use the drawing primitives available from QtQuick, such as AnimatedSprite, SpriteSequence, ShaderEffect and Canvas. Features requiring another module need a public K4 wrapper or a host service. Some existing examples and the current checker still allow broader imports; migrate those callers rather than treating the enforcement gap as permission for new private or platform imports.

3b · Showing up in places that are not yours

A plugin does not have to live only inside its island.

Your settings, in Settings. Without this, two options forced you to invent a screen, a button to open it and a way to save them — and the user had to learn a new place for every plugin.

K4.Ajustes {
    plugin: "hola"
    grupo: "Hello"
    opciones: [{ id: "saludar", nombre: "Greet on open",
                 desc: "Otherwise just show the counter",
                 glifo: 0xF1821 }]
    valores: ({ saludar: self.saludar })
    onCambiado: function (id, valor) { self.saludar = valor; self.apuntar() }
}

YOU keep the values: the bar asks through valores and notifies through cambiado. That way what is shown is always what you actually have saved, and not a copy that desynchronizes on the first failed write.

A switch per option is the default; tipo unlocks the other two:

opciones: [
    { id: "modelo", tipo: "eleccion", nombre: "Model",
      desc: "Which one answers", glifo: 0xF06A9,
      alternativas: [{ codigo: "rapido", nombre: "Fast" },
                     { codigo: "capaz",  nombre: "Capable" }] },
    { id: "clave", tipo: "texto", secreto: true,
      nombre: "API key",
      desc: "Saved wherever you say; the bar does not keep it",
      pista: "sk-…", glifo: 0xF0306 }
]

opciones is live: change it and the section redraws. An empty list hides the whole section, which is how you say "this does not apply right now" — useful when your options depend on something you only learn later, like whether a program is installed or whether there is network. It has to be a binding for that, not a value computed once:

// Without the required program, the entire section stays hidden.
opciones: !Consola.esNuestra ? [] : [ /* … */ ]

A choice shows its alternativas as chips and cambiado delivers the chosen codigo. A text is a free field — a URL, a model, a key: pista is the empty field's gray, secreto: true masks it with dots once typing stops, and the value arrives on confirm — Enter or a click outside — not keystroke by keystroke. With this, a plugin that talks to a service, an AI or a CLI configures itself in Settings like everything else, without inventing a screen of its own.

Your news, on the capsule itself. An indicator chip is small on purpose; some things deserve to be read from across the room. While your extension is set, the pill grows toward a screen edge carrying your text, and folds back when it is null:

K4.Capsule {
    plugin: "rec"
    extension: grabando ? ({
        lado: "right", texto: "Recording",
        glifo: 0xF037E, color: K4.Tema.rojo,
        maxLength: 300            // px the capsule may grow to
    }) : null
}

The width is not yours to fight for: the bar hugs your text with the pill's own font and caps it at maxLength and at the room to the edge. The pill's own content does not move, and while a deployed view owns the island the extension folds with the pill. extension must be a binding — a new object when your state changes — because mutating the old one in place tells no one.

Your pages, in Settings. A row of options is a comment; sometimes the work wants the whole screen. K4.Pagina contributes a page to Settings — its sidebar, its search, its scroll — nested under a family or as a section of your own:

K4.Pagina {
    plugin: "hola"
    name: "gretings"            // unique within YOUR plugin
    titulo: "Greetings"
    glifo: 0xF02FC
    desc: "How this plugin greets the desktop"
    claves: ["hello", "salute"]
    componente: Component { MiPagina {} }
}

The componente is instantiated only while its page is on screen, in your plugin's own context. Root it in a layout that reports implicitHeight and the window sizes and scrolls it for you. The page leaves the sidebar AND the search the moment your plugin is off — nobody renders a page whose author is gone.

Your blocks, in the control centre. The centre that opens from the bar is not a closed list: K4.Card puts one block of yours among the natives — toggles, media, shortcuts — wherever the stored order says, with the same width and the same editor. The user reorders and hides it in Settings → Control centre like any native block; visibility is theirs, not yours.

K4.Card {
    plugin: "correo"
    name: "unread"              // unique within YOUR plugin
    titulo: "Mail"
    glifo: 0xF01EE
    alto: 64                    // px the card occupies
    component: Component { MiFila {} }
    detailTitle: "Mail"
    detail: Component { MailDetails {} }
}

alto is the height the card OCCUPIES — fixed, like the native blocks' own heights — and the centre hands you exactly that room: fill it, don't fight it (width: parent.width, height: parent.height on the root). An optional detail stays plugin-owned but renders inside the centre. Call openDetail() from the card; detailTitle labels the shared header, Back and Escape return to the card list, and unloading the plugin closes the detail. The centre knows the card as "<plugin>.<name>". A card-only plugin has no view and never asks for the island's stage — it exists to put one block in the centre and that is all. ejemplos/worldclock/ ships a working card.

Your results, in the launcher. You answer when you can; if yours is expensive — a network query — you block nobody. Yours shows up below the system's applications: that panel is theirs, and you are there to be findable, not to compete.

K4.Lanzador {
    plugin: "hola"
    onBuscando: function (texto) {
        resultados = texto.length < 2 ? []
            : [{ id: "abrir", titulo: "Open Hello", desc: "…" }]
    }
    onElegido: function (id) { self.abierto = true }
}

3c · The island as a stage

Three pieces turn the bar into part of the game, not just its frame.

Tinting the ambience. K4.Tema.tintar(tuId, color, fuerza, duracionMs) tints the whole neutral scaffold — island, surfaces, tracks — and everything painted with the theme recolors itself. The ink and the colors with meaning (green, red…) are not touched, so text stays readable and an alert stays an alert. Strength is capped at 0.45 by the host; duracionMs 0 means "until you call destintar(tuId)", and disabling your plugin untints on its own. The current tint is readable back: K4.Tema.tinteDueno names whoever holds it and K4.Tema.tinteColor is its colour — the wallpaper palette uses exactly this to dress the bar from your image.

K4.Tema.tintar("mi-juego", "#26324f", 0.35, 4000)   // Abyss tint, 4 seconds.

Asking for gestures. K4.Isla.efecto(tuId, nombre, fuerza) moves the island like a physical object: "sacudida" (a hit), "empujon" (something heavy lands on it), "tiron" (something pulls at it, like a fish on the line). The host animates and arbitrates: one gesture every half second at most. The rare effect impresses because the bar is sober the rest of the time — ask for it at the moment that matters and let it breathe.

Painting outside. K4.Isla.rect gives the island's real on-screen geometry ({ x, y, ancho, alto }, the primary one if there are several); with several monitors, K4.Isla.rectEn(name) gives each screen's own, and a K4.Ventana picks its monitor with pantalla. With a transparent K4.Ventana above everything and that rect, anything can peek over the edge, fall off the bar or stroll across it — a waving hand, the pet climbing down. ejemplos/efectos/ ships all three pieces working, hand included.

And since the bar no longer lives only at the top — the edge is chosen in Settings — K4.Isla.posicion says which one it is ("arriba" or "abajo"). The host takes care of its side (anchoring, flipped silhouette, gestures pointing into the screen); your side is reading rect and posicion instead of assuming up is up.

Sliding it along the edge. The island is not nailed to the center either: the user picks its alignment in Settings, and a plugin can move it TEMPORARILY with K4.Isla.colocar(tuId, fraccion, duracionMs) — 0 flush left, 1 flush right, animated with the same spring as opening. It returns to the user's base on its own: on timeout, with soltar(tuId), or when your plugin is disabled. It is for what a scene lasts — the island dodging a hit, playing paddle, stepping aside to show something behind — not for staying: the permanent position belongs to the user. K4.Isla.colocacion tells the effective fraction right now.

And K4.Isla to know whether you are on display: abierta, ocupadaPor, raton, altoMaximo. Read-only — who holds the island is decided by the host comparing priorities, which is the only way two plugins do not fight over the screen. Use it to skip animating and polling when nobody is watching.

Taking the stage while the mouse rests. A hover view is not a separate API — it is the ladder plus one readable fact. K4.Isla.raton says whether the mouse is on the pill, and binding active to it takes the stage exactly the way the clock does:

K4.Plugin {
    priority: 51                 // pick your slot, see below
    active: habilitado && K4.Isla.raton
    islandWidth: 300; islandHeight: 84
    view: Component { PeekView { plugin: self } }
}

Pick the priority by who you want to beat: 1–39 stays under the clock (you show when the clock plugin is off), 51–54 stands over the clock and under the player, 56–58 over the player too. Leaving is the binding's job — raton clears a moment after the mouse goes, active follows, and the stage returns to the pill; closeOnHoverExit is for summoned views and a hover view never needs it. ejemplos/hoverpeek/ ships one working.

All three deregister on their own when your plugin is destroyed — disabled, reloaded, uninstalled — and the manager also sweeps by id: a Settings row that calls a dead plugin cannot exist.

3b · Commands: IPC targets

An K4.Ipc target is shared between every plugin on the bar. Two plugins cannot hold k4.notes: the compositor and Quickshell hand it to whoever registered first, and the loser stays loaded, without errors, simply not answering. That failure is invisible — it only shows up in the log.

So the bar checks it for you. tools/plugins.py reads the targets out of your QML — not out of the manifest, because what is registered is what the code says — and cross-checks them against everything already installed. Whoever asks for a taken command is listed as not loadable, with the reason:

  - notas: command k4.launcher is already registered by «launcher»

Repo plugins come first in the combined catalog, so an outside plugin never takes a command away from one of the bar's own — the same rule that already governs ids. The commands each plugin registers travel in the catalog under comandos, so Settings and the store can show them before you turn anything on.

One thing this does not do yet, and it is better to know: the scan looks for target in the first 400 characters of the block, so a target hidden far below is not seen.

4 · Permissions

The manifest declares what you use; the bar checks it before listing:

Permission What betrays it
procesos K4.Process, K4.Terminal.ejecutar, K4.Terminal.abrir
red XMLHttpRequest, WebSocket
ficheros K4.Fichero
sound K4.Sonido
audio K4.Audio.ponerVolumen, K4.Audio.alternarSilencio
medios K4.Medios.alternarPausa, .siguiente, .anterior, .buscar
notificaciones K4.Notificaciones.limpiar
portapapeles K4.Portapapeles — even just reading it
paginas K4.Pagina — injecting pages into Settings is UI power

All nine, and note where the line falls: ponerVolumen is watched and K4.Audio is not, because looking at the volume does nothing to anyone and changing it does. The clipboard is the only one reversed, because there the delicate part is reading.

Using something without declaring it makes the plugin not loadable, with the reason in Settings. And the honest part: this is not a sandbox. QML runs inside the bar's process and a loaded plugin can do whatever the bar can do. The permissions are informed consent — the user sees them before turning it on — plus an analysis that catches carelessness and simple deception, not a cage. Installing a plugin is trusting its author.

5 · The lifecycle

  1. Outside plugins arrive disabled: they are turned on in Settings (or k4 pluginEnable <id>), seeing description and permissions first.
  2. Disabled = not instantiated: your IPC does not even exist.
  3. If your QML does not compile, the bar starts without you and Settings shows the error with file and line; "retry" reloads from disk after you fix it, without restarting the bar.
  4. python3 tools/plugins.py validates your manifest and permissions without starting anything.

While you write it

The fastest loop does not involve your bar at all:

tools/plugins.py --test hola

That opens only your plugin, in a separate Quickshell instance: no bar, no services, no notifications, none of your session. If it hangs, it hangs there. Click outside to leave. It works for plugins written against the K4 API — which is every plugin from outside — because the API falls back to sane defaults when there is no bar behind it.

When you do want it in the real bar:

quickshell ipc -p ~/.config/quickshell/k4/shell.qml call k4 pluginReload hola

It destroys your plugin and recreates it from disk: edit, reload, look. It does not restart the bar or touch the others. It reloads the WHOLE directory — entry and views — so it works just as well for a view tweak. If the new version does not compile, your plugin is left in error with its reason and the "retry" in Settings; the rest of the bar does not even notice.

An honest warning: reloading destroys your object. Whatever holds state in memory and was not saved with K4.Guardado is lost — which for development is usually exactly what you want.

Native features and repository plugins

The pill, volume HUD, sound mixer, clock, player, notifications, control centre and session are native host features, not plugins: they are always on, live in services/*Island.qml with views in core/, and are described by features/catalog.json. Their ids (idle, volume, sound, clock, player, toast, panel, session, tray) and IPC targets (k4, k4.panel, k4.sound, k4.session, k4.tray) are reserved — an outside plugin claiming one fails validation.

Native notifications use the island while it is idle and a separate surface while another view is active. The separate surface uses the main island's edge-attached silhouette, curved wings and corner treatment, with the same content and action buttons. Settings → Island → Notification popups selects the separate surface's preferred corner (bottom-right by default). It appears on the open island's monitor, stays within the screen, and uses another corner when necessary to avoid the island where space allows. This native transient setting is separate from summoned surfaces' colocable popup settings cards.

Contributing any other module to the bar itself follows the plugin contract, with three differences: the directory goes in plugins/, it is registered in plugins/catalog.json, and the directory carries a qmldir with all its types (Quickshell's URL scheme does not resolve siblings without it — tools/plugins.py warns if it is missing).

Keeping this guide true

This page is the contract, and it does not get to lie. Two checkers read it against the code on every change:

python3 tools/api.py    # every public K4.* type must be mentioned in the quick-ref
python3 tools/docs_check.py   # every K4.<Type>.<member> the docs cite must exist;
                        # every permission and named rule must be in the table

That is why examples here are real paths (ejemplos/…) and why snippets name real members: the checkers verify both. The standard for anyone — human or agent — touching the plugin system is: the change and its documentation land together, and the checkers stay green. A type that is not documented does not exist for whoever builds against this guide; a doc that lies about the code is worse than none.