diff --git a/kineticwe-layouts/README.md b/kineticwe-layouts/README.md new file mode 100644 index 0000000..afd3e75 --- /dev/null +++ b/kineticwe-layouts/README.md @@ -0,0 +1,68 @@ +# KineticWE Layouts + +A bar widget that shows the current [KineticWE](https://github.com/notethene/KineticWE) +tiling layout under KWin and a dropdown panel to switch between the layouts you have +enabled. Rather than guessing, the plugin reads KineticWE's `/Tiling` DBus interface, so +the indicator stays in sync with layout keybinds, per-desktop layouts, and kwinrc changes. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `theblackdon/kineticwe-layouts` | +| Entries | Service: `bridge`; bar widget: `indicator`; panel: `layouts` | + +## Requirements + +- **KDE Plasma (KWin)** with **KineticWE** installed and built the standard way, which + exposes the `/Tiling` DBus interface (`org.kde.KWin.Tiling`). Without it the widget + shows an error tooltip and the panel explains that KineticWE must be rebuilt. +- **`qdbus-qt6`** (falls back to `qdbus6` / `qdbus`) on `PATH` — used to query and set the + current tiling layout. +- **`dbus-monitor`** on `PATH` — used to subscribe to layout and desktop-switch signals. + +## Usage + +Add the widget from **Settings → Bar**, choose a section, and pick **KineticWE Layouts** +from the widget list. The bar shows the active layout's glyph and, with the `show_text` +setting on, its name. Left-click the widget to open the layouts panel, which lists every +layout enabled in kwinrc (`[Tiling] EnabledLayouts`); clicking one switches the active +output's current desktop straight away. + +Open the panel from anywhere with: + +```sh +noctalia msg panel-toggle theblackdon/kineticwe-layouts:layouts +``` + +KineticWE applies layouts per virtual desktop, so the indicator follows the active +output's current desktop as you switch desktops, cycle layouts with keybinds, or change +kwinrc settings. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `show_text` | `bool` | `true` | Show the layout name next to the glyph in the bar. When off, only the glyph is shown. | + +## Notes + +**Processes.** The plugin shells out to two KWin/KDE tools, nothing else: + +- `qdbus-qt6 org.kde.KWin /Tiling org.kde.KWin.Tiling.currentLayout` — on load and + whenever a signal fires, plus a 5-second self-heal poll. +- `qdbus-qt6 org.kde.KWin /Tiling org.kde.KWin.Tiling.enabledLayouts` — on the same + cadence; keeps the panel's list in sync with kwinrc. +- `qdbus-qt6 org.kde.KWin /Tiling org.kde.KWin.Tiling.setLayout ` — only when a + layout is picked in the panel. +- `dbus-monitor --session "type='signal',interface='org.kde.KWin.Tiling'" + "type='signal',interface='org.kde.KWin.VirtualDesktopManager',member='currentChanged'"` + — a single long-lived stream that triggers the re-queries above; updates feel instant + while the 5-second poll doubles as a self-heal if the stream dies. + +**No network access. No filesystem reads or writes.** All state lives in the plugin's +in-memory state channel (`noctalia.state`) and is re-queried from the compositor; nothing +is written to disk. + +**Compositor support.** KWin / KDE Plasma only — KineticWE's tiling DBus interface is +KWin-specific, so the plugin shows a placeholder under any other compositor. diff --git a/kineticwe-layouts/panel.luau b/kineticwe-layouts/panel.luau new file mode 100644 index 0000000..e36d6d8 --- /dev/null +++ b/kineticwe-layouts/panel.luau @@ -0,0 +1,79 @@ +--!nonstrict +-- Layout picker panel: lists the layouts enabled in kwinrc and switches +-- the active output's current desktop on click. + +local KIND_INFO = { + MasterStack = { display = "MasterStack", glyph = "layout-sidebar-right" }, + Stacked = { display = "Stacked", glyph = "layout-rows" }, + CenterTile = { display = "Center Tile", glyph = "layout-columns" }, + Monocle = { display = "Monocle", glyph = "rectangle" }, + AutoGrid = { display = "Auto Grid", glyph = "layout-grid" }, + Columns = { display = "Columns", glyph = "columns-3" }, +} + +local isOpen = false + +local function selectLayout(kind) + local state = noctalia.state.get("layout") + local qdbus = (state and state.qdbus) or "qdbus-qt6" + noctalia.runAsync(qdbus .. " org.kde.KWin /Tiling org.kde.KWin.Tiling.setLayout " .. kind) + panel.close() +end + +local function render() + local state = noctalia.state.get("layout") + + local children = { + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = "Layouts", fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }), + ui.button({ glyph = "close", variant = "ghost", onClick = "onCloseClicked" }), + }), + ui.separator({}), + } + + if state == nil or not state.ok then + table.insert(children, ui.label({ + text = "KineticWE /Tiling DBus interface not found. Rebuild KineticWE and log back in.", + maxLines = 3, + })) + elseif #state.enabled == 0 then + table.insert(children, ui.label({ text = "No layouts enabled in kwinrc ([Tiling] EnabledLayouts)." })) + else + for _, kind in ipairs(state.enabled) do + local info = KIND_INFO[kind] or { display = kind, glyph = "layout-grid" } + table.insert(children, ui.button({ + key = "layout-" .. kind, + text = info.display, + glyph = info.glyph, + variant = (kind == state.current) and "primary" or "default", + contentAlign = "start", + onClick = function() + selectLayout(kind) + end, + })) + end + end + + panel.render(ui.column({ gap = 6, padding = 10 }, children)) +end + +function onOpen(_context) + isOpen = true + render() +end + +function onClose() + isOpen = false +end + +function onCloseClicked() + panel.close() +end + +-- Keep the highlight in sync if the layout changes while the panel is open +-- (e.g. a keybind cycle). +noctalia.state.watch("layout", function() + if isOpen then + render() + end +end) diff --git a/kineticwe-layouts/plugin.toml b/kineticwe-layouts/plugin.toml new file mode 100644 index 0000000..e52f23d --- /dev/null +++ b/kineticwe-layouts/plugin.toml @@ -0,0 +1,38 @@ +id = "theblackdon/kineticwe-layouts" +name = "KineticWE Layouts" +version = "1.0.0" +plugin_api = 9 +author = "theblackdon" +license = "MIT" +icon = "layout-grid" +description = "Shows the current KineticWE tiling layout on the bar and switches layouts from a dropdown." +tags = ["bar", "indicator", "service", "system"] +dependencies = ["qdbus-qt6", "dbus-monitor"] + +# Headless bridge: watches the compositor's /Tiling DBus interface and +# publishes the layout state for the widget and panel entries. +[[service]] +id = "bridge" +entry = "service.luau" + +# Bar indicator: glyph + current layout name; click opens the layouts panel. +[[widget]] +id = "indicator" +entry = "widget.luau" + + [[widget.setting]] + key = "show_text" + type = "bool" + label_key = "settings.show_text.label" + description_key = "settings.show_text.description" + default = true + +# Dropdown listing the enabled layouts; attached so it opens from the bar +# next to the widget that was clicked. +[[panel]] +id = "layouts" +entry = "panel.luau" +width = 260 +height = 340 +placement = "attached" +open_near_click = true diff --git a/kineticwe-layouts/service.luau b/kineticwe-layouts/service.luau new file mode 100644 index 0000000..3c8dadc --- /dev/null +++ b/kineticwe-layouts/service.luau @@ -0,0 +1,149 @@ +--!nonstrict +-- KineticWE layout bridge service. +-- +-- Watches the compositor's /Tiling DBus interface (plus virtual-desktop +-- switches, which implicitly change the "current" layout) and publishes a +-- single plain-data table on noctalia.state key "layout" for the widget and +-- panel entries: +-- +-- { ok = true, current = "Columns", enabled = { "MasterStack", ... }, +-- qdbus = "qdbus-qt6" } +-- { ok = false, reason = "missing-qdbus" | "missing-monitor" | "missing-interface" } +-- +-- "current" is the kind string for the active output's current desktop, or +-- "" when native tiling is disabled. + +local KINDS = { + MasterStack = true, + Stacked = true, + CenterTile = true, + Monocle = true, + AutoGrid = true, + Columns = true, +} + +local QDBUS = nil +local refreshing = false +local refreshQueued = false +local lastPublished = nil + +local function findQdbus() + for _, name in ipairs({ "qdbus-qt6", "qdbus6", "qdbus" }) do + if noctalia.commandExists(name) then + return name + end + end + return nil +end + +local function publish(state) + noctalia.state.set("layout", state) + lastPublished = state +end + +-- qdbus prints a QStringList reply as { "MasterStack", "Stacked", ... }. +-- Some builds print bare words instead, so fall back to word splitting and +-- keep only tokens that are real layout kinds. +local function parseKindList(out) + local items, seen = {}, {} + local function add(s) + if KINDS[s] and not seen[s] then + seen[s] = true + table.insert(items, s) + end + end + for s in string.gmatch(out, '"([^"]+)"') do + add(s) + end + if #items == 0 then + for s in string.gmatch(out, "[%a]+") do + add(s) + end + end + return items +end + +local function finishRefresh() + refreshing = false + if refreshQueued then + refreshQueued = false + refresh() + end +end + +function refresh() + if refreshing then + -- Coalesce signal bursts (e.g. one per desktop on a reconcile pass) + -- into a single follow-up query. + refreshQueued = true + return + end + refreshing = true + + if QDBUS == nil then + QDBUS = findQdbus() + if QDBUS == nil then + publish({ ok = false, reason = "missing-qdbus" }) + finishRefresh() + return + end + end + + noctalia.runAsync(QDBUS .. " org.kde.KWin /Tiling org.kde.KWin.Tiling.currentLayout", function(res) + if res.exitCode ~= 0 then + publish({ ok = false, reason = "missing-interface" }) + finishRefresh() + return + end + local current = noctalia.string.trim(res.stdout) + noctalia.runAsync(QDBUS .. " org.kde.KWin /Tiling org.kde.KWin.Tiling.enabledLayouts", function(res2) + if res2.exitCode ~= 0 then + publish({ ok = false, reason = "missing-interface" }) + finishRefresh() + return + end + local enabled = parseKindList(res2.stdout) + local unchanged = lastPublished + and lastPublished.ok + and lastPublished.current == current + and table.concat(lastPublished.enabled, ",") == table.concat(enabled, ",") + if not unchanged then + publish({ ok = true, current = current, enabled = enabled, qdbus = QDBUS }) + end + finishRefresh() + end) + end) +end + +local function onMonitorLine(line) + -- Any of: member=layoutChanged, member=enabledLayoutsChanged, + -- member=currentChanged (desktop switch). Re-query rather than parse + -- arguments: it is cheap and always yields a consistent snapshot. + if string.find(line, "member=") then + refresh() + end +end + +local function startMonitor() + if not noctalia.commandExists("dbus-monitor") then + noctalia.log("dbus-monitor not found; falling back to polling only") + return + end + noctalia.runStream( + "dbus-monitor --session" + .. " \"type='signal',interface='org.kde.KWin.Tiling'\"" + .. " \"type='signal',interface='org.kde.KWin.VirtualDesktopManager',member='currentChanged'\"", + onMonitorLine + ) +end + +-- Startup: query immediately, then keep a slow poll as a self-healing net +-- (recovers when the monitor dies or the compositor gains /Tiling after a +-- rebuild). Signal-driven refreshes make updates feel instant. +refresh() +startMonitor() +noctalia.setUpdateInterval(5000) + +function update() + refresh() +end diff --git a/kineticwe-layouts/thumbnail.webp b/kineticwe-layouts/thumbnail.webp new file mode 100644 index 0000000..b314a89 Binary files /dev/null and b/kineticwe-layouts/thumbnail.webp differ diff --git a/kineticwe-layouts/translations/en.json b/kineticwe-layouts/translations/en.json new file mode 100644 index 0000000..c89a25e --- /dev/null +++ b/kineticwe-layouts/translations/en.json @@ -0,0 +1,8 @@ +{ + "settings": { + "show_text": { + "label": "Show layout name", + "description": "Show the current layout name next to the glyph in the bar. When off, only the glyph is shown." + } + } +} diff --git a/kineticwe-layouts/widget.luau b/kineticwe-layouts/widget.luau new file mode 100644 index 0000000..e21fefb --- /dev/null +++ b/kineticwe-layouts/widget.luau @@ -0,0 +1,64 @@ +--!nonstrict +-- Bar indicator for the current KineticWE tiling layout. +-- Purely reactive: the bridge service publishes state changes. + +local KIND_INFO = { + MasterStack = { display = "MasterStack", glyph = "layout-sidebar-right" }, + Stacked = { display = "Stacked", glyph = "layout-rows" }, + CenterTile = { display = "Center Tile", glyph = "layout-columns" }, + Monocle = { display = "Monocle", glyph = "rectangle" }, + AutoGrid = { display = "Auto Grid", glyph = "layout-grid" }, + Columns = { display = "Columns", glyph = "columns-3" }, +} + +local showText = true + +local function readShowText() + local value = noctalia.getConfig("show_text") + if value == nil then + value = true + end + showText = value +end + +readShowText() + +local function render(state) + if state == nil then + return + end + + if not state.ok then + barWidget.setGlyph("alert-triangle") + barWidget.setText("") + barWidget.setTooltip("KineticWE layouts: /Tiling DBus interface not found — rebuild KineticWE") + return + end + + if state.current == "" then + barWidget.setGlyph("layout-off") + barWidget.setText(showText and "Floating" or "") + barWidget.setTooltip("KineticWE tiling is disabled") + return + end + + local info = KIND_INFO[state.current] + local display = info and info.display or state.current + barWidget.setGlyph(info and info.glyph or "layout-grid") + barWidget.setText(showText and display or "") + barWidget.setTooltip("KineticWE layout: " .. display) +end + +noctalia.state.watch("layout", render) +-- Paint the latest value immediately in case the service published first. +render(noctalia.state.get("layout")) + +-- Apply the show_text setting without a reload. +function onConfigChanged() + readShowText() + render(noctalia.state.get("layout")) +end + +function onClick() + noctalia.togglePanel("theblackdon/kineticwe-layouts:layouts") +end