diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml
new file mode 100644
index 0000000..81c125e
--- /dev/null
+++ b/.github/workflows/releaser.yml
@@ -0,0 +1,31 @@
+name: goreleaser
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ goreleaser:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ -
+ name: Set up Go
+ uses: actions/setup-go@v4
+ -
+ name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v5
+ with:
+ distribution: goreleaser
+ version: latest
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
new file mode 100644
index 0000000..d9ac7bf
--- /dev/null
+++ b/.goreleaser.yml
@@ -0,0 +1,12 @@
+builds:
+ - id: "sway-yast"
+ main: "./main.go"
+ binary: "sway-yast"
+ goos:
+ - linux
+ goarch:
+ - 386
+ - amd64
+ - arm64
+ goarm:
+ - 7
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..53b9d6e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,9 @@
+## v0.2.0
+- mouse follows focus
+- pick space
+- pick win
+- run path
+- live config changes
+
+## v0.1.0
+- initial release
\ No newline at end of file
diff --git a/README.md b/README.md
index 8e8881f..d452fb4 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,84 @@
# sway-yast
-**Y**et **A**nother **S**way **T**ab is a single-binary alt+tab clone (Most Recently Used) window switcher for [Sway WM](https://github.com/swaywm/sway).
-
-![Dark mode](./assets/dark.png)
-![Light mode](./assets/light.png)
-
-## Features
-
-1. Daemon (IPC & RPC) architecture, filesystem-free
-2. Uses `fzf`, works in the terminal
-3. Renders a popup using `foot` (optional)
-4. Dark mode support (optional)
- Checks `gsettings get org.gnome.desktop.interface color-scheme`
-5. Shows workspaces and outputs (especially headless)
-6. 1-hand compatible keystrokes
-
-## Usage
-
-1. Install
- `go install github.com/pancsta/sway-yast@latest`
+Sway **Y**et **A**nother **S**way **T**ab is a text-based window switcher which mimics alt+tab behavior (Most Recently Used order)
+for [Sway WM](https://github.com/swaywm/sway).
+
+
+| Dark Mode | Light Mode |
+|------------------------------------------|------------------------------------------|
+| ![Dark mode](./assets/dark.png) | ![Light mode](./assets/light.png) |
+
+```text
+$ sway-yast --help
+Usage:
+ sway-yast [flags]
+ sway-yast [command]
+
+Available Commands:
+ completion Generate the autocompletion script for the specified shell
+ config Change the config of a running daemon process
+ daemon Start tracking focus in sway
+ fzf Run fzf with a list of windows
+ fzf-path Run fzf with a list of executable files from PATH
+ fzf-pick-space Run fzf with a list of workspaces to pick
+ fzf-pick-win Run fzf with a list of windows to pick
+ help Help about any command
+ mru-list Print a list of MRU window IDs
+ path Show the +x files from PATH using foot
+ pick-space Show the workspace picker using foot
+ pick-win Show the window picker using foot
+ switcher Show the switcher window using foot
+
+Flags:
+ -h, --help help for sway-yast
+ --version Print version and exit
+
+Use "sway-yast [command] --help" for more information about a command.
+```
+
+```text
+$ sway-yast daemon --help
+Start tracking focus in sway
+
+Usage:
+ sway-yast daemon [flags]
+
+Flags:
+ --autoconfig Automatic configuration of layout (default true)
+ --default-keybindings Add default keybindings
+ -h, --help help for daemon
+ --mouse-follows-focus Calls 'input ... map_to_output OUTPUT' on each focus
+
+```
+
+## features
+
+- daemon (IPC & RPC) architecture, filesystem-free
+- uses `fzf` so it works in the terminal
+- renders a floating popup using `foot` (optional)
+- dark mode support (optional)
+ checks `gsettings get org.gnome.desktop.interface color-scheme`
+- 1-hand keystrokes
+- [mouse follows focus](#mouse-follows-focus) mode (optional)
+- additional features (popups)
+ - move a workspace to the current output
+ - move a window to the current workspace
+ - run anything in your `PATH`
+- general MRU watcher via `mru-list`
+
+## usage
+
+1. Install using one of
+ - Binary from [the releases page](https://github.com/pancsta/sway-yast/releases/latest)
+ - `go install github.com/pancsta/sway-yast@latest`
+ - `git clone && go mod tidy && go build`
2. Start the daemon
- `sway-yast daemon`
-3. Add a binding (optional)
- `swaymsg bindsym alt+tab exec sway-yast switcher`
-4. Run in the terminal (optional)
+ `sway-yast daemon --default-keystrokes`
+3. Use directly in the terminal (optional)
`sway-yast fzf`
-5. Press `alt+tab`
+4. Press `alt+tab`
-## Key bindings
+## keystrokes
Normal mode:
@@ -57,11 +108,79 @@ Example - switch to Krusader by name:
- `k`, `r`, `u`
- `enter`
-## Configuration
+### default keystrokes
+
+Various ways to get the default keybindings.
+
+```bash
+$ sway-yast daemon --default-keybindings
+```
+
+```bash
+# shell
+swaymsg bindsym alt+tab exec sway-yast switcher
+swaymsg bindsym mod4+o exec sway-yast pick-space
+swaymsg bindsym mod4+p exec sway-yast pick-win
+swaymsg bindsym mod4+d exec sway-yast path
+```
+
+```text
+# config
+bindsym alt+tab exec sway-yast switcher
+bindsym $mod+o exec sway-yast pick-space
+bindsym $mod+p exec sway-yast pick-win
+bindsym $mod+d exec sway-yast path
+```
+
+## mouse follows focus
+
+```bash
+$ sway-yast daemon --mouse-follows-focus
+```
+
+Using `input map_to_output`, traps the relative cursor inside the currently focused output. Changing focus moves the cursor between outputs (thus the name). Useful for VNC screens on separate machines. When combined with [waycorner](https://github.com/AndreasBackx/waycorner), it creates a synergy-like effect.
+
+### waycorner config example
+
+```toml
+# HEADLESS-1 (right screen)
+[pro5-left]
+enter_command = [ "sway-pointer-output", "2" ]
+locations = ["left"]
+[pro5-left.output]
+description = ".*output 1.*"
+
+# HEADLESS-2 (left screen)
+[mini6-right]
+enter_command = [ "sway-pointer-output", "1" ]
+locations = ["right"]
+[mini6-right.output]
+description = ".*output 2.*"
+```
+
+## configuration
+
+See the [top config section in main.go](main.go), modify and `go build`.
+
+## additional features (popups)
+
+### move a workspace to the current output
+
+![move workspace](assets/move-workspace.png)
+
+### move a window to the current workspace
+
+![move window](assets/move-window.png)
+
+### run anything in your `PATH`
+
+![path runner](assets/path-runner.png)
+
+## troubleshooting
-See the [config section in main.go](main.go), modify and `go build`.
+`env YAST_LOG=1 sway-yast`
-## Kudos
+## kudos
- [applist.py](https://github.com/davxy/dotfiles/blob/main/_old/sway/applist.py)
- [sway-fzfify](https://github.com/ldelossa/sway-fzfify)
diff --git a/assets/dark.png b/assets/dark.png
index 719f77a..5cfb27c 100644
Binary files a/assets/dark.png and b/assets/dark.png differ
diff --git a/assets/grafana-dashboard-dark.png b/assets/grafana-dashboard-dark.png
new file mode 100644
index 0000000..4b3056c
Binary files /dev/null and b/assets/grafana-dashboard-dark.png differ
diff --git a/assets/grafana-dashboard-light.png b/assets/grafana-dashboard-light.png
new file mode 100644
index 0000000..a1d2e4f
Binary files /dev/null and b/assets/grafana-dashboard-light.png differ
diff --git a/assets/light.png b/assets/light.png
index ac1074d..17213de 100644
Binary files a/assets/light.png and b/assets/light.png differ
diff --git a/assets/move-window.png b/assets/move-window.png
new file mode 100644
index 0000000..2a7366e
Binary files /dev/null and b/assets/move-window.png differ
diff --git a/assets/move-workspace.png b/assets/move-workspace.png
new file mode 100644
index 0000000..38b66d4
Binary files /dev/null and b/assets/move-workspace.png differ
diff --git a/assets/path-runner.png b/assets/path-runner.png
new file mode 100644
index 0000000..e76c818
Binary files /dev/null and b/assets/path-runner.png differ
diff --git a/cmds.go b/cmds.go
new file mode 100644
index 0000000..d99eedf
--- /dev/null
+++ b/cmds.go
@@ -0,0 +1,282 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "regexp"
+ "runtime/debug"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// TODO put these in a config and make it customizable
+const (
+ shellFzf = `
+ fzf \
+ --prompt 'Switcher: ' \
+ --bind "load:pos(2)" \
+ --bind "change:pos(1)" \
+ --layout=reverse --info=hidden \
+ --bind=space:accept,tab:offset-down,btab:offset-up
+`
+ shellFzfPickWin = `
+ fzf \
+ --prompt 'Move which window to this workspace?: ' \
+ --layout=reverse --info=hidden \
+ --bind=space:accept,tab:offset-down,btab:offset-up
+`
+ shellFzfPickSpace = `
+ fzf \
+ --prompt 'Move which workspace to this output?: ' \
+ --layout=reverse --info=hidden \
+ --bind=space:accept,tab:offset-down,btab:offset-up
+`
+ shellFzfPath = `
+ fzf \
+ --prompt 'Run: ' \
+ --layout=reverse --info=hidden \
+ --bind=space:accept,tab:offset-down,btab:offset-up
+`
+ // junegunn/seoul256.vim (light)
+ shellFzfLight = ` \
+ --color=bg+:#D9D9D9,bg:#E1E1E1,border:#C8C8C8,spinner:#719899,hl:#719872,fg:#616161,header:#719872,info:#727100,pointer:#E12672,marker:#E17899,fg+:#616161,preview-bg:#D9D9D9,prompt:#0099BD,hl+:#719899
+`
+ shellSwitcher = `
+ foot --title "sway-yast" sway-yast fzf
+`
+ shellPickWin = `
+ foot --title "sway-yast" sway-yast fzf-pick-win
+`
+ shellPickSpace = `
+ foot --title "sway-yast" sway-yast fzf-pick-space
+`
+ shellPath = `
+ foot --title "sway-yast" sway-yast fzf-path
+`
+)
+
+///// TERM WRAPPER COMMANDS /////
+
+// TODO open on all visible outputs, as screen session clients
+// use https://github.com/rajveermalviya/go-wayland
+func cmdSwitcher(_ *cobra.Command, _ []string) {
+ if !shouldOpen() {
+ log.Fatal("fzf error: already open")
+ }
+ _, err := run(shellSwitcher)
+ if err != nil {
+ log.Fatalf("foot error: %s", err)
+ }
+}
+
+func cmdPickWin(_ *cobra.Command, _ []string) {
+ if !shouldOpen() {
+ log.Fatal("fzf error: already open")
+ }
+ _, err := run(shellPickWin)
+ if err != nil {
+ log.Fatal("foot error: " + err.Error())
+ }
+}
+
+func cmdPickSpace(_ *cobra.Command, _ []string) {
+ if !shouldOpen() {
+ log.Fatal("fzf error: already open")
+ }
+ _, err := run(shellPickSpace)
+ if err != nil {
+ log.Fatalf("foot error: %s", err)
+ }
+}
+
+func cmdPath(_ *cobra.Command, _ []string) {
+ if !shouldOpen() {
+ log.Fatal("fzf error: already open")
+ }
+ _, err := run(shellPath)
+ if err != nil {
+ log.Fatalf("foot error: %s", err)
+ }
+}
+
+func cmdConfig(cmd *cobra.Command, _ []string) {
+ mouseFollow, _ := cmd.Flags().GetBool("mouse-follows-focus")
+ result, err := rpcCall("Daemon.RemoteSetConfig", RPCArgs{
+ MouseFollowsFocus: mouseFollow,
+ })
+ if err != nil {
+ log.Fatalf("rpc error: %s", err)
+ }
+ if result != "" {
+ log.Fatal("config error")
+ }
+ fmt.Println("Config updated")
+ // TODO print the current config
+}
+
+func cmdRoot(cmd *cobra.Command, _ []string) {
+ version, _ := cmd.Flags().GetBool("version")
+ if version {
+ build, ok := debug.ReadBuildInfo()
+ if !ok {
+ panic("No build info available")
+ }
+ fmt.Println(build.Main.Version)
+ os.Exit(0)
+ } else {
+ fmt.Println("Yet Another Sway Tab\n\nUsage:\n$ sway-yast daemon\n$ sway-yast --help")
+ }
+}
+
+///// FZF COMMANDS /////
+
+func cmdFzf(_ *cobra.Command, _ []string) {
+ // req the daemon
+ input, err := rpcCall("Daemon.RemoteFZFList", RPCArgs{})
+ if err != nil {
+ log.Fatalf("rpc error: %s", err)
+ }
+
+ // run fzf
+ result, err := fzf(shellFzf, &input)
+ if err != nil {
+ log.Fatalf("fzf error: %s", err)
+ }
+
+ // match the window's ID at the end of the line
+ winID, err := matchWinID(result)
+ if err != nil {
+ log.Fatalf("error: %s", err)
+ }
+
+ // focus the window
+ _, err = rpcCall("Daemon.RemoteFocusWinID", RPCArgs{WinID: winID})
+ if err != nil {
+ log.Fatalf("rpc error: %s", err)
+ }
+}
+
+func cmdFzfPickWin(_ *cobra.Command, _ []string) {
+ // req the daemon
+ input, err := rpcCall("Daemon.RemoteFZFListPickWin", RPCArgs{})
+ if err != nil {
+ log.Fatalf("rpc error: %s", err)
+ }
+ // run fzf
+ result, err := fzf(shellFzfPickWin, &input)
+ if err != nil {
+ log.Fatalf("fzf error: %s", err)
+ }
+
+ // match the window's ID at the end of the line
+ winID, err := matchWinID(result)
+ if err != nil {
+ log.Fatalf("error: %s", err)
+ }
+
+ // move the window to the current workspace
+ _, err = rpcCall("Daemon.RemoteMoveWinToSpace", RPCArgs{WinID: winID})
+ if err != nil {
+ log.Fatalf("rpc error: %s", err)
+ }
+}
+
+func cmdFzfPickSpace(_ *cobra.Command, _ []string) {
+ // req the daemon
+ list, err := rpcCall("Daemon.RemoteFZFListPickSpace", RPCArgs{})
+ if err != nil {
+ log.Fatalf("rpc error: %s", err)
+ }
+
+ // run fzf to pick the workspace
+ result, err := fzf(shellFzfPickSpace, &list)
+ if err != nil {
+ log.Fatalf("fzf error: %s", err)
+ }
+
+ // move the workspace to the current output
+ _, err = rpcCall("Daemon.RemoteMoveSpaceToOutput", RPCArgs{
+ Workspace: strings.Trim(result, " \n"),
+ })
+ if err != nil {
+ log.Fatalf("rpc error: %s", err)
+ }
+}
+
+func cmdFzfPath(_ *cobra.Command, _ []string) {
+ // req the daemon
+ list, err := rpcCall("Daemon.RemoteGetPathFiles", RPCArgs{})
+ if err != nil {
+ log.Fatalf("rpc error: %s", err)
+ }
+
+ // run fzf
+ result, err := fzf(shellFzfPath, &list)
+ if err != nil {
+ log.Fatalf("fzf error: %s", err)
+ }
+
+ // return the picked exe
+ log.Printf("path: %s", result)
+ _, err = rpcCall("Daemon.RemoteExec", RPCArgs{
+ ExePath: result,
+ })
+ if err != nil {
+ log.Fatalf("error: cant run %s", result)
+ }
+}
+
+///// HELPERS /////
+
+func matchWinID(result string) (int, error) {
+ re := regexp.MustCompile(`\((\d+)\)\s*$`)
+ match := re.FindStringSubmatch(result)
+ if len(match) == 0 {
+ return 0, fmt.Errorf("no winID match")
+ }
+ return strconv.Atoi(match[1])
+}
+
+func shouldOpen() bool {
+ pid := os.Getpid()
+ shouldOpen, err := rpcCall("Daemon.RemoteShouldOpen", RPCArgs{PID: pid})
+ if err != nil {
+ log.Printf("rpc error: %s", err)
+ return false
+ }
+ return shouldOpen == "true"
+}
+
+func fzf(cmd string, input *string) (string, error) {
+ shell := os.Getenv("SHELL")
+ if len(shell) == 0 {
+ shell = "sh"
+ }
+ if isLightMode() {
+ cmd = strings.TrimRight(cmd, " \n") + shellFzfLight
+ }
+ fzf := exec.Command(shell, "-c", cmd)
+ fzf.Stdin = bytes.NewBuffer([]byte(*input))
+ // bind the UI
+ fzf.Stderr = os.Stderr
+ // read the result
+ result, err := fzf.Output()
+ if err != nil {
+ return "", err
+ }
+ return string(result), nil
+}
+
+func run(cmd string) (string, error) {
+ shell := os.Getenv("SHELL")
+ if len(shell) == 0 {
+ shell = "sh"
+ }
+ out, err := exec.Command(shell, "-c", cmd).Output()
+ return string(out), err
+}
diff --git a/go.mod b/go.mod
index e4c14ca..3eb33bb 100644
--- a/go.mod
+++ b/go.mod
@@ -4,10 +4,25 @@ go 1.21.6
require (
github.com/Difrex/gosway/ipc v0.0.0-20230801141530-6213ced34703
+ github.com/fsnotify/fsnotify v1.7.0
+ github.com/pancsta/asyncmachine-go v0.5.0
github.com/spf13/cobra v1.8.0
)
require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/google/uuid v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/prometheus/client_golang v1.19.0 // indirect
+ github.com/prometheus/client_model v0.5.0 // indirect
+ github.com/prometheus/common v0.48.0 // indirect
+ github.com/prometheus/procfs v0.12.0 // indirect
+ github.com/samber/lo v1.39.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
+ go.opentelemetry.io/otel v1.24.0 // indirect
+ go.opentelemetry.io/otel/trace v1.24.0 // indirect
+ golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
+ golang.org/x/sys v0.16.0 // indirect
+ google.golang.org/protobuf v1.32.0 // indirect
)
diff --git a/go.sum b/go.sum
index 4afca1f..3121013 100644
--- a/go.sum
+++ b/go.sum
@@ -1,12 +1,51 @@
github.com/Difrex/gosway/ipc v0.0.0-20230801141530-6213ced34703 h1:jFBAp9zuZcZTkF3eOXwJ18fMKpyDC8HMwkyyACgy1Ao=
github.com/Difrex/gosway/ipc v0.0.0-20230801141530-6213ced34703/go.mod h1:hZTd2MPJSe6oi3O8arSxP2WEoIuHLgct4v0t1ssBtTU=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
+github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
+github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
+github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
+github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
+github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
+github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
+github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
+github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
+github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
+github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
+go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
+go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
+go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
+golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
+golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
+golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
+golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
+google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
index 6b4ab0b..42510f3 100644
--- a/main.go
+++ b/main.go
@@ -1,50 +1,33 @@
package main
import (
- "bytes"
+ "context"
"fmt"
+ "io"
"log"
- "net"
- "net/rpc"
"os"
"os/exec"
- "regexp"
- "runtime/debug"
"strconv"
"strings"
- "syscall"
"time"
"github.com/Difrex/gosway/ipc"
+ "github.com/pancsta/sway-yast/watcher"
+ "github.com/samber/lo"
"github.com/spf13/cobra"
)
// CONFIG
const (
maxTracked = 100
- lenSpace = 6
- lenID = 4
+ lenSpace = 8
lenDisplay = 3
lenApp = 15
- lenTitle = 35
+ lenTitle = 40
rpcHost = "localhost:7853"
+ rpcHostDbg = "localhost:7854"
// how long a PID can hold the switcher
pidTimeout = time.Second * 3
- cmdFzf = `
- fzf \
- --prompt 'Switcher: ' \
- --bind "load:pos(2)" \
- --bind "change:pos(1)" \
- --layout=reverse --info=hidden \
- --bind=space:accept,tab:offset-down,btab:offset-up
-`
- // junegunn/seoul256.vim (light)
- cmdFzfLight = ` \
- --color=bg+:#D9D9D9,bg:#E1E1E1,border:#C8C8C8,spinner:#719899,hl:#719872,fg:#616161,header:#719872,info:#727100,pointer:#E12672,marker:#E17899,fg+:#616161,preview-bg:#D9D9D9,prompt:#0099BD,hl+:#719899
-`
- cmdSwitcher = `
- foot --title "sway-yast" sway-yast fzf
-`
)
// config end
@@ -52,21 +35,154 @@ const (
type WindowFocus []string
type WindowData struct {
+ ID int
Output string
Workspace string
Title string
App string
}
-var winFocus WindowFocus
-var winData map[string]WindowData
-
type Daemon struct {
- conn *ipc.SwayConnection
+ conn *ipc.SwayConnection
+ mouseFollowsFocus bool
+ watcher *watcher.PathWatcher
+ ctx context.Context
+ winFocus WindowFocus
+ winData map[string]WindowData
+ openedByPID int
+ openedAt time.Time
+ autoconfig bool
+ defaultKeybindings bool
}
+func main() {
+ // TODO --status (PID, config, windows count)
+ // TODO readme, screenshots
+ // TODO auto bind default shortcuts via --bind-default-keys
+ // alt+tab, cmd+o, cmd+p
+ // TODO include desktop shortcuts in "path" (Name, Exe)
+ // - ~/.local/share/applications
+ // - /usr/share/applications
+ if os.Getenv("YAST_LOG") == "" {
+ log.SetOutput(io.Discard)
+ }
+ cmdDaemon := &cobra.Command{
+ Use: "daemon",
+ Short: "Start tracking focus in sway",
+ Run: func(cmd *cobra.Command, args []string) {
+ mouseFollow, _ := cmd.Flags().GetBool("mouse-follows-focus")
+ autoconfig, _ := cmd.Flags().GetBool("autoconfig")
+ defaultKeybindings, _ := cmd.Flags().GetBool("default-keybindings")
+ d := &Daemon{
+ mouseFollowsFocus: mouseFollow,
+ autoconfig: autoconfig,
+ defaultKeybindings: defaultKeybindings,
+ }
+ if mouseFollow {
+ log.Println("Mouse follows focus enabled")
+ }
+ d.Start()
+ },
+ }
+ // TODO extract
+ cmdDaemon.Flags().Bool("mouse-follows-focus", false,
+ "Calls 'input ... map_to_output OUTPUT' on each focus")
+ cmdDaemon.Flags().Bool("autoconfig", true,
+ "Automatic configuration of layout")
+ cmdDaemon.Flags().Bool("default-keybindings", false,
+ "Add default keybindings")
+
+ cmdList := &cobra.Command{
+ Use: "mru-list",
+ Short: "Print a list of MRU window IDs",
+ Run: func(cmd *cobra.Command, args []string) {
+ fmt.Print(rpcCall("Daemon.RemoteWinList", RPCArgs{}))
+ },
+ }
+
+ cmdFzf := &cobra.Command{
+ Use: "fzf",
+ Short: "Run fzf with a list of windows",
+ Run: cmdFzf,
+ }
+
+ cmdFzfPickWin := &cobra.Command{
+ Use: "fzf-pick-win",
+ Short: "Run fzf with a list of windows to pick",
+ Run: cmdFzfPickWin,
+ }
+
+ cmdFzfPickSpace := &cobra.Command{
+ Use: "fzf-pick-space",
+ Short: "Run fzf with a list of workspaces to pick",
+ Run: cmdFzfPickSpace,
+ }
+
+ cmdFzfPath := &cobra.Command{
+ Use: "fzf-path",
+ Short: "Run fzf with a list of executable files from PATH",
+ Run: cmdFzfPath,
+ }
+
+ cmdSwitcher := &cobra.Command{
+ Use: "switcher",
+ Short: "Show the switcher window using foot",
+ Run: cmdSwitcher,
+ }
+
+ cmdPickWin := &cobra.Command{
+ Use: "pick-win",
+ Short: "Show the window picker using foot",
+ Run: cmdPickWin,
+ }
+
+ cmdPickSpace := &cobra.Command{
+ Use: "pick-space",
+ Short: "Show the workspace picker using foot",
+ Run: cmdPickSpace,
+ }
+
+ cmdPath := &cobra.Command{
+ Use: "path",
+ Short: "Show the +x files from PATH using foot",
+ Run: cmdPath,
+ }
+
+ cmdConfig := &cobra.Command{
+ Use: "config",
+ Short: "Change the config of a running daemon process",
+ Run: cmdConfig,
+ }
+ // TODO extract
+ cmdConfig.Flags().Bool("mouse-follows-focus", false,
+ "Calls 'input ... map_to_output OUTPUT' on each focus")
+
+ var rootCmd = &cobra.Command{
+ Use: "sway-yast",
+ Run: cmdRoot,
+ }
+ rootCmd.AddCommand(cmdDaemon, cmdList, cmdFzf, cmdSwitcher, cmdFzfPickWin, cmdPickWin, cmdConfig, cmdFzfPickSpace,
+ cmdPickSpace, cmdPath, cmdFzfPath)
+ rootCmd.Flags().Bool("version", false,
+ "Print version and exit")
+
+ err := rootCmd.Execute()
+ if err != nil {
+ log.Fatal("cobra error:", err)
+ }
+}
+
+///// DAEMON /////
+
func (d *Daemon) Start() {
- winData = make(map[string]WindowData)
+ var err error
+ d.ctx = context.Background()
+ d.winData = make(map[string]WindowData)
+ d.watcher, err = watcher.New(d.ctx)
+ if err != nil {
+ log.Fatalf("error: %s", err)
+ }
+ // TODO reconnect backoff?
conn, err := ipc.NewSwayConnection()
if err != nil {
panic(err)
@@ -87,11 +203,34 @@ func (d *Daemon) Start() {
}
// set up the window layout
- _, err = conn.RunSwayCommand(`for_window [title="sway-yast"] floating enable border none`)
- if err != nil {
- log.Fatal("error:", err)
+ if d.autoconfig {
+ // TODO support --autoconfig=false
+ // unbind existing and bind our own alt+tab binding
+ msgs := []string{
+ `for_window [title="sway-yast"] floating enable`,
+ `for_window [title="sway-yast"] border none`,
+ `for_window [title="sway-yast"] sticky enable`,
+ }
+ err = d.SwayMsgs(msgs)
+ if err != nil {
+ log.Fatal("error:", err)
+ }
}
+ if d.defaultKeybindings {
+ msgs := []string{
+ `bindsym alt+tab exec sway-yast switcher`,
+ `bindsym mod4+o exec sway-yast pick-space`,
+ `bindsym mod4+p exec sway-yast pick-win`,
+ `bindsym mod4+d exec sway-yast path`,
+ }
+ err = d.SwayMsgs(msgs)
+ if err != nil {
+ log.Fatal("error:", err)
+ }
+ }
+
+ // TODO reconnect backoff?
subCon, err := ipc.NewSwayConnection()
if err != nil {
panic(err)
@@ -108,6 +247,7 @@ func (d *Daemon) Start() {
defer s.Close()
go rpcServer(d)
+ d.watcher.Start()
log.Println("Listening for sway events...")
for {
@@ -116,28 +256,55 @@ func (d *Daemon) Start() {
if event.Change == "focus" {
d.OnFocus(&event.Container)
}
+ // TODO test if event exist, needed when moving to another workspace, find the dest one
+ //if event.Change == "blur" {
+ // d.OnBlur(&event.Container)
+ //}
if event.Change == "close" {
d.OnClose(&event.Container)
}
case err := <-s.Errors:
+ // TODO reconnect / backoff
log.Println("Error:", err)
break
}
}
}
+// ListSpaces returns names of the current workspaces.
+func (d *Daemon) ListSpaces(skipOutputs []string) ([]string, error) {
+ tree, err := d.conn.GetTree()
+ if err != nil {
+ return nil, err
+ }
+ var ret = []string{}
+ for _, output := range tree.Nodes {
+ if lo.Contains(skipOutputs, output.Name) {
+ continue
+ }
+ for _, workspace := range output.Nodes {
+ if workspace.Name == "__i3_scratch" {
+ continue
+ }
+ ret = append(ret, workspace.Name)
+ }
+ }
+ return ret, nil
+}
+
func (d *Daemon) parseNode(con *ipc.Node, space, output string) {
if con.Layout != "splith" && con.Layout != "splitv" && con.Layout != "tabbed" && con.Layout != "stacked" {
id := strconv.Itoa(int(con.ID))
data := WindowData{
+ ID: int(con.ID),
Output: output,
Workspace: space,
Title: con.Name,
// TODO AppID
App: con.WindowProperties.Class,
}
- winData[id] = data
- winFocus, _ = unshiftAndTrim(winFocus, id)
+ d.winData[id] = data
+ d.winFocus, _ = unshiftAndTrim(d.winFocus, id)
}
for _, node := range con.Nodes {
d.parseNode(&node, space, output)
@@ -147,24 +314,12 @@ func (d *Daemon) parseNode(con *ipc.Node, space, output string) {
func (d *Daemon) OnClose(c *ipc.Container) {
id := strconv.Itoa(c.ID)
// remove ID from winFocus
- for i, v := range winFocus {
- if v == id {
- winFocus = append(winFocus[:i], winFocus[i+1:]...)
- break
- }
- }
+ d.winFocus = lo.Without(d.winFocus, id)
// remove from winData
- delete(winData, id)
+ delete(d.winData, id)
}
func (d *Daemon) OnFocus(con *ipc.Container) {
- // TODO debug
- defer func() {
- if r := recover(); r != nil {
- log.Println("Recovered from panic:", r)
- fmt.Printf("Stack trace: %s\n", debug.Stack())
- }
- }()
// skip self
if con.Name == "sway-yast" {
return
@@ -175,6 +330,7 @@ func (d *Daemon) OnFocus(con *ipc.Container) {
}
id := strconv.Itoa(con.ID)
data := WindowData{
+ ID: con.ID,
Output: space.Output,
Workspace: space.Name,
Title: con.Name,
@@ -186,201 +342,58 @@ func (d *Daemon) OnFocus(con *ipc.Container) {
} else {
data.App = "unknown"
}
- winData[id] = data
+ d.winData[id] = data
var removed []string
- winFocus, removed = unshiftAndTrim(winFocus, id)
+ d.winFocus, removed = unshiftAndTrim(d.winFocus, id)
for _, id := range removed {
- delete(winData, id)
- }
-}
-
-func main() {
- cmdDaemon := &cobra.Command{
- Use: "daemon",
- Short: "Start tracking focus in sway",
- Run: func(_ *cobra.Command, _ []string) {
- d := &Daemon{}
- d.Start()
- },
- }
-
- cmdList := &cobra.Command{
- Use: "mru-list",
- Short: "Returns a list of MRU window IDs",
- Run: func(cmd *cobra.Command, args []string) {
- fmt.Print(rpcCall("Daemon.WinList", RPCArgs{}))
- },
- }
-
- cmdFzf := &cobra.Command{
- Use: "fzf",
- Short: "Render fzf with a list of windows",
- Run: execFzf,
- }
-
- cmdSwitcher := &cobra.Command{
- Use: "switcher",
- Short: "Show the switcher window using foot",
- Run: execSwitcher,
- }
-
- var rootCmd = &cobra.Command{Use: "app"}
- rootCmd.AddCommand(cmdDaemon, cmdList, cmdFzf, cmdSwitcher)
- err := rootCmd.Execute()
- if err != nil {
- log.Fatal("cobra error:", err)
- }
-}
-
-func execSwitcher(_ *cobra.Command, _ []string) {
- pid := os.Getpid()
- if rpcCall("Daemon.ShouldOpen", RPCArgs{PID: pid}) != "true" {
- log.Fatal("fzf error: already open")
- }
- shell := os.Getenv("SHELL")
- if len(shell) == 0 {
- shell = "sh"
- }
- err := exec.Command(shell, "-c", cmdSwitcher).Run()
- if err != nil {
- log.Fatal("foot error: " + err.Error())
- }
-}
-
-func execFzf(_ *cobra.Command, _ []string) {
- // run fzf
- fzfInput := rpcCall("Daemon.FZFList", RPCArgs{})
- shell := os.Getenv("SHELL")
- if len(shell) == 0 {
- shell = "sh"
- }
- cmd := cmdFzf
- if isLightMode() {
- cmd = strings.TrimRight(cmd, " \n") + cmdFzfLight
- }
- fzf := exec.Command(shell, "-c", cmd)
- fzf.Stdin = bytes.NewBuffer([]byte(fzfInput))
- // bind the UI
- fzf.Stderr = os.Stderr
- // read the result
- result, err := fzf.Output()
- if err != nil {
- log.Fatal("fzf error: " + err.Error())
+ delete(d.winData, id)
}
- re := regexp.MustCompile(`\((\d+)\)`)
- match := re.FindStringSubmatch(string(result))
- if len(match) == 0 {
- log.Fatal("fzf error: no match")
+ // move the pointer
+ if !d.mouseFollowsFocus {
+ return
}
- // focus the window
- winID, err := strconv.Atoi(match[1])
+ err = d.MouseToOutput(data.Output)
if err != nil {
- log.Fatal("fzf error: " + err.Error())
- }
- rpcCall("Daemon.FocusWinID", RPCArgs{WinID: winID})
-}
-
-// RPC
-
-type RPCArgs struct {
- WinID int
- PID int
-}
-
-// RPC method
-func (d *Daemon) WinList(_ RPCArgs, reply *string) error {
- ids := ""
- for _, id := range winFocus {
- ids += fmt.Sprintf("%s ", id)
+ log.Printf("error: %s", err)
}
- *reply = ids
- return nil
}
-// RPC method
-func (d *Daemon) FZFList(_ RPCArgs, reply *string) error {
- ret := ""
- for _, id := range winFocus {
- data := winData[id]
- display := strings.Replace(data.Output, "HEADLESS-", "H-", 1)
- ret += fmt.Sprintf("%-*s (%s) %-*s | %-*s | %-*s | %-*s \n",
- lenSpace, maxLen(data.Workspace, lenSpace),
- id, lenID-len(id), " ",
- lenDisplay, maxLen(display, lenDisplay),
- lenApp, maxLen(data.App, lenApp),
- lenTitle, maxLen(data.Title, lenTitle),
- )
- }
- *reply = ret
- return nil
-}
-
-var openedByPID int
-var openedAt time.Time
-
-// RPC method
-func (d *Daemon) ShouldOpen(args RPCArgs, reply *string) error {
- if openedByPID == 0 {
- *reply = "true"
- openedByPID = args.PID
- openedAt = time.Now()
- return nil
- }
- // check if the holding process is alive
- proc, _ := os.FindProcess(openedByPID)
- timeoutOut := time.Now().Sub(openedAt) > pidTimeout
- if dead := proc.Signal(syscall.Signal(0)); dead != nil || timeoutOut {
- openedByPID = args.PID
- openedAt = time.Now()
- *reply = "true"
- } else {
- *reply = "false"
+func (d *Daemon) CurrentWin() WindowData {
+ if len(d.winFocus) == 0 {
+ return WindowData{}
}
- return nil
+ id := d.winFocus[0]
+ return d.winData[id]
}
-// RPC method
-func (d *Daemon) FocusWinID(args RPCArgs, _ *string) error {
- log.Printf("focusing %d...", args.WinID)
- _, err := d.conn.RunSwayCommand(fmt.Sprintf("[con_id=\"%d\"] focus", args.WinID))
+func (d *Daemon) FocusWinID(id int) error {
+ _, err := d.conn.RunSwayCommand(fmt.Sprintf(`[con_id="%d"] focus`, id))
if err != nil {
return err
}
return nil
}
-var client *rpc.Client
-
-func rpcCall(method string, args RPCArgs) string {
- var err error
- if client == nil {
- client, err = rpc.Dial("tcp", rpcHost)
+func (d *Daemon) SwayMsgs(msgs []string) error {
+ for _, msg := range msgs {
+ _, err := d.conn.RunSwayCommand(msg)
if err != nil {
- fmt.Println("rpc connection error, is the daemon running?")
- os.Exit(1)
+ return err
}
}
- var reply string
- err = client.Call(method, args, &reply)
- if err != nil {
- log.Fatal("rpc error:", err)
- }
- return reply
+ return nil
}
-func rpcServer(server any) {
- err := rpc.Register(server)
+func (d *Daemon) MouseToOutput(output string) error {
+ _, err := d.conn.RunSwayCommand(fmt.Sprintf(
+ `input 0:0:wlr_virtual_pointer_v1 map_to_output "%s"`, output))
if err != nil {
- log.Fatal("register error:", err)
- }
- l, err := net.Listen("tcp", rpcHost)
- if err != nil {
- log.Fatal("listen error:", err)
+ return err
}
- rpc.Accept(l)
+ return nil
}
-// Utils
+///// UTILS /////
func unshiftAndTrim(slice []string, id string) ([]string, []string) {
for i, v := range slice {
diff --git a/rpc.go b/rpc.go
new file mode 100644
index 0000000..d8b5b3a
--- /dev/null
+++ b/rpc.go
@@ -0,0 +1,258 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "net/rpc"
+ "os"
+ "strings"
+ "syscall"
+ "time"
+
+ ss "github.com/pancsta/sway-yast/watcher/states"
+)
+
+// RPC
+
+var client *rpc.Client
+
+type RPCArgs struct {
+ WinID int
+ Workspace string
+ PID int
+ MouseFollowsFocus bool
+ ExePath string
+}
+
+// RPC method
+func (d *Daemon) RemoteWinList(_ RPCArgs, reply *string) error {
+ ids := ""
+ for _, id := range d.winFocus {
+ ids += fmt.Sprintf("%s ", id)
+ }
+ *reply = ids
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteFZFList(_ RPCArgs, reply *string) error {
+ ret := ""
+ // TODO extract
+ for _, id := range d.winFocus {
+ data := d.winData[id]
+ display := strings.Replace(data.Output, "HEADLESS-", "H-", 1)
+ // ret += fmt.Sprintf("%-*s (%s) %s| %-*s | %-*s | %-*s \n",
+ ret += fmt.Sprintf("%-*s | %-*s | %-*s | %-*s (%s) \n",
+ lenDisplay, maxLen(display, lenDisplay),
+ lenSpace, maxLen(data.Workspace, lenSpace),
+ lenApp, maxLen(data.App, lenApp),
+ lenTitle, maxLen(data.Title, lenTitle),
+ id,
+ )
+ }
+ *reply = ret
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteFZFListPickWin(_ RPCArgs, reply *string) error {
+ wspace := d.winData[d.winFocus[0]].Workspace
+ ret := ""
+ // TODO extract
+ for _, id := range d.winFocus {
+ data := d.winData[id]
+ // skip same workspace
+ if data.Workspace == wspace {
+ continue
+ }
+ display := strings.Replace(data.Output, "HEADLESS-", "H-", 1)
+ // ret += fmt.Sprintf("%-*s (%s) %s| %-*s | %-*s | %-*s \n",
+ ret += fmt.Sprintf("%-*s | %-*s | %-*s | %-*s (%s) \n",
+ lenDisplay, maxLen(display, lenDisplay),
+ lenSpace, maxLen(data.Workspace, lenSpace),
+ lenApp, maxLen(data.App, lenApp),
+ lenTitle, maxLen(data.Title, lenTitle),
+ id,
+ )
+ }
+ *reply = ret
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteFZFListPickSpace(_ RPCArgs, reply *string) error {
+ currWin := d.CurrentWin()
+ spaces, err := d.ListSpaces([]string{currWin.Output})
+ if err != nil {
+ log.Printf("error: %s", err)
+ return err
+ }
+ *reply = strings.Join(spaces, "\n")
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteShouldOpen(args RPCArgs, reply *string) error {
+ if d.openedByPID == 0 {
+ *reply = "true"
+ d.openedByPID = args.PID
+ d.openedAt = time.Now()
+ return nil
+ }
+ // check if the holding process is alive
+ proc, _ := os.FindProcess(d.openedByPID)
+ timeoutOut := time.Now().Sub(d.openedAt) > pidTimeout
+ if dead := proc.Signal(syscall.Signal(0)); dead != nil || timeoutOut {
+ d.openedByPID = args.PID
+ d.openedAt = time.Now()
+ *reply = "true"
+ } else {
+ *reply = "false"
+ }
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteFocusWinID(args RPCArgs, _ *string) error {
+ log.Printf("focusing %d...", args.WinID)
+ err := d.FocusWinID(args.WinID)
+ if err != nil {
+ log.Printf("error: %s", err)
+ return err
+ }
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteMoveSpaceToOutput(args RPCArgs, _ *string) error {
+ currentWin := d.CurrentWin()
+ if currentWin.Output == "" {
+ err := errors.New("no focused window / output")
+ log.Printf("error: %s", err)
+ return err
+ }
+ log.Printf("moving space %s to %s", args.Workspace, currentWin.Output)
+ msgs := []string{
+ fmt.Sprintf("workspace %s", args.Workspace),
+ fmt.Sprintf("move workspace to output %s", currentWin.Output),
+ }
+ err := d.SwayMsgs(msgs)
+ if err != nil {
+ log.Printf("error: %s", err)
+ return err
+ }
+ time.Sleep(100 * time.Millisecond)
+ // focus the original window back
+ err = d.FocusWinID(currentWin.ID)
+ if err != nil {
+ log.Printf("error: %s", err)
+ return err
+ }
+ if d.mouseFollowsFocus {
+ err = d.MouseToOutput(currentWin.Output)
+ if err != nil {
+ log.Printf("error: %s", err)
+ return err
+ }
+ }
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteMoveWinToSpace(args RPCArgs, _ *string) error {
+ space := d.CurrentWin().Workspace
+ if space == "" {
+ err := errors.New("no focused window / space")
+ log.Printf("error: %s", err)
+ return err
+ }
+ log.Printf("moving win %d to %s", args.WinID, space)
+ _, err := d.conn.RunSwayCommand(fmt.Sprintf(`[con_id="%d"] move workspace %s`, args.WinID, space))
+ if err != nil {
+ log.Printf("error: %s", err)
+ return err
+ }
+ err = d.FocusWinID(args.WinID)
+ if err != nil {
+ log.Printf("error: %s", err)
+ return err
+ }
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteSetConfig(args RPCArgs, _ *string) error {
+ log.Printf("RemoteSetConfig %v...", args.MouseFollowsFocus)
+ d.mouseFollowsFocus = args.MouseFollowsFocus
+ if !d.mouseFollowsFocus {
+ // set the pointer to all the outputs
+ _, err := d.conn.RunSwayCommand(`input 0:0:wlr_virtual_pointer_v1 map_to_output "*"`)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteGetPathFiles(args RPCArgs, ret *string) error {
+ log.Printf("RemoteGetPathFiles...")
+ <-d.watcher.Mach.When1(ss.AllRefreshed, nil)
+ log.Printf("AllRefreshed...")
+ d.watcher.ResultsLock.Lock()
+ defer d.watcher.ResultsLock.Unlock()
+ *ret += strings.Join(d.watcher.Results, "\n")
+ return nil
+}
+
+// RPC method
+func (d *Daemon) RemoteExec(args RPCArgs, ret *string) error {
+ log.Printf("RemoteExec...")
+ path := args.ExePath
+ _, err := d.conn.RunSwayCommand("exec " + path)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// TODO timeout
+func rpcCall(method string, args RPCArgs) (string, error) {
+ log.Printf("rpcCall %s...", method)
+ var err error
+ url := rpcHost
+ if os.Getenv("YAST_DEBUG") != "" {
+ url = rpcHostDbg
+ }
+ if client == nil {
+ client, err = rpc.Dial("tcp", url)
+ if err != nil {
+ fmt.Println("rpc connection error, is the daemon running?")
+ os.Exit(1)
+ }
+ }
+ var reply string
+ err = client.Call(method, args, &reply)
+ if err != nil {
+ return "", err
+ }
+ return reply, nil
+}
+
+func rpcServer(server any) {
+ err := rpc.Register(server)
+ if err != nil {
+ log.Fatal("register error:", err)
+ }
+ url := rpcHost
+ if os.Getenv("YAST_DEBUG") != "" {
+ url = rpcHostDbg
+ }
+ l, err := net.Listen("tcp", url)
+ if err != nil {
+ log.Fatal("listen error:", err)
+ }
+ rpc.Accept(l)
+}
diff --git a/watcher/states/ss_watcher.go b/watcher/states/ss_watcher.go
new file mode 100644
index 0000000..f683fb6
--- /dev/null
+++ b/watcher/states/ss_watcher.go
@@ -0,0 +1,65 @@
+package states
+
+import am "github.com/pancsta/asyncmachine-go/pkg/machine"
+
+// S is a type alias for a list of state names.
+type S = am.S
+
+// States map defines relations and properties of states (for files).
+var States = am.Struct{
+ Init: {Add: S{Watching}},
+
+ Watching: {
+ Add: S{Init},
+ After: S{Init},
+ },
+ ChangeEvent: {
+ Multi: true,
+ Require: S{Watching},
+ },
+
+ Refreshing: {
+ Multi: true,
+ Remove: S{AllRefreshed},
+ },
+ Refreshed: {Multi: true},
+ AllRefreshed: {},
+}
+
+// StatesDir map defines relations and properties of states (for directories).
+var StatesDir = am.Struct{
+ Refreshing: {Remove: groupRefreshed},
+ Refreshed: {Remove: groupRefreshed},
+ DirDebounced: {Remove: groupRefreshed},
+ DirCached: {},
+}
+
+// Groups of mutually exclusive states.
+
+var groupRefreshed = S{Refreshing, Refreshed, DirDebounced}
+
+//#region boilerplate defs
+
+// Names of all the states (pkg enum).
+
+const (
+ Init = "Init"
+ Watching = "Watching"
+ ChangeEvent = "ChangeEvent"
+ Refreshing = "Refreshing"
+ Refreshed = "Refreshed"
+ AllRefreshed = "AllRefreshed"
+
+ // dir-only states
+
+ DirDebounced = "DirDebounced"
+ DirCached = "DirCached"
+)
+
+// Names is an ordered list of all the state names for files.
+var Names = S{Init, Watching, ChangeEvent, Refreshing, Refreshed, AllRefreshed}
+
+// NamesDir is an ordered list of all the state names for directories.
+var NamesDir = S{Refreshing, Refreshed, DirDebounced, DirCached}
+
+//#endregion
diff --git a/watcher/watcher.go b/watcher/watcher.go
new file mode 100644
index 0000000..1445b00
--- /dev/null
+++ b/watcher/watcher.go
@@ -0,0 +1,378 @@
+package watcher
+
+import (
+ "context"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+ am "github.com/pancsta/asyncmachine-go/pkg/machine"
+ "github.com/pancsta/asyncmachine-go/pkg/telemetry"
+ ss "github.com/pancsta/sway-yast/watcher/states"
+)
+
+// PathWatcher watches all dirs in PATH for changes and returns a list
+// of executables.
+type PathWatcher struct {
+ am.ExceptionHandler
+
+ Mach *am.Machine
+ ResultsLock sync.Mutex
+ Results []string
+ EnvPath string
+
+ watcher *fsnotify.Watcher
+ dirCache map[string][]string
+ dirState map[string]*am.Machine
+ ongoing map[string]context.Context
+ lastRefresh map[string]time.Time
+}
+
+func New(ctx context.Context) (*PathWatcher, error) {
+ w := &PathWatcher{
+ EnvPath: os.Getenv("PATH"),
+ dirCache: make(map[string][]string),
+ dirState: make(map[string]*am.Machine),
+ ongoing: make(map[string]context.Context),
+ lastRefresh: make(map[string]time.Time),
+ }
+ opts := &am.Opts{
+ ID: "watcher",
+ }
+
+ if os.Getenv("YAST_DEBUG") != "" {
+ opts.HandlerTimeout = time.Minute
+ opts.DontPanicToException = true
+ }
+ w.Mach = am.New(ctx, ss.States, opts)
+
+ err := w.Mach.VerifyStates(ss.Names)
+ if err != nil {
+ return nil, err
+ }
+
+ err = w.Mach.BindHandlers(w)
+ if err != nil {
+ return nil, err
+ }
+
+ w.Mach.SetTestLogger(log.Printf, am.LogChanges)
+ if os.Getenv("YAST_DEBUG") != "" {
+ err = telemetry.TransitionsToDBG(w.Mach, "")
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return w, nil
+}
+
+func (w *PathWatcher) InitState(e *am.Event) {
+ var err error
+
+ w.watcher, err = fsnotify.NewWatcher()
+ if err != nil {
+ w.Mach.Remove1(ss.Init, nil)
+ w.Mach.AddErr(err)
+ }
+}
+
+func (w *PathWatcher) InitEnd(e *am.Event) {
+ w.watcher.Close()
+}
+
+func (w *PathWatcher) WatchingState(e *am.Event) {
+ path := w.EnvPath
+ dirs := strings.Split(path, string(os.PathListSeparator))
+
+ // start the loop (bound to this instance)
+ ctx := e.Machine.NewStateCtx(ss.Watching)
+ go w.watchLoop(ctx)
+
+ // subscribe
+ for _, dirName := range dirs {
+
+ // if path doesn't exist, continue
+ if _, err := os.Stat(dirName); os.IsNotExist(err) {
+ continue
+ }
+
+ // create state per dir
+ err := w.watcher.Add(dirName)
+ if err != nil {
+ e.Machine.AddErr(err)
+ }
+
+ // create a state for each dir
+ state := am.New(ctx, ss.StatesDir, nil)
+ err = state.VerifyStates(ss.NamesDir)
+ if err != nil {
+ e.Machine.AddErr(err)
+ continue
+ }
+
+ w.dirState[dirName] = state
+
+ // schedule a refresh
+ w.Mach.Add1(ss.Refreshing, am.A{"dirName": dirName})
+ }
+}
+
+func (w *PathWatcher) WatchingEnd(e *am.Event) {
+ paths := w.watcher.WatchList()
+
+ for _, path := range paths {
+ err := w.watcher.Remove(path)
+ if err != nil {
+ e.Machine.AddErr(err)
+ }
+ }
+}
+
+func (w *PathWatcher) watchLoop(ctx context.Context) {
+ for {
+ select {
+
+ case event, ok := <-w.watcher.Events:
+ if !ok {
+ w.Mach.Remove1(ss.Watching, nil)
+ return
+ }
+ w.Mach.Add1(ss.ChangeEvent, am.A{
+ "fsnotify.Event": event,
+ })
+
+ case err, ok := <-w.watcher.Errors:
+ if !ok {
+ w.Mach.Remove1(ss.Watching, nil)
+ return
+ }
+ w.Mach.AddErr(err)
+
+ case <-ctx.Done():
+ // state expired
+ return
+ }
+ }
+}
+
+func (w *PathWatcher) ChangeEventState(e *am.Event) {
+ defer e.Machine.Remove1(ss.ChangeEvent, nil)
+ event := e.Args["fsnotify.Event"].(fsnotify.Event)
+
+ // exe
+ isRemove := event.Op&fsnotify.Remove == fsnotify.Remove
+ if !isRemove {
+ isExe, err := isExecutable(event.Name)
+ if !isExe || err != nil {
+ return
+ }
+ }
+ dirName := filepath.Dir(event.Name)
+
+ w.Mach.Add1(ss.Refreshing, am.A{
+ "dirName": dirName,
+ })
+}
+
+func (w *PathWatcher) ExceptionState(e *am.Event) {
+ w.ExceptionHandler.ExceptionState(e)
+}
+
+func (w *PathWatcher) RefreshingEnter(e *am.Event) bool {
+
+ // validate req params
+ _, ok1 := e.Args["dirName"]
+ dirName, ok2 := e.Args["dirName"].(string)
+ dirState, ok3 := w.dirState[dirName]
+ depsOk := ok1 && ok2 && ok3
+ if !depsOk {
+ return false
+ }
+
+ // let the debounced refreshes pass
+ isDebounce, _ := e.Args["isDebounce"].(bool)
+ if dirState.Is1(ss.Refreshing) || (dirState.Is1(ss.DirDebounced) && !isDebounce) {
+ return false
+ }
+
+ return true
+}
+
+func (w *PathWatcher) RefreshingState(e *am.Event) {
+ w.Mach.Remove1(ss.Refreshing, nil)
+
+ dirName := e.Args["dirName"].(string)
+ dirState := w.dirState[dirName]
+ // TODO config
+ debounce := time.Second
+
+ // max 1 refresh per second
+ since := time.Since(w.lastRefresh[dirName])
+ shouldDebounce := since < debounce
+ if dirState.Is1(ss.DirCached) && shouldDebounce {
+ w.Mach.Log("Debounce for %s", dirName)
+ dirState.Add1(ss.DirDebounced, nil)
+
+ go func() {
+ time.Sleep(debounce)
+ w.Mach.Add1(ss.Refreshing, am.A{
+ "dirName": dirName,
+ "isDebounce": true,
+ })
+ }()
+
+ return
+ }
+
+ w.Mach.Log("Refreshing execs in %s", dirName)
+ dirState.Add1(ss.Refreshing, nil)
+ w.ongoing[dirName] = dirState.NewStateCtx(ss.Refreshing)
+ ctx := w.ongoing[dirName]
+
+ go func() {
+ if ctx.Err() != nil {
+ return // expired
+ }
+
+ executables, err := listExecutables(dirName)
+ if err != nil {
+ e.Machine.AddErr(err)
+ }
+
+ w.Mach.Remove1(ss.Refreshing, am.A{
+ "dirName": dirName,
+ })
+ w.Mach.Add1(ss.Refreshed, am.A{
+ "dirName": dirName,
+ "executables": executables,
+ })
+ }()
+}
+
+func (w *PathWatcher) RefreshingExit(e *am.Event) bool {
+ // GC
+ _, ok := e.Args["dirName"]
+ if ok {
+ dirName, ok := e.Args["dirName"].(string)
+ if ok {
+ delete(w.ongoing, dirName)
+ }
+ }
+
+ // check completions
+ mut := e.Mutation()
+
+ // removing Init is a force shutdown
+ removeInit := mut.Type == am.MutationRemove && mut.StateWasCalled(ss.Init)
+
+ return len(w.ongoing) == 0 || removeInit
+}
+
+func (w *PathWatcher) RefreshingEnd(e *am.Event) {
+ // forced cleanup
+ for i := range w.ongoing {
+ delete(w.ongoing, i)
+ }
+}
+
+func (w *PathWatcher) RefreshedEnter(e *am.Event) bool {
+ // validate req params
+ _, ok1 := e.Args["dirName"].(string)
+ _, ok2 := e.Args["executables"].([]string)
+
+ return ok1 && ok2
+}
+
+func (w *PathWatcher) RefreshedState(e *am.Event) {
+ w.Mach.Remove1(ss.Refreshed, nil)
+
+ dirName := e.Args["dirName"].(string)
+ executables := e.Args["executables"].([]string)
+ w.dirCache[dirName] = executables
+ w.lastRefresh[dirName] = time.Now()
+
+ // update the per-dir state
+ w.dirState[dirName].Add(am.S{ss.Refreshed, ss.DirCached}, nil)
+
+ // try to finish the whole refresh
+ w.Mach.Add1(ss.AllRefreshed, nil)
+}
+
+func (w *PathWatcher) AllRefreshedEnter(e *am.Event) bool {
+ return len(w.ongoing) == 0
+}
+
+func (w *PathWatcher) AllRefreshedState(e *am.Event) {
+ w.ResultsLock.Lock()
+ defer w.ResultsLock.Unlock()
+
+ for _, executables := range w.dirCache {
+ w.Results = append(w.Results, executables...)
+ }
+ w.Results = uniqueStrings(w.Results)
+}
+
+func (w *PathWatcher) Start() {
+ w.Mach.Add1(ss.Init, nil)
+}
+
+func (w *PathWatcher) Stop() {
+ w.Mach.Remove1(ss.Init, nil)
+}
+
+///// HELPERS /////
+
+func isExecutable(path string) (bool, error) {
+ info, err := os.Stat(path)
+ if err != nil {
+ return false, err
+ }
+
+ return info.Mode().Perm()&0111 != 0, nil
+}
+
+func listExecutables(dirPath string) ([]string, error) {
+ files, err := os.ReadDir(dirPath)
+ if err != nil {
+ return nil, err
+ }
+
+ var executables []string
+ for _, file := range files {
+ if file.IsDir() {
+ continue
+ }
+
+ fullPath := dirPath + "/" + file.Name()
+ isExe, err := isExecutable(fullPath)
+ if err != nil {
+ continue
+ }
+
+ if isExe {
+ executables = append(executables, file.Name())
+ }
+ }
+
+ return executables, nil
+}
+
+func uniqueStrings(s []string) []string {
+ seen := make(map[string]struct{})
+ var result []string
+
+ for _, v := range s {
+ if _, ok := seen[v]; ok {
+ continue
+ }
+ seen[v] = struct{}{}
+ result = append(result, v)
+ }
+
+ return result
+}