11 Commits

8 changed files with 615 additions and 106 deletions

BIN
.github/assets/screenshot.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "monthly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"

87
.github/workflows/release.yaml vendored Normal file
View File

@@ -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}}

View File

@@ -1,2 +1,99 @@
# swaycycle
Cycle through all visible windows on a sway[fx] workspace
Cycle through all visible windows on a sway[fx] workspace including
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
Download the binary for your architecture from the [release
page](https://github.com/TLINDEN/swaycycle/releases) and copy to to to
some location within your `$PATH`.
To build the tool from source, checkout the repo and execute
`make`. You'll need the go toolkit. Then copy the binary `swaycycle`
to some location within your `$PATH`.
## Configuration
Add such a line to your sway config file (e.g. in `$HOME/.config/sway/config`):
```default
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
```
## How does it work?
`swaycycle` is being executed by sway when the user presses a key
(e.g. `ALT-tab`). It then connects to the running sway instance via
the provided IPC unix domain socket as available in the environment
variable `SWAYSOCK`. Via that connection it sends the `GET_TREE`
command and processes the retrieved JSON response. This JSON tree
contains all information about the running instance such as outputs,
workspaces and containers.
Then it determines which workspace is the current active one and
builds a list of all windows visible on that workspace, whether
floating or not.
Next it determines which window is following the one in the list with
the current active focus. If the active one is at the end of the list,
it starts from the top.
Finally `swaycycle` sends the propper switch focus command via the IPC
connection to sway, e.g.:
`[con_id=14] focus`
## Getting help
Although I'm happy to hear from swaycycle users in private email, that's the
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.
## See also
- [sway-ipc(7)](https://www.mankier.com/7/sway-ipc)
- [swaywm](https://github.com/swaywm/sway/)
- [swayfs](https://github.com/WillPower3309/swayfx)
## Copyright and license
This software is licensed under the GNU GENERAL PUBLIC LICENSE version 3.
## Authors
T.v.Dein <tom AT vondein DOT org>
## Project homepage
https://github.com/tlinden/swaycycle
## Copyright and License
Licensed under the GNU GENERAL PUBLIC LICENSE version 3.
## Author
T.v.Dein <tom AT vondein DOT org>

7
go.mod
View File

@@ -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
)

19
go.sum
View File

@@ -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=

430
main.go
View File

@@ -18,28 +18,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"bytes"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"net"
"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.1"
)
type Node struct {
Id int `json:"id"`
Nodetype string `json:"type"` // output, workspace or container
@@ -52,14 +48,66 @@ type Node struct {
Current_workspace string `json:"current_workspace"`
}
var Visibles = []Node{}
var CurrentWorkspace = ""
var Debug = false
var Version = false
type Response struct {
Success bool `json:"success"`
ParseError bool `json:"parse_error"`
Error string `json:"error"`
}
const (
root = iota + 1
output
workspace
con
floating
LevelNotice = slog.Level(2)
VERSION = "v0.2.0"
IPC_HEADER_SIZE = 14
IPC_MAGIC = "i3-ipc"
// message types
IPC_GET_TREE = 4
IPC_RUN_COMMAND = 0
)
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 [-vdDn] [-l <log>]
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, --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(&Notswitch, "no-switch", "n", false, "do not switch windows")
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 {
@@ -67,20 +115,174 @@ func main() {
os.Exit(0)
}
// fills Visibles node list
fetchSwayTree()
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)
}
// connect to sway unix socket
unixsock, err := setupIPC()
if err != nil {
log.Fatalf("Failed to connect to sway unix socket: %s", err)
}
// retrieve the raw json tree
rawjson, err := getTree(unixsock)
if err != nil {
log.Fatalf("Failed to retrieve raw json tree: %s", err)
}
// traverse the tree and find visible windows
if err := processJSON(rawjson); err != nil {
log.Fatalf("%s", err)
}
if len(Visibles) == 0 {
os.Exit(0)
}
id := findNextWindow()
slog.Debug("findNextWindow", "nextid", id)
if id > 0 {
switchFocus(id)
if id > 0 && !Notswitch {
switchFocus(id, unixsock)
}
}
// connect to unix socket
func setupIPC() (net.Conn, error) {
sockfile := os.Getenv("SWAYSOCK")
if sockfile == "" {
return nil, fmt.Errorf("Environment variable SWAYSOCK does not exist or is empty")
}
conn, err := net.Dial("unix", sockfile)
if err != nil {
return nil, err
}
return conn, nil
}
// send a sway message header
func sendHeaderIPC(sock net.Conn, messageType uint32, len uint32) error {
sendPayload := make([]byte, IPC_HEADER_SIZE)
copy(sendPayload, []byte(IPC_MAGIC))
binary.LittleEndian.PutUint32(sendPayload[6:], len)
binary.LittleEndian.PutUint32(sendPayload[10:], messageType)
_, err := sock.Write(sendPayload)
if err != nil {
return fmt.Errorf("failed to send header to IPC %w", err)
}
return nil
}
// send a payload, header had to be sent before
func sendPayloadIPC(sock net.Conn, payload []byte) error {
_, err := sock.Write(payload)
if err != nil {
return fmt.Errorf("failed to send payload to IPC %w", err)
}
return nil
}
// read a response, reads response header and returns payload only
func readResponseIPC(sock net.Conn) ([]byte, error) {
// read header
buf := make([]byte, IPC_HEADER_SIZE)
_, err := sock.Read(buf)
if err != nil {
return nil, fmt.Errorf("failed to read header from socket: %s", err)
}
slog.Debug("got IPC header", "header", hex.EncodeToString(buf))
if string(buf[:6]) != IPC_MAGIC {
return nil, fmt.Errorf("got invalid IPC response from sway socket")
}
payloadLen := binary.LittleEndian.Uint32(buf[6:10])
if payloadLen == 0 {
return nil, fmt.Errorf("got empty payload IPC response from sway socket")
}
// read payload
payload := make([]byte, payloadLen)
_, err = sock.Read(payload)
if err != nil {
return nil, fmt.Errorf("failed to read payload from socket: %s", err)
}
return payload, nil
}
// get raw JSON tree via sway IPC
func getTree(sock net.Conn) ([]byte, error) {
err := sendHeaderIPC(sock, IPC_GET_TREE, 0)
if err != nil {
return nil, err
}
payload, err := readResponseIPC(sock)
if err != nil {
return nil, err
}
return payload, nil
}
// 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")
}
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
CurrentWorkspace = node.Current_workspace
recurseNodes(node.Nodes)
break
}
}
slog.Debug("processed visible windows", "visibles", Visibles)
return nil
}
// find the next window after the one with current focus. if the last
// one has focus, return the first
func findNextWindow() int {
@@ -109,61 +311,120 @@ func findNextWindow() int {
}
// actually switch focus using a swaymsg command
func switchFocus(id int) {
var cmd *exec.Cmd
arg := fmt.Sprintf("[con_id=%d]", id)
func switchFocus(id int, sock net.Conn) error {
command := fmt.Sprintf("[con_id=%d] focus", id)
if Debug {
fmt.Printf("executing: swaymsg %s focus\n", arg)
slog.Debug("executing", "command", command)
// send switch focus command
err := sendHeaderIPC(sock, IPC_RUN_COMMAND, uint32(len(command)))
if err != nil {
return err
}
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)
return fmt.Errorf("failed to send run_command to IPC %w", err)
}
err = sendPayloadIPC(sock, []byte(command))
if err != nil {
return fmt.Errorf("failed to send switch focus command: %w", err)
}
// check response from sway
payload, err := readResponseIPC(sock)
if err != nil {
return err
}
responses := []Response{}
if err := json.Unmarshal(payload, &responses); err != nil {
return fmt.Errorf("Failed to unmarshal json response: %w", err)
}
if len(responses) == 0 {
return fmt.Errorf("Got invalid IPC zero response")
}
if !responses[0].Success {
slog.Debug("IPC response to switch focus command", "response", responses)
return fmt.Errorf("Failed to switch focus: %s", responses[0].Error)
}
slog.Info("switched focus", "con_id", id)
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
}
// ignore other workspaces
continue
}
// 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)
}
}
if errbuf.String() != "" {
log.Fatalf("swaymsg error: %s", errbuf.String())
}
}
// execute swaymsg to get its internal tree
func fetchSwayTree() {
var cmd *exec.Cmd
// we use line wise logging, unless debugging is enabled
func setupLogging(output io.Writer) {
logLevel := &slog.LevelVar{}
if Debug {
fmt.Println("executing: swaymsg -t get_tree -r")
}
if !Debug {
// default logging
opts := &tint.Options{
Level: logLevel,
AddSource: false,
NoColor: IsNoTty(),
}
cmd = exec.Command("swaymsg", "-t", "get_tree", "-r")
logLevel.Set(slog.LevelInfo)
errbuf := &bytes.Buffer{}
cmd.Stderr = errbuf
handler := tint.NewHandler(output, opts)
logger := slog.New(handler)
out, err := cmd.Output()
slog.SetDefault(logger)
} else {
// we're using a more verbose logger in debug mode
buildInfo, _ := debug.ReadBuildInfo()
opts := &yadu.Options{
Level: logLevel,
AddSource: true,
}
if err != nil {
log.Fatalf("Failed to execute swaymsg to get json tree: %s", err)
}
logLevel.Set(slog.LevelDebug)
if errbuf.String() != "" {
log.Fatalf("swaymsg error: %s", errbuf.String())
}
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),
),
)
if err := processJSON(out); err != nil {
log.Fatalf("%s", err)
slog.SetDefault(debuglogger)
}
}
// little helper to distinguish sway tree node types
func istype(nd Node, which int) bool {
switch nd.Nodetype {
case "root":
@@ -181,53 +442,12 @@ func istype(nd Node, which int) bool {
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)
// returns TRUE if stdout is NOT a tty or windows
func IsNoTty() bool {
if !isatty.IsTerminal(os.Stdout.Fd()) {
return true
}
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)
}
}
// it is a tty
return false
}

69
mkrel.sh Executable file
View File

@@ -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 <http://www.gnu.org/licenses/>.
# 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 <tool name> <release version>"
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