Files
swaycycle/main.go

353 lines
7.7 KiB
Go
Raw Normal View History

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 (
"fmt"
"io"
2025-08-10 22:44:01 +02:00
"log"
"log/slog"
2025-08-10 22:44:01 +02:00
"os"
"runtime/debug"
"sort"
2025-08-10 22:44:01 +02:00
"github.com/lmittmann/tint"
"github.com/mattn/go-isatty"
2025-08-15 12:31:22 +02:00
"github.com/tlinden/i3ipc"
"github.com/tlinden/yadu"
2025-08-10 22:44:01 +02:00
flag "github.com/spf13/pflag"
)
const (
root = iota + 1
output
workspace
con
floating
LevelNotice = slog.Level(2)
2025-08-25 09:53:53 +02:00
VERSION = "v0.3.1"
IPC_HEADER_SIZE = 14
IPC_MAGIC = "i3-ipc"
// message types
IPC_GET_TREE = 4
IPC_RUN_COMMAND = 0
)
var (
2025-08-15 12:31:22 +02:00
Visibles = []*i3ipc.Node{}
CurrentWorkspace = ""
Previous = false
Debug = false
Dumptree = false
2025-08-25 09:02:14 +02:00
Dumpvisibles = 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:
-p, --prev cycle backward
-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)
-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.
`
2025-08-10 22:44:01 +02:00
func main() {
flag.BoolVarP(&Previous, "prev", "p", false, "cycle backward")
2025-08-10 22:44:01 +02:00
flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging")
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)")
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")
2025-08-10 22:44:01 +02:00
flag.Parse()
if Version {
fmt.Printf("This is swaycycle version %s\n", VERSION)
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)
}
// connect to sway unix socket
2025-08-15 12:31:22 +02:00
ipc := i3ipc.NewI3ipc()
err := ipc.Connect()
if err != nil {
2025-08-15 12:31:22 +02:00
log.Fatal(err)
}
2025-08-15 12:31:22 +02:00
defer ipc.Close()
2025-08-15 12:31:22 +02:00
sway, err := ipc.GetTree()
if err != nil {
2025-08-15 12:31:22 +02:00
log.Fatal(err)
}
// traverse the tree and find visible windows
2025-08-15 12:31:22 +02:00
if err := processJSON(sway); err != nil {
log.Fatalf("%s", err)
}
2025-08-10 22:44:01 +02:00
if len(Visibles) == 0 {
os.Exit(0)
}
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
if id > 0 && !Notswitch {
2025-08-15 12:31:22 +02:00
switchFocus(id, ipc)
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 {
return fmt.Errorf("Invalid or empty JSON structure")
}
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)
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
}
// 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
}
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
}
// 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")
if err != nil {
2025-08-15 12:31:22 +02:00
log.Fatalf("failed to send focus command to container %d: %w (%s)",
id, responses[0].Error, err)
}
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 {
//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
}
// 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
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-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)
}
// 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 {
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()) {
return true
}
// it is a tty
return false
}