Research 026/04 counted two plain dmenu calls failing with dmenu installed nowhere. Measured, they are one line on each workstation: dunst's `dmenu = /usr/bin/dmenu -p dunst:`. Installing the package fixes both by existing; the line stays the dunst module's. No claim: node-launcher is not in the controller's seat table yet, and the module says so. Two Go tools: menu, shaped like that seat's verb (chosen line, index, typed, cancelled, timed out within 25 s), and session.
113 lines
3.2 KiB
Go
113 lines
3.2 KiB
Go
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
|
|
}
|