feat(app): add 'neferbar app' to print the focused window's id, name and title, and show them in the bundled center module
- commit
- 4bf13d9ee7310e15d0f1f8502648873dca5527e0
- author
- brice · · 2026-10-05 17:26 UTC
- parent
- b65ab9e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +432 -19
M README.md +14 -0
| @@ -232,6 +232,20 @@ neferbar [-config file] [-display socket] [-pprof addr] [-memstats interval] | |||
| 232 | 232 | | `-pprof` | Serve Go profiling on a loopback address, such as `localhost:6060`. | | |
| 233 | 233 | | `-memstats` | Log allocation counters at this interval. | | |
| 234 | 234 | ||
| 235 | + | ## Reading the focused window | |
| 236 | + | ||
| 237 | + | `neferbar app` prints fields of the window that has the focus: one line now, then a new one each time the focus or a title changes. It works on compositors that offer `zwlr_foreign_toplevel_manager_v1` (NeferWL, Sway, Hyprland, River, Niri). | |
| 238 | + | ||
| 239 | + | ``` | |
| 240 | + | neferbar app title # Dumber - docs | |
| 241 | + | neferbar app id # com.github.bnema.dumber | |
| 242 | + | neferbar app name # dumber (the part of the id after the last dot) | |
| 243 | + | neferbar app name title # several fields, separated by a tab | |
| 244 | + | neferbar app -once title # print once and exit | |
| 245 | + | ``` | |
| 246 | + | ||
| 247 | + | A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`. | |
| 248 | + | ||
| 235 | 249 | ## Test without touching your desktop | |
| 236 | 250 | ||
| 237 | 251 | ```sh | |
| @@ -232,6 +232,20 @@ neferbar [-config file] [-display socket] [-pprof addr] [-memstats interval] | |||||
| 232 | | `-pprof` | Serve Go profiling on a loopback address, such as `localhost:6060`. | | 232 | | `-pprof` | Serve Go profiling on a loopback address, such as `localhost:6060`. | | ||
| 233 | | `-memstats` | Log allocation counters at this interval. | | 233 | | `-memstats` | Log allocation counters at this interval. | | ||
| 234 | 234 | ||||
| 235 | + | ## Reading the focused window | |||
| 236 | + | ||||
| 237 | + | `neferbar app` prints fields of the window that has the focus: one line now, then a new one each time the focus or a title changes. It works on compositors that offer `zwlr_foreign_toplevel_manager_v1` (NeferWL, Sway, Hyprland, River, Niri). | |||
| 238 | + | ||||
| 239 | + | ``` | |||
| 240 | + | neferbar app title # Dumber - docs | |||
| 241 | + | neferbar app id # com.github.bnema.dumber | |||
| 242 | + | neferbar app name # dumber (the part of the id after the last dot) | |||
| 243 | + | neferbar app name title # several fields, separated by a tab | |||
| 244 | + | neferbar app -once title # print once and exit | |||
| 245 | + | ``` | |||
| 246 | + | ||||
| 247 | + | A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`. | |||
| 248 | + | ||||
| 235 | ## Test without touching your desktop | 249 | ## Test without touching your desktop | ||
| 236 | 250 | ||||
| 237 | ```sh | 251 | ```sh | ||
A cmd/neferbar/app.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "flag" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | ||
| 9 | + | "git.bnema.dev/bnema/neferbar/internal/app" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const appUsage = `usage: neferbar app [-once] <field>... | |
| 13 | + | ||
| 14 | + | Prints fields of the window that has the focus: one line now, then one more | |
| 15 | + | each time it changes. With several fields, a tab separates them on the line | |
| 16 | + | (a title never contains a tab). Fields are empty when no window has the focus. | |
| 17 | + | ||
| 18 | + | fields: | |
| 19 | + | id the application id, such as com.github.bnema.dumber | |
| 20 | + | name the short form of the id, such as dumber | |
| 21 | + | title the window title, without control characters | |
| 22 | + | ||
| 23 | + | flags: | |
| 24 | + | ` | |
| 25 | + | ||
| 26 | + | // runApp is "neferbar app <field>": the focused window, for scripts. | |
| 27 | + | func runApp(ctx context.Context, args []string, display string) error { | |
| 28 | + | fs := flag.NewFlagSet("neferbar app", flag.ContinueOnError) | |
| 29 | + | once := fs.Bool("once", false, "print the current value and exit") | |
| 30 | + | fs.Usage = func() { fmt.Fprint(os.Stderr, appUsage); fs.PrintDefaults() } | |
| 31 | + | if err := fs.Parse(args); err != nil { | |
| 32 | + | return err | |
| 33 | + | } | |
| 34 | + | if fs.NArg() == 0 { | |
| 35 | + | fs.Usage() | |
| 36 | + | return fmt.Errorf("give at least one field: id, name or title") | |
| 37 | + | } | |
| 38 | + | var picks []func(app.Window) string | |
| 39 | + | for _, field := range fs.Args() { | |
| 40 | + | switch field { | |
| 41 | + | case "id": | |
| 42 | + | picks = append(picks, func(w app.Window) string { return app.Clean(w.ID) }) | |
| 43 | + | case "name": | |
| 44 | + | picks = append(picks, func(w app.Window) string { return app.Clean(app.Name(w.ID)) }) | |
| 45 | + | case "title": | |
| 46 | + | picks = append(picks, func(w app.Window) string { return app.Clean(w.Title) }) | |
| 47 | + | default: | |
| 48 | + | return fmt.Errorf("%q is not a field; use id, name or title", field) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ctx, cancel := context.WithCancel(ctx) | |
| 52 | + | defer cancel() | |
| 53 | + | return app.Watch(ctx, display, func(w app.Window) error { | |
| 54 | + | var line []byte | |
| 55 | + | for i, pick := range picks { | |
| 56 | + | if i > 0 { | |
| 57 | + | line = append(line, '\t') | |
| 58 | + | } | |
| 59 | + | line = append(line, pick(w)...) | |
| 60 | + | } | |
| 61 | + | if _, err := os.Stdout.Write(append(line, '\n')); err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | if *once { | |
| 65 | + | cancel() | |
| 66 | + | } | |
| 67 | + | return nil | |
| 68 | + | }) | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "flag" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | ||||
| 9 | + | "git.bnema.dev/bnema/neferbar/internal/app" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const appUsage = `usage: neferbar app [-once] <field>... | |||
| 13 | + | ||||
| 14 | + | Prints fields of the window that has the focus: one line now, then one more | |||
| 15 | + | each time it changes. With several fields, a tab separates them on the line | |||
| 16 | + | (a title never contains a tab). Fields are empty when no window has the focus. | |||
| 17 | + | ||||
| 18 | + | fields: | |||
| 19 | + | id the application id, such as com.github.bnema.dumber | |||
| 20 | + | name the short form of the id, such as dumber | |||
| 21 | + | title the window title, without control characters | |||
| 22 | + | ||||
| 23 | + | flags: | |||
| 24 | + | ` | |||
| 25 | + | ||||
| 26 | + | // runApp is "neferbar app <field>": the focused window, for scripts. | |||
| 27 | + | func runApp(ctx context.Context, args []string, display string) error { | |||
| 28 | + | fs := flag.NewFlagSet("neferbar app", flag.ContinueOnError) | |||
| 29 | + | once := fs.Bool("once", false, "print the current value and exit") | |||
| 30 | + | fs.Usage = func() { fmt.Fprint(os.Stderr, appUsage); fs.PrintDefaults() } | |||
| 31 | + | if err := fs.Parse(args); err != nil { | |||
| 32 | + | return err | |||
| 33 | + | } | |||
| 34 | + | if fs.NArg() == 0 { | |||
| 35 | + | fs.Usage() | |||
| 36 | + | return fmt.Errorf("give at least one field: id, name or title") | |||
| 37 | + | } | |||
| 38 | + | var picks []func(app.Window) string | |||
| 39 | + | for _, field := range fs.Args() { | |||
| 40 | + | switch field { | |||
| 41 | + | case "id": | |||
| 42 | + | picks = append(picks, func(w app.Window) string { return app.Clean(w.ID) }) | |||
| 43 | + | case "name": | |||
| 44 | + | picks = append(picks, func(w app.Window) string { return app.Clean(app.Name(w.ID)) }) | |||
| 45 | + | case "title": | |||
| 46 | + | picks = append(picks, func(w app.Window) string { return app.Clean(w.Title) }) | |||
| 47 | + | default: | |||
| 48 | + | return fmt.Errorf("%q is not a field; use id, name or title", field) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 52 | + | defer cancel() | |||
| 53 | + | return app.Watch(ctx, display, func(w app.Window) error { | |||
| 54 | + | var line []byte | |||
| 55 | + | for i, pick := range picks { | |||
| 56 | + | if i > 0 { | |||
| 57 | + | line = append(line, '\t') | |||
| 58 | + | } | |||
| 59 | + | line = append(line, pick(w)...) | |||
| 60 | + | } | |||
| 61 | + | if _, err := os.Stdout.Write(append(line, '\n')); err != nil { | |||
| 62 | + | return err | |||
| 63 | + | } | |||
| 64 | + | if *once { | |||
| 65 | + | cancel() | |||
| 66 | + | } | |||
| 67 | + | return nil | |||
| 68 | + | }) | |||
| 69 | + | } | |||
M cmd/neferbar/main.go +11 -0
| @@ -3,6 +3,7 @@ package main | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "flag" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "log/slog" | |
| @@ -20,6 +21,16 @@ import ( | |||
| 20 | 21 | ) | |
| 21 | 22 | ||
| 22 | 23 | func main() { | |
| 24 | + | if len(os.Args) > 1 && os.Args[1] == "app" { | |
| 25 | + | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 26 | + | err := runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY")) | |
| 27 | + | stop() | |
| 28 | + | if err != nil && !errors.Is(err, flag.ErrHelp) { | |
| 29 | + | fmt.Fprintf(os.Stderr, "neferbar app: %v\n", err) | |
| 30 | + | os.Exit(1) | |
| 31 | + | } | |
| 32 | + | return | |
| 33 | + | } | |
| 23 | 34 | cfgPath := flag.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)") | |
| 24 | 35 | display := flag.String("display", os.Getenv("NEFERBAR_DISPLAY"), "Wayland socket name or absolute path (default: $NEFERBAR_DISPLAY, then $WAYLAND_DISPLAY)") | |
| 25 | 36 | pprofAddr := flag.String("pprof", "", "serve pprof on this loopback address, e.g. localhost:6060") | |
| @@ -3,6 +3,7 @@ package main | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "flag" | 7 | "flag" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "log/slog" | 9 | "log/slog" | ||
| @@ -20,6 +21,16 @@ import ( | |||||
| 20 | ) | 21 | ) | ||
| 21 | 22 | ||||
| 22 | func main() { | 23 | func main() { | ||
| 24 | + | if len(os.Args) > 1 && os.Args[1] == "app" { | |||
| 25 | + | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |||
| 26 | + | err := runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY")) | |||
| 27 | + | stop() | |||
| 28 | + | if err != nil && !errors.Is(err, flag.ErrHelp) { | |||
| 29 | + | fmt.Fprintf(os.Stderr, "neferbar app: %v\n", err) | |||
| 30 | + | os.Exit(1) | |||
| 31 | + | } | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 23 | cfgPath := flag.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)") | 34 | cfgPath := flag.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)") | ||
| 24 | display := flag.String("display", os.Getenv("NEFERBAR_DISPLAY"), "Wayland socket name or absolute path (default: $NEFERBAR_DISPLAY, then $WAYLAND_DISPLAY)") | 35 | display := flag.String("display", os.Getenv("NEFERBAR_DISPLAY"), "Wayland socket name or absolute path (default: $NEFERBAR_DISPLAY, then $WAYLAND_DISPLAY)") | ||
| 25 | pprofAddr := flag.String("pprof", "", "serve pprof on this loopback address, e.g. localhost:6060") | 36 | pprofAddr := flag.String("pprof", "", "serve pprof on this loopback address, e.g. localhost:6060") | ||
M examples/bundle/center.sh +14 -17
| @@ -1,23 +1,20 @@ | |||
| 1 | 1 | #!/bin/bash | |
| 2 | - | # Center: the focused window. NeferWL's state file has the app id but no title; | |
| 3 | - | # a title needs the compositor's toplevel protocol, which the bar does not read yet. | |
| 2 | + | # Center: the focused window, as "icon name title". "neferbar app" asks the | |
| 3 | + | # compositor and prints one line each time the focus or a title changes, so this | |
| 4 | + | # script sleeps until something happens. | |
| 4 | 5 | . "$(dirname "$0")/lib.sh" | |
| 5 | 6 | ||
| 6 | - | [ -r "${NEFERWL_STATE:-}" ] || { | |
| 7 | - | echo | |
| 8 | - | exec sleep infinity | |
| 9 | - | } | |
| 7 | + | MAX_TITLE=${NEFERBAR_TITLE_MAX:-60} # cells; a longer title ends with an ellipsis | |
| 10 | 8 | ||
| 11 | - | draw() { | |
| 12 | - | local app | |
| 13 | - | # An app id is chosen by the application: drop control characters, which | |
| 14 | - | # would otherwise reach the bar as escape sequences. | |
| 15 | - | app=$(jq -r '(.window.app_id // empty) | explode | map(select(. >= 32 and . != 127)) | implode' "$NEFERWL_STATE" 2>/dev/null) || return | |
| 16 | - | if [ -n "$app" ]; then | |
| 17 | - | printf '%s%s%s %s%s%s\f' "$(bg "$BAR")" "$(fg "$NEFERBAR_COLOR3")" "$ICON_APP" "$(fg "$NEFERBAR_FOREGROUND")" "$app" "$RESET" | |
| 18 | - | else | |
| 9 | + | # $NEFERBAR_BIN is the neferbar that started this script. A line ends the bar | |
| 10 | + | # when the compositor goes away; the bar then restarts the script. | |
| 11 | + | "$NEFERBAR_BIN" app name title | while IFS=$'\t' read -r name title; do | |
| 12 | + | if [ -z "$name$title" ]; then | |
| 19 | 13 | printf '\f' | |
| 14 | + | continue | |
| 20 | 15 | fi | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | state_changed_loop draw | |
| 16 | + | ((${#title} > MAX_TITLE)) && title="${title:0:MAX_TITLE-1}…" | |
| 17 | + | printf '%s%s%s %s%s%s' "$(bg "$BAR")" "$(fg "$NEFERBAR_COLOR3")" "$ICON_APP" "$(fg "$NEFERBAR_FOREGROUND")" "$name" "$RESET" | |
| 18 | + | [ -n "$title" ] && printf ' %s%s%s' "$(fg "$DIM")" "$title" "$RESET" | |
| 19 | + | printf '\f' | |
| 20 | + | done | |
| @@ -1,23 +1,20 @@ | |||||
| 1 | #!/bin/bash | 1 | #!/bin/bash | ||
| 2 | - | # Center: the focused window. NeferWL's state file has the app id but no title; | 2 | + | # Center: the focused window, as "icon name title". "neferbar app" asks the |
| 3 | - | # a title needs the compositor's toplevel protocol, which the bar does not read yet. | 3 | + | # compositor and prints one line each time the focus or a title changes, so this |
| 4 | + | # script sleeps until something happens. | |||
| 4 | . "$(dirname "$0")/lib.sh" | 5 | . "$(dirname "$0")/lib.sh" | ||
| 5 | 6 | ||||
| 6 | - | [ -r "${NEFERWL_STATE:-}" ] || { | 7 | + | MAX_TITLE=${NEFERBAR_TITLE_MAX:-60} # cells; a longer title ends with an ellipsis |
| 7 | - | echo | |||
| 8 | - | exec sleep infinity | |||
| 9 | - | } | |||
| 10 | 8 | ||||
| 11 | - | draw() { | 9 | + | # $NEFERBAR_BIN is the neferbar that started this script. A line ends the bar |
| 12 | - | local app | 10 | + | # when the compositor goes away; the bar then restarts the script. |
| 13 | - | # An app id is chosen by the application: drop control characters, which | 11 | + | "$NEFERBAR_BIN" app name title | while IFS=$'\t' read -r name title; do |
| 14 | - | # would otherwise reach the bar as escape sequences. | 12 | + | if [ -z "$name$title" ]; then |
| 15 | - | app=$(jq -r '(.window.app_id // empty) | explode | map(select(. >= 32 and . != 127)) | implode' "$NEFERWL_STATE" 2>/dev/null) || return | |||
| 16 | - | if [ -n "$app" ]; then | |||
| 17 | - | printf '%s%s%s %s%s%s\f' "$(bg "$BAR")" "$(fg "$NEFERBAR_COLOR3")" "$ICON_APP" "$(fg "$NEFERBAR_FOREGROUND")" "$app" "$RESET" | |||
| 18 | - | else | |||
| 19 | printf '\f' | 13 | printf '\f' | ||
| 14 | + | continue | |||
| 20 | fi | 15 | fi | ||
| 21 | - | } | 16 | + | ((${#title} > MAX_TITLE)) && title="${title:0:MAX_TITLE-1}…" |
| 22 | - | 17 | + | printf '%s%s%s %s%s%s' "$(bg "$BAR")" "$(fg "$NEFERBAR_COLOR3")" "$ICON_APP" "$(fg "$NEFERBAR_FOREGROUND")" "$name" "$RESET" | |
| 23 | - | state_changed_loop draw | 18 | + | [ -n "$title" ] && printf ' %s%s%s' "$(fg "$DIM")" "$title" "$RESET" |
| 19 | + | printf '\f' | |||
| 20 | + | done | |||
M go.mod +2 -2
| @@ -4,17 +4,17 @@ go 1.27 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/BurntSushi/toml v1.6.0 | |
| 7 | + | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | 8 | github.com/bnema/neferclient v0.3.0 | |
| 8 | 9 | github.com/bnema/purego-vulkan v0.6.0 | |
| 9 | 10 | github.com/bnema/vev-vt v0.6.0 | |
| 11 | + | github.com/bnema/wlturbo v0.6.2 | |
| 10 | 12 | golang.org/x/image v0.46.0 | |
| 11 | 13 | golang.org/x/sys v0.48.0 | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 14 | 16 | require ( | |
| 15 | - | github.com/bnema/go-wayland-bindings v0.1.0 // indirect | |
| 16 | 17 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | |
| 17 | 18 | github.com/bnema/purego-xkbcommon v0.2.0 // indirect | |
| 18 | - | github.com/bnema/wlturbo v0.6.2 // indirect | |
| 19 | 19 | golang.org/x/text v0.42.0 // indirect | |
| 20 | 20 | ) | |
| @@ -4,17 +4,17 @@ go 1.27 | |||||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/BurntSushi/toml v1.6.0 | 6 | github.com/BurntSushi/toml v1.6.0 | ||
| 7 | + | github.com/bnema/go-wayland-bindings v0.1.0 | |||
| 7 | github.com/bnema/neferclient v0.3.0 | 8 | github.com/bnema/neferclient v0.3.0 | ||
| 8 | github.com/bnema/purego-vulkan v0.6.0 | 9 | github.com/bnema/purego-vulkan v0.6.0 | ||
| 9 | github.com/bnema/vev-vt v0.6.0 | 10 | github.com/bnema/vev-vt v0.6.0 | ||
| 11 | + | github.com/bnema/wlturbo v0.6.2 | |||
| 10 | golang.org/x/image v0.46.0 | 12 | golang.org/x/image v0.46.0 | ||
| 11 | golang.org/x/sys v0.48.0 | 13 | golang.org/x/sys v0.48.0 | ||
| 12 | ) | 14 | ) | ||
| 13 | 15 | ||||
| 14 | require ( | 16 | require ( | ||
| 15 | - | github.com/bnema/go-wayland-bindings v0.1.0 // indirect | |||
| 16 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | 17 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | ||
| 17 | github.com/bnema/purego-xkbcommon v0.2.0 // indirect | 18 | github.com/bnema/purego-xkbcommon v0.2.0 // indirect | ||
| 18 | - | github.com/bnema/wlturbo v0.6.2 // indirect | |||
| 19 | golang.org/x/text v0.42.0 // indirect | 19 | golang.org/x/text v0.42.0 // indirect | ||
| 20 | ) | 20 | ) | ||
A internal/app/app.go +213 -0
| @@ -0,0 +1,213 @@ | |||
| 1 | + | // Package app follows the window that has the keyboard focus, using the | |
| 2 | + | // wlr-foreign-toplevel-management protocol that NeferWL, Sway, Hyprland, River | |
| 3 | + | // and Niri speak. | |
| 4 | + | package app | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "unicode" | |
| 13 | + | ||
| 14 | + | wlr "github.com/bnema/go-wayland-bindings/client/wlrforeigntoplevelmanagement" | |
| 15 | + | "github.com/bnema/neferclient" | |
| 16 | + | wl "github.com/bnema/wlturbo" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Window is what the bar shows about the focused window. The zero Window means | |
| 20 | + | // that nothing has the focus. | |
| 21 | + | type Window struct { | |
| 22 | + | ID string // the application id, such as "org.mozilla.firefox" | |
| 23 | + | Title string | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // stateActivated is the protocol's "this window has the focus" state. | |
| 27 | + | const stateActivated = 2 | |
| 28 | + | ||
| 29 | + | // Tracker keeps what the compositor said about each window and answers which | |
| 30 | + | // one has the focus. A window's properties apply together when its Done comes, | |
| 31 | + | // as the protocol says, so Focused never shows half an update. | |
| 32 | + | // | |
| 33 | + | // The connection's reader goroutine calls the recording methods while another | |
| 34 | + | // goroutine calls Focused, so a mutex guards the state. | |
| 35 | + | type Tracker struct { | |
| 36 | + | mu sync.Mutex | |
| 37 | + | wins map[uint32]*window | |
| 38 | + | seq uint64 | |
| 39 | + | // Changed has a signal waiting whenever Done or Closed may have changed the | |
| 40 | + | // focus. Capacity 1: a missed signal is harmless, as Focused is read after. | |
| 41 | + | Changed chan struct{} | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type window struct { | |
| 45 | + | pending, shown Window | |
| 46 | + | pendingActive bool | |
| 47 | + | active bool | |
| 48 | + | since uint64 // when it last became active: the newest wins | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // NewTracker returns an empty tracker. | |
| 52 | + | func NewTracker() *Tracker { | |
| 53 | + | return &Tracker{wins: map[uint32]*window{}, Changed: make(chan struct{}, 1)} | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (t *Tracker) signal() { | |
| 57 | + | select { | |
| 58 | + | case t.Changed <- struct{}{}: | |
| 59 | + | default: | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (t *Tracker) get(h uint32) *window { | |
| 64 | + | w := t.wins[h] | |
| 65 | + | if w == nil { | |
| 66 | + | w = &window{} | |
| 67 | + | t.wins[h] = w | |
| 68 | + | } | |
| 69 | + | return w | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Title, ID and State record an update that applies at the next Done. | |
| 73 | + | func (t *Tracker) Title(h uint32, s string) { | |
| 74 | + | t.mu.Lock() | |
| 75 | + | defer t.mu.Unlock() | |
| 76 | + | t.get(h).pending.Title = s | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (t *Tracker) ID(h uint32, s string) { | |
| 80 | + | t.mu.Lock() | |
| 81 | + | defer t.mu.Unlock() | |
| 82 | + | t.get(h).pending.ID = s | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (t *Tracker) State(h uint32, state []byte) { | |
| 86 | + | t.mu.Lock() | |
| 87 | + | defer t.mu.Unlock() | |
| 88 | + | t.get(h).pendingActive = hasState(state, stateActivated) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Done applies the updates recorded for window h. | |
| 92 | + | func (t *Tracker) Done(h uint32) { | |
| 93 | + | defer t.signal() | |
| 94 | + | t.mu.Lock() | |
| 95 | + | defer t.mu.Unlock() | |
| 96 | + | w := t.get(h) | |
| 97 | + | w.shown = w.pending | |
| 98 | + | if w.pendingActive && !w.active { | |
| 99 | + | t.seq++ | |
| 100 | + | w.since = t.seq | |
| 101 | + | } | |
| 102 | + | w.active = w.pendingActive | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Closed forgets window h. | |
| 106 | + | func (t *Tracker) Closed(h uint32) { | |
| 107 | + | defer t.signal() | |
| 108 | + | t.mu.Lock() | |
| 109 | + | defer t.mu.Unlock() | |
| 110 | + | delete(t.wins, h) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Focused returns the window that has the focus; when several do (one per | |
| 114 | + | // seat or output), the one that got it last. | |
| 115 | + | func (t *Tracker) Focused() Window { | |
| 116 | + | t.mu.Lock() | |
| 117 | + | defer t.mu.Unlock() | |
| 118 | + | var best *window | |
| 119 | + | for _, w := range t.wins { | |
| 120 | + | if w.active && (best == nil || w.since > best.since) { | |
| 121 | + | best = w | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if best == nil { | |
| 125 | + | return Window{} | |
| 126 | + | } | |
| 127 | + | return best.shown | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // hasState reports whether a state array (native-endian uint32 values) has s. | |
| 131 | + | func hasState(state []byte, s uint32) bool { | |
| 132 | + | for i := 0; i+4 <= len(state); i += 4 { | |
| 133 | + | v := uint32(state[i]) | uint32(state[i+1])<<8 | uint32(state[i+2])<<16 | uint32(state[i+3])<<24 | |
| 134 | + | if v == s { | |
| 135 | + | return true | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Name is the short form of an application id: the part after the last dot, | |
| 142 | + | // so "com.github.bnema.dumber" is "dumber". An id without a dot is its own name. | |
| 143 | + | func Name(id string) string { | |
| 144 | + | if i := strings.LastIndexByte(id, '.'); i >= 0 && i < len(id)-1 { | |
| 145 | + | return id[i+1:] | |
| 146 | + | } | |
| 147 | + | return id | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // Clean removes control characters. An application chooses its own title, and | |
| 151 | + | // a title must never be able to move the cursor or end a line in a terminal. | |
| 152 | + | func Clean(s string) string { | |
| 153 | + | return strings.Map(func(r rune) rune { | |
| 154 | + | if unicode.IsControl(r) { | |
| 155 | + | return -1 | |
| 156 | + | } | |
| 157 | + | return r | |
| 158 | + | }, s) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // Watch calls emit with the focused window once, and again each time it | |
| 162 | + | // changes, until ctx ends or the connection fails. display is the Wayland | |
| 163 | + | // socket; empty uses $WAYLAND_DISPLAY. | |
| 164 | + | func Watch(ctx context.Context, display string, emit func(Window) error) error { | |
| 165 | + | conn, err := neferclient.Connect(ctx, display) | |
| 166 | + | if err != nil { | |
| 167 | + | return fmt.Errorf("cannot connect to the compositor: %w", err) | |
| 168 | + | } | |
| 169 | + | defer conn.Close() | |
| 170 | + | ||
| 171 | + | t := NewTracker() | |
| 172 | + | _, _, err = conn.Bind("zwlr_foreign_toplevel_manager_v1", 3, func(c *wl.Context) *wlr.ForeignToplevelManager { | |
| 173 | + | m := wlr.NewForeignToplevelManager(c) | |
| 174 | + | m.OnToplevel(func(h *wlr.ForeignToplevelHandle) { | |
| 175 | + | id := h.ID() | |
| 176 | + | h.OnTitle(func(s string) { t.Title(id, s) }) | |
| 177 | + | h.OnAppId(func(s string) { t.ID(id, s) }) | |
| 178 | + | h.OnState(func(b []byte) { t.State(id, b) }) | |
| 179 | + | h.OnDone(func() { t.Done(id) }) | |
| 180 | + | h.OnClosed(func() { t.Closed(id); _ = h.Destroy() }) | |
| 181 | + | }) | |
| 182 | + | return m | |
| 183 | + | }) | |
| 184 | + | if errors.Is(err, neferclient.ErrGlobalNotFound) { | |
| 185 | + | return errors.New("this compositor does not offer zwlr_foreign_toplevel_manager_v1, so the focused window is unknown") | |
| 186 | + | } | |
| 187 | + | if err != nil { | |
| 188 | + | return fmt.Errorf("cannot ask the compositor for its windows: %w", err) | |
| 189 | + | } | |
| 190 | + | // The windows that exist now arrive after one round trip. | |
| 191 | + | if err = conn.Roundtrip(); err != nil { | |
| 192 | + | return err | |
| 193 | + | } | |
| 194 | + | var last Window | |
| 195 | + | first := true | |
| 196 | + | for { | |
| 197 | + | if err = conn.Dispatch(nil); err != nil { | |
| 198 | + | return fmt.Errorf("lost the connection to the compositor: %w", err) | |
| 199 | + | } | |
| 200 | + | if now := t.Focused(); first || now != last { | |
| 201 | + | first, last = false, now | |
| 202 | + | if err = emit(now); err != nil { | |
| 203 | + | return err | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | select { | |
| 207 | + | case <-ctx.Done(): | |
| 208 | + | return nil | |
| 209 | + | case <-conn.Wake(): // the connection's own events: drain them | |
| 210 | + | case <-t.Changed: // the compositor changed a window | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | } | |
| @@ -0,0 +1,213 @@ | |||||
| 1 | + | // Package app follows the window that has the keyboard focus, using the | |||
| 2 | + | // wlr-foreign-toplevel-management protocol that NeferWL, Sway, Hyprland, River | |||
| 3 | + | // and Niri speak. | |||
| 4 | + | package app | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "unicode" | |||
| 13 | + | ||||
| 14 | + | wlr "github.com/bnema/go-wayland-bindings/client/wlrforeigntoplevelmanagement" | |||
| 15 | + | "github.com/bnema/neferclient" | |||
| 16 | + | wl "github.com/bnema/wlturbo" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Window is what the bar shows about the focused window. The zero Window means | |||
| 20 | + | // that nothing has the focus. | |||
| 21 | + | type Window struct { | |||
| 22 | + | ID string // the application id, such as "org.mozilla.firefox" | |||
| 23 | + | Title string | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // stateActivated is the protocol's "this window has the focus" state. | |||
| 27 | + | const stateActivated = 2 | |||
| 28 | + | ||||
| 29 | + | // Tracker keeps what the compositor said about each window and answers which | |||
| 30 | + | // one has the focus. A window's properties apply together when its Done comes, | |||
| 31 | + | // as the protocol says, so Focused never shows half an update. | |||
| 32 | + | // | |||
| 33 | + | // The connection's reader goroutine calls the recording methods while another | |||
| 34 | + | // goroutine calls Focused, so a mutex guards the state. | |||
| 35 | + | type Tracker struct { | |||
| 36 | + | mu sync.Mutex | |||
| 37 | + | wins map[uint32]*window | |||
| 38 | + | seq uint64 | |||
| 39 | + | // Changed has a signal waiting whenever Done or Closed may have changed the | |||
| 40 | + | // focus. Capacity 1: a missed signal is harmless, as Focused is read after. | |||
| 41 | + | Changed chan struct{} | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type window struct { | |||
| 45 | + | pending, shown Window | |||
| 46 | + | pendingActive bool | |||
| 47 | + | active bool | |||
| 48 | + | since uint64 // when it last became active: the newest wins | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // NewTracker returns an empty tracker. | |||
| 52 | + | func NewTracker() *Tracker { | |||
| 53 | + | return &Tracker{wins: map[uint32]*window{}, Changed: make(chan struct{}, 1)} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (t *Tracker) signal() { | |||
| 57 | + | select { | |||
| 58 | + | case t.Changed <- struct{}{}: | |||
| 59 | + | default: | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (t *Tracker) get(h uint32) *window { | |||
| 64 | + | w := t.wins[h] | |||
| 65 | + | if w == nil { | |||
| 66 | + | w = &window{} | |||
| 67 | + | t.wins[h] = w | |||
| 68 | + | } | |||
| 69 | + | return w | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Title, ID and State record an update that applies at the next Done. | |||
| 73 | + | func (t *Tracker) Title(h uint32, s string) { | |||
| 74 | + | t.mu.Lock() | |||
| 75 | + | defer t.mu.Unlock() | |||
| 76 | + | t.get(h).pending.Title = s | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (t *Tracker) ID(h uint32, s string) { | |||
| 80 | + | t.mu.Lock() | |||
| 81 | + | defer t.mu.Unlock() | |||
| 82 | + | t.get(h).pending.ID = s | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (t *Tracker) State(h uint32, state []byte) { | |||
| 86 | + | t.mu.Lock() | |||
| 87 | + | defer t.mu.Unlock() | |||
| 88 | + | t.get(h).pendingActive = hasState(state, stateActivated) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Done applies the updates recorded for window h. | |||
| 92 | + | func (t *Tracker) Done(h uint32) { | |||
| 93 | + | defer t.signal() | |||
| 94 | + | t.mu.Lock() | |||
| 95 | + | defer t.mu.Unlock() | |||
| 96 | + | w := t.get(h) | |||
| 97 | + | w.shown = w.pending | |||
| 98 | + | if w.pendingActive && !w.active { | |||
| 99 | + | t.seq++ | |||
| 100 | + | w.since = t.seq | |||
| 101 | + | } | |||
| 102 | + | w.active = w.pendingActive | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Closed forgets window h. | |||
| 106 | + | func (t *Tracker) Closed(h uint32) { | |||
| 107 | + | defer t.signal() | |||
| 108 | + | t.mu.Lock() | |||
| 109 | + | defer t.mu.Unlock() | |||
| 110 | + | delete(t.wins, h) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // Focused returns the window that has the focus; when several do (one per | |||
| 114 | + | // seat or output), the one that got it last. | |||
| 115 | + | func (t *Tracker) Focused() Window { | |||
| 116 | + | t.mu.Lock() | |||
| 117 | + | defer t.mu.Unlock() | |||
| 118 | + | var best *window | |||
| 119 | + | for _, w := range t.wins { | |||
| 120 | + | if w.active && (best == nil || w.since > best.since) { | |||
| 121 | + | best = w | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if best == nil { | |||
| 125 | + | return Window{} | |||
| 126 | + | } | |||
| 127 | + | return best.shown | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // hasState reports whether a state array (native-endian uint32 values) has s. | |||
| 131 | + | func hasState(state []byte, s uint32) bool { | |||
| 132 | + | for i := 0; i+4 <= len(state); i += 4 { | |||
| 133 | + | v := uint32(state[i]) | uint32(state[i+1])<<8 | uint32(state[i+2])<<16 | uint32(state[i+3])<<24 | |||
| 134 | + | if v == s { | |||
| 135 | + | return true | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | return false | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Name is the short form of an application id: the part after the last dot, | |||
| 142 | + | // so "com.github.bnema.dumber" is "dumber". An id without a dot is its own name. | |||
| 143 | + | func Name(id string) string { | |||
| 144 | + | if i := strings.LastIndexByte(id, '.'); i >= 0 && i < len(id)-1 { | |||
| 145 | + | return id[i+1:] | |||
| 146 | + | } | |||
| 147 | + | return id | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // Clean removes control characters. An application chooses its own title, and | |||
| 151 | + | // a title must never be able to move the cursor or end a line in a terminal. | |||
| 152 | + | func Clean(s string) string { | |||
| 153 | + | return strings.Map(func(r rune) rune { | |||
| 154 | + | if unicode.IsControl(r) { | |||
| 155 | + | return -1 | |||
| 156 | + | } | |||
| 157 | + | return r | |||
| 158 | + | }, s) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // Watch calls emit with the focused window once, and again each time it | |||
| 162 | + | // changes, until ctx ends or the connection fails. display is the Wayland | |||
| 163 | + | // socket; empty uses $WAYLAND_DISPLAY. | |||
| 164 | + | func Watch(ctx context.Context, display string, emit func(Window) error) error { | |||
| 165 | + | conn, err := neferclient.Connect(ctx, display) | |||
| 166 | + | if err != nil { | |||
| 167 | + | return fmt.Errorf("cannot connect to the compositor: %w", err) | |||
| 168 | + | } | |||
| 169 | + | defer conn.Close() | |||
| 170 | + | ||||
| 171 | + | t := NewTracker() | |||
| 172 | + | _, _, err = conn.Bind("zwlr_foreign_toplevel_manager_v1", 3, func(c *wl.Context) *wlr.ForeignToplevelManager { | |||
| 173 | + | m := wlr.NewForeignToplevelManager(c) | |||
| 174 | + | m.OnToplevel(func(h *wlr.ForeignToplevelHandle) { | |||
| 175 | + | id := h.ID() | |||
| 176 | + | h.OnTitle(func(s string) { t.Title(id, s) }) | |||
| 177 | + | h.OnAppId(func(s string) { t.ID(id, s) }) | |||
| 178 | + | h.OnState(func(b []byte) { t.State(id, b) }) | |||
| 179 | + | h.OnDone(func() { t.Done(id) }) | |||
| 180 | + | h.OnClosed(func() { t.Closed(id); _ = h.Destroy() }) | |||
| 181 | + | }) | |||
| 182 | + | return m | |||
| 183 | + | }) | |||
| 184 | + | if errors.Is(err, neferclient.ErrGlobalNotFound) { | |||
| 185 | + | return errors.New("this compositor does not offer zwlr_foreign_toplevel_manager_v1, so the focused window is unknown") | |||
| 186 | + | } | |||
| 187 | + | if err != nil { | |||
| 188 | + | return fmt.Errorf("cannot ask the compositor for its windows: %w", err) | |||
| 189 | + | } | |||
| 190 | + | // The windows that exist now arrive after one round trip. | |||
| 191 | + | if err = conn.Roundtrip(); err != nil { | |||
| 192 | + | return err | |||
| 193 | + | } | |||
| 194 | + | var last Window | |||
| 195 | + | first := true | |||
| 196 | + | for { | |||
| 197 | + | if err = conn.Dispatch(nil); err != nil { | |||
| 198 | + | return fmt.Errorf("lost the connection to the compositor: %w", err) | |||
| 199 | + | } | |||
| 200 | + | if now := t.Focused(); first || now != last { | |||
| 201 | + | first, last = false, now | |||
| 202 | + | if err = emit(now); err != nil { | |||
| 203 | + | return err | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | select { | |||
| 207 | + | case <-ctx.Done(): | |||
| 208 | + | return nil | |||
| 209 | + | case <-conn.Wake(): // the connection's own events: drain them | |||
| 210 | + | case <-t.Changed: // the compositor changed a window | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | } | |||
A internal/app/app_test.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func state(vals ...uint32) []byte { | |
| 9 | + | var b []byte | |
| 10 | + | for _, v := range vals { | |
| 11 | + | b = binary.NativeEndian.AppendUint32(b, v) | |
| 12 | + | } | |
| 13 | + | return b | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // open maps a window: its properties and its state, then the done that applies them. | |
| 17 | + | func open(t *Tracker, h uint32, id, title string, st ...uint32) { | |
| 18 | + | t.ID(h, id) | |
| 19 | + | t.Title(h, title) | |
| 20 | + | t.State(h, state(st...)) | |
| 21 | + | t.Done(h) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestNothingFocusedIsTheZeroWindow(t *testing.T) { | |
| 25 | + | tr := NewTracker() | |
| 26 | + | if tr.Focused() != (Window{}) { | |
| 27 | + | t.Fatal("an empty tracker must have no focus") | |
| 28 | + | } | |
| 29 | + | open(tr, 1, "a", "A") // mapped, not activated | |
| 30 | + | if tr.Focused() != (Window{}) { | |
| 31 | + | t.Fatalf("a window without the activated state has no focus: %+v", tr.Focused()) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestFocusFollowsTheActivatedState(t *testing.T) { | |
| 36 | + | tr := NewTracker() | |
| 37 | + | open(tr, 1, "org.mozilla.firefox", "Docs", 0, stateActivated) // maximized + activated | |
| 38 | + | open(tr, 2, "kitty", "~") | |
| 39 | + | if got := tr.Focused(); got != (Window{"org.mozilla.firefox", "Docs"}) { | |
| 40 | + | t.Fatalf("focused = %+v", got) | |
| 41 | + | } | |
| 42 | + | // Focus moves: 1 loses it, 2 gets it. | |
| 43 | + | open(tr, 1, "org.mozilla.firefox", "Docs", 0) | |
| 44 | + | open(tr, 2, "kitty", "~", stateActivated) | |
| 45 | + | if got := tr.Focused(); got != (Window{"kitty", "~"}) { | |
| 46 | + | t.Fatalf("after the focus moved: %+v", got) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestATitleChangeAppliesOnlyAtDone(t *testing.T) { | |
| 51 | + | tr := NewTracker() | |
| 52 | + | open(tr, 1, "kitty", "one", stateActivated) | |
| 53 | + | tr.Title(1, "two") | |
| 54 | + | if got := tr.Focused().Title; got != "one" { | |
| 55 | + | t.Fatalf("title changed before done: %q", got) | |
| 56 | + | } | |
| 57 | + | tr.Done(1) | |
| 58 | + | if got := tr.Focused().Title; got != "two" { | |
| 59 | + | t.Fatalf("title after done: %q", got) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestClosingTheFocusedWindowClearsTheFocus(t *testing.T) { | |
| 64 | + | tr := NewTracker() | |
| 65 | + | open(tr, 1, "kitty", "~", stateActivated) | |
| 66 | + | tr.Closed(1) | |
| 67 | + | if tr.Focused() != (Window{}) { | |
| 68 | + | t.Fatalf("a closed window still has the focus: %+v", tr.Focused()) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestTheWindowThatGotTheFocusLastWins(t *testing.T) { | |
| 73 | + | tr := NewTracker() | |
| 74 | + | open(tr, 1, "a", "A", stateActivated) | |
| 75 | + | open(tr, 2, "b", "B", stateActivated) // two seats, two focused windows | |
| 76 | + | if got := tr.Focused().ID; got != "b" { | |
| 77 | + | t.Fatalf("focused = %q, want b (the newest)", got) | |
| 78 | + | } | |
| 79 | + | open(tr, 1, "a", "A2", stateActivated) // still active, only its title changed | |
| 80 | + | if got := tr.Focused().ID; got != "b" { | |
| 81 | + | t.Fatalf("a title change must not steal the focus: %q", got) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestCleanRemovesControlCharacters(t *testing.T) { | |
| 86 | + | in := "fire\x1b[2Jfox\n\ttab\x07 ok \u00e9" | |
| 87 | + | if got, want := Clean(in), "fire[2Jfoxtab ok \u00e9"; got != want { | |
| 88 | + | t.Fatalf("Clean = %q, want %q", got, want) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestNameIsTheLastPartOfTheID(t *testing.T) { | |
| 93 | + | for id, want := range map[string]string{ | |
| 94 | + | "com.github.bnema.dumber": "dumber", | |
| 95 | + | "org.mozilla.firefox": "firefox", | |
| 96 | + | "kitty": "kitty", | |
| 97 | + | "": "", | |
| 98 | + | "trailing.": "trailing.", | |
| 99 | + | } { | |
| 100 | + | if got := Name(id); got != want { | |
| 101 | + | t.Errorf("Name(%q) = %q, want %q", id, got, want) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| @@ -0,0 +1,104 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func state(vals ...uint32) []byte { | |||
| 9 | + | var b []byte | |||
| 10 | + | for _, v := range vals { | |||
| 11 | + | b = binary.NativeEndian.AppendUint32(b, v) | |||
| 12 | + | } | |||
| 13 | + | return b | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // open maps a window: its properties and its state, then the done that applies them. | |||
| 17 | + | func open(t *Tracker, h uint32, id, title string, st ...uint32) { | |||
| 18 | + | t.ID(h, id) | |||
| 19 | + | t.Title(h, title) | |||
| 20 | + | t.State(h, state(st...)) | |||
| 21 | + | t.Done(h) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestNothingFocusedIsTheZeroWindow(t *testing.T) { | |||
| 25 | + | tr := NewTracker() | |||
| 26 | + | if tr.Focused() != (Window{}) { | |||
| 27 | + | t.Fatal("an empty tracker must have no focus") | |||
| 28 | + | } | |||
| 29 | + | open(tr, 1, "a", "A") // mapped, not activated | |||
| 30 | + | if tr.Focused() != (Window{}) { | |||
| 31 | + | t.Fatalf("a window without the activated state has no focus: %+v", tr.Focused()) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestFocusFollowsTheActivatedState(t *testing.T) { | |||
| 36 | + | tr := NewTracker() | |||
| 37 | + | open(tr, 1, "org.mozilla.firefox", "Docs", 0, stateActivated) // maximized + activated | |||
| 38 | + | open(tr, 2, "kitty", "~") | |||
| 39 | + | if got := tr.Focused(); got != (Window{"org.mozilla.firefox", "Docs"}) { | |||
| 40 | + | t.Fatalf("focused = %+v", got) | |||
| 41 | + | } | |||
| 42 | + | // Focus moves: 1 loses it, 2 gets it. | |||
| 43 | + | open(tr, 1, "org.mozilla.firefox", "Docs", 0) | |||
| 44 | + | open(tr, 2, "kitty", "~", stateActivated) | |||
| 45 | + | if got := tr.Focused(); got != (Window{"kitty", "~"}) { | |||
| 46 | + | t.Fatalf("after the focus moved: %+v", got) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestATitleChangeAppliesOnlyAtDone(t *testing.T) { | |||
| 51 | + | tr := NewTracker() | |||
| 52 | + | open(tr, 1, "kitty", "one", stateActivated) | |||
| 53 | + | tr.Title(1, "two") | |||
| 54 | + | if got := tr.Focused().Title; got != "one" { | |||
| 55 | + | t.Fatalf("title changed before done: %q", got) | |||
| 56 | + | } | |||
| 57 | + | tr.Done(1) | |||
| 58 | + | if got := tr.Focused().Title; got != "two" { | |||
| 59 | + | t.Fatalf("title after done: %q", got) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestClosingTheFocusedWindowClearsTheFocus(t *testing.T) { | |||
| 64 | + | tr := NewTracker() | |||
| 65 | + | open(tr, 1, "kitty", "~", stateActivated) | |||
| 66 | + | tr.Closed(1) | |||
| 67 | + | if tr.Focused() != (Window{}) { | |||
| 68 | + | t.Fatalf("a closed window still has the focus: %+v", tr.Focused()) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestTheWindowThatGotTheFocusLastWins(t *testing.T) { | |||
| 73 | + | tr := NewTracker() | |||
| 74 | + | open(tr, 1, "a", "A", stateActivated) | |||
| 75 | + | open(tr, 2, "b", "B", stateActivated) // two seats, two focused windows | |||
| 76 | + | if got := tr.Focused().ID; got != "b" { | |||
| 77 | + | t.Fatalf("focused = %q, want b (the newest)", got) | |||
| 78 | + | } | |||
| 79 | + | open(tr, 1, "a", "A2", stateActivated) // still active, only its title changed | |||
| 80 | + | if got := tr.Focused().ID; got != "b" { | |||
| 81 | + | t.Fatalf("a title change must not steal the focus: %q", got) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestCleanRemovesControlCharacters(t *testing.T) { | |||
| 86 | + | in := "fire\x1b[2Jfox\n\ttab\x07 ok \u00e9" | |||
| 87 | + | if got, want := Clean(in), "fire[2Jfoxtab ok \u00e9"; got != want { | |||
| 88 | + | t.Fatalf("Clean = %q, want %q", got, want) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestNameIsTheLastPartOfTheID(t *testing.T) { | |||
| 93 | + | for id, want := range map[string]string{ | |||
| 94 | + | "com.github.bnema.dumber": "dumber", | |||
| 95 | + | "org.mozilla.firefox": "firefox", | |||
| 96 | + | "kitty": "kitty", | |||
| 97 | + | "": "", | |||
| 98 | + | "trailing.": "trailing.", | |||
| 99 | + | } { | |||
| 100 | + | if got := Name(id); got != want { | |||
| 101 | + | t.Errorf("Name(%q) = %q, want %q", id, got, want) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | } | |||
M internal/module/module.go +5 -0
| @@ -151,6 +151,11 @@ func (m *Module) runOnce(ctx context.Context) error { | |||
| 151 | 151 | cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) } | |
| 152 | 152 | cmd.WaitDelay = 2 * time.Second | |
| 153 | 153 | cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name) | |
| 154 | + | // Scripts call "$NEFERBAR_BIN app title" and friends: the path of the very | |
| 155 | + | // binary that started them, which need not be in $PATH. | |
| 156 | + | if exe, err := os.Executable(); err == nil { | |
| 157 | + | cmd.Env = append(cmd.Env, "NEFERBAR_BIN="+exe) | |
| 158 | + | } | |
| 154 | 159 | out, err := cmd.StdoutPipe() | |
| 155 | 160 | if err != nil { | |
| 156 | 161 | return err | |
| @@ -151,6 +151,11 @@ func (m *Module) runOnce(ctx context.Context) error { | |||||
| 151 | cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) } | 151 | cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) } | ||
| 152 | cmd.WaitDelay = 2 * time.Second | 152 | cmd.WaitDelay = 2 * time.Second | ||
| 153 | cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name) | 153 | cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name) | ||
| 154 | + | // Scripts call "$NEFERBAR_BIN app title" and friends: the path of the very | |||
| 155 | + | // binary that started them, which need not be in $PATH. | |||
| 156 | + | if exe, err := os.Executable(); err == nil { | |||
| 157 | + | cmd.Env = append(cmd.Env, "NEFERBAR_BIN="+exe) | |||
| 158 | + | } | |||
| 154 | out, err := cmd.StdoutPipe() | 159 | out, err := cmd.StdoutPipe() | ||
| 155 | if err != nil { | 160 | if err != nil { | ||
| 156 | return err | 161 | return err | ||