diff --git a/.gitignore b/.gitignore index ef9b0f2..a6d76d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ contents/ui/config_ui.py -KZones.kwinscript \ No newline at end of file +kzones.kwinscript \ No newline at end of file diff --git a/README.md b/README.md index 69fecef..daa14fc 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ Each **layout** object needs the following keys: - `name`: The name of the layout, shown when cycling between layouts - `padding`: The amount of space between the window and the zone in pixels - `zones`: An array containing all zone objects for this layout +- `screens`: An optional array of monitor connector names (e.g. `["DP-1", "HDMI-A-1"]`) restricting this layout to those monitors. Omit or leave empty to show the layout on every monitor. To discover connector names, bind the **KZones: Show detected monitors** shortcut in `System Settings / Shortcuts` and press it, or run `kscreen-doctor -o`. When any layout sets `screens`, per-screen tracking of the active layout is enabled automatically. Each **zone** object can contain the following keys: @@ -323,6 +324,7 @@ List of all available shortcuts: | Move active window right | Meta + Right | | Snap all windows | Meta + Space | | Snap active window | Meta + Shift + Space | +| Show detected monitors (OSD) | *(unbound — set in System Settings → Shortcuts)* | *To change the default bindings, go to `System Settings / Shortcuts` and search for KZones* diff --git a/src/contents/code/core.mjs b/src/contents/code/core.mjs index 0cb7c30..2fed5b2 100644 --- a/src/contents/code/core.mjs +++ b/src/contents/code/core.mjs @@ -3,12 +3,80 @@ export let Workspace = null; export let QML = {}; export let config = {}; +// Native-JS reference to parsed layouts. Avoids round-tripping through +// `config.layouts`, which QML coerces to QVariantList and loses Array identity. +let _layoutsRaw = []; + export function init(kwin, workspace) { console.log("KZones: Loading APIs..."); KWin = kwin || null; Workspace = workspace || null; } +export function getScreenId(screen) { + return screen && screen.name ? String(screen.name) : ""; +} + +// QML wraps JS arrays as QVariantList across the property-var boundary, +// which breaks `Array.isArray`. +function isArrayLike(x) { + return x != null && typeof x !== "string" && typeof x.length === "number"; +} + +// Missing/empty `screens` means: apply to every monitor (back-compat). +export function layoutAppliesToScreen(layout, screenId) { + if (!layout) return true; + const s = layout.screens; + if (!isArrayLike(s) || s.length === 0) return true; + for (let i = 0; i < s.length; i++) { + if (String(s[i]) === screenId) return true; + } + return false; +} + +// Returns [{ layout, index }, ...] visible on `screenId`. `index` is the +// position in the unfiltered layouts array so callers can keep referring to +// layouts by their original index. +export function getLayoutsForScreen(screenId) { + const out = []; + const layouts = _layoutsRaw; + if (!isArrayLike(layouts)) return out; + for (let i = 0; i < layouts.length; i++) { + if (layoutAppliesToScreen(layouts[i], screenId)) + out.push({ layout: layouts[i], index: i }); + } + return out; +} + +// Enumerate attached monitors. Surfaces connector names users need for the +// per-layout `screens` field; KWin scripting can't auto-populate the KCM. +export function getDetectedScreens() { + if (!Workspace) return []; + let screens = []; + try { + if (Array.isArray(Workspace.screens)) { + screens = Workspace.screens; + } else if (Workspace.screens && typeof Workspace.screens.length === "number") { + for (let i = 0; i < Workspace.screens.length; i++) + screens.push(Workspace.screens[i]); + } else if (typeof Workspace.numScreens === "number" && typeof Workspace.screenAt === "function") { + for (let i = 0; i < Workspace.numScreens; i++) + screens.push(Workspace.screenAt(i)); + } + } catch (e) { + console.error("KZones: getDetectedScreens enumeration failed:", e); + return []; + } + return screens.map(s => { + const g = (s && s.geometry) || {}; + return { + name: (s && s.name) ? String(s.name) : "", + width: g.width || 0, + height: g.height || 0 + }; + }); +} + export function registerQMLComponent(name, component) { console.log("KZones: Registering QML component:", name); try { @@ -49,6 +117,11 @@ export function loadConfig() { // TODO: Notify user about invalid config and using defaults instead layouts = defaultLayouts; } + _layoutsRaw = layouts; + + // Any screen-scoped layout forces per-screen tracking; otherwise switching + // screens could land on an index hidden by the filter. + const anyScopedLayout = layouts.some(l => isArrayLike(l && l.screens) && l.screens.length > 0); config.enableZoneSelector = KWin.readConfig("enableZoneSelector", true); config.zoneSelectorTriggerDistance = KWin.readConfig("zoneSelectorTriggerDistance", 1); @@ -59,7 +132,7 @@ export function loadConfig() { config.enableEdgeSnapping = KWin.readConfig("enableEdgeSnapping", false); config.edgeSnappingTriggerDistance = KWin.readConfig("edgeSnappingTriggerDistance", 1); config.rememberWindowGeometries = KWin.readConfig("rememberWindowGeometries", true); - config.trackLayoutPerScreen = KWin.readConfig("trackLayoutPerScreen", false); + config.trackLayoutPerScreen = KWin.readConfig("trackLayoutPerScreen", false) || anyScopedLayout; config.trackLayoutPerDesktop = KWin.readConfig("trackLayoutPerDesktop", false); config.showOsdMessages = KWin.readConfig("showOsdMessages", true); config.fadeWindowsWhileMoving = KWin.readConfig("fadeWindowsWhileMoving", false); diff --git a/src/contents/ui/components/Debug.qml b/src/contents/ui/components/Debug.qml index ccff7d6..b62ee69 100644 --- a/src/contents/ui/components/Debug.qml +++ b/src/contents/ui/components/Debug.qml @@ -1,6 +1,6 @@ +import "../components" as Components import QtQuick import QtQuick.Layouts -import "../components" as Components ColumnLayout { property var config: new Object() diff --git a/src/contents/ui/components/Indicator.qml b/src/contents/ui/components/Indicator.qml index 0c417cf..9d6fcf3 100644 --- a/src/contents/ui/components/Indicator.qml +++ b/src/contents/ui/components/Indicator.qml @@ -1,5 +1,5 @@ -import QtQuick import "../components" as Components +import QtQuick Rectangle { id: indicator diff --git a/src/contents/ui/components/Selector.qml b/src/contents/ui/components/Selector.qml index c0cda35..57da70d 100644 --- a/src/contents/ui/components/Selector.qml +++ b/src/contents/ui/components/Selector.qml @@ -1,6 +1,6 @@ +import "../components" as Components import QtQuick import QtQuick.Layouts -import "../components" as Components Item { id: selector @@ -8,6 +8,9 @@ Item { property var config property int currentLayout property int highlightedZone + // [{ layout, index }, ...] — the layouts to display for the active screen. + // `index` is the position in the unfiltered config.layouts. + property var availableLayouts: [] property bool expanded: false property bool near: false property bool animating: false @@ -43,14 +46,14 @@ Item { Repeater { id: repeater - model: config.layouts + model: availableLayouts Components.Indicator { - zones: modelData.zones - activeZone: (currentLayout == index) ? highlightedZone : -1 + zones: modelData.layout.zones + activeZone: (currentLayout === modelData.index) ? highlightedZone : -1 width: 160 - 30 height: 100 - 30 - hovering: (currentLayout == index) + hovering: (currentLayout === modelData.index) } } diff --git a/src/contents/ui/components/Shortcuts.qml b/src/contents/ui/components/Shortcuts.qml index 69f60c2..66a09ab 100644 --- a/src/contents/ui/components/Shortcuts.qml +++ b/src/contents/ui/components/Shortcuts.qml @@ -17,6 +17,7 @@ Item { signal moveActiveWindowRight() signal snapActiveWindow() signal snapAllWindows() + signal showDetectedMonitors() ShortcutHandler { name: "KZones: Cycle layouts" @@ -169,4 +170,14 @@ Item { } } + ShortcutHandler { + name: "KZones: Show detected monitors" + text: "KZones: Show detected monitors" + // No default binding — bind in System Settings → Shortcuts. + sequence: "" + onActivated: { + showDetectedMonitors(); + } + } + } diff --git a/src/contents/ui/components/Zones.qml b/src/contents/ui/components/Zones.qml index 1fa5837..c5e5db5 100644 --- a/src/contents/ui/components/Zones.qml +++ b/src/contents/ui/components/Zones.qml @@ -1,6 +1,6 @@ +import "../components" as Components import QtQuick import QtQuick.Layouts -import "../components" as Components Item { id: zones diff --git a/src/contents/ui/config.ui b/src/contents/ui/config.ui index 80866a8..2949054 100644 --- a/src/contents/ui/config.ui +++ b/src/contents/ui/config.ui @@ -361,6 +361,16 @@ Layouts + + + + Add an optional "screens" array (e.g. ["DP-1", "HDMI-A-1"]) to a layout to restrict it to specific monitors. Omit or leave empty to show on every monitor. Bind the "Show detected monitors" shortcut in System Settings → Shortcuts to discover connector names. + + + true + + + diff --git a/src/contents/ui/main.qml b/src/contents/ui/main.qml index ea9b7d3..00f64ef 100644 --- a/src/contents/ui/main.qml +++ b/src/contents/ui/main.qml @@ -1,11 +1,11 @@ -import QtQuick -import QtQuick.Layouts -import org.kde.plasma.core as PlasmaCore -import org.kde.plasma.components as PlasmaComponents -import org.kde.kwin import "../code/core.mjs" as Core import "../code/utils.mjs" as Utils +import QtQuick +import QtQuick.Layouts import "components" as Components +import org.kde.kwin +import org.kde.plasma.components as PlasmaComponents +import org.kde.plasma.core as PlasmaCore Item { id: root @@ -21,15 +21,35 @@ Item { property var screenLayouts: new Object() property int highlightedZone: -1 property var activeScreen: null + // [{ layout, index }, ...] — layouts visible on activeScreen. `index` is + // the position in the unfiltered config.layouts so existing references + // (client.layout, repeaterLayout.itemAt(...), etc.) stay valid. + property var availableLayouts: [] property bool showZoneOverlay: config.zoneOverlayShowWhen == 0 function refreshClientArea() { activeScreen = Workspace.activeScreen; clientArea = Workspace.clientArea(KWin.FullScreenArea, activeScreen, Workspace.currentDesktop); displaySize = Workspace.virtualScreenSize; + refreshAvailableLayouts(); currentLayout = getCurrentLayout(); } + function refreshAvailableLayouts() { + availableLayouts = Core.getLayoutsForScreen(Core.getScreenId(activeScreen)); + } + + function firstAvailableIndex() { + return availableLayouts.length > 0 ? availableLayouts[0].index : 0; + } + + function isLayoutAvailable(idx) { + for (let i = 0; i < availableLayouts.length; i++) if (availableLayouts[i].index === idx) { + return true; + } + return false; + } + function matchZone(client) { refreshClientArea(); client.zone = -1; @@ -313,15 +333,21 @@ Item { function getCurrentLayout() { if (config.trackLayoutPerScreen || config.trackLayoutPerDesktop) { const key = getLayoutKey(); - if (!screenLayouts[key]) - screenLayouts[key] = 0; + if (screenLayouts[key] === undefined || !isLayoutAvailable(screenLayouts[key])) + screenLayouts[key] = firstAvailableIndex(); return screenLayouts[key]; } + if (!isLayoutAvailable(currentLayout)) + return firstAvailableIndex(); + return currentLayout; } function setCurrentLayout(layout) { + if (!isLayoutAvailable(layout)) + return ; + if (config.trackLayoutPerScreen || config.trackLayoutPerDesktop) screenLayouts[getLayoutKey()] = layout; @@ -554,13 +580,13 @@ Item { // zone selector if (config.enableZoneSelector) { if (!zoneSelector.animating && zoneSelector.expanded) { - zoneSelector.repeater.model.forEach((layout, layoutIndex) => { - const layoutItem = zoneSelector.repeater.itemAt(layoutIndex); - layout.zones.forEach((zone, zoneIndex) => { + zoneSelector.repeater.model.forEach((entry, repeaterIndex) => { + const layoutItem = zoneSelector.repeater.itemAt(repeaterIndex); + entry.layout.zones.forEach((zone, zoneIndex) => { const zoneItem = layoutItem.children[zoneIndex]; if (Utils.isHovering(zoneItem)) { hoveringZone = zoneIndex; - setCurrentLayout(layoutIndex); + setCurrentLayout(entry.index); } }); }); @@ -645,7 +671,10 @@ Item { "oldGeometry": Workspace.activeWindow && Workspace.activeWindow.oldGeometry, "activeScreen": activeScreen && activeScreen.name, "currentLayout": currentLayout, - "screenLayouts": screenLayouts + "screenLayouts": screenLayouts, + "availableLayouts": availableLayouts.map((e) => { + return e.index + ":" + e.layout.name; + }) }) config: root.config } @@ -662,7 +691,7 @@ Item { currentLayout: root.currentLayout highlightedZone: root.highlightedZone layoutIndex: index - visible: index == root.currentLayout + visible: index === root.currentLayout && root.isLayoutAvailable(index) } } @@ -673,6 +702,7 @@ Item { config: root.config currentLayout: root.currentLayout highlightedZone: root.highlightedZone + availableLayouts: root.availableLayouts } } @@ -683,12 +713,26 @@ Item { Components.Shortcuts { onCycleLayouts: { - setCurrentLayout((currentLayout + 1) % config.layouts.length); + if (availableLayouts.length === 0) + return ; + + const pos = availableLayouts.findIndex((e) => { + return e.index === currentLayout; + }); + const next = availableLayouts[(pos + 1) % availableLayouts.length].index; + setCurrentLayout(next); highlightedZone = -1; Utils.osd(osdLayoutName()); } onCycleLayoutsReversed: { - setCurrentLayout((currentLayout - 1 + config.layouts.length) % config.layouts.length); + if (availableLayouts.length === 0) + return ; + + const pos = availableLayouts.findIndex((e) => { + return e.index === currentLayout; + }); + const prev = availableLayouts[(pos - 1 + availableLayouts.length) % availableLayouts.length].index; + setCurrentLayout(prev); highlightedZone = -1; Utils.osd(osdLayoutName()); } @@ -726,12 +770,13 @@ Item { moveClientToZone(Workspace.activeWindow, zone); } onActivateLayout: { - if (layout <= config.layouts.length - 1) { - setCurrentLayout(layout); + if (layout >= 0 && layout < availableLayouts.length) { + setCurrentLayout(availableLayouts[layout].index); highlightedZone = -1; Utils.osd(osdLayoutName()); } else { - Utils.osd(`Layout ${layout + 1} does not exist`); + const screenName = activeScreen && activeScreen.name ? activeScreen.name : "this screen"; + Utils.osd(`Layout ${layout + 1} does not exist on ${screenName}`); } } onMoveActiveWindowUp: { @@ -752,6 +797,17 @@ Item { onSnapAllWindows: { moveAllClientsToClosestZone(); } + onShowDetectedMonitors: { + const screens = Core.getDetectedScreens(); + if (screens.length === 0) { + Utils.osd("KZones: no monitors detected"); + return ; + } + const summary = screens.map((s) => { + return `${s.name} (${s.width}x${s.height})`; + }).join(" "); + Utils.osd("Monitors: " + summary); + } } DBusCall { @@ -778,6 +834,14 @@ Item { } + function onActiveScreenChanged() { + refreshClientArea(); + } + + function onScreensChanged() { + refreshClientArea(); + } + function onWindowAdded(client) { connectSignals(client); // check if client is in a zone application list diff --git a/src/metadata.json b/src/metadata.json index 0426b47..91256cf 100644 --- a/src/metadata.json +++ b/src/metadata.json @@ -16,7 +16,7 @@ "ServiceTypes": [ "KWin/Script" ], - "Version": "0.9.3", + "Version": "0.10.0", "Website": "https://github.com/gerritdevriese/kzones" }, "X-KDE-ConfigModule": "kwin/effects/configs/kcm_kwin4_genericscripted",