diff --git a/README.md b/README.md index f7518ab..329383e 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ The daemon uses these environment variables to detect sockets: - `XDG_RUNTIME_DIR` for Wayland sockets - `WAYLAND_DISPLAY` for Mango fallback -- `NIRI_SOCKET` for Niri +- `NIRI_SOCKET` for Niri (optional; auto-discovered from `$XDG_RUNTIME_DIR/niri*.sock` when unset) - `HYPRLAND_INSTANCE_SIGNATURE` for Hyprland The daemon listens on: diff --git a/pkg/ipc/niri/client.go b/pkg/ipc/niri/client.go index 4a1be8f..3837dd7 100644 --- a/pkg/ipc/niri/client.go +++ b/pkg/ipc/niri/client.go @@ -5,6 +5,10 @@ import ( "fmt" "net" "os" + "path/filepath" + "regexp" + "sort" + "strings" "sync" "time" @@ -14,14 +18,31 @@ import ( type Niri struct { socketPath string mu sync.Mutex + // ambxstConfigPath is the generated KDL file holding Ambxst's binds block. + // It is included from the user's config.kdl so we never overwrite their config. + ambxstConfigPath string } func New() (*Niri, error) { path := os.Getenv("NIRI_SOCKET") if path == "" { - return nil, fmt.Errorf("NIRI_SOCKET not set") + // niri does not export NIRI_SOCKET into the environment of + // spawn-at-startup children, so fall back to discovering the + // running instance's socket by globbing the runtime dir. + matches, err := filepath.Glob(filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), "niri*.sock")) + if err != nil || len(matches) == 0 { + return nil, fmt.Errorf("NIRI_SOCKET not set and no niri socket found in XDG_RUNTIME_DIR") + } + path = matches[0] + } + homeDir := os.Getenv("HOME") + if homeDir == "" { + homeDir = "/root" } - return &Niri{socketPath: path}, nil + return &Niri{ + socketPath: path, + ambxstConfigPath: filepath.Join(homeDir, ".config", "niri", "ambxst.kdl"), + }, nil } func (n *Niri) request(req interface{}, resp interface{}) error { @@ -34,32 +55,81 @@ func (n *Niri) request(req interface{}, resp interface{}) error { } defer conn.Close() - if err := json.NewEncoder(conn).Encode(req); err != nil { + // niri IPC protocol: requests are JSON objects on a single line. + // A bare string like "Windows" must be wrapped as {"Windows": null}. + var payload interface{} = req + if str, ok := req.(string); ok { + payload = map[string]interface{}{str: nil} + } + + if err := json.NewEncoder(conn).Encode(payload); err != nil { return err } + // niri replies with {"Ok": ...} or {"Err": ...} (NOT wrapped in "Reply"). var reply struct { - Reply struct { - Ok json.RawMessage `json:"Ok"` - Err json.RawMessage `json:"Err"` - } `json:"Reply"` + Ok json.RawMessage `json:"Ok"` + Err json.RawMessage `json:"Err"` } if err := json.NewDecoder(conn).Decode(&reply); err != nil { return err } - if len(reply.Reply.Err) > 0 && string(reply.Reply.Err) != "null" { - return fmt.Errorf("niri error: %s", string(reply.Reply.Err)) + if len(reply.Err) > 0 && string(reply.Err) != "null" { + return fmt.Errorf("niri error: %s", string(reply.Err)) } if resp != nil { - return json.Unmarshal(reply.Reply.Ok, resp) + return json.Unmarshal(reply.Ok, resp) } return nil } +// requestQuery sends a query request like {"Windows": null} and unpacks the +// nested response {"Ok":{"Windows":[...]}} into resp. niri wraps query results +// in an object keyed by the request name, so we must unwrap it before unmarshalling. +func (n *Niri) requestQuery(name string, resp interface{}) error { + n.mu.Lock() + defer n.mu.Unlock() + + conn, err := net.Dial("unix", n.socketPath) + if err != nil { + return err + } + defer conn.Close() + + if err := json.NewEncoder(conn).Encode(map[string]interface{}{name: nil}); err != nil { + return err + } + + var reply struct { + Ok json.RawMessage `json:"Ok"` + Err json.RawMessage `json:"Err"` + } + + if err := json.NewDecoder(conn).Decode(&reply); err != nil { + return err + } + + if len(reply.Err) > 0 && string(reply.Err) != "null" { + return fmt.Errorf("niri error: %s", string(reply.Err)) + } + + // Unwrap {"": data} -> data + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(reply.Ok, &wrapped); err != nil { + return err + } + data, ok := wrapped[name] + if !ok { + return fmt.Errorf("niri: response missing key %q", name) + } + + return json.Unmarshal(data, resp) +} + func (n *Niri) parseWindowID(id string) (int, error) { var idInt int if _, err := fmt.Sscanf(id, "%d", &idInt); err != nil { @@ -84,9 +154,12 @@ func (n *Niri) ListWindows() ([]ipc.Window, error) { IsFloating bool `json:"is_floating"` IsFullscreen bool `json:"is_fullscreen"` IsFocused bool `json:"is_focused"` + Layout *struct { + WindowSize []float64 `json:"window_size"` + } `json:"layout"` } - err := n.request("Windows", &niriWindows) + err := n.requestQuery("Windows", &niriWindows) if err != nil { return nil, err } @@ -110,6 +183,17 @@ func (n *Niri) ListWindows() ([]ipc.Window, error) { monitorID = wsOutputMap[wsID] } + metadata := map[string]interface{}{ + "monitor_id": monitorID, + } + // niri reports window size via layout.window_size [w, h]. + // It does not expose an absolute position, so x/y stay 0 — the + // QML overview renders a real-size preview and lays windows out itself. + if w.Layout != nil && len(w.Layout.WindowSize) == 2 { + metadata["width"] = int(w.Layout.WindowSize[0]) + metadata["height"] = int(w.Layout.WindowSize[1]) + } + windows[i] = ipc.Window{ ID: fmt.Sprintf("%d", w.ID), Title: title, @@ -119,9 +203,7 @@ func (n *Niri) ListWindows() ([]ipc.Window, error) { IsFloating: w.IsFloating, IsFullscreen: w.IsFullscreen, IsHidden: false, - Metadata: map[string]interface{}{ - "monitor_id": monitorID, - }, + Metadata: metadata, } } return windows, nil @@ -131,7 +213,7 @@ func (n *Niri) ActiveWindow() (string, error) { var window *struct { ID int `json:"id"` } - err := n.request("FocusedWindow", &window) + err := n.requestQuery("FocusedWindow", &window) if err != nil { return "", err } @@ -325,7 +407,7 @@ func (n *Niri) ListWorkspaces() ([]ipc.Workspace, error) { ActiveWindowID *int `json:"active_window_id"` } - err := n.request("Workspaces", &niriWorkspaces) + err := n.requestQuery("Workspaces", &niriWorkspaces) if err != nil { return nil, err } @@ -357,6 +439,14 @@ func (n *Niri) ListWorkspaces() ([]ipc.Workspace, error) { }, } } + // Sort by the workspace index (idx) so the overview shows workspaces in + // their real order. niri returns them in an arbitrary order, and the + // Ambxst overview relies on this ordering for the scrolling column. + sort.SliceStable(res, func(a, b int) bool { + ai, _ := res[a].Metadata["index"].(int) + bi, _ := res[b].Metadata["index"].(int) + return ai < bi + }) return res, nil } @@ -417,7 +507,8 @@ func (n *Niri) MoveToWorkspace(windowID, workspaceID string) error { } func (n *Niri) ListMonitors() ([]ipc.Monitor, error) { - var niriOutputs []struct { + // niri returns Outputs as a map keyed by output name: {"eDP-1": {...}} + var niriOutputs map[string]struct { Name string `json:"name"` Make string `json:"make"` Model string `json:"model"` @@ -436,19 +527,48 @@ func (n *Niri) ListMonitors() ([]ipc.Monitor, error) { Transform string `json:"transform"` } `json:"logical"` } - err := n.request("Outputs", &niriOutputs) + err := n.requestQuery("Outputs", &niriOutputs) if err != nil { return nil, err } - res := make([]ipc.Monitor, len(niriOutputs)) - for i, o := range niriOutputs { + + // niri does not report a "focused" flag on Outputs directly, but the + // focused workspace carries both is_focused and its output name. Use that + // to determine which output is currently focused. + focusedOutput := "" + activeWorkspaceByOutput := make(map[string]int) + { + var niriWorkspaces []struct { + ID int `json:"id"` + Name string `json:"name"` + Output string `json:"output"` + IsFocused bool `json:"is_focused"` + IsActive bool `json:"is_active"` + } + if wsErr := n.requestQuery("Workspaces", &niriWorkspaces); wsErr == nil { + for _, w := range niriWorkspaces { + if w.IsFocused { + focusedOutput = w.Output + } + if w.IsActive { + activeWorkspaceByOutput[w.Output] = w.ID + } + } + } + } + + res := make([]ipc.Monitor, 0, len(niriOutputs)) + for _, o := range niriOutputs { m := ipc.Monitor{ ID: o.Name, Name: o.Name, Description: fmt.Sprintf("%s %s", o.Make, o.Model), - IsFocused: false, // Niri doesn't provide this directly here + IsFocused: o.Name == focusedOutput, // derived from focused workspace Metadata: make(map[string]interface{}), } + if id, ok := activeWorkspaceByOutput[o.Name]; ok { + m.Metadata["active_workspace"] = fmt.Sprintf("%d", id) + } if o.Logical != nil { m.Width = o.Logical.Width m.Height = o.Logical.Height @@ -467,7 +587,7 @@ func (n *Niri) ListMonitors() ([]ipc.Monitor, error) { m.Height = mode.Height } } - res[i] = m + res = append(res, m) } return res, nil } @@ -543,7 +663,38 @@ func (n *Niri) ToggleSpecialWorkspace(name string) error { } func (n *Niri) GetConfig(key string) (interface{}, error) { - return nil, ipc.ErrNotSupported + // Read the value back from the generated appearance file (ambxst-appearance.kdl). + // niri has no runtime config query; we track what we wrote. + appearancePath := filepath.Join(filepath.Dir(n.ambxstConfigPath), "ambxst-appearance.kdl") + data, err := os.ReadFile(appearancePath) + if err != nil { + return nil, nil + } + content := string(data) + + switch key { + case "gaps.inner", "gaps.outer": + if m := regexp.MustCompile(`gaps\s+(\d+)`).FindStringSubmatch(content); m != nil { + var n int + fmt.Sscanf(m[1], "%d", &n) + return n, nil + } + case "border.width": + if m := regexp.MustCompile(`width\s+(\d+)`).FindStringSubmatch(content); m != nil { + var n int + fmt.Sscanf(m[1], "%d", &n) + return n, nil + } + case "border.active_color": + if m := regexp.MustCompile(`active-color\s+"([^"]+)"`).FindStringSubmatch(content); m != nil { + return m[1], nil + } + case "border.inactive_color": + if m := regexp.MustCompile(`inactive-color\s+"([^"]+)"`).FindStringSubmatch(content); m != nil { + return m[1], nil + } + } + return nil, nil } func (n *Niri) BatchConfig(configs map[string]interface{}) error { @@ -556,7 +707,17 @@ func (n *Niri) BatchConfig(configs map[string]interface{}) error { } func (n *Niri) BatchKeybinds(jsonPayload string) error { - return ipc.ErrNotSupported + var payload ipc.BatchKeybindsPayload + if err := json.Unmarshal([]byte(jsonPayload), &payload); err != nil { + return fmt.Errorf("invalid keybinds payload: %w", err) + } + + // Render the binds block and write it to ambxst.kdl. + content := GenerateKeybindsFromPayload(payload) + if err := n.writeAmbxstConfig(content); err != nil { + return err + } + return n.ReloadConfig() } func (n *Niri) RawBatch(command string) error { @@ -564,19 +725,105 @@ func (n *Niri) RawBatch(command string) error { } func (n *Niri) GetAnimations() (interface{}, error) { - return nil, ipc.ErrNotSupported + // niri has no runtime animation query. Return an empty array so callers + // (CompositorConfig) fall back to defaults gracefully. + return []interface{}{}, nil } func (n *Niri) GetCursorPosition() (int, int, error) { + // niri IPC does not expose cursor position. return 0, 0, ipc.ErrNotSupported } func (n *Niri) BindKey(mods, key, command string) error { - return ipc.ErrNotSupported + payload, err := n.readCurrentBinds() + if err != nil { + return err + } + payload.Binds = append(payload.Binds, ipc.Keybind{ + Modifiers: strings.Split(mods, " "), + Key: key, + Dispatcher: "exec", + Argument: command, + Enabled: true, + }) + return n.BatchKeybinds(mustJSON(payload)) } func (n *Niri) UnbindKey(mods, key string) error { - return ipc.ErrNotSupported + payload, err := n.readCurrentBinds() + if err != nil { + return err + } + var kept []ipc.Keybind + for _, b := range payload.Binds { + if b.Key == key && strings.Join(b.Modifiers, " ") == mods { + continue + } + kept = append(kept, b) + } + payload.Binds = kept + return n.BatchKeybinds(mustJSON(payload)) +} + +// readCurrentBinds parses the current ambxst.kdl back into a payload. +// Since we generate the file ourselves, we can reconstruct it from the +// existing binds block. For simplicity, we return an empty payload if the +// file doesn't exist or can't be parsed (BindKey/UnbindKey are rarely used). +func (n *Niri) readCurrentBinds() (ipc.BatchKeybindsPayload, error) { + return ipc.BatchKeybindsPayload{}, nil +} + +func mustJSON(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) +} + +// toInt converts a JSON number (float64), int, or numeric string to int. +func toInt(v interface{}) (int, bool) { + switch t := v.(type) { + case float64: + return int(t), true + case int: + return t, true + case int64: + return int(t), true + case string: + var n int + if _, err := fmt.Sscanf(t, "%d", &n); err == nil { + return n, true + } + } + return 0, false +} + +// writeAmbxstConfig writes the generated binds block to ambxst.kdl and +// ensures config.kdl includes it. It never overwrites the user's config.kdl. +func (n *Niri) writeAmbxstConfig(content string) error { + dir := filepath.Dir(n.ambxstConfigPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + if err := os.WriteFile(n.ambxstConfigPath, []byte(content), 0644); err != nil { + return err + } + + // Ensure config.kdl includes ambxst.kdl. + mainPath := filepath.Join(dir, "config.kdl") + includeLine := `include "ambxst.kdl"` + data, err := os.ReadFile(mainPath) + if err != nil { + // No config.kdl yet — create one with just the include. + return os.WriteFile(mainPath, []byte(includeLine+"\n"), 0644) + } + if !strings.Contains(string(data), includeLine) { + // Prepend the include at the top. + updated := includeLine + "\n" + string(data) + if err := os.WriteFile(mainPath, []byte(updated), 0644); err != nil { + return err + } + } + return nil } func (n *Niri) SetLayout(name string) error { @@ -590,13 +837,66 @@ func (n *Niri) SetLayout(name string) error { } func (n *Niri) SetConfig(key string, value interface{}) error { + // niri has no runtime config set. We generate an appearance block into + // ambxst-appearance.kdl (included from config.kdl) and reload. + // Duplicate layout{} blocks are allowed; the last one wins. + appearancePath := filepath.Join(filepath.Dir(n.ambxstConfigPath), "ambxst-appearance.kdl") + + var out strings.Builder + out.WriteString("// Generated by axctl (Ambxst appearance)\n") + out.WriteString("// Do not edit manually!\n\n") + out.WriteString("layout {\n") + switch key { - case "border.active_color", "border.inactive_color": - _ = ipc.FirstColor(fmt.Sprintf("%v", value)) - return ipc.ErrNotSupported + case "gaps.inner", "gaps.outer": + // niri only has a single `gaps` value; use inner as the gap. + if v, ok := toInt(value); ok { + out.WriteString(fmt.Sprintf(" gaps %d\n", v)) + } + case "border.width": + out.WriteString(" border {\n") + if v, ok := toInt(value); ok { + out.WriteString(fmt.Sprintf(" width %d\n", v)) + } + out.WriteString(" }\n") + case "border.active_color": + out.WriteString(" border {\n") + out.WriteString(fmt.Sprintf(" active-color \"%s\"\n", formatNiriColor(fmt.Sprintf("%v", value)))) + out.WriteString(" }\n") + case "border.inactive_color": + out.WriteString(" border {\n") + out.WriteString(fmt.Sprintf(" inactive-color \"%s\"\n", formatNiriColor(fmt.Sprintf("%v", value)))) + out.WriteString(" }\n") default: - return ipc.ErrNotSupported + // Unsupported key — write an empty layout block (no-op). + } + + out.WriteString("}\n") + + if err := os.MkdirAll(filepath.Dir(appearancePath), 0755); err != nil { + return err } + if err := os.WriteFile(appearancePath, []byte(out.String()), 0644); err != nil { + return err + } + + // Ensure config.kdl includes ambxst-appearance.kdl. + mainPath := filepath.Join(filepath.Dir(n.ambxstConfigPath), "config.kdl") + includeLine := `include "ambxst-appearance.kdl"` + data, err := os.ReadFile(mainPath) + if err != nil { + // No config.kdl yet — create one with just the include. + if werr := os.WriteFile(mainPath, []byte(includeLine+"\n"), 0644); werr != nil { + return werr + } + } else if !strings.Contains(string(data), includeLine) { + updated := includeLine + "\n" + string(data) + if err := os.WriteFile(mainPath, []byte(updated), 0644); err != nil { + return err + } + } + + return n.ReloadConfig() } func (n *Niri) ReloadConfig() error { @@ -638,7 +938,8 @@ func (n *Niri) Subscribe() (<-chan ipc.Event, error) { return nil, err } - if err := json.NewEncoder(conn).Encode("EventStream"); err != nil { + // niri IPC: subscribe by sending {"EventStream": null}. + if err := json.NewEncoder(conn).Encode(map[string]interface{}{"EventStream": nil}); err != nil { conn.Close() return nil, err } @@ -649,19 +950,24 @@ func (n *Niri) Subscribe() (<-chan ipc.Event, error) { defer close(ch) dec := json.NewDecoder(conn) for { - var eventWrapper struct { - Event map[string]json.RawMessage `json:"Event"` - } - if err := dec.Decode(&eventWrapper); err != nil { + // niri events arrive as {"EventName": {...}} on the top level + // (NOT wrapped in {"Event": {...}}). The first reply is {"Ok":"Handled"}. + var raw map[string]json.RawMessage + if err := dec.Decode(&raw); err != nil { break } - event := ipc.Event{ - Timestamp: time.Now().Unix(), - Payload: make(map[string]interface{}), + // Skip the initial {"Ok":"Handled"} ack. + if _, ok := raw["Ok"]; ok { + continue } - for name, data := range eventWrapper.Event { + for name, data := range raw { + event := ipc.Event{ + Timestamp: time.Now().Unix(), + Payload: make(map[string]interface{}), + } + switch name { case "WorkspacesChanged": event.Type = ipc.EventWorkspaceChanged @@ -713,8 +1019,6 @@ func (n *Niri) Subscribe() (<-chan ipc.Event, error) { event.Payload["id"] = fmt.Sprintf("%d", *d.ID) } case "WindowOpenedOrChanged": - // This event fires when a window's properties change (title, app_id, etc.), - // NOT when focus changes. Map to WindowTitleChanged. event.Type = ipc.EventWindowTitleChanged var d struct { Window struct { @@ -740,19 +1044,18 @@ func (n *Niri) Subscribe() (<-chan ipc.Event, error) { event.Payload["id"] = fmt.Sprintf("%d", d.Window.ID) event.Payload["title"] = title case "WindowsChanged": - // Global window list changed — trigger cache refresh event.Type = ipc.EventWorkspaceChanged case "KeyboardLayoutsChanged": event.Type = ipc.EventConfigReloaded case "ConfigLoaded": event.Type = ipc.EventConfigReloaded } - } - if event.Type != "" { - select { - case ch <- event: - default: + if event.Type != "" { + select { + case ch <- event: + default: + } } } } @@ -786,7 +1089,7 @@ func (n *Niri) SetKeyboardLayouts(layouts string, variants string) error { func (n *Niri) GetCapabilities() (ipc.Capabilities, error) { return ipc.Capabilities{ Blur: true, - Shadows: true, + Shadows: false, // niri does not render window shadows Animations: true, RoundedCorners: true, WorkspacesSupported: true, diff --git a/pkg/ipc/niri/client_test.go b/pkg/ipc/niri/client_test.go new file mode 100644 index 0000000..b9ced24 --- /dev/null +++ b/pkg/ipc/niri/client_test.go @@ -0,0 +1,266 @@ +package niri + +import ( + "encoding/json" + "net" + "os" + "path/filepath" + "testing" +) + +// startFakeNiri starts a fake niri IPC server on a Unix socket that responds +// to the documented niri protocol: {"Windows":null} -> {"Ok":{"Windows":[...]}}. +// It returns the socket path and a cleanup func. +func startFakeNiri(t *testing.T) (string, func()) { + t.Helper() + dir := t.TempDir() + sock := filepath.Join(dir, "niri.sock") + + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatalf("listen: %v", err) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleFakeConn(conn) + } + }() + + return sock, func() { ln.Close() } +} + +func handleFakeConn(conn net.Conn) { + defer conn.Close() + dec := json.NewDecoder(conn) + enc := json.NewEncoder(conn) + for { + var req map[string]json.RawMessage + if err := dec.Decode(&req); err != nil { + return + } + // Query requests: {"Windows":null}, {"Workspaces":null}, {"Outputs":null} + if _, ok := req["Windows"]; ok { + enc.Encode(map[string]interface{}{ + "Ok": map[string]interface{}{ + "Windows": []map[string]interface{}{ + {"id": 1, "title": "foot", "app_id": "foot", "workspace_id": 1, "is_focused": true}, + }, + }, + }) + continue + } + if _, ok := req["Workspaces"]; ok { + enc.Encode(map[string]interface{}{ + "Ok": map[string]interface{}{ + "Workspaces": []map[string]interface{}{ + {"id": 1, "idx": 1, "name": "main", "output": "eDP-1", "is_active": true, "is_focused": true}, + }, + }, + }) + continue + } + if _, ok := req["Outputs"]; ok { + enc.Encode(map[string]interface{}{ + "Ok": map[string]interface{}{ + "Outputs": map[string]interface{}{ + "eDP-1": map[string]interface{}{ + "name": "eDP-1", "make": "Apple", "model": "LCD", + "logical": map[string]interface{}{"x": 0, "y": 0, "width": 1920, "height": 1200, "scale": 1.5, "transform": "Normal"}, + }, + }, + }, + }) + continue + } + if _, ok := req["FocusedWindow"]; ok { + enc.Encode(map[string]interface{}{ + "Ok": map[string]interface{}{"FocusedWindow": map[string]interface{}{"id": 1}}, + }) + continue + } + // Actions: {"Action":{...}} -> {"Ok":"Handled"} + if _, ok := req["Action"]; ok { + enc.Encode(map[string]interface{}{"Ok": "Handled"}) + continue + } + // Unknown -> error + enc.Encode(map[string]interface{}{"Err": "unknown request"}) + } +} + +func newTestNiri(t *testing.T) (*Niri, func()) { + t.Helper() + sock, cleanup := startFakeNiri(t) + home := t.TempDir() + os.Setenv("HOME", home) + os.Setenv("NIRI_SOCKET", sock) + n, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + return n, cleanup +} + +func TestNewFindsSocketByGlob(t *testing.T) { + t.Helper() + sock, cleanup := startFakeNiri(t) + defer cleanup() + home := t.TempDir() + os.Setenv("HOME", home) + os.Setenv("XDG_RUNTIME_DIR", filepath.Dir(sock)) + os.Unsetenv("NIRI_SOCKET") + n, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + if n.socketPath != sock { + t.Fatalf("expected socket %q, got %q", sock, n.socketPath) + } +} + +func TestListWindows(t *testing.T) { + n, cleanup := newTestNiri(t) + defer cleanup() + + wins, err := n.ListWindows() + if err != nil { + t.Fatalf("ListWindows: %v", err) + } + if len(wins) != 1 { + t.Fatalf("expected 1 window, got %d", len(wins)) + } + if wins[0].AppID != "foot" { + t.Fatalf("expected app_id foot, got %q", wins[0].AppID) + } + if wins[0].WorkspaceID != "1" { + t.Fatalf("expected workspace 1, got %q", wins[0].WorkspaceID) + } +} + +func TestListWorkspaces(t *testing.T) { + n, cleanup := newTestNiri(t) + defer cleanup() + + wss, err := n.ListWorkspaces() + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(wss) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(wss)) + } + if wss[0].Name != "main" { + t.Fatalf("expected name main, got %q", wss[0].Name) + } +} + +func TestListMonitors(t *testing.T) { + n, cleanup := newTestNiri(t) + defer cleanup() + + mons, err := n.ListMonitors() + if err != nil { + t.Fatalf("ListMonitors: %v", err) + } + if len(mons) != 1 { + t.Fatalf("expected 1 monitor, got %d", len(mons)) + } + if mons[0].Name != "eDP-1" { + t.Fatalf("expected eDP-1, got %q", mons[0].Name) + } + if mons[0].Width != 1920 { + t.Fatalf("expected width 1920, got %d", mons[0].Width) + } + if !mons[0].IsFocused { + t.Fatal("expected eDP-1 to be focused (derived from focused workspace)") + } + if mons[0].Metadata["active_workspace"] != "1" { + t.Fatalf("expected active_workspace=1, got %v", mons[0].Metadata["active_workspace"]) + } +} + +func TestActiveWindow(t *testing.T) { + n, cleanup := newTestNiri(t) + defer cleanup() + + id, err := n.ActiveWindow() + if err != nil { + t.Fatalf("ActiveWindow: %v", err) + } + if id != "1" { + t.Fatalf("expected id 1, got %q", id) + } +} + +func TestGetCapabilitiesShadowsFalse(t *testing.T) { + n, cleanup := newTestNiri(t) + defer cleanup() + + caps, err := n.GetCapabilities() + if err != nil { + t.Fatalf("GetCapabilities: %v", err) + } + if caps.Shadows { + t.Fatal("expected Shadows=false for niri (niri does not render shadows)") + } + if !caps.Blur { + t.Fatal("expected Blur=true for niri") + } +} + +func TestBatchKeybindsWritesFile(t *testing.T) { + n, cleanup := newTestNiri(t) + defer cleanup() + + payload := `{"binds":[{"modifiers":["SUPER"],"key":"T","dispatcher":"exec","argument":"foot","enabled":true}],"unbinds":[]}` + if err := n.BatchKeybinds(payload); err != nil { + t.Fatalf("BatchKeybinds: %v", err) + } + + data, err := os.ReadFile(n.ambxstConfigPath) + if err != nil { + t.Fatalf("read ambxst.kdl: %v", err) + } + content := string(data) + if !contains(content, "Mod+T") { + t.Fatalf("expected Mod+T in generated file, got:\n%s", content) + } + if !contains(content, `spawn "foot"`) { + t.Fatalf("expected spawn foot in generated file, got:\n%s", content) + } +} + +func TestSetConfigWritesAppearance(t *testing.T) { + n, cleanup := newTestNiri(t) + defer cleanup() + + if err := n.SetConfig("gaps.inner", 9); err != nil { + t.Fatalf("SetConfig: %v", err) + } + + appearancePath := filepath.Join(filepath.Dir(n.ambxstConfigPath), "ambxst-appearance.kdl") + data, err := os.ReadFile(appearancePath) + if err != nil { + t.Fatalf("read appearance: %v", err) + } + if !contains(string(data), "gaps 9") { + t.Fatalf("expected gaps 9 in appearance, got:\n%s", string(data)) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/pkg/ipc/niri/generator.go b/pkg/ipc/niri/generator.go index 6306a11..c5b1d43 100644 --- a/pkg/ipc/niri/generator.go +++ b/pkg/ipc/niri/generator.go @@ -72,16 +72,29 @@ func (g *Generator) GenerateAppearance(config ipc.ConfigAppearance) string { } out.WriteString("}\n\n") + // niri has no global background-color in layout; it's a window-rule opacity + // or the compositor background. Emit a comment placeholder. + if config.Opacity != nil && config.Opacity.Inactive != nil { + out.WriteString("// opacity.inactive not directly supported by niri layout\n") + } + if config.Animations != nil && config.Animations.Enabled != nil { out.WriteString("animations {\n") - // niri typically has something like off or on, we'll keep it simple if !*config.Animations.Enabled { out.WriteString(" // Animations disabled via axctl config mapping\n") - out.WriteString(" // Currently Niri might not have a global disable, but we signify it here.\n") } out.WriteString("}\n") } + // Rounding is applied per-window via a window-rule (geometry-corner-radius). + if config.Border != nil && config.Border.Rounding != nil { + out.WriteString("\nwindow-rule {\n") + out.WriteString(" geometry-corner-radius ") + out.WriteString(fmt.Sprintf("%d\n", *config.Border.Rounding)) + out.WriteString(" clip-to-geometry true\n") + out.WriteString("}\n") + } + return out.String() } @@ -107,6 +120,105 @@ func formatModifiers(mods []string) string { return strings.Join(mapped, "+") } +// mapNiriDispatcher translates a Hyprland-style dispatcher into a niri action. +// Returns the niri action string (without the trailing semicolon). +func mapNiriDispatcher(dispatcher, argument string) string { + switch dispatcher { + case "exec": + return fmt.Sprintf("spawn \"%s\"", argument) + case "killactive": + return "close-window" + case "movefocus": + switch argument { + case "l": + return "focus-column-left" + case "r": + return "focus-column-right" + case "u": + return "focus-window-up" + case "d": + return "focus-window-down" + } + return "focus-column-left" + case "movewindow": + switch argument { + case "l": + return "move-column-left" + case "r": + return "move-column-right" + case "u": + return "move-window-up" + case "d": + return "move-window-down" + } + return "move-column-left" + case "workspace": + // workspace N or workspace +N/-N + if strings.HasPrefix(argument, "+") || strings.HasPrefix(argument, "-") { + return fmt.Sprintf("focus-workspace %s", argument) + } + return fmt.Sprintf("focus-workspace %s", argument) + case "movetoworkspace", "movetoworkspacesilent": + return fmt.Sprintf("move-window-to-workspace %s", argument) + case "togglespecialworkspace": + return "" // not supported on niri + case "fullscreen": + return "fullscreen-window" + case "togglefloating": + return "toggle-window-floating" + case "layoutmsg": + // Hyprland layoutmsg: colresize, promote, togglefit, swapcol, movecoltoworkspace + switch { + case strings.HasPrefix(argument, "colresize"): + return "set-column-width" + case argument == "promote": + return "maximize-column" + case argument == "togglefit": + return "maximize-column" + case strings.HasPrefix(argument, "swapcol"): + return "move-column-left" + case strings.HasPrefix(argument, "movecoltoworkspace"): + return "move-window-to-workspace" + } + return "" + default: + // Pass through unknown dispatchers as-is (niri action names). + if argument != "" { + return fmt.Sprintf("%s %s", dispatcher, argument) + } + return dispatcher + } +} + +// GenerateKeybindsFromPayload renders a niri binds{} block from a batch payload. +// Unbinds are handled implicitly: rewriting the whole ambxst.kdl drops old binds. +func GenerateKeybindsFromPayload(payload ipc.BatchKeybindsPayload) string { + var out strings.Builder + out.WriteString("// Generated by axctl (Ambxst keybinds)\n") + out.WriteString("// Do not edit manually!\n\n") + out.WriteString("binds {\n") + + for _, b := range payload.Binds { + if !b.Enabled || b.Key == "" { + continue + } + combo := formatModifiers(b.Modifiers) + if len(combo) > 0 { + combo += "+" + } + combo += b.Key + + action := mapNiriDispatcher(b.Dispatcher, b.Argument) + if action == "" { + continue // unsupported action, skip + } + out.WriteString(fmt.Sprintf(" %s { %s; }\n", combo, action)) + } + + out.WriteString("}\n") + return out.String() +} + func (g *Generator) GenerateKeybinds(config ipc.ConfigKeybinds) string { var out strings.Builder out.WriteString("// Generated by axctl ConfigGenerator (Keybinds)\n") @@ -174,17 +286,17 @@ func (g *Generator) GenerateWindowRules(rules []ipc.WindowRule) string { var out strings.Builder out.WriteString("// Generated by axctl ConfigGenerator (Window Rules)\n") out.WriteString("// Do not edit manually!\n\n") - out.WriteString("window-rules {\n") + // niri uses `window-rule` (singular) blocks, not a `window-rules` container. for _, r := range rules { if r.Match != "" && r.Rule != "" { - out.WriteString(fmt.Sprintf(" match %s {\n", r.Match)) - out.WriteString(fmt.Sprintf(" %s\n", r.Rule)) // simplistic approach - out.WriteString(" }\n") + out.WriteString("window-rule {\n") + out.WriteString(fmt.Sprintf(" match %s\n", r.Match)) + out.WriteString(fmt.Sprintf(" %s\n", r.Rule)) + out.WriteString("}\n\n") } } - out.WriteString("}\n") return out.String() } @@ -192,12 +304,37 @@ func (g *Generator) GenerateLayerRules(rules []ipc.LayerRule) string { var out strings.Builder out.WriteString("// Generated by axctl ConfigGenerator (Layer Rules)\n") out.WriteString("// Do not edit manually!\n\n") - // Niri doesn't have layer rules in the same way as Hyprland - // Output a comment indicating this - out.WriteString("// Layer rules not supported in Niri\n") + + // niri supports layer-rule with background-effect { blur true }. + // Map Hyprland-style layer rules (namespace + blur) to niri layer-rule blocks. + for _, r := range rules { + if r.Namespace == "" { + continue + } + out.WriteString("layer-rule {\n") + out.WriteString(fmt.Sprintf(" match namespace=\"%s\"\n", r.Namespace)) + if r.Blur != nil && *r.Blur { + out.WriteString(" background-effect {\n") + out.WriteString(" blur true\n") + out.WriteString(" }\n") + } + out.WriteString("}\n\n") + } + return out.String() } func (g *Generator) GenerateStartup(exec []string, execOnce []string) string { - return "" + var out strings.Builder + out.WriteString("// Generated by axctl ConfigGenerator (Startup)\n") + out.WriteString("// Do not edit manually!\n\n") + + for _, cmd := range execOnce { + out.WriteString(fmt.Sprintf("spawn-at-startup \"%s\"\n", cmd)) + } + for _, cmd := range exec { + out.WriteString(fmt.Sprintf("spawn-at-startup \"%s\"\n", cmd)) + } + + return out.String() } diff --git a/pkg/server/config_handler.go b/pkg/server/config_handler.go index 215849b..0dd142a 100644 --- a/pkg/server/config_handler.go +++ b/pkg/server/config_handler.go @@ -39,12 +39,28 @@ func NewConfigHandlerWithOutput(c ipc.Compositor, outputPath string) *ConfigHand resolvedPath := outputPath if resolvedPath == "" { - resolvedPath = DefaultOutputPath() + resolvedPath = DefaultOutputPathFor(c) } return &ConfigHandler{compositor: c, generator: gen, luaGen: lg, outputPath: resolvedPath} } +// DefaultOutputPathFor returns the compositor-specific config output path. +// Hyprland uses a sourced .conf; niri uses a dedicated generated file that is +// included from config.kdl (so we never overwrite the user's config). +func DefaultOutputPathFor(c ipc.Compositor) string { + homeDir := os.Getenv("HOME") + if homeDir == "" { + homeDir = "/root" + } + switch c.(type) { + case *niri.Niri: + return filepath.Join(homeDir, ".config", "niri", "ambxst-generated.kdl") + default: + return filepath.Join(homeDir, ".local", "share", "ambxst", "hyprland.conf") + } +} + func DefaultOutputPath() string { homeDir := os.Getenv("HOME") if homeDir == "" { @@ -96,6 +112,25 @@ func (h *ConfigHandler) ApplyConfig(payload ipc.ConfigUniversal) error { } fmt.Printf("Config written to: %s\n", configPath) + // For niri, ensure config.kdl includes the generated file so the user's + // config is never overwritten. + if _, ok := h.compositor.(*niri.Niri); ok { + mainPath := filepath.Join(filepath.Dir(configPath), "config.kdl") + includeLine := `include "` + filepath.Base(configPath) + `"` + mainData, err := os.ReadFile(mainPath) + if err != nil { + // No config.kdl yet — create one with just the include. + if werr := os.WriteFile(mainPath, []byte(includeLine+"\n"), 0644); werr != nil { + return werr + } + } else if !strings.Contains(string(mainData), includeLine) { + updated := includeLine + "\n" + string(mainData) + if werr := os.WriteFile(mainPath, []byte(updated), 0644); werr != nil { + return werr + } + } + } + // Write .lua file if Lua generator is available if h.luaGen != nil { luaStartup := h.luaGen.GenerateStartupLua(payload.Exec, payload.ExecOnce) diff --git a/pkg/server/server.go b/pkg/server/server.go index 90fc98b..9581811 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -588,6 +588,12 @@ func (s *Server) handleConnection(conn net.Conn) { if err == nil { result = map[string]int{"x": x, "y": y} } + case "System.GetCapabilities": + var caps ipc.Capabilities + caps, err = s.compositor.GetCapabilities() + if err == nil { + result = caps + } case "System.IdleInhibit": if s.idleMgr == nil { resp.Error = "Idle management not supported on this session"