13 Commits

Author SHA1 Message Date
83ab028c66 use swayipc 0.3.0 2025-08-16 21:02:39 +02:00
ab8b3a7816 use renamed ipc module 2025-08-16 19:59:32 +02:00
14cc48feb4 swich to i3ipc library 2025-08-15 12:31:22 +02:00
3402df69b4 Merge branch 'main' of github.com:TLINDEN/swaycycle 2025-08-14 20:51:39 +02:00
b21d8ebed9 duplicate error check 2025-08-14 20:51:14 +02:00
Thomas von Dein
a481cc7172 get rid screenshot, doesnt help anyone 2025-08-12 21:39:17 +02:00
Thomas von Dein
cb8421e6f6 cleanup doc 2025-08-12 21:38:24 +02:00
051b68c266 little doc tweaks 2025-08-12 18:34:26 +02:00
bde1301e2c added links 2025-08-12 18:20:18 +02:00
977c374197 get rid of os/exec and talk with sway directly via IPC 2025-08-12 18:20:18 +02:00
4741481527 updated docs 2025-08-12 18:20:18 +02:00
dacdc5c214 add doc about inner workings 2025-08-11 11:38:56 +02:00
1bc1b2a963 added release hint 2025-08-11 09:41:17 +02:00
5 changed files with 160 additions and 167 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -5,12 +5,16 @@ 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
copy the binary `swaycycle` to some location within your `$PATH`.
Download the binary for your architecture from the [release
page](https://github.com/TLINDEN/swaycycle/releases) and copy 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
@@ -35,6 +39,29 @@ It's also possible to debug an instance executed by sway using the
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
@@ -44,6 +71,12 @@ 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/)
- [swayfx](https://github.com/WillPower3309/swayfx)
## Copyright and license
This software is licensed under the GNU GENERAL PUBLIC LICENSE version 3.

13
go.mod
View File

@@ -3,13 +3,16 @@ module swaycycle
go 1.23
require (
github.com/alecthomas/repr v0.5.1 // indirect
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20
github.com/spf13/pflag v1.0.7
github.com/tlinden/swayipc v0.3.0
github.com/tlinden/yadu v0.1.3
)
require (
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
)

8
go.sum
View File

@@ -1,5 +1,3 @@
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=
@@ -11,13 +9,17 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
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/swayipc v0.0.0-20250816175030-177eecd4757f h1:SP/fEurr6crxQI+j85L61rMppsHmOlJwxVzXnCvYJ40=
github.com/tlinden/swayipc v0.0.0-20250816175030-177eecd4757f/go.mod h1:JwlMIC7eBwV8soCt2UDqlAyBudobLo07ZvepIA0irY8=
github.com/tlinden/swayipc v0.3.0 h1:hGNWeEZUZIHfeP+MxpAKsUzPf3YSJ0FYX2XEu/yqNXA=
github.com/tlinden/swayipc v0.3.0/go.mod h1:JwlMIC7eBwV8soCt2UDqlAyBudobLo07ZvepIA0irY8=
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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=

267
main.go
View File

@@ -18,35 +18,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"os"
"os/exec"
"runtime/debug"
"github.com/lmittmann/tint"
"github.com/mattn/go-isatty"
"github.com/tlinden/swayipc"
"github.com/tlinden/yadu"
flag "github.com/spf13/pflag"
)
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"`
}
const (
root = iota + 1
output
@@ -56,11 +42,11 @@ const (
LevelNotice = slog.Level(2)
VERSION = "v0.1.3"
VERSION = "v0.4.0"
)
var (
Visibles = []Node{}
Visibles = []*swayipc.Node{}
CurrentWorkspace = ""
Debug = false
Dumptree = false
@@ -73,15 +59,14 @@ var (
const Usage string = `This is swaycycle - cycle focus through all visible windows on a sway workspace.
Usage: swaycycle [-vVdDn] [-l <log>]
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, --verbose enable verbose logging
-V, --version show program version
-v, --version show program version
Copyleft (L) 2025 Thomas von Dein.
Licensed under the terms of the GNU GPL version 3.
@@ -90,9 +75,8 @@ 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")
@@ -120,60 +104,60 @@ func main() {
setupLogging(os.Stdout)
}
// fills Visibles node list
fetchSwayTree()
// connect to sway unix socket
ipc := swayipc.NewSwayIPC()
err := ipc.Connect()
if err != nil {
log.Fatal(err)
}
defer ipc.Close()
sway, err := ipc.GetTree()
if err != nil {
log.Fatal(err)
}
// traverse the tree and find visible windows
if err := processJSON(sway); err != nil {
log.Fatalf("%s", err)
}
if len(Visibles) == 0 {
os.Exit(0)
}
id := findNextWindow()
slog.Debug("findNextWindow", "nextid", id)
if id > 0 && !Notswitch {
switchFocus(id)
switchFocus(id, ipc)
}
}
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)
// get into the sway tree, determine current workspace and extract all
// its visible windows, store them in the global var Visibles
func processJSON(sway *swayipc.Node) error {
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
@@ -204,107 +188,20 @@ 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)
slog.Debug("executing", "command", "swaymsg "+arg+" focus")
cmd = exec.Command("swaymsg", arg, "focus")
errbuf := &bytes.Buffer{}
cmd.Stderr = errbuf
out, err := cmd.Output()
func switchFocus(id int, ipc *swayipc.SwayIPC) error {
responses, err := ipc.RunContainerCommand(id, "focus")
if err != nil {
slog.Debug("failed to execute swaymsg", "output", out)
log.Fatalf("Failed to execute swaymsg to switch focus: %s", err)
}
if errbuf.String() != "" {
log.Fatalf("swaymsg error: %s", errbuf.String())
log.Fatalf("failed to send focus command to container %d: %w (%s)",
id, responses[0].Error, err)
}
slog.Info("switched focus", "con_id", id)
}
// execute swaymsg to get its internal tree
func fetchSwayTree() {
var cmd *exec.Cmd
slog.Debug("executing", "command", "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")
}
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
}
// iterate recursively over given node list extracting visible windows
func recurseNodes(nodes []Node) {
func recurseNodes(nodes []*swayipc.Node) {
for _, node := range nodes {
// we handle nodes and floating_nodes identical
node.Nodes = append(node.Nodes, node.FloatingNodes...)
@@ -330,6 +227,64 @@ func recurseNodes(nodes []Node) {
}
}
// we use line wise logging, unless debugging is enabled
func setupLogging(output io.Writer) {
logLevel := &slog.LevelVar{}
if !Debug {
// default logging
opts := &tint.Options{
Level: logLevel,
AddSource: false,
NoColor: IsNoTty(),
}
logLevel.Set(slog.LevelInfo)
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)
}
}
// little helper to distinguish sway tree node types
func istype(nd *swayipc.Node, which int) bool {
switch nd.Type {
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
}
// returns TRUE if stdout is NOT a tty or windows
func IsNoTty() bool {
if !isatty.IsTerminal(os.Stdout.Fd()) {