package main import ( "fmt" "strconv" "strings" "time" ) // MostWait is the longest a menu stays open: below the runtime's 30 s call limit, so an unanswered // menu is answered as such rather than as a call the runtime gave up on. const MostWait = 25 // MostChoices bounds the list. const MostChoices = 1000 // Ask is one menu. type Ask struct { Choices []string Prompt string Lines int CaseInsensitive bool Timeout int } // MenuAnswer is what dmenu_menu answers. type MenuAnswer struct { Chosen string `json:"chosen,omitempty"` Index int `json:"index"` Typed bool `json:"typed"` Cancelled bool `json:"cancelled"` TimedOut bool `json:"timed_out"` Session Session `json:"session"` } // Menu shows the choices and answers the one taken. dmenu prints the selected (or typed) line and // exits 0; it exits 1 with nothing printed when dismissed. func Menu(a Ask) (MenuAnswer, error) { if len(a.Choices) == 0 { return MenuAnswer{}, fmt.Errorf("choices is required: at least one line") } if len(a.Choices) > MostChoices { return MenuAnswer{}, fmt.Errorf("%d choices; at most %d are shown", len(a.Choices), MostChoices) } for _, c := range a.Choices { if strings.ContainsAny(c, "\n\r") { return MenuAnswer{}, fmt.Errorf("a choice holds a line break: %q", c) } } if strings.ContainsAny(a.Prompt, "\n\r") { return MenuAnswer{}, fmt.Errorf("the prompt holds a line break") } s, err := findSession() if err != nil { return MenuAnswer{}, err } args := []string{} if a.CaseInsensitive { args = append(args, "-i") } if a.Lines > 0 { args = append(args, "-l", strconv.Itoa(a.Lines)) } if a.Prompt != "" { args = append(args, "-p", a.Prompt) } c := Cmd{Name: "dmenu", Args: args, Stdin: strings.Join(a.Choices, "\n") + "\n", Env: s.Env(), Timeout: time.Duration(a.Timeout) * time.Second} r := run(c) out := MenuAnswer{Index: -1, Session: s} switch { case r.Error == "timeout": out.TimedOut = true return out, nil case r.Error != "": return MenuAnswer{}, failure(c, r) case strings.Contains(r.Stderr, "cannot open display"): return MenuAnswer{}, fmt.Errorf("the X session at %s (found by %s) refused the connection", s.Display, s.FoundBy) case strings.Contains(r.Stderr, "cannot grab keyboard"): return MenuAnswer{}, fmt.Errorf("dmenu could not take the keyboard: another program holds it (a locked screen, an open menu)") case r.Status == 1 && strings.TrimSpace(r.Stdout) == "": out.Cancelled = true return out, nil case r.Status != 0: return MenuAnswer{}, failure(c, r) } out.Chosen = strings.TrimRight(r.Stdout, "\r\n") for i, ch := range a.Choices { if ch == out.Chosen { out.Index = i break } } out.Typed = out.Index < 0 return out, nil } // SessionCheck answers the session the menu would appear in, and dmenu's version. func SessionCheck() (map[string]any, error) { out := map[string]any{} if r := run(Cmd{Name: "dmenu", Args: []string{"-v"}}); r.Error == "" { out["dmenu"] = strings.TrimSpace(r.Stdout + r.Stderr) } else { out["dmenu"] = failure(Cmd{Name: "dmenu"}, r).Error() } s, err := findSession() if err != nil { out["found"], out["why"] = false, err.Error() return out, nil } out["found"], out["session"] = true, s return out, nil }