7 Commits

Author SHA1 Message Date
T. von Dein
9703fd4ad0 migrate to codeberg (#1) 2025-11-10 20:14:32 +01:00
dependabot[bot]
85a1e6530c Bump actions/checkout from 4 to 5 (#5) 2025-09-19 07:56:48 +02:00
feba0f3580 bump version 2025-08-25 09:53:53 +02:00
2fbb3ebc59 add doc about prev flag 2025-08-25 09:53:40 +02:00
T.v.Dein
8e48b42bad enhance window switch debugging (#4) 2025-08-25 09:02:14 +02:00
kkvark
7a5657b778 add --prev option and sort floating_nodes (#3)
* add --prev option
* sort floating_nodes to avoid skipping floating windows
2025-08-25 08:59:21 +02:00
0bdef90f97 swich to i3ipc library 2025-08-15 12:32:33 +02:00
9 changed files with 237 additions and 266 deletions

View File

@@ -1,87 +0,0 @@
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}}

65
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,65 @@
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
gitea_urls:
api: https://codeberg.org/api/v1
download: https://codeberg.org
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- freebsd
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}_{{ .Tag }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [zip]
- goos: linux
formats: [tar.gz,binary]
files:
- src: "*.md"
strip_parent: true
- src: Makefile.dist
dst: Makefile
wrap_in_directory: true
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
groups:
- title: Improved
regexp: '^.*?(feat|add|new)(\([[:word:]]+\))??!?:.+$'
order: 0
- title: Fixed
regexp: '^.*?(bug|fix)(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Changed
order: 999
release:
header: "# Release Notes"
footer: >-
---
Full Changelog: [{{ .PreviousTag }}...{{ .Tag }}](https://codeberg.org/scip/swaycycle/compare/{{ .PreviousTag }}...{{ .Tag }})

36
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,36 @@
matrix:
platform:
- linux/amd64
goversion:
- 1.24
labels:
platform: ${platform}
steps:
build:
when:
event: [push]
image: golang:${goversion}
commands:
- go get
- go build
linter:
when:
event: [push]
image: golang:${goversion}
commands:
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.5.0
- golangci-lint --version
- golangci-lint run ./...
depends_on: [build]
test:
when:
event: [push]
image: golang:${goversion}
commands:
- go get
- go test -v -cover
depends_on: [build,linter]

15
.woodpecker/release.yaml Normal file
View File

@@ -0,0 +1,15 @@
# build release
labels:
platform: linux/amd64
steps:
goreleaser:
image: goreleaser/goreleaser
when:
event: [tag]
environment:
GITEA_TOKEN:
from_secret: DEPLOY_TOKEN
commands:
- goreleaser release --clean --verbose

18
Makefile.dist Normal file
View File

@@ -0,0 +1,18 @@
# -*-make-*-
.PHONY: install all
tool = rpn
PREFIX = /usr/local
UID = root
GID = 0
all:
@echo "Type 'sudo make install' to install the tool."
@echo "To change prefix, type 'sudo make install PREFIX=/opt'"
install:
install -d -o $(UID) -g $(GID) $(PREFIX)/bin
install -d -o $(UID) -g $(GID) $(PREFIX)/share/doc
install -o $(UID) -g $(GID) -m 555 $(tool) $(PREFIX)/sbin/
install -o $(UID) -g $(GID) -m 444 *.md $(PREFIX)/share/doc/

View File

@@ -1,3 +1,7 @@
[![status-badge](https://ci.codeberg.org/api/badges/15562/status.svg)](https://ci.codeberg.org/repos/15562)
[![License](https://img.shields.io/badge/license-GPL-blue.svg)](https://codeberg.org/scip/swaycycle/raw/branch/main/LICENSE)
[![Go Report Card](https://goreportcard.com/badge/codeberg.org/scip/epuppy)](https://goreportcard.com/report/codeberg.org/scip/swaycycle)
# swaycycle # swaycycle
Cycle through all visible windows on a sway[fx] workspace including Cycle through all visible windows on a sway[fx] workspace including
@@ -9,7 +13,7 @@ the tool to `ALT-tab` and there you go.
## Installation ## Installation
Download the binary for your architecture from the [release Download the binary for your architecture from the [release
page](https://github.com/TLINDEN/swaycycle/releases) and copy to page](https://codeberg.org/scip/swaycycle/releases) and copy to
some location within your `$PATH`. some location within your `$PATH`.
To build the tool from source, checkout the repo and execute To build the tool from source, checkout the repo and execute
@@ -24,6 +28,13 @@ Add such a line to your sway config file (e.g. in `$HOME/.config/sway/config`):
bindsym $mod+Tab exec ~/bin/swaycycle bindsym $mod+Tab exec ~/bin/swaycycle
``` ```
You may also add a second key binding to do the reverse, which is
sometimes very useful:
```default
bindsym $mod+Shift+Tab exec ~/bin/swaycycle --prev
```
## Debugging ## Debugging
You may call `swaycycle` in a terminal window on a workspace with at You may call `swaycycle` in a terminal window on a workspace with at
@@ -69,7 +80,7 @@ best way for me to forget to do something.
In order to report a bug, unexpected behavior, feature requests or to In order to report a bug, unexpected behavior, feature requests or to
submit a patch, please open an issue on github: submit a patch, please open an issue on github:
https://github.com/tlinden/swaycycle/issues. https://codeberg.org/scip/swaycycle/issues.
## See also ## See also
@@ -87,7 +98,7 @@ T.v.Dein <tom AT vondein DOT org>
## Project homepage ## Project homepage
https://github.com/tlinden/swaycycle https://codeberg.org/scip/swaycycle
## Copyright and License ## Copyright and License

1
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/pflag v1.0.7 // indirect
github.com/tlinden/i3ipc v0.0.0-20250815101608-4f7e27528be3 // indirect
github.com/tlinden/yadu v0.1.3 // indirect github.com/tlinden/yadu v0.1.3 // indirect
golang.org/x/sys v0.14.0 // indirect golang.org/x/sys v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

2
go.sum
View File

@@ -11,6 +11,8 @@ 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/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 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/tlinden/i3ipc v0.0.0-20250815101608-4f7e27528be3 h1:/kIZO4852sAVemXtqnsBid0r4Q1h87jDwHa8f7v1h5I=
github.com/tlinden/i3ipc v0.0.0-20250815101608-4f7e27528be3/go.mod h1:mc0toDHmgqgX6FpE69U5yMPnHuLTdekHRslSLDp8xSE=
github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY= github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY=
github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA= 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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

262
main.go
View File

@@ -18,41 +18,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main package main
import ( import (
"encoding/binary" "errors"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"log/slog" "log/slog"
"net"
"os" "os"
"runtime/debug" "runtime/debug"
"sort"
"github.com/lmittmann/tint" "github.com/lmittmann/tint"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"github.com/tlinden/i3ipc"
"github.com/tlinden/yadu" "github.com/tlinden/yadu"
flag "github.com/spf13/pflag" 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"`
}
type Response struct {
Success bool `json:"success"`
ParseError bool `json:"parse_error"`
Error string `json:"error"`
}
const ( const (
root = iota + 1 root = iota + 1
output output
@@ -62,7 +44,7 @@ const (
LevelNotice = slog.Level(2) LevelNotice = slog.Level(2)
VERSION = "v0.2.0" VERSION = "v0.3.1"
IPC_HEADER_SIZE = 14 IPC_HEADER_SIZE = 14
IPC_MAGIC = "i3-ipc" IPC_MAGIC = "i3-ipc"
@@ -73,10 +55,12 @@ const (
) )
var ( var (
Visibles = []Node{} Visibles = []*i3ipc.Node{}
CurrentWorkspace = "" CurrentWorkspace = ""
Previous = false
Debug = false Debug = false
Dumptree = false Dumptree = false
Dumpvisibles = false
Version = false Version = false
Verbose = false Verbose = false
Notswitch = false Notswitch = false
@@ -89,19 +73,22 @@ const Usage string = `This is swaycycle - cycle focus through all visible window
Usage: swaycycle [-vdDn] [-l <log>] Usage: swaycycle [-vdDn] [-l <log>]
Options: Options:
-p, --prev cycle backward
-n, --no-switch do not switch windows -n, --no-switch do not switch windows
-d, --debug enable debugging -d, --debug enable debugging
-D, --dump dump the sway tree (needs -d as well) -D, --dump dump the sway tree (needs -d as well)
--dump-visibles dump a list of visible windows on current workspace (needs -d)
-l, --logfile string write output to logfile -l, --logfile string write output to logfile
-v, --version show program version -v, --version show program version
Copyleft (L) 2025 Thomas von Dein. Copyleft (L) 2025 Thomas von Dein.
Licensed under the terms of the GNU GPL version 3. Licensed under the terms of the GNU GPL version 3.`
`
func main() { func main() {
flag.BoolVarP(&Previous, "prev", "p", false, "cycle backward")
flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging") flag.BoolVarP(&Debug, "debug", "d", false, "enable debugging")
flag.BoolVarP(&Dumptree, "dump", "D", false, "dump the sway tree (needs -d as well)") flag.BoolVarP(&Dumptree, "dump", "D", false, "dump the sway tree (needs -d as well)")
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(&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.BoolVarP(&Showhelp, "help", "h", Showhelp, "show help")
@@ -125,26 +112,32 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to open logfile %s: %s", Logfile, err) log.Fatalf("Failed to open logfile %s: %s", Logfile, err)
} }
defer file.Close() defer func() {
if err := file.Close(); err != nil {
log.Fatalf("failed to close log file: %s", err)
}
}()
setupLogging(file) setupLogging(file)
} else { } else {
setupLogging(os.Stdout) setupLogging(os.Stdout)
} }
// connect to sway unix socket // connect to sway unix socket
unixsock, err := setupIPC() ipc := i3ipc.NewI3ipc()
if err != nil {
log.Fatalf("Failed to connect to sway unix socket: %s", err)
}
// retrieve the raw json tree err := ipc.Connect()
rawjson, err := getTree(unixsock)
if err != nil { if err != nil {
log.Fatalf("Failed to retrieve raw json tree: %s", err) log.Fatal(err)
}
defer ipc.Close()
sway, err := ipc.GetTree()
if err != nil {
log.Fatal(err)
} }
// traverse the tree and find visible windows // traverse the tree and find visible windows
if err := processJSON(rawjson); err != nil { if err := processJSON(sway); err != nil {
log.Fatalf("%s", err) log.Fatalf("%s", err)
} }
@@ -152,116 +145,27 @@ func main() {
os.Exit(0) os.Exit(0)
} }
id := findNextWindow() id := 0
slog.Debug("findNextWindow", "nextid", id) if Previous {
id = findPrevWindow()
slog.Debug("findPrevWindow", "nextid", id)
} else {
id = findNextWindow()
slog.Debug("findNextWindow", "nextid", id)
}
if id > 0 && !Notswitch { if id > 0 && !Notswitch {
switchFocus(id, unixsock) if err := switchFocus(id, ipc); err != nil {
log.Fatalf("%s", err)
}
} }
} }
// 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 // get into the sway tree, determine current workspace and extract all
// its visible windows, store them in the global var Visibles // its visible windows, store them in the global var Visibles
func processJSON(jsoncode []byte) error { func processJSON(sway *i3ipc.Node) 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 { if !istype(sway, root) && len(sway.Nodes) == 0 {
return fmt.Errorf("Invalid or empty JSON structure") return errors.New("invalid or empty JSON structure")
} }
if Dumptree { if Dumptree {
@@ -277,7 +181,9 @@ func processJSON(jsoncode []byte) error {
} }
} }
slog.Debug("processed visible windows", "visibles", Visibles) if Dumpvisibles {
dumpVisibles()
}
return nil return nil
} }
@@ -309,42 +215,30 @@ func findNextWindow() int {
return 0 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 // actually switch focus using a swaymsg command
func switchFocus(id int, sock net.Conn) error { func switchFocus(id int, ipc *i3ipc.I3ipc) error {
command := fmt.Sprintf("[con_id=%d] focus", id) responses, err := ipc.RunContainerCommand(id, "focus")
slog.Debug("sending ipc", "command", command)
// send switch focus command
err := sendHeaderIPC(sock, IPC_RUN_COMMAND, uint32(len(command)))
if err != nil { if err != nil {
return fmt.Errorf("failed to send run_command to IPC %w", err) log.Fatalf("failed to send focus command to container %d: %s (%s)",
} id, responses[0].Error, 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) slog.Info("switched focus", "con_id", id)
@@ -353,13 +247,19 @@ func switchFocus(id int, sock net.Conn) error {
} }
// iterate recursively over given node list extracting visible windows // iterate recursively over given node list extracting visible windows
func recurseNodes(nodes []Node) { func recurseNodes(nodes []*i3ipc.Node) {
for _, node := range nodes { for _, node := range nodes {
// we handle nodes and floating_nodes identical
node.Nodes = append(node.Nodes, node.FloatingNodes...)
if istype(node, workspace) { if istype(node, workspace) {
if node.Name == CurrentWorkspace { 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...)
recurseNodes(node.Nodes) recurseNodes(node.Nodes)
return return
} }
@@ -379,6 +279,16 @@ func recurseNodes(nodes []Node) {
} }
} }
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 // we use line wise logging, unless debugging is enabled
func setupLogging(output io.Writer) { func setupLogging(output io.Writer) {
logLevel := &slog.LevelVar{} logLevel := &slog.LevelVar{}
@@ -420,8 +330,8 @@ func setupLogging(output io.Writer) {
} }
// little helper to distinguish sway tree node types // little helper to distinguish sway tree node types
func istype(nd Node, which int) bool { func istype(nd *i3ipc.Node, which int) bool {
switch nd.Nodetype { switch nd.Type {
case "root": case "root":
return which == root return which == root
case "output": case "output":