2025-08-10 22:44:01 +02:00
|
|
|
/*
|
|
|
|
|
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/>.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
2025-11-10 20:14:32 +01:00
|
|
|
"errors"
|
2025-08-10 22:44:01 +02:00
|
|
|
"fmt"
|
2025-08-11 08:47:41 +02:00
|
|
|
"io"
|
2025-08-10 22:44:01 +02:00
|
|
|
"log"
|
2025-08-11 08:47:41 +02:00
|
|
|
"log/slog"
|
2025-08-10 22:44:01 +02:00
|
|
|
"os"
|
2025-08-11 08:47:41 +02:00
|
|
|
"runtime/debug"
|
2025-08-25 13:59:21 +07:00
|
|
|
"sort"
|
2025-08-10 22:44:01 +02:00
|
|
|
|
2025-08-11 08:47:41 +02:00
|
|
|
"github.com/lmittmann/tint"
|
|
|
|
|
"github.com/mattn/go-isatty"
|
2025-08-15 12:31:22 +02:00
|
|
|
"github.com/tlinden/i3ipc"
|
2025-08-11 08:47:41 +02:00
|
|
|
"github.com/tlinden/yadu"
|
2025-08-10 22:44:01 +02:00
|
|
|
|
|
|
|
|
flag "github.com/spf13/pflag"
|
|
|
|
|
)
|
|
|
|
|
|
2025-08-11 08:47:41 +02:00
|
|
|
const (
|
|
|
|
|
root = iota + 1
|
|
|
|
|
output
|
|
|
|
|
workspace
|
|
|
|
|
con
|
|
|
|
|
floating
|
|
|
|
|
|
|
|
|
|
LevelNotice = slog.Level(2)
|
|
|
|
|
|
2025-08-25 09:53:53 +02:00
|
|
|
VERSION = "v0.3.1"
|
2025-08-12 18:16:32 +02:00
|
|
|
|
|
|
|
|
IPC_HEADER_SIZE = 14
|
|
|
|
|
IPC_MAGIC = "i3-ipc"
|
|
|
|
|
|
|
|
|
|
// message types
|
|
|
|
|
IPC_GET_TREE = 4
|
|
|
|
|
IPC_RUN_COMMAND = 0
|
2025-08-11 08:47:41 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
2025-08-15 12:31:22 +02:00
|
|
|
Visibles = []*i3ipc.Node{}
|
2025-08-11 08:47:41 +02:00
|
|
|
CurrentWorkspace = ""
|
2025-08-25 13:59:21 +07:00
|
|
|
Previous = false
|
2025-08-11 08:47:41 +02:00
|
|
|
Debug = false
|
|
|
|
|
Dumptree = false
|
2025-08-25 09:02:14 +02:00
|
|
|
Dumpvisibles = false
|
2025-08-11 08:47:41 +02:00
|
|
|
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.
|
|
|
|
|
|
2025-08-12 18:16:32 +02:00
|
|
|
Usage: swaycycle [-vdDn] [-l <log>]
|
2025-08-11 08:47:41 +02:00
|
|
|
|
|
|
|
|
Options:
|
2025-08-25 13:59:21 +07:00
|
|
|
-p, --prev cycle backward
|
2025-08-11 08:47:41 +02:00
|
|
|
-n, --no-switch do not switch windows
|
|
|
|
|
-d, --debug enable debugging
|
|
|
|
|
-D, --dump dump the sway tree (needs -d as well)
|
2025-08-25 09:02:14 +02:00
|
|
|
--dump-visibles dump a list of visible windows on current workspace (needs -d)
|
2025-08-11 08:47:41 +02:00
|
|
|
-l, --logfile string write output to logfile
|
2025-08-12 18:16:32 +02:00
|
|
|
-v, --version show program version
|
2025-08-11 08:47:41 +02:00
|
|
|
|
|
|
|
|
Copyleft (L) 2025 Thomas von Dein.
|
2025-11-10 20:14:32 +01:00
|
|
|
Licensed under the terms of the GNU GPL version 3.`
|
2025-08-10 22:44:01 +02:00
|
|
|
|
|
|
|
|
func main() {
|
2025-08-25 13:59:21 +07:00
|
|
|
flag.BoolVarP(&Previous, "prev", "p", false, "cycle backward")
|
2025-08-10 22:44:01 +02:00
|
|
|
flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging")
|
2025-08-11 08:47:41 +02:00
|
|
|
flag.BoolVarP(&Dumptree, "dump", "D", false, "dump the sway tree (needs -d as well)")
|
2025-08-25 09:02:14 +02:00
|
|
|
flag.BoolVarP(&Dumpvisibles, "dump-visibles", "", false, "dump a list of visible windows on current workspace (needs -d)")
|
2025-08-10 23:31:18 +02:00
|
|
|
flag.BoolVarP(&Notswitch, "no-switch", "n", false, "do not switch windows")
|
2025-08-12 18:16:32 +02:00
|
|
|
flag.BoolVarP(&Version, "version", "v", false, "show program version")
|
2025-08-11 08:47:41 +02:00
|
|
|
flag.BoolVarP(&Showhelp, "help", "h", Showhelp, "show help")
|
|
|
|
|
|
|
|
|
|
flag.StringVarP(&Logfile, "logfile", "l", "", "write output to logfile")
|
2025-08-10 22:44:01 +02:00
|
|
|
flag.Parse()
|
|
|
|
|
|
|
|
|
|
if Version {
|
|
|
|
|
fmt.Printf("This is swaycycle version %s\n", VERSION)
|
|
|
|
|
os.Exit(0)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-11 08:47:41 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2025-11-10 20:14:32 +01:00
|
|
|
defer func() {
|
|
|
|
|
if err := file.Close(); err != nil {
|
|
|
|
|
log.Fatalf("failed to close log file: %s", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
2025-08-11 08:47:41 +02:00
|
|
|
setupLogging(file)
|
|
|
|
|
} else {
|
|
|
|
|
setupLogging(os.Stdout)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-12 18:16:32 +02:00
|
|
|
// connect to sway unix socket
|
2025-08-15 12:31:22 +02:00
|
|
|
ipc := i3ipc.NewI3ipc()
|
|
|
|
|
|
|
|
|
|
err := ipc.Connect()
|
2025-08-12 18:16:32 +02:00
|
|
|
if err != nil {
|
2025-08-15 12:31:22 +02:00
|
|
|
log.Fatal(err)
|
2025-08-12 18:16:32 +02:00
|
|
|
}
|
2025-08-15 12:31:22 +02:00
|
|
|
defer ipc.Close()
|
2025-08-12 18:16:32 +02:00
|
|
|
|
2025-08-15 12:31:22 +02:00
|
|
|
sway, err := ipc.GetTree()
|
2025-08-12 18:16:32 +02:00
|
|
|
if err != nil {
|
2025-08-15 12:31:22 +02:00
|
|
|
log.Fatal(err)
|
2025-08-12 18:16:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// traverse the tree and find visible windows
|
2025-08-15 12:31:22 +02:00
|
|
|
if err := processJSON(sway); err != nil {
|
2025-08-12 18:16:32 +02:00
|
|
|
log.Fatalf("%s", err)
|
|
|
|
|
}
|
2025-08-10 22:44:01 +02:00
|
|
|
|
|
|
|
|
if len(Visibles) == 0 {
|
|
|
|
|
os.Exit(0)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-25 13:59:21 +07:00
|
|
|
id := 0
|
|
|
|
|
if Previous {
|
|
|
|
|
id = findPrevWindow()
|
|
|
|
|
slog.Debug("findPrevWindow", "nextid", id)
|
|
|
|
|
} else {
|
|
|
|
|
id = findNextWindow()
|
|
|
|
|
slog.Debug("findNextWindow", "nextid", id)
|
|
|
|
|
}
|
2025-08-10 22:44:01 +02:00
|
|
|
|
2025-08-10 23:31:18 +02:00
|
|
|
if id > 0 && !Notswitch {
|
2025-11-10 20:14:32 +01:00
|
|
|
if err := switchFocus(id, ipc); err != nil {
|
|
|
|
|
log.Fatalf("%s", err)
|
|
|
|
|
}
|
2025-08-10 22:44:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// get into the sway tree, determine current workspace and extract all
|
|
|
|
|
// its visible windows, store them in the global var Visibles
|
2025-08-15 12:31:22 +02:00
|
|
|
func processJSON(sway *i3ipc.Node) error {
|
2025-08-10 22:44:01 +02:00
|
|
|
if !istype(sway, root) && len(sway.Nodes) == 0 {
|
2025-11-10 20:14:32 +01:00
|
|
|
return errors.New("invalid or empty JSON structure")
|
2025-08-10 22:44:01 +02:00
|
|
|
}
|
|
|
|
|
|
2025-08-11 08:47:41 +02:00
|
|
|
if Dumptree {
|
|
|
|
|
slog.Debug("processed sway tree", "sway", sway)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 22:44:01 +02:00
|
|
|
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)
|
2025-08-10 23:31:18 +02:00
|
|
|
break
|
2025-08-10 22:44:01 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-25 09:02:14 +02:00
|
|
|
if Dumpvisibles {
|
|
|
|
|
dumpVisibles()
|
|
|
|
|
}
|
2025-08-10 22:44:01 +02:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-12 18:16:32 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-25 13:59:21 +07:00
|
|
|
func findPrevWindow() int {
|
|
|
|
|
vislen := len(Visibles)
|
|
|
|
|
if vislen == 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
prevnode := Visibles[vislen-1].Id
|
|
|
|
|
|
|
|
|
|
for _, node := range Visibles {
|
|
|
|
|
if node.Focused {
|
|
|
|
|
return prevnode
|
|
|
|
|
}
|
|
|
|
|
prevnode = node.Id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-12 18:16:32 +02:00
|
|
|
// actually switch focus using a swaymsg command
|
2025-08-15 12:31:22 +02:00
|
|
|
func switchFocus(id int, ipc *i3ipc.I3ipc) error {
|
|
|
|
|
responses, err := ipc.RunContainerCommand(id, "focus")
|
2025-08-12 18:16:32 +02:00
|
|
|
if err != nil {
|
2025-11-10 20:14:32 +01:00
|
|
|
log.Fatalf("failed to send focus command to container %d: %s (%s)",
|
2025-08-15 12:31:22 +02:00
|
|
|
id, responses[0].Error, err)
|
2025-08-12 18:16:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
slog.Info("switched focus", "con_id", id)
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 22:44:01 +02:00
|
|
|
// iterate recursively over given node list extracting visible windows
|
2025-08-15 12:31:22 +02:00
|
|
|
func recurseNodes(nodes []*i3ipc.Node) {
|
2025-08-10 22:44:01 +02:00
|
|
|
for _, node := range nodes {
|
|
|
|
|
|
|
|
|
|
if istype(node, workspace) {
|
|
|
|
|
if node.Name == CurrentWorkspace {
|
2025-08-25 13:59:21 +07:00
|
|
|
//floating_nodes need to be sorted because
|
|
|
|
|
//order changes each time they are focused.
|
|
|
|
|
FloatVis := node.FloatingNodes
|
|
|
|
|
sort.Slice(FloatVis, func(i, j int) bool {
|
|
|
|
|
return FloatVis[i].Id < FloatVis[j].Id
|
|
|
|
|
})
|
|
|
|
|
//now we can handle nodes and floating_nodes identical
|
|
|
|
|
node.Nodes = append(node.Nodes, FloatVis...)
|
2025-08-10 22:44:01 +02:00
|
|
|
recurseNodes(node.Nodes)
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-08-10 23:31:18 +02:00
|
|
|
|
|
|
|
|
// ignore other workspaces
|
|
|
|
|
continue
|
2025-08-10 22:44:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// the first nodes seen are workspaces, so if we see a con
|
|
|
|
|
// node, we are already inside the current workspace
|
2025-08-10 23:31:18 +02:00
|
|
|
if (istype(node, con) || istype(node, floating)) &&
|
|
|
|
|
(node.Window > 0 || node.X11Window != "") {
|
2025-08-10 22:44:01 +02:00
|
|
|
Visibles = append(Visibles, node)
|
|
|
|
|
} else {
|
|
|
|
|
recurseNodes(node.Nodes)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-11 08:47:41 +02:00
|
|
|
|
2025-08-25 09:02:14 +02:00
|
|
|
func dumpVisibles() {
|
|
|
|
|
windows := make([]string, len(Visibles))
|
|
|
|
|
|
|
|
|
|
for idx, node := range Visibles {
|
|
|
|
|
windows[idx] = fmt.Sprintf("id: %02d, focus: %5t, name: %s", node.Id, node.Focused, node.Name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
slog.Debug("visible windows on current workspace", "visibles", windows)
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-12 18:16:32 +02:00
|
|
|
// 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
|
2025-08-15 12:31:22 +02:00
|
|
|
func istype(nd *i3ipc.Node, which int) bool {
|
|
|
|
|
switch nd.Type {
|
2025-08-12 18:16:32 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-11 08:47:41 +02:00
|
|
|
// 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
|
|
|
|
|
}
|