package main import ( "context" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "time" "i3/internal/desktop" ) type i3 struct { d desktop.Desk socket func() (string, error) configDir string layouts string watcher *Watcher } func (t *i3) ask(kind uint32, payload string, out any) error { sock, err := t.socket() if err != nil { return err } return Ask(sock, kind, payload, out) } // command runs i3 commands and fails when any of them did. func (t *i3) command(cmd string) ([]Outcome, error) { var out []Outcome if err := t.ask(RunCommand, cmd, &out); err != nil { return nil, err } for _, o := range out { if !o.Success { return out, fmt.Errorf("i3 refused %q: %s", cmd, o.Error) } } return out, nil } func (t *i3) tree() (Node, error) { var root Node err := t.ask(GetTree, "", &root) return root, err } // check runs `i3 -C` on the configuration on disk. It needs no display. func (t *i3) check(ctx context.Context) ([]ConfigError, error) { res := t.d.Plain(ctx, "i3", "-C", "-c", filepath.Join(t.configDir, "config")) if res.Code == 127 { return nil, fmt.Errorf("i3 is not installed here") } problems := append([]ConfigError{}, ParseCheck(res.Stdout+"\n"+res.Stderr)...) if !res.OK() && len(problems) == 0 { problems = []ConfigError{{Text: strings.TrimSpace(res.Stdout + res.Stderr)}} } return problems, nil } func (t *i3) watcherStatus() any { if t.watcher == nil { return nil } return t.watcher.Status() } func (t *i3) reload(ctx context.Context, a desktop.Args) (any, error) { force, _, err := a.Bool("force") if err != nil { return nil, err } problems, err := t.check(ctx) if err != nil { return nil, err } if len(problems) > 0 && !force { return map[string]any{"reloaded": false, "errors": problems, "why": "the configuration on disk has errors; fix them, or force"}, nil } out, err := t.command("reload") if err != nil { return nil, err } return map[string]any{"reloaded": true, "i3": out, "errors": problems, "watcher": t.watcherStatus()}, nil } // WorkspaceInfo is one workspace as GET_WORKSPACES answers it. type WorkspaceInfo struct { Num int `json:"num"` Name string `json:"name"` Output string `json:"output"` Visible bool `json:"visible"` Focused bool `json:"focused"` Urgent bool `json:"urgent"` } func (t *i3) workspaces(ctx context.Context, a desktop.Args) (any, error) { var ws []WorkspaceInfo if err := t.ask(GetWorkspaces, "", &ws); err != nil { return nil, err } return map[string]any{"workspaces": ws}, nil } func (t *i3) windows(ctx context.Context, a desktop.Args) (any, error) { root, err := t.tree() if err != nil { return nil, err } all := Windows(root) ws := a.Opt("workspace", "") out := []Window{} for _, w := range all { if ws == "" || w.Workspace == ws { out = append(out, w) } } return map[string]any{"windows": out}, nil } func (t *i3) focus(ctx context.Context, a desktop.Args) (any, error) { crit, err := Criteria(a) if err != nil { return nil, err } var cmd string switch ws := a.Opt("workspace", ""); { case crit != "" && ws != "": return nil, fmt.Errorf("a window or a workspace, not both") case crit != "": cmd = crit + " focus" case ws != "": cmd = "workspace " + Quote(ws) default: return nil, fmt.Errorf("name a window (con_id, window, class, instance, title, mark) or a workspace") } out, err := t.command(cmd) if err != nil { return nil, err } return map[string]any{"command": cmd, "i3": out}, nil } var direction = regexp.MustCompile(`^(left|right|up|down|current|primary|next|prev)$`) func target(output string) string { if direction.MatchString(output) { return output } return Quote(output) } func (t *i3) move(ctx context.Context, a desktop.Args) (any, error) { crit, err := Criteria(a) if err != nil { return nil, err } toWS, toOut, wsMove := a.Opt("to_workspace", ""), a.Opt("to_output", ""), a.Opt("workspace_to_output", "") var cmd string switch { case wsMove != "": if toOut == "" { return nil, fmt.Errorf("workspace_to_output needs to_output") } cmd = "[workspace=" + Quote("^"+regexp.QuoteMeta(wsMove)+"$") + "] move workspace to output " + target(toOut) case toWS != "" && toOut != "": return nil, fmt.Errorf("to a workspace or to an output, not both") case toWS != "": cmd = strings.TrimSpace(crit + " move container to workspace " + Quote(toWS)) case toOut != "": cmd = strings.TrimSpace(crit + " move container to output " + target(toOut)) default: return nil, fmt.Errorf("name to_workspace or to_output") } out, err := t.command(cmd) if err != nil { return nil, err } return map[string]any{"command": cmd, "i3": out}, nil } var layoutName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) func (t *i3) layoutSave(ctx context.Context, a desktop.Args) (any, error) { name, err := a.Text("name") if err != nil { return nil, err } if !layoutName.MatchString(name) { return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore") } root, err := t.tree() if err != nil { return nil, err } ws, ok := Workspace(root, a.Opt("workspace", "")) if !ok { return nil, fmt.Errorf("no workspace %q", a.Opt("workspace", "(focused)")) } top, windows := Layout(ws) if windows == 0 { return nil, fmt.Errorf("workspace %s holds no windows to save", str(ws.Name)) } if err := os.MkdirAll(t.layouts, 0o755); err != nil { return nil, err } path := filepath.Join(t.layouts, name+".json") if err := os.WriteFile(path, []byte(LayoutFile(str(ws.Name), time.Now().Format(time.RFC3339), top)), 0o644); err != nil { return nil, err } return map[string]any{"name": name, "workspace": str(ws.Name), "windows": windows, "path": path}, nil } func (t *i3) savedLayouts() []map[string]string { files, _ := filepath.Glob(filepath.Join(t.layouts, "*.json")) sort.Strings(files) out := []map[string]string{} for _, f := range files { b, _ := os.ReadFile(f) out = append(out, map[string]string{"name": strings.TrimSuffix(filepath.Base(f), ".json"), "workspace": SavedWorkspace(string(b)), "path": f}) } return out } func (t *i3) layoutRestore(ctx context.Context, a desktop.Args) (any, error) { name, err := a.Text("name") if err != nil { return nil, err } if name == "list" { return map[string]any{"layouts": t.savedLayouts()}, nil } if !layoutName.MatchString(name) { return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore") } path := filepath.Join(t.layouts, name+".json") b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("no saved layout %q (i3_layout_restore with name list shows them)", name) } ws := a.Opt("workspace", SavedWorkspace(string(b))) if ws == "" { return nil, fmt.Errorf("name the workspace to lay it on") } cmd := "workspace " + Quote(ws) + "; append_layout " + Quote(path) out, err := t.command(cmd) if err != nil { return nil, err } return map[string]any{"layout": name, "workspace": ws, "i3": out, "then": "each placeholder swallows the next window of its class as it opens; start the programs (i3_exec) to fill them"}, nil } func (t *i3) exec(ctx context.Context, a desktop.Args) (any, error) { command, err := a.Text("command") if err != nil { return nil, err } cmd := "exec --no-startup-id " + Quote(command) if ws := a.Opt("workspace", ""); ws != "" { cmd = "workspace " + Quote(ws) + "; " + cmd } out, err := t.command(cmd) if err != nil { return nil, err } return map[string]any{"started": command, "i3": out, "how": "a child of i3, in the session; i3 does not answer whether the program itself started"}, nil } func (t *i3) kill(ctx context.Context, a desktop.Args) (any, error) { crit, err := Criteria(a) if err != nil { return nil, err } focused, _, err := a.Bool("focused") if err != nil { return nil, err } if crit == "" && !focused { return nil, fmt.Errorf("name the windows to close, or focused: true") } force, _, _ := a.Bool("force") cmd := strings.TrimSpace(crit + " kill") if force { cmd += " client" } before, _ := t.tree() out, err := t.command(cmd) if err != nil { return nil, err } return map[string]any{"command": cmd, "i3": out, "windows_before": len(Windows(before))}, nil } type configReply struct { Config string `json:"config"` Included []struct { Path string `json:"path"` Raw string `json:"raw_contents"` Replaced string `json:"variable_replaced_contents"` } `json:"included_configs"` } // loaded is each file the running i3 loaded, with the text it read. func (t *i3) loaded() (map[string]string, error) { var c configReply if err := t.ask(GetConfig, "", &c); err != nil { return nil, err } out := map[string]string{} for _, inc := range c.Included { out[inc.Path] = inc.Raw } if len(out) == 0 { out[filepath.Join(t.configDir, "config")] = c.Config } return out, nil } func (t *i3) bindings(ctx context.Context, a desktop.Args) (any, error) { var c configReply if err := t.ask(GetConfig, "", &c); err != nil { return nil, err } var all []Binding if len(c.Included) == 0 { all = Bindings("(main)", c.Config) } for _, inc := range c.Included { all = append(all, Bindings(inc.Path, inc.Replaced)...) } match := strings.ToLower(a.Opt("match", "")) out := []Binding{} for _, b := range all { if match == "" || strings.Contains(strings.ToLower(b.Keys+" "+b.Command), match) { out = append(out, b) } } return map[string]any{"bindings": out, "from": "the configuration i3 loaded at its last start or reload"}, nil } func (t *i3) configCheck(ctx context.Context, a desktop.Args) (any, error) { problems, err := t.check(ctx) if err != nil { return nil, err } answer := map[string]any{"valid": len(problems) == 0, "errors": problems, "config": filepath.Join(t.configDir, "config"), "on_disk": sortedKeys(Watched(t.configDir)), "watcher": t.watcherStatus()} var v struct { Loaded string `json:"loaded_config_file_name"` Included []string `json:"included_config_file_names"` Human string `json:"human_readable"` } if err := t.ask(GetVersion, "", &v); err == nil { answer["running"] = map[string]any{"version": v.Human, "loaded": append([]string{v.Loaded}, v.Included...)} } return answer, nil } func sortedKeys(m map[string]string) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } func (t *i3) marks(ctx context.Context, a desktop.Args) (any, error) { root, err := t.tree() if err != nil { return nil, err } type marked struct { Mark string `json:"mark"` Window Window `json:"window"` } out := []marked{} for _, w := range Windows(root) { for _, m := range w.Marks { out = append(out, marked{m, w}) } } return map[string]any{"marks": out}, nil } func (t *i3) scratchpad(ctx context.Context, a desktop.Args) (any, error) { action, err := a.OneOf("action", "list", "list", "show", "move") if err != nil { return nil, err } crit, err := Criteria(a) if err != nil { return nil, err } switch action { case "list": root, err := t.tree() if err != nil { return nil, err } out := []Window{} for _, w := range Windows(root) { if w.Scratchpad { out = append(out, w) } } return map[string]any{"scratchpad": out}, nil case "show": cmd := strings.TrimSpace(crit + " scratchpad show") out, err := t.command(cmd) if err != nil { return nil, err } return map[string]any{"command": cmd, "i3": out}, nil default: cmd := strings.TrimSpace(crit + " move scratchpad") out, err := t.command(cmd) if err != nil { return nil, err } return map[string]any{"command": cmd, "i3": out}, nil } }