~rkintzi/statusbar

d54a0a00a10baafcce07ecfd8527d772e70e3992 — RadosÅ‚aw Kintzi 9 months ago 4cf4fc8
Add Window widgt
4 files changed, 259 insertions(+), 0 deletions(-)

M go.mod
M go.sum
A widgets/window.go
A widgets/window_test.go
M go.mod => go.mod +1 -0
@@ 8,6 8,7 @@ require (
	github.com/google/go-cmp v0.6.0
	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
	github.com/shirou/gopsutil/v3 v3.23.10
	gobytes.dev/swayipc v0.0.0-20231109133834-244c14b18a41
	gopkg.in/yaml.v3 v3.0.1
)


M go.sum => go.sum +2 -0
@@ 39,6 39,8 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
gobytes.dev/swayipc v0.0.0-20231109133834-244c14b18a41 h1:DqSMIV/EHVurViBC0FEsPcpB/SlNJMnLqdzCihFrG84=
gobytes.dev/swayipc v0.0.0-20231109133834-244c14b18a41/go.mod h1:Ie3kt/A1HXdk0zFj5BzaYFyKuGcCxbpjgdrnTFY6kaE=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

A widgets/window.go => widgets/window.go +226 -0
@@ 0,0 1,226 @@
package widgets

import (
	"context"
	"strings"
	"sync"
	"unicode"

	"gobytes.dev/statusbar"
	"gobytes.dev/statusbar/internal/registry"
	"gobytes.dev/swayipc"
)

type Window struct {
	TitlePrefix   string   `yaml:"titlePrefix"`
	MaxTitleLen   int      `yaml:"maxTitleLength"`
	Ellipsis      string   `yaml:"ellipsis"`
	ExcludeAppIDs []string `yaml:"excludeAppIDs"`
	AllowSymbols  bool     `yaml:"allowSymbols"`

	excludeAppIDs map[string]bool
	events        chan *swayipc.WindowEvent
	conn          *swayipc.Conn
	update        statusbar.UpdateFunc
	baseWidget
}

func (w *Window) WidgetName() string    { return "Window" }
func (w *Window) New() statusbar.Widget { return new(Window) }

func (w *Window) Run(ctx context.Context, update statusbar.UpdateFunc, done func()) {
	defer done()
	w.init()
	w.update = update
	w.events = make(chan *swayipc.WindowEvent)
	conn, err := swayipc.Connect(ctx)
	if err != nil {
		w.errorf(err.Error())
		return
	}
	w.conn = conn
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	var wg sync.WaitGroup
	wg.Add(1)
	go w.watchEvents(ctx, wg.Done)
	conn.RegisterEventHandler(w)
	_, err = conn.Subscribe(swayipc.WindowEventType, swayipc.WorkspaceEventType)
	if err != nil {
		w.errorf(err.Error())
		cancel()
	}
	tree, err := conn.GetTree()
	if err != nil {
		w.errorf(err.Error())
		cancel()
	}
	cont := w.findFocused(&tree)
	if cont != nil {
		w.events <- &swayipc.WindowEvent{
			Change:    "not-an-event",
			Container: *cont,
		}
	}
	wg.Wait()
	<-ctx.Done()
}

func (w *Window) findFocused(node *swayipc.Node) *swayipc.Node {
	if (node.Type == "con" || node.Type == "floating_con") && node.Focused {
		return node
	}
	for _, child := range node.Nodes {
		focused := w.findFocused(&child)
		if focused != nil {
			return focused
		}
	}
	return nil
}

func (w *Window) HandleEvent(ev swayipc.Event) {
	switch ev := ev.(type) {
	case *swayipc.WindowEvent:
		w.events <- ev
	case *swayipc.WorkspaceEvent:
		if ev.Change != "init" && ev.Current.Focused {
			w.events <- &swayipc.WindowEvent{
				Change: "close",
			}
		}
	}
}

func (w *Window) watchEvents(ctx context.Context, done func()) {
	defer done()
	var hadEvent bool
	for {
		select {
		case <-ctx.Done():
			return
		case ev := <-w.events:
			switch ev.Change {
			case "not-an-event":
				if hadEvent {
					continue
				}
				w.printTitle(w.parseNode(&ev.Container))
				continue
			case "focus":
				w.printTitle(w.parseNode(&ev.Container))
			case "title":
				if ev.Container.Focused {
					w.printTitle(w.parseNode(&ev.Container))
				}
			case "close":
				w.printTitle("")
			}
			hadEvent = true
		}
	}
}

func removeUnclosed(name string) string {
	count := 0
	for i := len(name) - 1; i >= 0; i-- {
		switch name[i] {
		case '}', ']', ')':
			count += 1
		case '{', '[', '(':
			count -= 1
		}
		if count < 0 {
			name = name[:i]
			count = 0
		}
	}
	name = strings.TrimRightFunc(name, func(r rune) bool {
		return (unicode.IsSpace(r) || unicode.IsPunct(r))
	})
	return name
}

func (w *Window) removeAppId(name, appId string) string {
	name = strings.TrimSpace(name)
	trimmed := strings.TrimPrefix(name, appId)
	if trimmed != name {
		trimmed = strings.TrimLeftFunc(trimmed, func(r rune) bool {
			return (unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r)) &&
				(r != '[' && r != '(' && r != '{') &&
				(r != '~' && r != '/')
		})
	}
	name = trimmed
	trimmed = strings.TrimSuffix(name, appId)
	if trimmed != name {
		trimmed = strings.TrimRightFunc(trimmed, func(r rune) bool {
			return (unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r)) &&
				(r != ']' && r != ')' && r != '}') &&
				(r != '~' && r != '/')
		})
	}
	name = trimmed
	return name
}

func (w *Window) parseNode(c *swayipc.Node) string {
	var (
		name   = c.Name
		appId  = c.AppId
		suffix string
	)
	if appId == "" {
		appId = c.WindowProperties.Class
	}
	name = w.removeAppId(name, appId)
	if name == "" {
		suffix = appId
	} else {
		if !w.excludeAppIDs[appId] {
			if !strings.Contains(strings.ToLower(name), strings.ToLower(appId)) {
				suffix = " - " + appId
			}
		}
	}
	if w.MaxTitleLen > 0 && len(name)+len(suffix)+len(w.TitlePrefix) > w.MaxTitleLen {
		maxLen := w.MaxTitleLen - len(suffix) - len(w.TitlePrefix)
		name = name[0 : maxLen-len(w.Ellipsis)]
		name = removeUnclosed(name)
		name += w.Ellipsis
	}
	return w.TitlePrefix + name + suffix
}

func (w *Window) printTitle(title string) {
	w.update([]statusbar.Block{
		{
			FullText: title,
			Align:    "left",
			Name:     "window",
			Instance: "0",
			Markup:   "pango",
		},
	})
}

func (w *Window) init() {
	w.prefix = "Windows"
	w.excludeAppIDs = make(map[string]bool)
	for _, id := range w.ExcludeAppIDs {
		w.excludeAppIDs[id] = true
	}
	if w.MaxTitleLen == 0 {
		w.MaxTitleLen = 80
	}
	if w.Ellipsis == "" {
		w.Ellipsis = "\u2026"
	}
	if w.TitlePrefix == "" {
		w.TitlePrefix = "\uf2a3 "
	}
}

func init() {
	registry.RegisterWidget(new(Window))
}

A widgets/window_test.go => widgets/window_test.go +30 -0
@@ 0,0 1,30 @@
package widgets

import (
	"fmt"
	"testing"
)

func TestWindow(t *testing.T) {
	tcs := []struct {
		name string
		exp  string
	}{
		{name: "title - appid"},
		{name: "appid - title"},
		{name: "appid -- title -- appid"},
		{name: "appid -- [title] -- appid", exp: "[title]"},
	}
	for i, tc := range tcs {
		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
			w := Window{}
			trimmed := w.removeAppId(tc.name, "appid")
			exp := tc.exp
			if exp == "" {
				exp = "title"
			}
			o := opts{}
			o.assertEqual(t, trimmed, exp)
		})
	}
}