Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
;;
Expand All @@ -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
;;
Expand Down
8 changes: 8 additions & 0 deletions modules/dock/DockContent.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 23 additions & 6 deletions modules/globals/GlobalStates.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions modules/services/AxctlService.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uid>/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;

Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
137 changes: 137 additions & 0 deletions modules/services/CompositorConfig.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
Loading