diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc4c7f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +swaycycle diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a3539bc --- /dev/null +++ b/Makefile @@ -0,0 +1,87 @@ +# Copyright © 2025 Thomas von Dein + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +# +# no need to modify anything below +tool = swaycycle +VERSION = $(shell grep VERSION main.go | head -1 | cut -d '"' -f2) +archs = darwin freebsd linux +PREFIX = /usr/local +UID = root +GID = 0 +HAVE_POD := $(shell pod2text -h 2>/dev/null) + +all: buildlocal + + +buildlocal: + CGO_LDFLAGS='-static' go build -tags osusergo,netgo -ldflags "-extldflags=-static" -o $(tool) + +install: buildlocal + install -d -o $(UID) -g $(GID) $(PREFIX)/bin + install -d -o $(UID) -g $(GID) $(PREFIX)/man/man1 + install -o $(UID) -g $(GID) -m 555 $(tool) $(PREFIX)/sbin/ + install -o $(UID) -g $(GID) -m 444 $(tool).1 $(PREFIX)/man/man1/ + +clean: + rm -rf $(tool) coverage.out testdata t/out + +test: clean + mkdir -p t/out + go test ./... $(ARGS) + +testlint: test lint + +lint: + golangci-lint run + +lint-full: + golangci-lint run --enable-all --exclude-use-default --disable exhaustivestruct,exhaustruct,depguard,interfacer,deadcode,golint,structcheck,scopelint,varcheck,ifshort,maligned,nosnakecase,godot,funlen,gofumpt,cyclop,noctx,gochecknoglobals,paralleltest + +testfuzzy: clean + go test -fuzz ./... $(ARGS) + +singletest: + @echo "Call like this: make singletest TEST=TestPrepareColumns ARGS=-v" + go test -run $(TEST) $(ARGS) + +cover-report: + go test ./... -cover -coverprofile=coverage.out + go tool cover -html=coverage.out + +goupdate: + go get -t -u=patch ./... + +buildall: + ./mkrel.sh $(tool) $(VERSION) + +release: + gh release create v$(VERSION) --generate-notes + +show-versions: buildlocal + @echo "### swaycycle version:" + @./swaycycle -V + + @echo + @echo "### go module versions:" + @go list -m all + + @echo + @echo "### go version used for building:" + @grep -m 1 go go.mod + +# lint: +# golangci-lint run -p bugs -p unused diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1918980 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module swaycycle + +go 1.23 + +require ( + github.com/alecthomas/repr v0.5.1 // indirect + github.com/spf13/pflag v1.0.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bb9bbd3 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7e7bbff --- /dev/null +++ b/main.go @@ -0,0 +1,233 @@ +/* +Copyright © 2025 Thomas von Dein + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + + "github.com/alecthomas/repr" + + flag "github.com/spf13/pflag" +) + +const ( + root = iota + 1 + output + workspace + con + floating + + VERSION = "v0.1.1" +) + +type Node struct { + Id int `json:"id"` + Nodetype string `json:"type"` // output, workspace or container + Name string `json:"name"` // workspace number or app name + Nodes []Node `json:"nodes"` + FloatingNodes []Node `json:"floating_nodes"` + Focused bool `json:"focused"` + Window int `json:"window"` // wayland native + X11Window string `json:"app_id"` // x11 compat + Current_workspace string `json:"current_workspace"` +} + +var Visibles = []Node{} +var CurrentWorkspace = "" +var Debug = false +var Version = false + +func main() { + flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging") + flag.BoolVarP(&Version, "version", "v", false, "show program version") + flag.Parse() + + if Version { + fmt.Printf("This is swaycycle version %s\n", VERSION) + os.Exit(0) + } + + // fills Visibles node list + fetchSwayTree() + + if len(Visibles) == 0 { + os.Exit(0) + } + + id := findNextWindow() + + if id > 0 { + switchFocus(id) + } +} + +// find the next window after the one with current focus. if the last +// one has focus, return the first +func findNextWindow() int { + if len(Visibles) == 0 { + return 0 + } + + seenfocused := false + + for _, node := range Visibles { + if node.Focused { + seenfocused = true + continue + } + + if seenfocused { + return node.Id + } + } + + if seenfocused { + return Visibles[0].Id + } + + return 0 +} + +// actually switch focus using a swaymsg command +func switchFocus(id int) { + var cmd *exec.Cmd + arg := fmt.Sprintf("[con_id=%d]", id) + + if Debug { + fmt.Printf("executing: swaymsg %s focus\n", arg) + } + cmd = exec.Command("swaymsg", arg, "focus") + + errbuf := &bytes.Buffer{} + cmd.Stderr = errbuf + + out, err := cmd.Output() + + if err != nil { + log.Fatalf("Failed to execute swaymsg to switch focus: %s", err) + if Debug { + fmt.Println(out) + } + } + + if errbuf.String() != "" { + log.Fatalf("swaymsg error: %s", errbuf.String()) + } + +} + +// execute swaymsg to get its internal tree +func fetchSwayTree() { + var cmd *exec.Cmd + + if Debug { + fmt.Println("executing: swaymsg -t get_tree -r") + } + + cmd = exec.Command("swaymsg", "-t", "get_tree", "-r") + + errbuf := &bytes.Buffer{} + cmd.Stderr = errbuf + + out, err := cmd.Output() + + if err != nil { + log.Fatalf("Failed to execute swaymsg to get json tree: %s", err) + } + + if errbuf.String() != "" { + log.Fatalf("swaymsg error: %s", errbuf.String()) + } + + if err := processJSON(out); err != nil { + log.Fatalf("%s", err) + } +} + +func istype(nd Node, which int) bool { + switch nd.Nodetype { + case "root": + return which == root + case "output": + return which == output + case "workspace": + return which == workspace + case "con": + return which == con + case "floating_con": + return which == floating + } + + return false +} + +// get into the sway tree, determine current workspace and extract all +// its visible windows, store them in the global var Visibles +func processJSON(jsoncode []byte) error { + sway := Node{} + + if err := json.Unmarshal(jsoncode, &sway); err != nil { + return fmt.Errorf("Failed to unmarshal json: %w", err) + } + + if !istype(sway, root) && len(sway.Nodes) == 0 { + return fmt.Errorf("Invalid or empty JSON structure") + } + + for _, node := range sway.Nodes { + if node.Current_workspace != "" { + // this is an output node containing the current workspace + CurrentWorkspace = node.Current_workspace + recurseNodes(node.Nodes) + } + } + + if Debug { + repr.Println(Visibles) + } + + return nil +} + +// iterate recursively over given node list extracting visible windows +func recurseNodes(nodes []Node) { + for _, node := range nodes { + // we handle nodes and floating_nodes identical + node.Nodes = append(node.Nodes, node.FloatingNodes...) + + if istype(node, workspace) { + if node.Name == CurrentWorkspace { + recurseNodes(node.Nodes) + return + } + } + + // the first nodes seen are workspaces, so if we see a con + // node, we are already inside the current workspace + if (istype(node, con) || istype(node, floating)) && (node.Window > 0 || node.X11Window != "") { + Visibles = append(Visibles, node) + } else { + recurseNodes(node.Nodes) + } + } +}