rofi: the launcher as a module, claiming node-launcher and serving menu (hq ADR 0208)
Places the seat's dmenu-compatible command, the launcher and power menu, its themes in the decided faces, and its key bindings as an i3 drop-in; Go tools menu, applications, themes and run.
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The menu waits for a person, bounded below the runtime's call limit.
|
||||
const (
|
||||
menuDefault = 20
|
||||
menuMost = 25
|
||||
mostItems = 1000
|
||||
)
|
||||
|
||||
// MenuQuestion is what node-launcher.menu asks.
|
||||
type MenuQuestion struct {
|
||||
Items []string
|
||||
Prompt string
|
||||
Message string
|
||||
AllowCustom bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func menuQuestion(args map[string]any) (MenuQuestion, error) {
|
||||
var q MenuQuestion
|
||||
var err error
|
||||
if q.Items, err = texts(args, "items"); err != nil {
|
||||
return q, err
|
||||
}
|
||||
if len(q.Items) == 0 {
|
||||
return q, errors.New("items is required: at least one choice")
|
||||
}
|
||||
if len(q.Items) > mostItems {
|
||||
return q, fmt.Errorf("%d items; a menu shows at most %d", len(q.Items), mostItems)
|
||||
}
|
||||
for i, item := range q.Items {
|
||||
if strings.ContainsAny(item, "\n\r") {
|
||||
return q, fmt.Errorf("items[%d] spans lines; a choice is one line", i)
|
||||
}
|
||||
}
|
||||
if q.Prompt, err = text(args, "prompt", false); err != nil {
|
||||
return q, err
|
||||
}
|
||||
if q.Prompt == "" {
|
||||
q.Prompt = "Choose"
|
||||
}
|
||||
if q.Message, err = text(args, "message", false); err != nil {
|
||||
return q, err
|
||||
}
|
||||
if q.AllowCustom, err = flag(args, "allow_custom", false); err != nil {
|
||||
return q, err
|
||||
}
|
||||
q.Timeout, err = seconds(args, "timeout_seconds", menuDefault, menuMost)
|
||||
return q, err
|
||||
}
|
||||
|
||||
// MenuAnswer is what node-launcher.menu answers.
|
||||
type MenuAnswer struct {
|
||||
Chosen string `json:"chosen,omitempty"`
|
||||
Index int `json:"index"`
|
||||
Custom bool `json:"custom,omitempty"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
TimedOut bool `json:"timed_out,omitempty"`
|
||||
}
|
||||
|
||||
// Menu shows the list with `rofi -dmenu` in the operator's session and answers the choice.
|
||||
func Menu(q MenuQuestion) (MenuAnswer, error) {
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
return MenuAnswer{}, err
|
||||
}
|
||||
args := []string{"-dmenu", "-i", "-p", q.Prompt, "-format", "s"}
|
||||
if q.Message != "" {
|
||||
args = append(args, "-mesg", q.Message)
|
||||
}
|
||||
if !q.AllowCustom {
|
||||
args = append(args, "-no-custom")
|
||||
}
|
||||
r, err := s.run(q.Timeout, strings.Join(q.Items, "\n")+"\n", "rofi", args...)
|
||||
if errors.Is(err, ErrTimedOut) {
|
||||
return MenuAnswer{Index: -1, Cancelled: true, TimedOut: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return MenuAnswer{}, err
|
||||
}
|
||||
switch r.Code {
|
||||
case 0:
|
||||
case 1:
|
||||
return MenuAnswer{Index: -1, Cancelled: true}, nil
|
||||
default:
|
||||
return MenuAnswer{}, fmt.Errorf("rofi exited %d: %s", r.Code, strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
chosen := strings.TrimRight(r.Stdout, "\n")
|
||||
for i, item := range q.Items {
|
||||
if item == chosen {
|
||||
return MenuAnswer{Chosen: chosen, Index: i}, nil
|
||||
}
|
||||
}
|
||||
return MenuAnswer{Chosen: chosen, Index: -1, Custom: true}, nil
|
||||
}
|
||||
|
||||
// Application is one desktop entry the launcher offers.
|
||||
type Application struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
GenericName string `json:"generic_name,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Exec string `json:"exec"`
|
||||
Terminal bool `json:"terminal,omitempty"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
File string `json:"file"`
|
||||
keywords string
|
||||
}
|
||||
|
||||
// ApplicationsResult is what rofi_applications answers.
|
||||
type ApplicationsResult struct {
|
||||
Directories []string `json:"directories"`
|
||||
Count int `json:"count"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Applications []Application `json:"applications"`
|
||||
}
|
||||
|
||||
// applicationDirs are the directories desktop entries are read from, the first winning an id: the
|
||||
// account's own, then XDG_DATA_DIRS (as the session has it), then flatpak's exports.
|
||||
func applicationDirs() []string {
|
||||
home := operatorHome()
|
||||
dirs := []string{filepath.Join(home, ".local", "share", "applications")}
|
||||
data := os.Getenv("XDG_DATA_DIRS")
|
||||
if data == "" {
|
||||
data = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, d := range strings.Split(data, ":") {
|
||||
if d != "" {
|
||||
dirs = append(dirs, filepath.Join(d, "applications"))
|
||||
}
|
||||
}
|
||||
dirs = append(dirs, filepath.Join(home, ".local", "share", "flatpak", "exports", "share", "applications"),
|
||||
"/var/lib/flatpak/exports/share/applications")
|
||||
return unique(dirs)
|
||||
}
|
||||
|
||||
func unique(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Applications reads every desktop entry in dirs, the first directory winning an id.
|
||||
func Applications(dirs []string, query string, showHidden bool, limit int) ApplicationsResult {
|
||||
byID := map[string]Application{}
|
||||
for _, dir := range dirs {
|
||||
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".desktop") {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, path)
|
||||
id := strings.ReplaceAll(rel, string(filepath.Separator), "-")
|
||||
if _, taken := byID[id]; taken {
|
||||
return nil
|
||||
}
|
||||
if app, ok := readDesktopEntry(path); ok {
|
||||
app.ID = id
|
||||
byID[id] = app
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
q := strings.ToLower(query)
|
||||
out := ApplicationsResult{Directories: dirs, Applications: []Application{}}
|
||||
ids := make([]string, 0, len(byID))
|
||||
for id := range byID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
a, b := byID[ids[i]], byID[ids[j]]
|
||||
if strings.ToLower(a.Name) != strings.ToLower(b.Name) {
|
||||
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
for _, id := range ids {
|
||||
app := byID[id]
|
||||
if app.Hidden && !showHidden {
|
||||
continue
|
||||
}
|
||||
if q != "" && !strings.Contains(strings.ToLower(app.Name+"\x00"+app.GenericName+"\x00"+app.keywords+"\x00"+app.Exec), q) {
|
||||
continue
|
||||
}
|
||||
out.Count++
|
||||
if len(out.Applications) < limit {
|
||||
out.Applications = append(out.Applications, app)
|
||||
} else {
|
||||
out.Truncated = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readDesktopEntry reads the [Desktop Entry] group of an application's file, unlocalised.
|
||||
func readDesktopEntry(path string) (Application, bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return Application{}, false
|
||||
}
|
||||
defer f.Close()
|
||||
app := Application{File: path}
|
||||
kind := ""
|
||||
in := false
|
||||
scan := bufio.NewScanner(f)
|
||||
for scan.Scan() {
|
||||
line := strings.TrimSpace(scan.Text())
|
||||
if strings.HasPrefix(line, "[") {
|
||||
in = line == "[Desktop Entry]"
|
||||
continue
|
||||
}
|
||||
if !in || line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
switch k {
|
||||
case "Type":
|
||||
kind = v
|
||||
case "Name":
|
||||
app.Name = v
|
||||
case "GenericName":
|
||||
app.GenericName = v
|
||||
case "Comment":
|
||||
app.Comment = v
|
||||
case "Exec":
|
||||
app.Exec = v
|
||||
case "Keywords":
|
||||
app.keywords = v
|
||||
case "Terminal":
|
||||
app.Terminal = v == "true"
|
||||
case "Categories":
|
||||
for _, c := range strings.Split(v, ";") {
|
||||
if c != "" {
|
||||
app.Categories = append(app.Categories, c)
|
||||
}
|
||||
}
|
||||
case "NoDisplay", "Hidden":
|
||||
app.Hidden = app.Hidden || v == "true"
|
||||
}
|
||||
}
|
||||
if kind != "Application" || app.Name == "" {
|
||||
return Application{}, false
|
||||
}
|
||||
return app, true
|
||||
}
|
||||
|
||||
// Theme is one launcher theme.
|
||||
type Theme struct {
|
||||
Name string `json:"name"`
|
||||
File string `json:"file"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ThemesResult is what rofi_themes answers.
|
||||
type ThemesResult struct {
|
||||
Current string `json:"current"`
|
||||
Themes []Theme `json:"themes"`
|
||||
}
|
||||
|
||||
type themeDir struct{ path, source string }
|
||||
|
||||
func configFile() string { return filepath.Join(operatorHome(), ".config", "rofi", "config.rasi") }
|
||||
|
||||
func themeDirs() []themeDir {
|
||||
home := operatorHome()
|
||||
return []themeDir{
|
||||
{filepath.Join(home, ".config", "rofi", "themes"), "the account's (the mesh places mesh and mesh-powermenu here)"},
|
||||
{filepath.Join(home, ".local", "share", "rofi", "themes"), "the account's"},
|
||||
{"/usr/share/rofi/themes", "the distribution's"},
|
||||
}
|
||||
}
|
||||
|
||||
var themeLine = regexp.MustCompile(`(?m)^\s*@theme\s+"([^"]+)"`)
|
||||
|
||||
// Themes lists every .rasi theme, the first directory winning a name, and the configured one.
|
||||
func Themes(dirs []themeDir, config string) ThemesResult {
|
||||
out := ThemesResult{Themes: []Theme{}}
|
||||
if raw, err := os.ReadFile(config); err == nil {
|
||||
if m := themeLine.FindSubmatch(raw); m != nil {
|
||||
out.Current = string(m[1])
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
entries, err := os.ReadDir(d.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
name, ok := strings.CutSuffix(e.Name(), ".rasi")
|
||||
if !ok || e.IsDir() || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out.Themes = append(out.Themes, Theme{Name: name, File: filepath.Join(d.path, e.Name()), Source: d.source})
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Themes, func(i, j int) bool { return out.Themes[i].Name < out.Themes[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// RunResult is what rofi_run answers.
|
||||
type RunResult struct {
|
||||
Unit string `json:"unit"`
|
||||
Argv []string `json:"argv"`
|
||||
}
|
||||
|
||||
// Run starts an application or a command in the session under the account's service manager.
|
||||
func Run(application string, command []string) (RunResult, error) {
|
||||
if (application == "") == (len(command) == 0) {
|
||||
return RunResult{}, errors.New("name an application or give a command, one of the two")
|
||||
}
|
||||
argv := command
|
||||
if application != "" {
|
||||
var app *Application
|
||||
for _, a := range Applications(applicationDirs(), "", true, 1<<20).Applications {
|
||||
if a.ID == application {
|
||||
a := a
|
||||
app = &a
|
||||
break
|
||||
}
|
||||
}
|
||||
if app == nil {
|
||||
return RunResult{}, fmt.Errorf("no desktop entry %s on this machine", application)
|
||||
}
|
||||
var err error
|
||||
if argv, err = execWords(app.Exec); err != nil {
|
||||
return RunResult{}, fmt.Errorf("%s: %w", application, err)
|
||||
}
|
||||
if app.Terminal {
|
||||
argv = append([]string{"rofi-sensible-terminal", "-e"}, argv...)
|
||||
}
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
unit := uniqueUnit("rofi-run")
|
||||
if err := s.detach(unit, argv...); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
return RunResult{Unit: unit + ".service", Argv: argv}, nil
|
||||
}
|
||||
|
||||
// execWords splits a desktop entry's Exec into words (its quoting rules), dropping the field codes a
|
||||
// launcher fills with files or URLs, since none are given.
|
||||
func execWords(exec string) ([]string, error) {
|
||||
var words []string
|
||||
var cur strings.Builder
|
||||
inWord, quoted := false, false
|
||||
for i := 0; i < len(exec); i++ {
|
||||
c := exec[i]
|
||||
switch {
|
||||
case quoted && c == '\\' && i+1 < len(exec):
|
||||
i++
|
||||
cur.WriteByte(exec[i])
|
||||
case c == '"':
|
||||
quoted = !quoted
|
||||
inWord = true
|
||||
case !quoted && (c == ' ' || c == '\t'):
|
||||
if inWord {
|
||||
words = append(words, cur.String())
|
||||
cur.Reset()
|
||||
inWord = false
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
inWord = true
|
||||
}
|
||||
}
|
||||
if quoted {
|
||||
return nil, fmt.Errorf("unbalanced quote in Exec %q", exec)
|
||||
}
|
||||
if inWord {
|
||||
words = append(words, cur.String())
|
||||
}
|
||||
var out []string
|
||||
for _, w := range words {
|
||||
if len(w) == 2 && w[0] == '%' {
|
||||
continue // %f %F %u %U %i %c %k %d %D %n %N %v %m
|
||||
}
|
||||
out = append(out, strings.ReplaceAll(w, "%%", "%"))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("Exec %q names no program", exec)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user