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/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/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..8b6a94ee1 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 { @@ -73,6 +112,23 @@ 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((code) => { + 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/services/CompositorConfig.qml b/modules/services/CompositorConfig.qml index 86c0a9bec..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 @@ -75,6 +76,120 @@ 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 + } + }, + 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 { + onRead: (data) => { + if (data) console.log("CompositorConfig[niri]:", data); + } + } + } + function applyCompositorConfigInternal() { // Ensure adapters are loaded before applying config. if (!Config.loader.loaded) { @@ -88,6 +203,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). @@ -393,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. 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 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 { 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..37310c62d 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 @@ -277,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: { @@ -288,6 +290,22 @@ 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. + readonly property bool canPreview: AxctlService.compositor !== "niri" + // Override position tracking for immediate visual update property real overrideBaseX: -1 property real overrideBaseY: -1 @@ -297,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; @@ -305,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; @@ -379,9 +401,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 } } @@ -391,9 +413,13 @@ 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 + visible: !(Config.performance.windowPreview && windowDelegate.canPreview) Behavior on color { enabled: (Config.animDuration !== undefined ? Config.animDuration : 0) > 0 @@ -414,7 +440,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 } @@ -730,6 +756,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 + } + } } } }