From d62f59f382decb86b7abb9ad36a77895d61bccac Mon Sep 17 00:00:00 2001 From: niri-port Date: Tue, 18 Aug 2026 11:20:33 +0300 Subject: [PATCH 1/7] feat(niri): branch QML layers by compositor, hide unsupported settings - AxctlService: detect compositor via NIRI_SOCKET/socket, no-op for special-workspace - CompositorConfig: niri path -> applyNiriConfig (axctl config apply) - GlobalStates: niri layout = scrolling (skip hyprctl), mark ready - CompositorPanel: hide Shadows section for niri - BindsPanel: hide layout selector for niri - cli.sh: add ambxst install niri / remove niri --- cli.sh | 51 ++++++++++++++++++- modules/globals/GlobalStates.qml | 29 ++++++++--- modules/services/AxctlService.qml | 39 ++++++++++++++ modules/services/CompositorConfig.qml | 46 +++++++++++++++++ .../widgets/dashboard/controls/BindsPanel.qml | 6 +++ .../dashboard/controls/CompositorPanel.qml | 6 ++- 6 files changed, 168 insertions(+), 9 deletions(-) diff --git a/cli.sh b/cli.sh index 8be75abb7..76757e996 100755 --- a/cli.sh +++ b/cli.sh @@ -573,8 +573,36 @@ install) else append_ambxst_hyprland_block "$HYPR_CONF" "$AMBXST_HYPR_CONF_SOURCE" "$AMBXST_HYPR_CONF_BLOCK" fi + elif [ "$TARGET" = "niri" ]; then + NIRI_DIR="$HOME/.config/niri" + NIRI_CONF="$NIRI_DIR/config.kdl" + mkdir -p "$NIRI_DIR" + + # Add spawn-at-startup for ambxst if not present + if ! grep -qF 'spawn-at-startup "ambxst"' "$NIRI_CONF" 2>/dev/null; then + printf '\nspawn-at-startup "ambxst"\n' >>"$NIRI_CONF" + echo "Added spawn-at-startup ambxst to $NIRI_CONF" + else + echo "spawn-at-startup ambxst already present in $NIRI_CONF" + fi + + # Add layer-rule for ambxst blur if not present + if ! grep -qF 'namespace="ambxst"' "$NIRI_CONF" 2>/dev/null; then + cat >>"$NIRI_CONF" <<'EOF' + +layer-rule { + match namespace="ambxst" + background-effect { + blur true + } +} +EOF + echo "Added ambxst layer-rule to $NIRI_CONF" + else + echo "ambxst layer-rule already present in $NIRI_CONF" + fi else - echo "Error: Unknown target '$TARGET'. Supported: hyprland" + echo "Error: Unknown target '$TARGET'. Supported: hyprland, niri" exit 1 fi ;; @@ -587,8 +615,27 @@ remove) remove_ambxst_hyprland_block "$HYPR_LUA" "$AMBXST_HYPR_LUA_SOURCE" remove_ambxst_hyprland_block "$HYPR_CONF" "$AMBXST_HYPR_CONF_SOURCE" + elif [ "$TARGET" = "niri" ]; then + NIRI_CONF="$HOME/.config/niri/config.kdl" + # Remove spawn-at-startup ambxst + sed -i '/spawn-at-startup "ambxst"/d' "$NIRI_CONF" 2>/dev/null + # Remove ambxst layer-rule block + awk ' + /^layer-rule \{/ { in_block=1; buf=$0"\n"; next } + in_block { + buf = buf $0 "\n" + if ($0 ~ /^}/) { + if (buf ~ /namespace="ambxst"/) { buf="" } + else { printf "%s", buf } + in_block=0; buf="" + } + next + } + { print } + ' "$NIRI_CONF" >"$NIRI_CONF.tmp" && mv "$NIRI_CONF.tmp" "$NIRI_CONF" + echo "Removed ambxst block from $NIRI_CONF" else - echo "Error: Unknown target '$TARGET'. Supported: hyprland" + echo "Error: Unknown target '$TARGET'. Supported: hyprland, niri" exit 1 fi ;; diff --git a/modules/globals/GlobalStates.qml b/modules/globals/GlobalStates.qml index 1126a2bf3..bf03a693d 100644 --- a/modules/globals/GlobalStates.qml +++ b/modules/globals/GlobalStates.qml @@ -82,10 +82,14 @@ Singleton { } } - function setCompositorLayout(layout) { - if (availableLayouts.includes(layout)) { - compositorLayout = layout; - StateService.set("compositorLayout", layout); + // niri has a single scrollable-tiling layout; skip hyprctl and mark ready. + Connections { + target: AxctlService + function onCompositorChanged() { + if (AxctlService.compositor === "niri") { + root.compositorLayout = "scrolling"; + root.compositorLayoutReady = true; + } } } @@ -95,13 +99,26 @@ Singleton { setCompositorLayout(availableLayouts[nextIndex]); } + function setCompositorLayout(layout) { + if (availableLayouts.includes(layout)) { + compositorLayout = layout; + StateService.set("compositorLayout", layout); + } + } + // Ensure LockscreenService singleton is loaded Component.onCompleted: { // Reference the singleton to ensure it loads LockscreenService.toString(); - // Fetch the active layout from the compositor - getLayoutProcess.running = true; + // If niri is already detected, set layout immediately (skip hyprctl). + if (AxctlService.compositor === "niri") { + root.compositorLayout = "scrolling"; + root.compositorLayoutReady = true; + } else { + // Fetch the active layout from the compositor + getLayoutProcess.running = true; + } } // Persistent launcher state across monitors diff --git a/modules/services/AxctlService.qml b/modules/services/AxctlService.qml index 49218b448..cf80eb72d 100644 --- a/modules/services/AxctlService.qml +++ b/modules/services/AxctlService.qml @@ -27,9 +27,44 @@ Singleton { signal rawEvent(var event) + // Detected compositor: "hyprland" | "niri" | "mango" | "unknown" + property string compositor: "unknown" + // Config path for axctl daemon property string configPath: (Quickshell.env("XDG_DATA_HOME") || (Quickshell.env("HOME") + "/.local/share")) + "/ambxst/axctl.toml" + // Detect the active compositor by checking for its IPC socket. + function detectCompositor() { + const niriSock = Quickshell.env("NIRI_SOCKET") || ""; + if (niriSock) { + root.compositor = "niri"; + return; + } + // Fallback: glob /run/user//niri*.sock + const uid = Quickshell.env("UID") || "1000"; + const glob = "/run/user/" + uid + "/niri*.sock"; + detectProcess.command = ["sh", "-c", "ls " + glob + " 2>/dev/null | head -1"]; + detectProcess.running = true; + } + + property Process detectProcess: Process { + running: false + stdout: StdioCollector { + onStreamFinished: { + const path = text.trim(); + if (path) { + root.compositor = "niri"; + } else { + root.compositor = "hyprland"; + } + } + } + } + + Component.onCompleted: { + root.detectCompositor(); + } + function dispatch(command) { if (!command) return; @@ -59,6 +94,10 @@ Singleton { } else if (action === "focusmonitor") { cmdArgs = ["monitor", "focus", rawArgs]; } else if (action === "togglespecialworkspace") { + // niri has no special workspaces; no-op to avoid an axctl error. + if (root.compositor === "niri") { + return; + } cmdArgs = ["workspace", "toggle-special"]; if (rawArgs) cmdArgs.push(rawArgs); } else { diff --git a/modules/services/CompositorConfig.qml b/modules/services/CompositorConfig.qml index 86c0a9bec..61d04393b 100644 --- a/modules/services/CompositorConfig.qml +++ b/modules/services/CompositorConfig.qml @@ -75,6 +75,45 @@ QtObject { applyTimer.restart(); } + // niri path: build a universal appearance payload and let axctl's KDL + // generator write ambxst-generated.kdl + include + reload. + function applyNiriConfig() { + const gapsIn = Config.compositor.gapsIn !== undefined ? Config.compositor.gapsIn : 8; + const borderWidth = Config.compositorBorderSize !== undefined ? Config.compositorBorderSize : 2; + const rounding = Config.compositorRounding !== undefined ? Config.compositorRounding : 12; + + // Resolve border colors (single color for niri; gradients unsupported). + let activeColor = "#33b1ff"; + const borderColors = Config.compositor.syncBorderColor ? [Config.compositorBorderColor] : Config.compositor.activeBorderColor; + if (borderColors && borderColors.length > 0) { + const resolved = Config.resolveColor(borderColors[0]); + activeColor = (typeof resolved === 'string') ? resolved : "#33b1ff"; + } + + const payload = { + appearance: { + gaps: { inner: gapsIn }, + border: { + width: borderWidth, + active_color: activeColor, + rounding: rounding + } + } + }; + + niriApplyProcess.command = ["axctl", "config", "apply", JSON.stringify(payload)]; + niriApplyProcess.running = true; + } + + property Process niriApplyProcess: Process { + running: false + stdout: SplitParser { + onRead: (data) => { + if (data) console.log("CompositorConfig[niri]:", data); + } + } + } + function applyCompositorConfigInternal() { // Ensure adapters are loaded before applying config. if (!Config.loader.loaded) { @@ -88,6 +127,13 @@ QtObject { return; } + // niri path: build a universal payload and let axctl's KDL generator + // write ambxst-generated.kdl + include + reload. No Hyprland keywords. + if (AxctlService.compositor === "niri") { + applyNiriConfig(); + return; + } + // Determine active colors. let activeColorFormatted = ""; // Force compositorBorderColor if syncBorderColor is enabled, otherwise use configured list (supports gradients). diff --git a/modules/widgets/dashboard/controls/BindsPanel.qml b/modules/widgets/dashboard/controls/BindsPanel.qml index 6655f2052..f816b28c2 100644 --- a/modules/widgets/dashboard/controls/BindsPanel.qml +++ b/modules/widgets/dashboard/controls/BindsPanel.qml @@ -6,6 +6,7 @@ import QtQuick.Layouts import Quickshell.Io import qs.modules.theme import qs.modules.components +import qs.modules.services import qs.config import "../../../../config/KeybindActions.js" as KeybindActions @@ -1696,6 +1697,8 @@ Item { font.weight: Font.Medium color: Colors.overSurfaceVariant Layout.topMargin: 8 + // niri has a single scrollable-tiling layout; no selector. + visible: AxctlService.compositor !== "niri" } Text { @@ -1704,11 +1707,14 @@ Item { font.pixelSize: Styling.fontSize(-2) color: Colors.overSurfaceVariant Layout.topMargin: -4 + visible: AxctlService.compositor !== "niri" } Flow { Layout.fillWidth: true spacing: 8 + // niri has a single scrollable-tiling layout; no selector. + visible: AxctlService.compositor !== "niri" Repeater { model: root.availableLayouts diff --git a/modules/widgets/dashboard/controls/CompositorPanel.qml b/modules/widgets/dashboard/controls/CompositorPanel.qml index dfa858be6..8876f9587 100644 --- a/modules/widgets/dashboard/controls/CompositorPanel.qml +++ b/modules/widgets/dashboard/controls/CompositorPanel.qml @@ -8,6 +8,7 @@ import Quickshell import qs.modules.theme import qs.modules.components import qs.modules.globals +import qs.modules.services import qs.config Item { @@ -660,6 +661,8 @@ Item { SectionButton { text: "Shadows" sectionId: "shadows" + // niri does not render window shadows. + visible: AxctlService.compositor !== "niri" } SectionButton { text: "Blur" @@ -835,7 +838,8 @@ Item { // Shadows Section ColumnLayout { - visible: root.currentSection === "shadows" + // niri does not render window shadows. + visible: root.currentSection === "shadows" && AxctlService.compositor !== "niri" Layout.fillWidth: true spacing: 8 From 1d9889328ac6b35c51ef4e004dccf393b402a5df Mon Sep 17 00:00:00 2001 From: niri-port Date: Tue, 18 Aug 2026 13:29:48 +0300 Subject: [PATCH 2/7] fix(overview): use real workspace count and ids, add workspace names - ScrollingOverview: totalWorkspaces now reads from AxctlService.workspaces instead of Config.overview.rows*columns (which was wrong on niri) - Repeater uses the real workspace list; workspaceId/name come from axctl instead of index+1 (niri ids are not contiguous) - ScrollingWorkspace: render the workspace name as a top-left label --- .../widgets/overview/ScrollingOverview.qml | 19 ++++++++++++--- .../widgets/overview/ScrollingWorkspace.qml | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/modules/widgets/overview/ScrollingOverview.qml b/modules/widgets/overview/ScrollingOverview.qml index 66b6a4651..7d548c276 100644 --- a/modules/widgets/overview/ScrollingOverview.qml +++ b/modules/widgets/overview/ScrollingOverview.qml @@ -15,7 +15,13 @@ Item { // Config values readonly property real scale: Config.overview.scale - readonly property int totalWorkspaces: Config.overview.rows * Config.overview.columns + // Number of workspaces: prefer the real list from axctl, fall back to the + // configured grid. niri creates exactly the workspaces it uses (plus one + // empty), so the config rows*columns number is wrong in practice. + readonly property int totalWorkspaces: { + const ws = AxctlService.workspaces && AxctlService.workspaces.values ? AxctlService.workspaces.values.length : 0; + return ws > 0 ? ws : (Config.overview.rows * Config.overview.columns); + } readonly property int visibleWorkspaces: 3 // Show 3 workspaces at a time in viewport readonly property real workspaceSpacing: Config.overview.workspaceSpacing readonly property real workspacePadding: 4 @@ -256,11 +262,18 @@ Item { spacing: workspaceSpacing Repeater { - model: totalWorkspaces + model: AxctlService.workspaces && AxctlService.workspaces.values ? AxctlService.workspaces.values : (function() { + const arr = []; + for (let i = 0; i < totalWorkspaces; i++) arr.push({ id: i + 1, name: "" }); + return arr; + })() delegate: ScrollingWorkspace { id: scrollingWorkspace + required property var modelData required property int index - workspaceId: index + 1 + // Use the real workspace id from axctl (niri ids may not be contiguous) + workspaceId: (modelData && modelData.id !== undefined) ? modelData.id : (index + 1) + workspaceName: (modelData && modelData.name !== undefined) ? String(modelData.name || "") : "" workspaceWidth: scrollingOverviewRoot.workspaceWidth workspaceHeight: scrollingOverviewRoot.workspaceHeight workspacePadding: scrollingOverviewRoot.workspacePadding diff --git a/modules/widgets/overview/ScrollingWorkspace.qml b/modules/widgets/overview/ScrollingWorkspace.qml index 3047e5597..6af72dc0c 100644 --- a/modules/widgets/overview/ScrollingWorkspace.qml +++ b/modules/widgets/overview/ScrollingWorkspace.qml @@ -17,6 +17,7 @@ Item { id: root required property int workspaceId + property string workspaceName: "" required property real workspaceWidth required property real workspaceHeight required property real workspacePadding @@ -730,6 +731,29 @@ Item { } } } + + // Workspace name label (top-left) + Rectangle { + visible: root.workspaceName !== "" + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 4 + implicitWidth: nameText.implicitWidth + 8 + implicitHeight: nameText.implicitHeight + 4 + color: Colors.inverseSurface + opacity: 0.85 + radius: Styling.radius(-2) + + Text { + id: nameText + anchors.centerIn: parent + text: root.workspaceName + font.family: Config.theme.font + font.pixelSize: 10 + font.weight: Font.Bold + color: Colors.inverseOnSurface + } + } } } } From 7fa3cd2f82675c86d3000d566a1cbde290c8e3d2 Mon Sep 17 00:00:00 2001 From: niri-port Date: Tue, 18 Aug 2026 13:48:03 +0300 Subject: [PATCH 3/7] fix(overview): fall back to icon cards on niri (no live preview) niri cannot screencopy a window that sits under the fullscreen overview overlay (ScreencopyView logs 'non captureable object'). Detect niri via AxctlService.compositor and disable live preview, showing the icon card instead of empty window outlines. --- modules/widgets/overview/ScrollingWorkspace.qml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/modules/widgets/overview/ScrollingWorkspace.qml b/modules/widgets/overview/ScrollingWorkspace.qml index 6af72dc0c..9ccf8a6b3 100644 --- a/modules/widgets/overview/ScrollingWorkspace.qml +++ b/modules/widgets/overview/ScrollingWorkspace.qml @@ -289,6 +289,11 @@ Item { return candidates.find(t => t.title === (windowData.title || "")) || candidates[0]; } + // niri cannot screencopy a window that sits under the + // fullscreen overview overlay, so live preview is unsupported. + // Fall back to the icon card in that case. + readonly property bool canPreview: AxctlService.compositor !== "niri" + // Override position tracking for immediate visual update property real overrideBaseX: -1 property real overrideBaseY: -1 @@ -380,9 +385,9 @@ Item { ScreencopyView { id: windowPreview anchors.fill: parent - captureSource: Config.performance.windowPreview && GlobalStates.overviewOpen ? windowDelegate.toplevel : null + captureSource: Config.performance.windowPreview && GlobalStates.overviewOpen && windowDelegate.canPreview ? windowDelegate.toplevel : null live: GlobalStates.overviewOpen - visible: Config.performance.windowPreview + visible: Config.performance.windowPreview && windowDelegate.canPreview } } @@ -394,7 +399,7 @@ Item { color: windowDelegate.dragging ? Colors.surfaceBright : windowDelegate.hovered ? Colors.surface : Colors.background border.color: windowDelegate.isSelected ? Colors.tertiary : windowDelegate.isMatched ? Styling.srItem("overprimary") : Styling.srItem("overprimary") border.width: windowDelegate.isSelected ? 3 : windowDelegate.isMatched ? 2 : (windowDelegate.hovered ? 2 : 0) - visible: !Config.performance.windowPreview + visible: !(Config.performance.windowPreview && windowDelegate.canPreview) Behavior on color { enabled: (Config.animDuration !== undefined ? Config.animDuration : 0) > 0 @@ -415,7 +420,7 @@ Item { source: Quickshell.iconPath(windowDelegate.iconPath, "image-missing") sourceSize: Qt.size(iconSize, iconSize) asynchronous: true - visible: !Config.performance.windowPreview + visible: !Config.performance.windowPreview || !windowDelegate.canPreview z: 10 } From f590439ea825f8e83541b132d17cb6595fab313d Mon Sep 17 00:00:00 2001 From: niri-port Date: Tue, 18 Aug 2026 14:50:52 +0300 Subject: [PATCH 4/7] fix(overview): cascade windows on niri, translucent cards niri reports no absolute window position (all at=[0,0]), so windows stacked on top of each other. Detect this and lay them out in a cascade by index. Also make the fallback card translucent so the wallpaper shows through instead of a solid black block. --- .../widgets/overview/ScrollingWorkspace.qml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/modules/widgets/overview/ScrollingWorkspace.qml b/modules/widgets/overview/ScrollingWorkspace.qml index 9ccf8a6b3..37310c62d 100644 --- a/modules/widgets/overview/ScrollingWorkspace.qml +++ b/modules/widgets/overview/ScrollingWorkspace.qml @@ -278,6 +278,7 @@ Item { delegate: Item { id: windowDelegate required property var modelData + required property int index readonly property var windowData: modelData readonly property var toplevel: { @@ -289,6 +290,17 @@ Item { return candidates.find(t => t.title === (windowData.title || "")) || candidates[0]; } + // niri does not report absolute window positions (all at=[0,0]), + // so windows would stack on top of each other. Detect this and + // lay them out in a cascade instead. + readonly property bool hasRealPosition: { + const x = (windowData && windowData.at && windowData.at[0] !== undefined ? windowData.at[0] : 0) || 0; + const y = (windowData && windowData.at && windowData.at[1] !== undefined ? windowData.at[1] : 0) || 0; + return x !== 0 || y !== 0; + } + readonly property bool useCascade: AxctlService.compositor === "niri" || !hasRealPosition + readonly property real cascadeOffset: 24 * scale_ + // niri cannot screencopy a window that sits under the // fullscreen overview overlay, so live preview is unsupported. // Fall back to the icon card in that case. @@ -303,6 +315,8 @@ Item { readonly property real baseX: { if (useOverridePosition && overrideBaseX >= 0) return overrideBaseX; + if (useCascade) + return root.viewportOffset + root.horizontalScrollOffset + index * cascadeOffset; let base = ((windowData && windowData.at && windowData.at[0] !== undefined ? windowData.at[0] : 0) || 0) - ((monitorData && monitorData.x !== undefined ? monitorData.x : 0) || 0); if (barPosition === "left") base -= barReserved; @@ -311,6 +325,8 @@ Item { readonly property real baseY: { if (useOverridePosition && overrideBaseY >= 0) return overrideBaseY; + if (useCascade) + return index * cascadeOffset; let base = ((windowData && windowData.at && windowData.at[1] !== undefined ? windowData.at[1] : 0) || 0) - ((monitorData && monitorData.y !== undefined ? monitorData.y : 0) || 0); if (barPosition === "top") base -= barReserved; @@ -397,6 +413,10 @@ Item { anchors.fill: parent radius: windowDelegate.calculatedRadius color: windowDelegate.dragging ? Colors.surfaceBright : windowDelegate.hovered ? Colors.surface : Colors.background + // On niri there is no live preview, so keep the card + // translucent to let the wallpaper show through instead + // of a solid black block. + opacity: windowDelegate.canPreview ? 1.0 : 0.55 border.color: windowDelegate.isSelected ? Colors.tertiary : windowDelegate.isMatched ? Styling.srItem("overprimary") : Styling.srItem("overprimary") border.width: windowDelegate.isSelected ? 3 : windowDelegate.isMatched ? 2 : (windowDelegate.hovered ? 2 : 0) visible: !(Config.performance.windowPreview && windowDelegate.canPreview) From 6983ee3a1ce33a446106623036f024f3571b5aa1 Mon Sep 17 00:00:00 2001 From: niri-port Date: Tue, 18 Aug 2026 14:57:20 +0300 Subject: [PATCH 5/7] feat(niri): route overview toggle to niri's built-in overview On niri, the Ambxst overview cannot show live window previews (no absolute window geometry in IPC). Add AxctlService.toggleOverview() which opens the compositor's built-in overview (real windows, drag-and-drop) on niri, and falls back to the Ambxst overview on Hyprland. Wire it into OverviewButton and the UserInfo profile click. --- modules/services/AxctlService.qml | 15 +++++++++++++++ modules/widgets/defaultview/UserInfo.qml | 5 ++++- modules/widgets/overview/OverviewButton.qml | 4 ++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/modules/services/AxctlService.qml b/modules/services/AxctlService.qml index cf80eb72d..7e8fb632e 100644 --- a/modules/services/AxctlService.qml +++ b/modules/services/AxctlService.qml @@ -112,6 +112,21 @@ Singleton { proc.running = true; } + // Toggle the window overview. On niri we use the compositor's built-in + // overview (real windows, drag-and-drop) instead of the Ambxst one, which + // cannot show live previews on niri (no absolute window geometry). + // Returns true if the compositor handled it (niri), false otherwise. + function toggleOverview() { + if (root.compositor === "niri") { + let proc = Qt.createQmlObject('import Quickshell.Io; Process {}', root); + proc.command = ["niri", "msg", "action", "toggle-overview"]; + proc.onExited.connect(() => proc.destroy()); + proc.running = true; + return true; + } + return false; + } + function monitorFor(screen) { if (!screen) return null; let screenName = screen.name || screen; diff --git a/modules/widgets/defaultview/UserInfo.qml b/modules/widgets/defaultview/UserInfo.qml index 1cfad227d..fd85ada6b 100644 --- a/modules/widgets/defaultview/UserInfo.qml +++ b/modules/widgets/defaultview/UserInfo.qml @@ -32,7 +32,10 @@ Item { onClicked: { if (Visibilities.currentActiveModule === "dashboard") { - Visibilities.setActiveModule("overview"); + // On niri open the compositor's built-in overview. + if (!AxctlService.toggleOverview()) { + Visibilities.setActiveModule("overview"); + } } else if (Visibilities.currentActiveModule === "overview") { GlobalStates.launcherCurrentTab = 0; Visibilities.setActiveModule("launcher"); diff --git a/modules/widgets/overview/OverviewButton.qml b/modules/widgets/overview/OverviewButton.qml index 2e9218dc4..9f70b7350 100644 --- a/modules/widgets/overview/OverviewButton.qml +++ b/modules/widgets/overview/OverviewButton.qml @@ -10,6 +10,10 @@ ToggleButton { tooltipText: "Open Window Overview" onToggle: function () { + // On niri use the compositor's built-in overview (real windows). + if (AxctlService.toggleOverview()) { + return; + } if (GlobalStates.overviewOpen) { Visibilities.setActiveModule(""); } else { From 3d65f18765783538c39ef7e05104f5a83a1840ca Mon Sep 17 00:00:00 2001 From: niri-port Date: Tue, 18 Aug 2026 15:44:27 +0300 Subject: [PATCH 6/7] feat(niri): route dock overview button to niri's built-in overview The dock overview button (DockContent.qml) opened the Ambxst overview directly via visibilities.overview, bypassing the compositor-aware toggleOverview(). Route it through toggleOverview() so on niri it opens the compositor's built-in overview (real windows, drag-and-drop). --- modules/dock/DockContent.qml | 8 ++++++++ modules/services/AxctlService.qml | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/dock/DockContent.qml b/modules/dock/DockContent.qml index a6b31db30..a5a440960 100644 --- a/modules/dock/DockContent.qml +++ b/modules/dock/DockContent.qml @@ -519,6 +519,10 @@ Item { } onClicked: { + // On niri use the compositor's built-in overview. + if (AxctlService.toggleOverview()) { + return; + } let visibilities = Visibilities.getForScreen(root.screen.name); if (visibilities) { visibilities.overview = !visibilities.overview; @@ -653,6 +657,10 @@ Item { } onClicked: { + // On niri use the compositor's built-in overview. + if (AxctlService.toggleOverview()) { + return; + } let visibilities = Visibilities.getForScreen(root.screen.name); if (visibilities) { visibilities.overview = !visibilities.overview; diff --git a/modules/services/AxctlService.qml b/modules/services/AxctlService.qml index 7e8fb632e..8b6a94ee1 100644 --- a/modules/services/AxctlService.qml +++ b/modules/services/AxctlService.qml @@ -120,7 +120,9 @@ Singleton { if (root.compositor === "niri") { let proc = Qt.createQmlObject('import Quickshell.Io; Process {}', root); proc.command = ["niri", "msg", "action", "toggle-overview"]; - proc.onExited.connect(() => proc.destroy()); + proc.onExited.connect((code) => { + proc.destroy(); + }); proc.running = true; return true; } From 723fb6a5459f566cf4cc47fe65e11669c467eea6 Mon Sep 17 00:00:00 2001 From: niri-port Date: Tue, 18 Aug 2026 23:19:23 +0300 Subject: [PATCH 7/7] feat(niri): bridge binds.json to axctl config apply CompositorConfig.applyNiriConfig() now includes keybinds collected from Config.keybindsLoader (binds.json) alongside appearance. Ambxst/system module binds and custom binds are resolved via KeybindActions and pushed in the axctl universal payload format, so the niri ConfigGenerator writes them into ambxst-generated.kdl. Also re-apply compositor config (debounced) when binds.json changes, so edits made in the BindsPanel dashboard tab take effect immediately. --- modules/services/CompositorConfig.qml | 93 ++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/modules/services/CompositorConfig.qml b/modules/services/CompositorConfig.qml index 61d04393b..26c139286 100644 --- a/modules/services/CompositorConfig.qml +++ b/modules/services/CompositorConfig.qml @@ -6,6 +6,7 @@ import qs.config import qs.modules.theme import qs.modules.bar import qs.modules.globals +import "../../config/KeybindActions.js" as KeybindActions QtObject { id: root @@ -98,13 +99,88 @@ QtObject { active_color: activeColor, rounding: rounding } - } + }, + keybinds: root.collectKeybindsPayload() }; niriApplyProcess.command = ["axctl", "config", "apply", JSON.stringify(payload)]; niriApplyProcess.running = true; } + // Collect the keybinds from binds.json into the axctl universal payload format. + function collectKeybindsPayload() { + const keybinds = Config.keybindsLoader && Config.keybindsLoader.adapter + ? Config.keybindsLoader.adapter : null; + if (!keybinds) return {}; + + const result = {}; + + // Ambxst module binds (launcher, dashboard, assistant, ...) + const ambxstBinds = keybinds.ambxst; + if (ambxstBinds && typeof ambxstBinds === "object") { + const ambxstObj = {}; + for (const section in ambxstBinds) { + if (section === "system") continue; + const bind = ambxstBinds[section]; + const resolved = KeybindActions.resolveAction(bind.action); + if (!resolved) continue; + ambxstObj[section] = { + modifiers: bind.modifiers || [], + key: bind.key || "", + dispatcher: resolved.dispatcher || "exec", + argument: resolved.argument || "", + enabled: bind.enabled !== false + }; + } + const sysBinds = keybinds.ambxst.system; + if (sysBinds && typeof sysBinds === "object") { + const sysObj = {}; + for (const section in sysBinds) { + const bind = sysBinds[section]; + const resolved = KeybindActions.resolveAction(bind.action); + if (!resolved) continue; + sysObj[section] = { + modifiers: bind.modifiers || [], + key: bind.key || "", + dispatcher: resolved.dispatcher || "exec", + argument: resolved.argument || "", + enabled: bind.enabled !== false + }; + } + result.ambxst = { system: sysObj }; + } + if (Object.keys(ambxstObj).length > 0) { + result.ambxst = Object.assign(result.ambxst || {}, ambxstObj); + } + } + + // Custom binds + const custom = keybinds.custom; + if (custom && Array.isArray(custom) && custom.length > 0) { + const customArr = []; + for (let i = 0; i < custom.length; i++) { + const bind = custom[i]; + if (!bind || !bind.keys || !bind.actions || bind.enabled === false) continue; + const key = bind.keys[0]; + const action = bind.actions[0]; + const resolved = KeybindActions.resolveAction(action); + if (!resolved || !key) continue; + customArr.push({ + modifiers: key.modifiers || [], + key: key.key || "", + dispatcher: resolved.dispatcher || "exec", + argument: resolved.argument || "", + enabled: true + }); + } + if (customArr.length > 0) { + result.custom = customArr; + } + } + + return result; + } + property Process niriApplyProcess: Process { running: false stdout: SplitParser { @@ -439,6 +515,21 @@ QtObject { } } + // Re-apply compositor config when binds.json changes (BindsPanel edits keybinds). + property Connections keybindsConnections: Connections { + target: Config.keybindsLoader + function onFileChanged() { + // Debounce: binds.json may be written in several quick chunks. + keybindsApplyTimer.restart(); + } + } + + property Timer keybindsApplyTimer: Timer { + interval: 250 + repeat: false + onTriggered: applyCompositorConfig() + } + Component.onCompleted: { // Apply immediately if Config is already loaded.