diff --git a/.github/assets/screenshot.png b/.github/assets/screenshot.png new file mode 100644 index 0000000..3f2e06c Binary files /dev/null and b/.github/assets/screenshot.png differ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..99f12d2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "monthly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..35643cb --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,87 @@ +name: build-release +on: + push: + tags: + - "v*.*.*" + +jobs: + release: + name: Build Release Assets + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23.5 + + - name: Build the executables + run: ./mkrel.sh swaycycle ${{ github.ref_name}} + + - name: List the executables + run: ls -l ./releases + + - name: Upload the binaries + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ github.ref_name }} + file: ./releases/* + file_glob: true + + - name: Build Changelog + id: github_release + uses: mikepenz/release-changelog-builder-action@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + mode: "PR" + configurationJson: | + { + "template": "#{{CHANGELOG}}\n\n**Full Changelog**: #{{RELEASE_DIFF}}", + "pr_template": "- #{{TITLE}} (##{{NUMBER}}) by #{{AUTHOR}}\n#{{BODY}}", + "empty_template": "- no changes", + "categories": [ + { + "title": "## New Features", + "labels": ["add", "feature"] + }, + { + "title": "## Bug Fixes", + "labels": ["fix", "bug", "revert"] + }, + { + "title": "## Documentation Enhancements", + "labels": ["doc"] + }, + { + "title": "## Refactoring Efforts", + "labels": ["refactor"] + }, + { + "title": "## Miscellaneus Changes", + "labels": [] + } + ], + "ignore_labels": [ + "duplicate", "good first issue", "help wanted", "invalid", "question", "wontfix" + ], + "label_extractor": [ + { + "pattern": "(.) (.+)", + "target": "$1" + }, + { + "pattern": "(.) (.+)", + "target": "$1", + "on_property": "title" + } + ] + } + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + body: ${{steps.github_release.outputs.changelog}} diff --git a/README.md b/README.md index ece5016..0d50655 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ floating ones or windows in sub-containers. So it simulates the behavior of other window managers and desktop environments. Just bind the tool to `ALT-tab` and there you go. +![Screenshot](https://github.com/TLINDEN/swaycycle/blob/main/.github/assets/screenshot.png) + ## Installation Checkout the repo and execute `make`. You'll need the go toolkit. Then @@ -18,6 +20,21 @@ Add such a line to your sway config file (e.g. in `$HOME/.config/sway/config`): bindsym $mod+Tab exec ~/bin/swaycycle ``` +## Debugging + +You may call `swaycycle` in a terminal window on a workspace with at +least one another window to test it. Use the option `--debug (-d)` to +get comprehensive debugging output. Add the option `--dump (-D)` to +also get a dump of the sway data tree retrieved by swaycycle. You may +also try `--verbose (-v)` to get a oneliner about the switch. + +It's also possible to debug an instance executed by sway using the +`--logfile (-l)` switch, e.g.: + +```default +bindsym $mod+Tab exec ~/bin/swaycycle -d -l /tmp/cycle.log +``` + ## Getting help Although I'm happy to hear from swaycycle users in private email, that's the @@ -25,7 +42,7 @@ best way for me to forget to do something. In order to report a bug, unexpected behavior, feature requests or to submit a patch, please open an issue on github: -https://github.com/TLINDEN/swaycycle/issues. +https://github.com/tlinden/swaycycle/issues. ## Copyright and license @@ -37,7 +54,7 @@ T.v.Dein ## Project homepage -https://github.com/TLINDEN/swaycycle +https://github.com/tlinden/swaycycle ## Copyright and License diff --git a/go.mod b/go.mod index 1918980..21acef7 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,12 @@ go 1.23 require ( github.com/alecthomas/repr v0.5.1 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/lmittmann/tint v1.1.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/spf13/pflag v1.0.7 // indirect + github.com/tlinden/yadu v0.1.3 // indirect + golang.org/x/sys v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bb9bbd3..90d9289 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,23 @@ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY= +github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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 98741d4..b8593c6 100644 --- a/main.go +++ b/main.go @@ -21,25 +21,20 @@ import ( "bytes" "encoding/json" "fmt" + "io" "log" + "log/slog" "os" "os/exec" + "runtime/debug" - "github.com/alecthomas/repr" + "github.com/lmittmann/tint" + "github.com/mattn/go-isatty" + "github.com/tlinden/yadu" flag "github.com/spf13/pflag" ) -const ( - root = iota + 1 - output - workspace - con - floating - - VERSION = "v0.1.2" -) - type Node struct { Id int `json:"id"` Nodetype string `json:"type"` // output, workspace or container @@ -52,16 +47,55 @@ type Node struct { Current_workspace string `json:"current_workspace"` } -var Visibles = []Node{} -var CurrentWorkspace = "" -var Debug = false -var Version = false -var Notswitch = false +const ( + root = iota + 1 + output + workspace + con + floating + + LevelNotice = slog.Level(2) + + VERSION = "v0.1.2" +) + +var ( + Visibles = []Node{} + CurrentWorkspace = "" + Debug = false + Dumptree = false + Version = false + Verbose = false + Notswitch = false + Showhelp = false + Logfile = "" +) + +const Usage string = `This is swaycycle - cycle focus through all visible windows on a sway workspace. + +Usage: swaycycle [-vVdDn] [-l ] + +Options: + -n, --no-switch do not switch windows + -d, --debug enable debugging + -D, --dump dump the sway tree (needs -d as well) + -l, --logfile string write output to logfile + -v, --verbose enable verbose logging + -V, --version show program version + +Copyleft (L) 2025 Thomas von Dein. +Licensed under the terms of the GNU GPL version 3. +` func main() { flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging") + flag.BoolVarP(&Dumptree, "dump", "D", false, "dump the sway tree (needs -d as well)") + flag.BoolVarP(&Verbose, "verbose", "v", false, "enable verbose logging") flag.BoolVarP(&Notswitch, "no-switch", "n", false, "do not switch windows") - flag.BoolVarP(&Version, "version", "v", false, "show program version") + flag.BoolVarP(&Version, "version", "V", false, "show program version") + flag.BoolVarP(&Showhelp, "help", "h", Showhelp, "show help") + + flag.StringVarP(&Logfile, "logfile", "l", "", "write output to logfile") flag.Parse() if Version { @@ -69,6 +103,23 @@ func main() { os.Exit(0) } + if Showhelp { + fmt.Println(Usage) + os.Exit(0) + } + + // setup logging + if Logfile != "" { + file, err := os.OpenFile(Logfile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + log.Fatalf("Failed to open logfile %s: %s", Logfile, err) + } + defer file.Close() + setupLogging(file) + } else { + setupLogging(os.Stdout) + } + // fills Visibles node list fetchSwayTree() @@ -83,6 +134,48 @@ func main() { } } +func setupLogging(output io.Writer) { + logLevel := &slog.LevelVar{} + + if !Debug { + // default logging + opts := &tint.Options{ + Level: logLevel, + AddSource: false, + NoColor: IsNoTty(), + } + + if Verbose { + logLevel.Set(slog.LevelInfo) + } else { + logLevel.Set(LevelNotice) + } + + handler := tint.NewHandler(output, opts) + logger := slog.New(handler) + + slog.SetDefault(logger) + } else { + // we're using a more verbose logger in debug mode + buildInfo, _ := debug.ReadBuildInfo() + opts := &yadu.Options{ + Level: logLevel, + AddSource: true, + } + + logLevel.Set(slog.LevelDebug) + + handler := yadu.NewHandler(output, opts) + debuglogger := slog.New(handler).With( + slog.Group("program_info", + slog.Int("pid", os.Getpid()), + slog.String("go_version", buildInfo.GoVersion), + ), + ) + slog.SetDefault(debuglogger) + } +} + // find the next window after the one with current focus. if the last // one has focus, return the first func findNextWindow() int { @@ -115,9 +208,8 @@ 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) - } + slog.Debug("executing", "command", "swaymsg "+arg+" focus") + cmd = exec.Command("swaymsg", arg, "focus") errbuf := &bytes.Buffer{} @@ -126,25 +218,22 @@ func switchFocus(id int) { out, err := cmd.Output() if err != nil { + slog.Debug("failed to execute swaymsg", "output", out) 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()) } + slog.Info("switched focus", "con_id", id) } // execute swaymsg to get its internal tree func fetchSwayTree() { var cmd *exec.Cmd - if Debug { - fmt.Println("executing: swaymsg -t get_tree -r") - } + slog.Debug("executing", "command", "swaymsg -t get_tree -r") cmd = exec.Command("swaymsg", "-t", "get_tree", "-r") @@ -196,6 +285,10 @@ func processJSON(jsoncode []byte) error { return fmt.Errorf("Invalid or empty JSON structure") } + if Dumptree { + slog.Debug("processed sway tree", "sway", sway) + } + for _, node := range sway.Nodes { if node.Current_workspace != "" { // this is an output node containing the current workspace @@ -205,9 +298,7 @@ func processJSON(jsoncode []byte) error { } } - if Debug { - repr.Println(Visibles) - } + slog.Debug("processed visible windows", "visibles", Visibles) return nil } @@ -238,3 +329,13 @@ func recurseNodes(nodes []Node) { } } } + +// returns TRUE if stdout is NOT a tty or windows +func IsNoTty() bool { + if !isatty.IsTerminal(os.Stdout.Fd()) { + return true + } + + // it is a tty + return false +} diff --git a/mkrel.sh b/mkrel.sh new file mode 100755 index 0000000..de7113b --- /dev/null +++ b/mkrel.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# 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 . + + +# get list with: go tool dist list +DIST="darwin/amd64 +freebsd/amd64 +linux/amd64 +freebsd/arm64 +linux/arm64" + +tool="$1" +version="$2" + +if test -z "$version"; then + echo "Usage: $0 " + exit 1 +fi + +rm -rf releases +mkdir -p releases + + +for D in $DIST; do + os=${D/\/*/} + arch=${D/*\//} + binfile="releases/${tool}-${os}-${arch}-${version}" + + if test "$os" = "windows"; then + binfile="${binfile}.exe" + fi + + tardir="${tool}-${os}-${arch}-${version}" + tarfile="releases/${tool}-${os}-${arch}-${version}.tar.gz" + set -x + GOOS=${os} GOARCH=${arch} go build -tags osusergo,netgo -ldflags "-extldflags=-static" -o ${binfile} + mkdir -p ${tardir} + cp ${binfile} README.md LICENSE ${tardir}/ + echo 'tool = swaycycle +PREFIX = /usr/local +UID = root +GID = 0 + +install: + 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/' > ${tardir}/Makefile + tar cpzf ${tarfile} ${tardir} + sha256sum ${binfile} | cut -d' ' -f1 > ${binfile}.sha256 + sha256sum ${tarfile} | cut -d' ' -f1 > ${tarfile}.sha256 + rm -rf ${tardir} + set +x +done +