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)
+ }
+ }
+}