├── docs
├── .vitepress
│ ├── config.mts
│ └── theme
│ │ ├── index.ts
│ │ └── styles.css
├── pawbar.svg
├── package.json
├── docs
│ ├── modules.md
│ ├── getting-started.md
│ └── configuration.md
├── index.md
└── vitepress.config.mts
├── .gitmodules
├── uninstall.sh
├── install.sh
├── pkg
├── monitor
│ ├── monitor.h
│ ├── monitor.go
│ └── monitor.c
└── dbusmenukitty
│ ├── menu
│ ├── utils.go
│ ├── manager.go
│ └── types.go
│ ├── cmd
│ └── dbusmenu
│ │ └── main.go
│ └── tui
│ ├── state.go
│ ├── msghandler.go
│ ├── renderer.go
│ └── tui.go
├── internal
├── config
│ ├── template.go
│ ├── config.go
│ ├── registry.go
│ └── types.go
├── modules
│ ├── static.go
│ ├── runtime.go
│ ├── powerProfiles
│ │ ├── config.go
│ │ └── power.go
│ ├── custom
│ │ ├── config.go
│ │ └── custom.go
│ ├── locale
│ │ ├── config.go
│ │ └── locale.go
│ ├── title
│ │ ├── backend_hypr.go
│ │ ├── backend_i3.go
│ │ ├── config.go
│ │ └── title.go
│ ├── idleInhibitor
│ │ ├── config.go
│ │ └── idleInhibitor.go
│ ├── backlight
│ │ └── config.go
│ ├── clock
│ │ ├── config.go
│ │ └── clock.go
│ ├── module.go
│ ├── all
│ │ └── all.go
│ ├── mpris
│ │ └── config.go
│ ├── ws
│ │ ├── backend_i3.go
│ │ ├── config.go
│ │ ├── ws.go
│ │ └── backend_hypr.go
│ ├── volume
│ │ ├── config.go
│ │ └── volume.go
│ ├── bluetooth
│ │ └── config.go
│ ├── wifi
│ │ └── config.go
│ ├── ram
│ │ ├── config.go
│ │ └── ram.go
│ ├── disk
│ │ ├── config.go
│ │ └── disk.go
│ ├── battery
│ │ ├── config.go
│ │ └── battery.go
│ ├── cpu
│ │ ├── config.go
│ │ └── cpu.go
│ └── tray
│ │ └── tray.go
├── services
│ ├── service.go
│ ├── pulse
│ │ └── pulse.go
│ └── hypr
│ │ └── hypr.go
├── lookup
│ ├── icons
│ │ └── icons.go
│ └── units
│ │ └── byte.go
├── menus
│ ├── power
│ │ ├── client.go
│ │ └── tui
│ │ │ └── tui.go
│ └── calendar
│ │ ├── client.go
│ │ └── tui
│ │ └── tui.go
├── utils
│ └── utils.go
└── tui
│ ├── helpers.go
│ └── renderer.go
├── flake.nix
├── LICENSE
├── go.mod
├── flake.lock
├── cmd
└── pawbar
│ ├── signals.go
│ └── suspend.go
├── README.md
└── .gitignore
/docs/.vitepress/config.mts:
--------------------------------------------------------------------------------
1 | export { default } from "../vitepress.config.mts"
2 |
--------------------------------------------------------------------------------
/docs/pawbar.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "vaxis"]
2 | path = vaxis
3 | url = https://git.sr.ht/~codelif/vaxis
4 | branch = main
5 | update = rebase
6 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "scripts": {
4 | "dev": "vitepress dev .",
5 | "build": "vitepress build .",
6 | "preview": "vitepress preview ."
7 | },
8 | "devDependencies": {
9 | "vitepress": "^2.0.0-alpha.12",
10 | "vitepress-plugin-group-icons": "^1.6.3"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) 2025 Nekorg All rights reserved.
3 | * Use of this source code is governed by a BSD-style
4 | * license that can be found in the LICENSE file.
5 | *
6 | * SPDX-License-Identifier: bsd
7 | */
8 |
9 | import Theme from 'vitepress/theme'
10 | import './styles.css'
11 |
12 | export default Theme
13 |
--------------------------------------------------------------------------------
/uninstall.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/bash
2 | # Copyright (c) 2025 Nekorg All rights reserved.
3 | # Use of this source code is governed by a BSD-style
4 | # license that can be found in the LICENSE file.
5 | #
6 | # SPDX-License-Identifier: bsd
7 |
8 |
9 | sudo rm -f /usr/local/bin/pawbar
10 |
11 | rm -rf "$HOME/.config/pawbar/"
12 |
13 | echo "Uninstallation complete."
14 |
15 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/bash
2 | # Copyright (c) 2025 Nekorg All rights reserved.
3 | # Use of this source code is governed by a BSD-style
4 | # license that can be found in the LICENSE file.
5 | #
6 | # SPDX-License-Identifier: bsd
7 |
8 |
9 | sudo cp pawbar /usr/local/bin/
10 | mkdir -p "$HOME/.config/pawbar/"
11 | [ ! -f "$HOME/.config/pawbar/pawbar.yaml" ] && echo -e "right:\n - battery\n - sep\n - clock" > "$HOME/.config/pawbar/pawbar.yaml"
12 |
--------------------------------------------------------------------------------
/docs/docs/modules.md:
--------------------------------------------------------------------------------
1 | ---
2 | prev:
3 | text: 'Configuration'
4 | link: '/docs/configuration'
5 | next: false
6 | ---
7 |
8 | # Modules
9 |
10 | There are a total of **16** modules:
11 |
12 | ## `backlight`
13 | ## `battery`
14 | ## `bluetooth`
15 | ## `clock`
16 | ## `cpu`
17 | ## `custom`
18 | ## `disk`
19 | ## `idleInhibitor`
20 | ## `locale`
21 | ## `mpris`
22 | ## `ram`
23 | ## `title`
24 | ## `tray`
25 | ## `volume`
26 | ## `wifi`
27 | ## `ws`
28 |
--------------------------------------------------------------------------------
/pkg/monitor/monitor.h:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025 Nekorg All rights reserved.
3 | * Use of this source code is governed by a BSD-style
4 | * license that can be found in the LICENSE file.
5 | *
6 | * SPDX-License-Identifier: bsd
7 | */
8 |
9 | #include
10 | #include
11 |
12 | typedef struct {
13 | int width;
14 | int height;
15 | int refreshRate;
16 |
17 | float scaleX;
18 | float scaleY;
19 | } monitor;
20 |
21 | monitor *get_monitor_info();
22 | void free_monitor(monitor *m);
23 |
--------------------------------------------------------------------------------
/pkg/dbusmenukitty/menu/utils.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package menu
8 |
9 | func MaxLengthLabel(labels []Item) int {
10 | if len(labels) == 0 {
11 | return 0
12 | }
13 |
14 | maxLen := 0
15 | for _, l := range labels {
16 | curLen := len(l.Label.Display)
17 |
18 | if l.IconData != nil || l.IconName != "" {
19 | curLen += 2
20 | }
21 | if curLen > maxLen {
22 | maxLen = curLen
23 | }
24 | }
25 |
26 | return maxLen
27 | }
28 |
--------------------------------------------------------------------------------
/internal/config/template.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package config
8 |
9 | import (
10 | "fmt"
11 | "text/template"
12 | )
13 |
14 | func Funcs() template.FuncMap {
15 | return template.FuncMap{
16 | "round": func(p int, v interface{}) string {
17 | switch x := v.(type) {
18 | case float32, float64:
19 | return fmt.Sprintf("%.*f", p, x)
20 | default:
21 | return fmt.Sprintf("%v", v)
22 | }
23 | },
24 | }
25 | }
26 |
27 | func NewTemplate(src string) (*template.Template, error) {
28 | return template.New("format").Funcs(Funcs()).Parse(src)
29 | }
30 |
--------------------------------------------------------------------------------
/pkg/dbusmenukitty/cmd/dbusmenu/main.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package main
8 |
9 | import (
10 | "flag"
11 |
12 | "github.com/nekorg/pawbar/pkg/dbusmenukitty"
13 | )
14 |
15 | func main() {
16 | var x, y int
17 |
18 | // flag.StringVar(&service, "service", "", "DBus service name exposing a dbusmenu (e.g. org.freedesktop.network-manager-applet)")
19 | flag.IntVar(&x, "x", 0, "X coordinate for panel (pixels)")
20 | flag.IntVar(&y, "y", 0, "Y coordinate for panel (pixels)")
21 | flag.Parse()
22 |
23 | // LaunchMenu will not return until the panel closes (or an error occurs).
24 | dbusmenukitty.LaunchMenu(x, y)
25 | }
26 |
--------------------------------------------------------------------------------
/pkg/monitor/monitor.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package monitor
8 |
9 | // #cgo LDFLAGS: -lglfw
10 | // #include "monitor.h"
11 | import "C"
12 | import "fmt"
13 |
14 | type Monitor struct {
15 | Width, Height, RefreshRate int
16 | Scale Scale
17 | }
18 |
19 | type Scale struct {
20 | X, Y float64
21 | }
22 |
23 | func GetMonitorInfo() (Monitor, error) {
24 | m := C.get_monitor_info()
25 |
26 | if m == nil {
27 | return Monitor{}, fmt.Errorf("failed get monitor info")
28 | }
29 |
30 | mon := Monitor{
31 | Width: int(m.width),
32 | Height: int(m.height),
33 | RefreshRate: int(m.refreshRate),
34 | Scale: Scale{
35 | X: float64(m.scaleX),
36 | Y: float64(m.scaleY),
37 | },
38 | }
39 |
40 | C.free_monitor(m)
41 |
42 | return mon, nil
43 | }
44 |
--------------------------------------------------------------------------------
/internal/modules/static.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package modules
8 |
9 | type StaticModule struct {
10 | name string
11 | cells []EventCell
12 | deps []string
13 | }
14 |
15 | func NewStaticModule(name string, cells []EventCell, deps []string) *StaticModule {
16 | return &StaticModule{
17 | name: name,
18 | cells: cells,
19 | deps: deps,
20 | }
21 | }
22 |
23 | func (sm *StaticModule) Render() []EventCell {
24 | return sm.cells
25 | }
26 |
27 | func (sm *StaticModule) Run() (<-chan bool, chan<- Event, error) {
28 | return nil, nil, nil
29 | }
30 |
31 | func (sm *StaticModule) Channels() (<-chan bool, chan<- Event) {
32 | return nil, nil
33 | }
34 |
35 | func (sm *StaticModule) Name() string {
36 | return sm.name
37 | }
38 |
39 | func (sm *StaticModule) Dependencies() []string {
40 | return sm.deps
41 | }
42 |
--------------------------------------------------------------------------------
/internal/services/service.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package services
8 |
9 | import "github.com/nekorg/pawbar/internal/utils"
10 |
11 | type Service interface {
12 | Start() error
13 | Stop() error
14 | Name() string
15 | }
16 |
17 | var ServiceRegistry = make(map[string]Service)
18 |
19 | func Ensure(name string, factory func() Service) Service {
20 | if s, ok := ServiceRegistry[name]; ok {
21 | return s
22 | }
23 |
24 | s := factory()
25 | StartService(name, s)
26 | return s
27 | }
28 |
29 | func StartService(name string, s Service) {
30 | prevService, ok := ServiceRegistry[name]
31 | if ok {
32 | utils.Logger.Printf("services: stopping '%s'\n", s.Name())
33 | prevService.Stop()
34 | }
35 |
36 | utils.Logger.Printf("services: starting '%s'\n", s.Name())
37 | ServiceRegistry[name] = s
38 | s.Start()
39 | }
40 |
--------------------------------------------------------------------------------
/pkg/monitor/monitor.c:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025 Nekorg All rights reserved.
3 | * Use of this source code is governed by a BSD-style
4 | * license that can be found in the LICENSE file.
5 | *
6 | * SPDX-License-Identifier: bsd
7 | */
8 |
9 | #include "monitor.h"
10 |
11 | monitor *get_monitor_info() {
12 | if (!glfwInit()) {
13 | return NULL;
14 | }
15 |
16 | GLFWmonitor *primaryMonitor = glfwGetPrimaryMonitor();
17 | if (!primaryMonitor) {
18 | glfwTerminate();
19 | return NULL;
20 | }
21 |
22 | const GLFWvidmode *mode = glfwGetVideoMode(primaryMonitor);
23 | if (!mode) {
24 | glfwTerminate();
25 | return NULL;
26 | }
27 |
28 | float x, y;
29 | glfwGetMonitorContentScale(primaryMonitor, &x, &y);
30 |
31 | monitor *m = (monitor *)malloc(sizeof(monitor));
32 | m->height = mode->height;
33 | m->width = mode->width;
34 | m->refreshRate = mode->refreshRate;
35 | m->scaleX = x;
36 | m->scaleY = y;
37 |
38 | glfwTerminate();
39 | return m;
40 | }
41 |
42 | void free_monitor(monitor *m) { free(m); }
43 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "A kitten-panel based desktop panel for your desktop";
3 |
4 | inputs = {
5 | self.submodules = true;
6 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
7 | flake-utils.url = "github:numtide/flake-utils";
8 | };
9 |
10 | outputs =
11 | {
12 | nixpkgs,
13 | flake-utils,
14 | ...
15 | }:
16 | flake-utils.lib.eachDefaultSystem (
17 | system:
18 | let
19 | pkgs = import nixpkgs {
20 | inherit system;
21 | };
22 | in
23 | {
24 | packages.default = pkgs.buildGoModule (finalAttrs: {
25 | pname = "pawbar";
26 | version = "0-unstable-2025-08-31";
27 | src = ./.;
28 | subPackages = [ "cmd/pawbar" ];
29 | vendorHash = "sha256-5ysy7DGLE99svDPUw1vS05CT7HRcSP1ov27rTqm6a8Y=";
30 | buildInputs = with pkgs; [
31 | udev
32 | librsvg
33 | cairo
34 | ];
35 | nativeBuildInputs = with pkgs; [ pkg-config ];
36 | });
37 | }
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/internal/modules/runtime.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package modules
8 |
9 | import "github.com/nekorg/pawbar/internal/utils"
10 |
11 | func Init(left, middle, right []Module) (
12 | chan Module, // modev
13 | []Module, // left
14 | []Module, // middle
15 | []Module, // right
16 | ) {
17 | modev := make(chan Module)
18 | return modev, startModules(left, modev), startModules(middle, modev), startModules(right, modev)
19 | }
20 |
21 | func startModules(mods []Module, modev chan<- Module) []Module {
22 | var okMods []Module
23 |
24 | for _, m := range mods {
25 | rec, _, err := m.Run()
26 | if err != nil {
27 | utils.Logger.Printf("error starting module '%s': %v\n", m.Name(), err)
28 | continue
29 | }
30 |
31 | go func(m Module, rec <-chan bool) {
32 | for range rec {
33 | modev <- m
34 | }
35 | }(m, rec)
36 | okMods = append(okMods, m)
37 | }
38 | return okMods
39 | }
40 |
--------------------------------------------------------------------------------
/internal/modules/powerProfiles/config.go:
--------------------------------------------------------------------------------
1 | package powerprofiles
2 |
3 | import (
4 | "github.com/nekorg/pawbar/internal/config"
5 | "github.com/nekorg/pawbar/internal/modules"
6 | )
7 |
8 | func init() {
9 | config.RegisterModule("powerprofiles", defaultOptions, func(o Options) (modules.Module, error) { return &powerProfileModule{opts: o}, nil })
10 | }
11 |
12 | type Options struct {
13 | Fg config.Color `yaml:"fg"`
14 | Bg config.Color `yaml:"bg"`
15 | Cursor config.Cursor `yaml:"cursor"`
16 | Format config.Format `yaml:"format"`
17 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
18 | }
19 |
20 | type MouseOptions struct {
21 | Fg *config.Color `yaml:"fg"`
22 | Bg *config.Color `yaml:"bg"`
23 | Cursor *config.Cursor `yaml:"cursor"`
24 | Format *config.Format `yaml:"format"`
25 | }
26 |
27 | func defaultOptions() Options {
28 | pps, _ := config.NewTemplate("")
29 | return Options{
30 | Format: config.Format{Template: pps},
31 | OnClick: config.MouseActions[MouseOptions]{},
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | # https://vitepress.dev/reference/default-theme-home-page
3 | layout: home
4 |
5 | hero:
6 | name: pawbar
7 | text: Kat vibes for your desktop
8 | tagline: kitten-panel based desktop status bar
9 | actions:
10 | - theme: brand
11 | text: Getting Started
12 | link: /docs/getting-started
13 | - theme: alt
14 | text: Configure
15 | link: /docs/configuration
16 | image:
17 | src: /pawbar.svg
18 | alt: pawbar
19 |
20 | features:
21 | - title: Wait, it's all kitty? Always has been!
22 | details: The whole bar is a floating kitty window! Along with other floating kitty windows!
23 | icon: 📟
24 | - title: All your essential modules and more!
25 | details: "Built-in SNI tray with menus (yes its still all kitty, no gtk anywhere), battery, audio, backlight, Bluetooth, CPU/RAM/Disk, etc. Along with support for many Window Managers."
26 | icon: "🧰"
27 | - title: "Clean, templated YAML config"
28 | details: "Go-template formatting, rich color parsing (hex/rgb/names), cursor shapes, and easy defaults/overrides with extensible icons"
29 | icon: "🎨"
30 | ---
31 |
32 |
--------------------------------------------------------------------------------
/internal/lookup/icons/icons.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package icons
8 |
9 | import (
10 | "fmt"
11 | "regexp"
12 |
13 | "github.com/nekorg/pawbar/internal/utils"
14 | )
15 |
16 | var table = map[string]string{
17 | "disk": "",
18 | "compass": "",
19 | }
20 |
21 | func Register(name, glyph string) {
22 | table[name] = glyph
23 | }
24 |
25 | func Lookup(name string) (string, error) {
26 | g, ok := table[name]
27 | if !ok {
28 | return "", fmt.Errorf("unknown icon: %q", name)
29 | }
30 | return g, nil
31 | }
32 |
33 | var re = regexp.MustCompile(`@[@A-Za-z0-9_]+`)
34 |
35 | func Resolve(s string) string {
36 | return re.ReplaceAllStringFunc(s, func(m string) string {
37 | if m == "@@" {
38 | return "@"
39 | }
40 | if g, ok := table[m[1:]]; ok {
41 | return g
42 | }
43 | return m
44 | })
45 | }
46 |
47 | // linearly chooses an icon from a sorted list based on percent
48 | func Choose(icons []rune, percent int) rune {
49 | return icons[utils.Clamp((len(icons)-1)*percent/100, 0, len(icons)-1)]
50 | }
51 |
--------------------------------------------------------------------------------
/internal/modules/custom/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package custom
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/config"
11 | "github.com/nekorg/pawbar/internal/modules"
12 | )
13 |
14 | func init() {
15 | config.RegisterModule("custom", defaultOptions, func(o Options) (modules.Module, error) { return &CustomModule{opts: o}, nil })
16 | }
17 |
18 | type Options struct {
19 | Fg config.Color `yaml:"fg"`
20 | Bg config.Color `yaml:"bg"`
21 | Cursor config.Cursor `yaml:"cursor"`
22 | Tick config.Duration `yaml:"tick"`
23 | Format config.Format `yaml:"format"`
24 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
25 | }
26 |
27 | type MouseOptions struct {
28 | Fg *config.Color `yaml:"fg"`
29 | Bg *config.Color `yaml:"bg"`
30 | Tick *config.Duration `yaml:"tick"`
31 | Cursor *config.Cursor `yaml:"cursor"`
32 | Format *config.Format `yaml:"format"`
33 | }
34 |
35 | func defaultOptions() Options {
36 | f, _ := config.NewTemplate("")
37 | return Options{
38 | Format: config.Format{Template: f},
39 | OnClick: config.MouseActions[MouseOptions]{},
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package config
8 |
9 | import (
10 | "os"
11 |
12 | "github.com/nekorg/pawbar/internal/modules"
13 | "github.com/nekorg/pawbar/internal/utils"
14 | "gopkg.in/yaml.v3"
15 | )
16 |
17 | func InstantiateModules(cfg *BarConfig) (left, middle, right []modules.Module) {
18 | left = instantiate(cfg.Left)
19 | middle = instantiate(cfg.Middle)
20 | right = instantiate(cfg.Right)
21 |
22 | return left, middle, right
23 | }
24 |
25 | func Parse(path string) (*BarConfig, error) {
26 | b, err := os.ReadFile(path)
27 | if err != nil {
28 | return nil, err
29 | }
30 |
31 | var cfg BarConfig
32 | if err = yaml.Unmarshal(b, &cfg); err != nil {
33 | return nil, err
34 | }
35 | cfg.Bar.FillDefaults()
36 |
37 | return &cfg, err
38 | }
39 |
40 | func instantiate(specs []ModuleSpec) []modules.Module {
41 | var out []modules.Module
42 | for _, s := range specs {
43 | f, ok := factories[s.Name]
44 | if !ok {
45 | utils.Logger.Printf("unknown module '%q'\n", s.Name)
46 | continue
47 | }
48 |
49 | m, err := f(s.Params)
50 | if err != nil {
51 | utils.Logger.Printf("config error: %v", err)
52 | continue
53 | }
54 |
55 | out = append(out, m)
56 | }
57 |
58 | return out
59 | }
60 |
--------------------------------------------------------------------------------
/internal/modules/locale/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package locale
8 |
9 | import (
10 | "time"
11 |
12 | "github.com/nekorg/pawbar/internal/config"
13 | "github.com/nekorg/pawbar/internal/modules"
14 | )
15 |
16 | func init() {
17 | config.RegisterModule("locale", defaultOptions, func(o Options) (modules.Module, error) { return &LocaleModule{opts: o}, nil })
18 | }
19 |
20 | type Options struct {
21 | Fg config.Color `yaml:"fg"`
22 | Bg config.Color `yaml:"bg"`
23 | Cursor config.Cursor `yaml:"cursor"`
24 | Format config.Format `yaml:"format"`
25 | Tick config.Duration `yaml:"tick"`
26 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
27 | }
28 |
29 | type MouseOptions struct {
30 | Fg *config.Color `yaml:"fg"`
31 | Bg *config.Color `yaml:"bg"`
32 | Cursor *config.Cursor `yaml:"cursor"`
33 | Tick *config.Duration `yaml:"tick"`
34 | Format *config.Format `yaml:"format"`
35 | }
36 |
37 | func defaultOptions() Options {
38 | f, _ := config.NewTemplate("{{.Locale}}")
39 | return Options{
40 | Format: config.Format{Template: f},
41 | Tick: config.Duration(7 * time.Second),
42 | OnClick: config.MouseActions[MouseOptions]{},
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/internal/modules/title/backend_hypr.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package title
8 |
9 | import (
10 | "strings"
11 |
12 | "github.com/nekorg/pawbar/internal/services/hypr"
13 | )
14 |
15 | type hyprBackend struct {
16 | svc *hypr.Service
17 | ev chan hypr.HyprEvent
18 | class string
19 | title string
20 | sig chan struct{}
21 | }
22 |
23 | func newHyprBackend(s *hypr.Service) backend {
24 | b := &hyprBackend{
25 | svc: s,
26 | ev: make(chan hypr.HyprEvent),
27 | sig: make(chan struct{}, 1),
28 | }
29 |
30 | activews := hypr.GetActiveWorkspace()
31 | clients := hypr.GetClients()
32 |
33 | b.class = ""
34 | for _, c := range clients {
35 | if c.Address == activews.Lastwindow {
36 | b.class = c.Class
37 | }
38 | }
39 |
40 | b.title = hypr.GetActiveWorkspace().Lastwindowtitle
41 | b.svc.RegisterChannel("activewindow", b.ev)
42 | go b.loop()
43 | return b
44 | }
45 |
46 | func (b *hyprBackend) loop() {
47 | for e := range b.ev {
48 | b.class, b.title, _ = strings.Cut(e.Data, ",")
49 | b.signal()
50 | }
51 | }
52 |
53 | func (b *hyprBackend) signal() {
54 | select {
55 | case b.sig <- struct{}{}:
56 | default:
57 | }
58 | }
59 |
60 | func (b *hyprBackend) Window() Window {
61 | return Window{Title: b.title, Class: b.class}
62 | }
63 | func (b *hyprBackend) Events() <-chan struct{} { return b.sig }
64 |
--------------------------------------------------------------------------------
/internal/menus/power/client.go:
--------------------------------------------------------------------------------
1 | package power
2 |
3 | import (
4 | "github.com/nekorg/katnip"
5 | "github.com/nekorg/pawbar/internal/menus/power/tui"
6 | "github.com/nekorg/pawbar/internal/utils"
7 | )
8 |
9 | func LaunchMenu(x, y int) {
10 | kn := CreatePanel(x, y, 16, 3)
11 | kn.Wait()
12 | }
13 |
14 | func init() {
15 | katnip.RegisterFunc("power", tui.Panel)
16 | }
17 |
18 | func CreatePanel(x, y, w, h int) *katnip.Panel {
19 | conf := katnip.Config{
20 | Position: katnip.Vector{X: x, Y: y},
21 | Size: katnip.Vector{X: w, Y: h},
22 | Edge: katnip.EdgeNone,
23 | Layer: katnip.LayerTop,
24 | // FocusPolicy: katnip.FocusNotAllowed,
25 | FocusPolicy: katnip.FocusExclusive,
26 | ConfigFile: "NONE",
27 | KittyOverrides: []string{
28 | "font_size=12",
29 | "cursor_trail=0",
30 | "cursor_shape=beam",
31 | "cursor=#000000",
32 | "paste_actions=replace-dangerous-control-codes",
33 | "map kitty_mod+equal no_op",
34 | "map kitty_mod+plus no_op",
35 | "map kitty_mod+kp_add no_op",
36 | "map cmd+plus no_op",
37 | "map cmd+equal no_op",
38 | "map shift+cmd+equal no_op",
39 | "map kitty_mod+minus no_op",
40 | "map kitty_mod+kp_subtract no_op",
41 | "map cmd+minus no_op",
42 | "map shift+cmd+minus no_op",
43 | "map kitty_mod+backspace no_op",
44 | "map cmd+0 no_op",
45 | },
46 | }
47 |
48 | kn := katnip.NewPanel("power", conf)
49 | utils.Logger.Printf(kn.Cmd.String())
50 | kn.Start()
51 |
52 | return kn
53 | }
54 |
--------------------------------------------------------------------------------
/internal/menus/calendar/client.go:
--------------------------------------------------------------------------------
1 | package calendar
2 |
3 | import (
4 | "github.com/nekorg/katnip"
5 | "github.com/nekorg/pawbar/internal/menus/calendar/tui"
6 | "github.com/nekorg/pawbar/internal/utils"
7 | )
8 |
9 | func LaunchMenu(x, y int) {
10 | kn := CreatePanel(x, y, 21, 8)
11 | kn.Wait()
12 | }
13 |
14 | func init() {
15 | katnip.RegisterFunc("calendar", tui.Panel)
16 | }
17 |
18 | func CreatePanel(x, y, w, h int) *katnip.Panel {
19 | conf := katnip.Config{
20 | Position: katnip.Vector{X: x, Y: y},
21 | Size: katnip.Vector{X: w, Y: h},
22 | Edge: katnip.EdgeNone,
23 | Layer: katnip.LayerTop,
24 | // FocusPolicy: katnip.FocusNotAllowed,
25 | FocusPolicy: katnip.FocusExclusive,
26 | ConfigFile: "NONE",
27 | KittyOverrides: []string{
28 | "font_size=12",
29 | "cursor_trail=0",
30 | "cursor_shape=beam",
31 | "cursor=#000000",
32 | "paste_actions=replace-dangerous-control-codes",
33 | "map kitty_mod+equal no_op",
34 | "map kitty_mod+plus no_op",
35 | "map kitty_mod+kp_add no_op",
36 | "map cmd+plus no_op",
37 | "map cmd+equal no_op",
38 | "map shift+cmd+equal no_op",
39 | "map kitty_mod+minus no_op",
40 | "map kitty_mod+kp_subtract no_op",
41 | "map cmd+minus no_op",
42 | "map shift+cmd+minus no_op",
43 | "map kitty_mod+backspace no_op",
44 | "map cmd+0 no_op",
45 | },
46 | }
47 |
48 | kn := katnip.NewPanel("calendar", conf)
49 | utils.Logger.Printf(kn.Cmd.String())
50 | kn.Start()
51 |
52 | return kn
53 | }
54 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2025, Nekorg
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/docs/docs/getting-started.md:
--------------------------------------------------------------------------------
1 | # What is `pawbar`?
2 |
3 | `pawbar` is a customizable status bar for GNU/Linux systems. Currently it supports wayland compositors but compatibility may be added for X.org and macOS systems.
4 |
5 | # Installation
6 |
7 | Currently, you have to manually compile and install `pawbar`.
8 |
9 | ## Manual
10 | The following dependencies are required at compile time:
11 | ### Dependencies
12 | - `go`
13 | - `udev`
14 | - `librsvg`
15 |
16 |
17 | Clone and compile `pawbar`
18 | ```sh
19 | git clone --recurse-submodules https://github.com/codelif/pawbar
20 | cd pawbar
21 | go build ./cmd/pawbar
22 | ```
23 |
24 | Install using the installation script:
25 | ```sh
26 | ./install.sh
27 | ```
28 |
29 | # Configuration
30 |
31 | `pawbar` is configured using a configuration file.
32 |
33 |
34 | Configuration file is placed at `$HOME/.config/pawbar/pawbar.yaml`
35 |
36 | Simplest configuration file can be:
37 | ```yaml
38 | right:
39 | - clock
40 | ```
41 |
42 | It sets a right anchored `clock` module with default configuration
43 |
44 | A useful default configuration can be:
45 | ```yaml
46 | bar:
47 | truncate_priority:
48 | - middle
49 | - right
50 | - left
51 | left:
52 | - ws
53 | - title
54 | middle:
55 | - clock:
56 | format: "%a %H:%M"
57 | tick: 1m
58 | onmouse:
59 | hover:
60 | config:
61 | format: "%a %H:%M:%S"
62 | tick: 1s
63 | fg: indianred
64 | right:
65 | - volume:
66 | onmouse:
67 | left:
68 | run: "pavucontrol"
69 | - sep
70 | - backlight
71 | - sep
72 | - battery
73 | ```
74 |
--------------------------------------------------------------------------------
/internal/modules/idleInhibitor/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package idleinhibitor
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/config"
11 | "github.com/nekorg/pawbar/internal/modules"
12 | )
13 |
14 | func init() {
15 | config.RegisterModule("idleinhibitor", defaultOptions, func(o Options) (modules.Module, error) { return &IdleModule{opts: o}, nil })
16 | }
17 |
18 | type inhibitOptions struct {
19 | Fg config.Color `yaml:"fg"`
20 | Bg config.Color `yaml:"bg"`
21 | Format config.Format `yaml:"format"`
22 | }
23 |
24 | type Options struct {
25 | Fg config.Color `yaml:"fg"`
26 | Bg config.Color `yaml:"bg"`
27 | Cursor config.Cursor `yaml:"cursor"`
28 | Format config.Format `yaml:"format"`
29 | Inhibit inhibitOptions `yaml:"inhibit"`
30 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
31 | }
32 |
33 | type MouseOptions struct {
34 | Fg *config.Color `yaml:"fg"`
35 | Bg *config.Color `yaml:"bg"`
36 | Cursor *config.Cursor `yaml:"cursor"`
37 | Format *config.Format `yaml:"format"`
38 | }
39 |
40 | func defaultOptions() Options {
41 | fn, _ := config.NewTemplate("")
42 | f, _ := config.NewTemplate("")
43 | return Options{
44 | Inhibit: inhibitOptions{
45 | Format: config.Format{Template: fn},
46 | },
47 | Format: config.Format{Template: f},
48 | OnClick: config.MouseActions[MouseOptions]{},
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/nekorg/pawbar
2 |
3 | go 1.24.5
4 |
5 | replace git.sr.ht/~rockorager/vaxis => ./vaxis
6 |
7 | require (
8 | dario.cat/mergo v1.0.1
9 | git.sr.ht/~rockorager/vaxis v0.14.0
10 | github.com/Wifx/gonetworkmanager/v3 v3.2.0
11 | github.com/codelif/gorsvg v0.1.1
12 | github.com/codelif/pulseaudio v1.0.0
13 | github.com/codelif/xdgicons v0.3.1
14 | github.com/fxamacker/cbor/v2 v2.8.0
15 | github.com/godbus/dbus/v5 v5.1.0
16 | github.com/itchyny/timefmt-go v0.1.6
17 | github.com/jochenvg/go-udev v0.0.0-20240801134859-b65ed646224b
18 | github.com/nekorg/katnip v0.1.0
19 | github.com/shirou/gopsutil/v3 v3.24.5
20 | golang.org/x/image v0.9.0
21 | gopkg.in/yaml.v3 v3.0.1
22 | )
23 |
24 | require (
25 | github.com/codelif/shmstream v0.0.0-20250707213419-52bb1dd21b7b // indirect
26 | github.com/containerd/console v1.0.3 // indirect
27 | github.com/go-ole/go-ole v1.2.6 // indirect
28 | github.com/jkeiser/iter v0.0.0-20200628201005-c8aa0ae784d1 // indirect
29 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
30 | github.com/mattn/go-runewidth v0.0.16 // indirect
31 | github.com/mattn/go-sixel v0.0.5 // indirect
32 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
33 | github.com/rivo/uniseg v0.4.4 // indirect
34 | github.com/shoenig/go-m1cpu v0.1.6 // indirect
35 | github.com/soniakeys/quant v1.0.0 // indirect
36 | github.com/tklauser/go-sysconf v0.3.12 // indirect
37 | github.com/tklauser/numcpus v0.6.1 // indirect
38 | github.com/x448/float16 v0.8.4 // indirect
39 | github.com/yusufpapurcu/wmi v1.2.4 // indirect
40 | golang.org/x/sys v0.33.0 // indirect
41 | gopkg.in/ini.v1 v1.67.0 // indirect
42 | )
43 |
--------------------------------------------------------------------------------
/internal/modules/backlight/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package backlight
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/config"
11 | "github.com/nekorg/pawbar/internal/modules"
12 | )
13 |
14 | func init() {
15 | config.RegisterModule("backlight", defaultOptions, func(o Options) (modules.Module, error) { return &Backlight{opts: o}, nil })
16 | }
17 |
18 | type Options struct {
19 | Fg config.Color `yaml:"fg"`
20 | Bg config.Color `yaml:"bg"`
21 | Cursor config.Cursor `yaml:"cursor"`
22 | Format config.Format `yaml:"format"`
23 | Icons []rune `yaml:"icons"`
24 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
25 | }
26 |
27 | type MouseOptions struct {
28 | Fg *config.Color `yaml:"fg"`
29 | Bg *config.Color `yaml:"bg"`
30 | Cursor *config.Cursor `yaml:"cursor"`
31 | Format *config.Format `yaml:"format"`
32 | }
33 |
34 | func defaultOptions() Options {
35 | fv, _ := config.NewTemplate("{{.Icon}} {{.Percent}}%")
36 |
37 | return Options{
38 | Format: config.Format{Template: fv},
39 | Icons: []rune{'', '', '', ''},
40 | OnClick: config.MouseActions[MouseOptions]{
41 | Actions: map[string]*config.MouseAction[MouseOptions]{
42 | "wheel-up": {
43 | Run: []string{"brightnessctl", "set", "+5%"},
44 | },
45 | "wheel-down": {
46 | Run: []string{"brightnessctl", "set", "5%-"},
47 | },
48 | },
49 | },
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-utils": {
4 | "inputs": {
5 | "systems": "systems"
6 | },
7 | "locked": {
8 | "lastModified": 1731533236,
9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10 | "owner": "numtide",
11 | "repo": "flake-utils",
12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13 | "type": "github"
14 | },
15 | "original": {
16 | "owner": "numtide",
17 | "repo": "flake-utils",
18 | "type": "github"
19 | }
20 | },
21 | "nixpkgs": {
22 | "locked": {
23 | "lastModified": 1756542300,
24 | "narHash": "sha256-tlOn88coG5fzdyqz6R93SQL5Gpq+m/DsWpekNFhqPQk=",
25 | "owner": "nixos",
26 | "repo": "nixpkgs",
27 | "rev": "d7600c775f877cd87b4f5a831c28aa94137377aa",
28 | "type": "github"
29 | },
30 | "original": {
31 | "owner": "nixos",
32 | "ref": "nixos-unstable",
33 | "repo": "nixpkgs",
34 | "type": "github"
35 | }
36 | },
37 | "root": {
38 | "inputs": {
39 | "flake-utils": "flake-utils",
40 | "nixpkgs": "nixpkgs"
41 | }
42 | },
43 | "systems": {
44 | "locked": {
45 | "lastModified": 1681028828,
46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47 | "owner": "nix-systems",
48 | "repo": "default",
49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50 | "type": "github"
51 | },
52 | "original": {
53 | "owner": "nix-systems",
54 | "repo": "default",
55 | "type": "github"
56 | }
57 | }
58 | },
59 | "root": "root",
60 | "version": 7
61 | }
62 |
--------------------------------------------------------------------------------
/internal/modules/clock/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package clock
8 |
9 | import (
10 | "time"
11 |
12 | "github.com/nekorg/pawbar/internal/config"
13 | "github.com/nekorg/pawbar/internal/modules"
14 | )
15 |
16 | // Example config:
17 | //
18 | // clock:
19 | // format: "%Y-%m-%d %H:%M:%S"
20 | // tick: 5s # interval
21 | // onmouse:
22 | // left:
23 | // config:
24 | // format: "%a %H:%M"
25 | // right:
26 | // config:
27 | // format: "%d %B %Y (%A) %H:%M"
28 | //
29 | // NOTE: include an example in every module's config.go (also this message)
30 |
31 | func init() {
32 | config.RegisterModule("clock", defaultOptions, func(o Options) (modules.Module, error) { return &ClockModule{opts: o}, nil })
33 | }
34 |
35 | type Options struct {
36 | Fg config.Color `yaml:"fg"`
37 | Bg config.Color `yaml:"bg"`
38 | Cursor config.Cursor `yaml:"cursor"`
39 | Tick config.Duration `yaml:"tick"`
40 | Format string `yaml:"format"`
41 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
42 | }
43 |
44 | type MouseOptions struct {
45 | Fg *config.Color `yaml:"fg"`
46 | Bg *config.Color `yaml:"bg"`
47 | Cursor *config.Cursor `yaml:"cursor"`
48 | Tick *config.Duration `yaml:"tick"`
49 | Format *string `yaml:"format"`
50 | }
51 |
52 | func defaultOptions() Options {
53 | return Options{
54 | Format: "%Y-%m-%d %H:%M:%S",
55 | Tick: config.Duration(5 * time.Second),
56 | OnClick: config.MouseActions[MouseOptions]{},
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/internal/modules/title/backend_i3.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package title
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/services/i3"
11 | "github.com/nekorg/pawbar/internal/utils"
12 | )
13 |
14 | type i3Backend struct {
15 | svc *i3.Service
16 | ev chan interface{}
17 | ev2 chan interface{}
18 | instance string
19 | title string
20 | sig chan struct{}
21 | }
22 |
23 | func newI3Backend(s *i3.Service) backend {
24 | b := &i3Backend{
25 | svc: s,
26 | ev: make(chan interface{}),
27 | ev2: make(chan interface{}),
28 | sig: make(chan struct{}, 2),
29 | }
30 |
31 | b.instance, b.title = i3.GetTitleClass()
32 |
33 | b.svc.RegisterChannel("activeWindow", b.ev)
34 | b.svc.RegisterChannel("workspaces", b.ev2)
35 |
36 | go b.loop()
37 | return b
38 | }
39 |
40 | func (b *i3Backend) loop() {
41 | for {
42 | select {
43 | case e := <-b.ev:
44 | if _, ok := e.(i3.I3WEvent); ok {
45 | b.instance, b.title = i3.GetTitleClass()
46 | b.signal()
47 | } else {
48 | utils.Logger.Println("DEBUG: ws: i3: Unknown event on window event channel:", e)
49 | }
50 | case e := <-b.ev2:
51 | if _, ok := e.(i3.I3Event); ok {
52 | b.instance, b.title = i3.GetTitleClass()
53 | b.signal()
54 | } else {
55 | utils.Logger.Println("DEBUG: ws: i3: Unknown event type on workspace event channel:", e)
56 | }
57 | }
58 | }
59 | }
60 |
61 | func (b *i3Backend) signal() {
62 | select {
63 | case b.sig <- struct{}{}:
64 | default:
65 | }
66 | }
67 |
68 | func (b *i3Backend) Window() Window {
69 | return Window{Title: b.title, Class: b.instance}
70 | }
71 | func (b *i3Backend) Events() <-chan struct{} { return b.sig }
72 |
--------------------------------------------------------------------------------
/internal/modules/title/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package title
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/config"
11 | "github.com/nekorg/pawbar/internal/lookup/colors"
12 | "github.com/nekorg/pawbar/internal/modules"
13 | )
14 |
15 | func init() {
16 | config.RegisterModule("title", defaultOptions, func(o Options) (modules.Module, error) { return &Module{opts: o}, nil })
17 | }
18 |
19 | type DataOptions struct {
20 | Format config.Format `yaml:"format"`
21 | Fg config.Color `yaml:"fg"`
22 | Bg config.Color `yaml:"bg"`
23 | }
24 |
25 | type Options struct {
26 | Fg config.Color `yaml:"fg"`
27 | Bg config.Color `yaml:"bg"`
28 | Cursor config.Cursor `yaml:"cursor"`
29 | Title DataOptions `yaml:"title"`
30 | Class DataOptions `yaml:"class"`
31 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
32 | }
33 |
34 | type MouseOptions struct {
35 | Fg *config.Color `yaml:"fg"`
36 | Bg *config.Color `yaml:"bg"`
37 | Cursor *config.Cursor `yaml:"cursor"`
38 | }
39 |
40 | func defaultOptions() Options {
41 | fc, _ := config.NewTemplate("{{.Class}}")
42 | ft, _ := config.NewTemplate("{{.Title}}")
43 | clClr, _ := colors.ParseColor("@cool")
44 | blkClr, _ := colors.ParseColor("@black")
45 |
46 | return Options{
47 | Title: DataOptions{
48 | Format: config.Format{Template: ft},
49 | },
50 | Class: DataOptions{
51 | Format: config.Format{Template: fc},
52 | Bg: config.Color(clClr),
53 | Fg: config.Color(blkClr),
54 | },
55 | OnClick: config.MouseActions[MouseOptions]{
56 | Actions: map[string]*config.MouseAction[MouseOptions]{},
57 | },
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/internal/modules/module.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package modules
8 |
9 | import (
10 | "git.sr.ht/~rockorager/vaxis"
11 | )
12 |
13 | var (
14 | BLACK = vaxis.IndexColor(0)
15 | URGENT = vaxis.IndexColor(9)
16 | WARNING = vaxis.IndexColor(11)
17 | GOOD = vaxis.IndexColor(2)
18 | ACTIVE = vaxis.IndexColor(15)
19 | COOL = vaxis.RGBColor(173, 216, 230)
20 | SPECIAL = vaxis.RGBColor(0, 100, 0)
21 | )
22 |
23 | func Cell(r rune, s vaxis.Style) vaxis.Cell {
24 | return vaxis.Cell{Character: vaxis.Characters(string(r))[0], Style: s}
25 | }
26 |
27 | type EventCell struct {
28 | C vaxis.Cell
29 | Metadata string
30 | Mod Module
31 | MouseShape vaxis.MouseShape
32 | }
33 |
34 | type Module interface {
35 | Render() []EventCell
36 | Run() (<-chan bool, chan<- Event, error)
37 | Channels() (<-chan bool, chan<- Event)
38 | Name() string
39 | Dependencies() []string
40 | }
41 |
42 | type Event struct {
43 | Cell EventCell
44 | VaxisEvent vaxis.Event
45 | }
46 |
47 | type FocusIn struct {
48 | NewMod Module
49 | PrevMod Module
50 | }
51 | type FocusOut struct {
52 | NewMod Module
53 | PrevMod Module
54 | }
55 |
56 | func (FocusIn) String() string { return "FocusIn" }
57 | func (FocusOut) String() string { return "FocusOut" }
58 |
59 | var (
60 | ECSPACE = EventCell{
61 | C: vaxis.Cell{Character: vaxis.Characters(" ")[0]},
62 | Metadata: "",
63 | Mod: nil,
64 | MouseShape: "",
65 | }
66 | ECDOT = EventCell{
67 | C: vaxis.Cell{Character: vaxis.Characters(".")[0]},
68 | Metadata: "",
69 | Mod: nil,
70 | MouseShape: "",
71 | }
72 | ECELLIPSIS = EventCell{
73 | C: vaxis.Cell{Character: vaxis.Characters("…")[0]},
74 | Metadata: "",
75 | Mod: nil,
76 | MouseShape: "",
77 | }
78 | )
79 |
--------------------------------------------------------------------------------
/internal/modules/all/all.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package all
8 |
9 | import (
10 | "git.sr.ht/~rockorager/vaxis"
11 | "github.com/nekorg/pawbar/internal/config"
12 | "github.com/nekorg/pawbar/internal/modules"
13 | _ "github.com/nekorg/pawbar/internal/modules/backlight"
14 | _ "github.com/nekorg/pawbar/internal/modules/battery"
15 | _ "github.com/nekorg/pawbar/internal/modules/bluetooth"
16 | _ "github.com/nekorg/pawbar/internal/modules/clock"
17 | _ "github.com/nekorg/pawbar/internal/modules/cpu"
18 | _ "github.com/nekorg/pawbar/internal/modules/custom"
19 | _ "github.com/nekorg/pawbar/internal/modules/disk"
20 | _ "github.com/nekorg/pawbar/internal/modules/idleInhibitor"
21 | _ "github.com/nekorg/pawbar/internal/modules/locale"
22 | _ "github.com/nekorg/pawbar/internal/modules/mpris"
23 | _ "github.com/nekorg/pawbar/internal/modules/powerProfiles"
24 | _ "github.com/nekorg/pawbar/internal/modules/ram"
25 | _ "github.com/nekorg/pawbar/internal/modules/title"
26 | _ "github.com/nekorg/pawbar/internal/modules/tray"
27 | _ "github.com/nekorg/pawbar/internal/modules/volume"
28 | _ "github.com/nekorg/pawbar/internal/modules/wifi"
29 | _ "github.com/nekorg/pawbar/internal/modules/ws"
30 | "gopkg.in/yaml.v3"
31 | )
32 |
33 | func init() {
34 | config.Register("sep", func(n *yaml.Node) (modules.Module, error) {
35 | return modules.NewStaticModule(
36 | "sep",
37 | []modules.EventCell{
38 | {C: modules.ECSPACE.C},
39 | {C: vaxis.Cell{
40 | Character: vaxis.Character{
41 | Grapheme: "│",
42 | Width: 1,
43 | },
44 | }},
45 | {C: modules.ECSPACE.C},
46 | }, nil,
47 | ), nil
48 | })
49 |
50 | config.Register("space", func(raw *yaml.Node) (modules.Module, error) {
51 | return modules.NewStaticModule(
52 | "space",
53 | []modules.EventCell{
54 | {C: modules.ECSPACE.C},
55 | },
56 | nil,
57 | ), nil
58 | })
59 | }
60 |
--------------------------------------------------------------------------------
/internal/utils/utils.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package utils
8 |
9 | import (
10 | "cmp"
11 | "errors"
12 | "io"
13 | "io/fs"
14 | "log"
15 | "os"
16 | "os/exec"
17 | "strconv"
18 | "strings"
19 | "time"
20 | )
21 |
22 | var (
23 | NotifyIO NotificationWriter
24 | Logger *log.Logger
25 | )
26 |
27 | type NotificationWriter struct{}
28 |
29 | func (w *NotificationWriter) Write(p []byte) (n int, err error) {
30 | cmd := exec.Command("notify-send", strings.TrimRight(string(p), "\n"))
31 |
32 | done := make(chan error)
33 | go func() {
34 | err := cmd.Run()
35 | done <- err
36 | }()
37 |
38 | select {
39 | case <-time.After(1 * time.Second):
40 | return 0, errors.New("Command timed out.")
41 | case d := <-done:
42 | if d != nil {
43 | return 0, d
44 | }
45 | return len(p), d
46 | }
47 |
48 | return 0, errors.New("utils:NotifWriter:Write: Unreachable")
49 | }
50 |
51 | func Clamp[T cmp.Ordered](n, low, high T) T {
52 | if n < low {
53 | return low
54 | }
55 |
56 | if n > high {
57 | return high
58 | }
59 |
60 | return n
61 | }
62 |
63 | func InitLogger() (*log.Logger, *os.File) {
64 | var Fd *os.File
65 | Logger = log.New(io.Discard, "", 0)
66 | if len(os.Args) > 1 {
67 | Logger = log.New(&NotifyIO, "", 0)
68 | fi, err := os.Lstat(os.Args[1])
69 | if err != nil {
70 | Logger.Fatalln("There was an error accessing the char device path.")
71 | }
72 | if fi.Mode()&fs.ModeCharDevice == 0 {
73 | Logger.Fatalln("The given path is not a char device.")
74 | }
75 |
76 | device := os.Args[1]
77 | Fd, err = os.OpenFile(device, os.O_WRONLY, 0o620)
78 | if err != nil {
79 | Logger.Fatalln("There was an error opening the char device.")
80 | }
81 | Logger = log.New(Fd, "", log.LstdFlags)
82 | }
83 | return Logger, Fd
84 | }
85 |
86 | func set_font_size(size int) {
87 | exec.Command("kitty", "@", "set-font-size", strconv.Itoa(size)).Run()
88 | }
89 |
--------------------------------------------------------------------------------
/cmd/pawbar/signals.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package main
8 |
9 | import (
10 | "os"
11 | "os/signal"
12 | "syscall"
13 | )
14 |
15 | func setupUserSignals() <-chan os.Signal {
16 | chSig := make(chan os.Signal, 2)
17 | signal.Notify(chSig, syscall.SIGUSR1, syscall.SIGUSR2)
18 | return chSig
19 | }
20 |
21 | func canonicalSignalName(s os.Signal) string {
22 | switch s {
23 | case syscall.SIGHUP:
24 | return "SIGHUP"
25 | case syscall.SIGINT:
26 | return "SIGINT"
27 | case syscall.SIGQUIT:
28 | return "SIGQUIT"
29 | case syscall.SIGILL:
30 | return "SIGILL"
31 | case syscall.SIGTRAP:
32 | return "SIGTRAP"
33 | case syscall.SIGABRT:
34 | return "SIGABRT"
35 | case syscall.SIGBUS:
36 | return "SIGBUS"
37 | case syscall.SIGFPE:
38 | return "SIGFPE"
39 | case syscall.SIGKILL:
40 | return "SIGKILL"
41 | case syscall.SIGUSR1:
42 | return "SIGUSR1"
43 | case syscall.SIGSEGV:
44 | return "SIGSEGV"
45 | case syscall.SIGUSR2:
46 | return "SIGUSR2"
47 | case syscall.SIGPIPE:
48 | return "SIGPIPE"
49 | case syscall.SIGALRM:
50 | return "SIGALRM"
51 | case syscall.SIGSTKFLT:
52 | return "SIGSTKFLT"
53 | case syscall.SIGCHLD:
54 | return "SIGCHLD"
55 | case syscall.SIGCONT:
56 | return "SIGCONT"
57 | case syscall.SIGSTOP:
58 | return "SIGSTOP"
59 | case syscall.SIGTSTP:
60 | return "SIGTSTP"
61 | case syscall.SIGTTIN:
62 | return "SIGTTIN"
63 | case syscall.SIGTTOU:
64 | return "SIGTTOU"
65 | case syscall.SIGURG:
66 | return "SIGURG"
67 | case syscall.SIGXCPU:
68 | return "SIGXCPU"
69 | case syscall.SIGXFSZ:
70 | return "SIGXFSZ"
71 | case syscall.SIGVTALRM:
72 | return "SIGVTALRM"
73 | case syscall.SIGPROF:
74 | return "SIGPROF"
75 | case syscall.SIGWINCH:
76 | return "SIGWINCH"
77 | case syscall.SIGIO:
78 | return "SIGIO"
79 | case syscall.SIGPWR:
80 | return "SIGPWR"
81 | case syscall.SIGSYS:
82 | return "SIGSYS"
83 | default:
84 | return ""
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/internal/modules/mpris/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package mpris
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/config"
11 | "github.com/nekorg/pawbar/internal/modules"
12 | )
13 |
14 | func init() {
15 | config.RegisterModule("mpris", defaultOptions, func(o Options) (modules.Module, error) { return &MprisModule{opts: o}, nil })
16 | }
17 |
18 | type PlayOptions struct {
19 | Icon rune `yaml:"icon"`
20 | Fg config.Color `yaml:"fg"`
21 | Format config.Format `yaml:"format"`
22 | Bg config.Color `yaml:"bg"`
23 | }
24 |
25 | type PauseOptions struct {
26 | Icon rune `yaml:"icon"`
27 | Fg config.Color `yaml:"fg"`
28 | Bg config.Color `yaml:"bg"`
29 | Format config.Format `yaml:"format"`
30 | }
31 |
32 | type Options struct {
33 | Fg config.Color `yaml:"fg"`
34 | Bg config.Color `yaml:"bg"`
35 | Cursor config.Cursor `yaml:"cursor"`
36 | Tick config.Duration `yaml:"tick"`
37 | Pause PauseOptions `yaml:"pause"`
38 | Play PlayOptions `yaml:"play"`
39 | Format config.Format `yaml:"format"`
40 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
41 | }
42 |
43 | type MouseOptions struct {
44 | Fg *config.Color `yaml:"fg"`
45 | Bg *config.Color `yaml:"bg"`
46 | Cursor *config.Cursor `yaml:"cursor"`
47 | Tick *config.Duration `yaml:"tick"`
48 | Format *config.Format `yaml:"format"`
49 | }
50 |
51 | func defaultOptions() Options {
52 | f0, _ := config.NewTemplate("")
53 | f1, _ := config.NewTemplate("{{.Icon}} {{.Artists}} {{.Title}}")
54 | return Options{
55 | Format: config.Format{Template: f0},
56 | Pause: PauseOptions{
57 | Icon: '',
58 | Format: config.Format{Template: f1},
59 | },
60 | Play: PlayOptions{
61 | Icon: '',
62 | Format: config.Format{Template: f1},
63 | },
64 | OnClick: config.MouseActions[MouseOptions]{},
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/internal/config/registry.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package config
8 |
9 | import (
10 | "fmt"
11 | "reflect"
12 |
13 | "dario.cat/mergo"
14 | "github.com/nekorg/pawbar/internal/modules"
15 | "gopkg.in/yaml.v3"
16 | )
17 |
18 | type Factory func(raw *yaml.Node) (modules.Module, error)
19 |
20 | var factories = make(map[string]Factory)
21 |
22 | func Register(name string, f Factory) { factories[name] = f }
23 |
24 | func RegisterModule[T any](
25 | name string,
26 | defaultOpts func() T,
27 | constructor func(T) (modules.Module, error),
28 | ) {
29 | factories[name] = func(node *yaml.Node) (modules.Module, error) {
30 | if err := validateOnMouseNode(node); err != nil {
31 | return nil, fmt.Errorf("%s: %w", name, err)
32 | }
33 | opts := defaultOpts()
34 |
35 | if node != nil {
36 | var userOpts T
37 | if err := node.Decode(&userOpts); err != nil {
38 | return nil, fmt.Errorf("%s: bad config: %w", name, err)
39 | }
40 | if err := mergo.Merge(&opts, userOpts, mergo.WithOverride); err != nil {
41 | return nil, fmt.Errorf("%s: merge error: %w", name, err)
42 | }
43 | }
44 |
45 | if fv := reflect.ValueOf(&opts).Elem().FieldByName("OnClick"); fv.IsValid() {
46 | var m reflect.Value
47 |
48 | switch fv.Kind() {
49 | case reflect.Map:
50 | m = fv
51 |
52 | case reflect.Struct:
53 | if sub := fv.FieldByName("Actions"); sub.IsValid() && sub.Kind() == reflect.Map {
54 | m = sub
55 | }
56 | }
57 |
58 | if m.IsValid() {
59 | for _, key := range m.MapKeys() {
60 | val := m.MapIndex(key).Interface()
61 | if v, ok := val.(interface{ Validate() error }); ok {
62 | if err := v.Validate(); err != nil {
63 | return nil, fmt.Errorf("%s.onclick.%s: %w",
64 | name, key.String(), err)
65 | }
66 | }
67 | }
68 | }
69 | }
70 |
71 | if v, ok := any(&opts).(interface{ Validate() error }); ok {
72 | if err := v.Validate(); err != nil {
73 | return nil, fmt.Errorf("%s: %w", name, err)
74 | }
75 | }
76 |
77 | return constructor(opts)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/internal/modules/ws/backend_i3.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package ws
8 |
9 | import (
10 | "sort"
11 | "sync"
12 |
13 | "github.com/nekorg/pawbar/internal/services/i3"
14 | "github.com/nekorg/pawbar/internal/utils"
15 | )
16 |
17 | type i3Backend struct {
18 | svc *i3.Service
19 | ev chan interface{}
20 | ws map[int]*Workspace
21 | mu sync.RWMutex
22 | sig chan struct{}
23 | }
24 |
25 | func newI3Backend(s *i3.Service) backend {
26 | b := &i3Backend{
27 | svc: s,
28 | ev: make(chan interface{}),
29 | ws: make(map[int]*Workspace),
30 | sig: make(chan struct{}, 1),
31 | }
32 |
33 | b.refreshWorkspaceCache()
34 |
35 | b.svc.RegisterChannel("workspaces", b.ev)
36 |
37 | go b.loop()
38 | return b
39 | }
40 |
41 | func (b *i3Backend) loop() {
42 | for e := range b.ev {
43 | if evt, ok := e.(i3.I3Event); ok {
44 | utils.Logger.Println("DEBUG: ws: i3: Event type:", evt)
45 | b.refreshWorkspaceCache()
46 | b.signal()
47 | } else {
48 | utils.Logger.Println("DEBUG: ws: i3: Unknown event type", e)
49 | }
50 | }
51 | }
52 |
53 | func (b *i3Backend) refreshWorkspaceCache() {
54 | b.mu.Lock()
55 | defer b.mu.Unlock()
56 | b.ws = make(map[int]*Workspace)
57 |
58 | workspaces := i3.GetWorkspaces()
59 | active := i3.GetActiveWorkspace()
60 |
61 | for _, w := range workspaces {
62 | b.ws[w.Id] = &Workspace{
63 | ID: w.Id,
64 | Name: w.Name,
65 | Active: w.Id == active.Id,
66 | Urgent: w.Urgent,
67 | }
68 | }
69 | }
70 |
71 | func (b *i3Backend) signal() {
72 | select {
73 | case b.sig <- struct{}{}:
74 | default:
75 | }
76 | }
77 |
78 | func (b *i3Backend) List() []Workspace {
79 | b.mu.RLock()
80 | defer b.mu.RUnlock()
81 |
82 | ws := make([]Workspace, 0, len(b.ws))
83 | for _, v := range b.ws {
84 | ws = append(ws, Workspace{v.ID, v.Name, v.Active, v.Urgent, v.Special})
85 | }
86 | sort.Slice(ws, func(a, b int) bool { return ws[a].ID < ws[b].ID })
87 | return ws
88 | }
89 | func (b *i3Backend) Events() <-chan struct{} { return b.sig }
90 | func (b *i3Backend) Goto(name string) { i3.GoToWorkspace(name) }
91 |
--------------------------------------------------------------------------------
/internal/modules/volume/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package volume
8 |
9 | import (
10 | "text/template"
11 |
12 | "github.com/nekorg/pawbar/internal/config"
13 | "github.com/nekorg/pawbar/internal/lookup/colors"
14 | "github.com/nekorg/pawbar/internal/modules"
15 | )
16 |
17 | func init() {
18 | config.RegisterModule("volume", defaultOptions, func(o Options) (modules.Module, error) { return &VolumeModule{opts: o}, nil })
19 | }
20 |
21 | type Mutedoptions struct {
22 | MuteFormat string `yaml:"muteformat"`
23 | Fg config.Color `yaml:"fg"`
24 | Bg config.Color `yaml:"bg"`
25 | }
26 |
27 | type Options struct {
28 | Fg config.Color `yaml:"fg"`
29 | Bg config.Color `yaml:"bg"`
30 | Cursor config.Cursor `yaml:"cursor"`
31 | Format config.Format `yaml:"format"`
32 | Muted Mutedoptions `yaml:"muted"`
33 | Icons []rune `yaml:"icons"`
34 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
35 | }
36 |
37 | type MouseOptions struct {
38 | Fg *config.Color `yaml:"fg"`
39 | Bg *config.Color `yaml:"bg"`
40 | Cursor *config.Cursor `yaml:"cursor"`
41 | Format *config.Format `yaml:"format"`
42 | // Icons *[]rune `yaml:"icons"`
43 | }
44 |
45 | func defaultOptions() Options {
46 | fv, _ := template.New("format").Parse("{{.Icon}} {{.Percent}}%")
47 | muteColor, _ := colors.ParseColor("darkgray")
48 |
49 | return Options{
50 | Format: config.Format{Template: fv},
51 | Icons: []rune{'', '', ''},
52 | Muted: Mutedoptions{
53 | MuteFormat: " MUTED",
54 | Fg: config.Color(muteColor),
55 | },
56 | OnClick: config.MouseActions[MouseOptions]{
57 | Actions: map[string]*config.MouseAction[MouseOptions]{
58 | "wheel-up": {
59 | Run: []string{"pactl", "set-sink-volume", "@DEFAULT_SINK@", "+5%"},
60 | },
61 | "wheel-down": {
62 | Run: []string{"pactl", "set-sink-volume", "@DEFAULT_SINK@", "-5%"},
63 | },
64 | },
65 | },
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/pkg/dbusmenukitty/tui/state.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package tui
8 |
9 | import (
10 | "time"
11 |
12 | "github.com/nekorg/pawbar/pkg/dbusmenukitty/menu"
13 | )
14 |
15 | type MenuState struct {
16 | items []menu.Item
17 | mouseX int
18 | mouseY int
19 | mousePixelX int
20 | mousePixelY int
21 | lastMouseY int
22 | mousePressed bool
23 | mouseOnSurface bool
24 | hoverTimer *time.Timer
25 | hoverItemId int32
26 | ppc menu.PPC
27 | size menu.Size
28 | }
29 |
30 | func NewMenuState() *MenuState {
31 | return &MenuState{
32 | mouseX: -1,
33 | mouseY: -1,
34 | mousePixelX: -1,
35 | mousePixelY: -1,
36 | lastMouseY: -1,
37 | hoverItemId: 0,
38 | }
39 | }
40 |
41 | func (m *MenuState) cancelHoverTimer() {
42 | if m.hoverTimer != nil {
43 | m.hoverTimer.Stop()
44 | m.hoverTimer = nil
45 | }
46 | // Don't clear hoverItemId here - it's needed to track active submenus
47 | }
48 |
49 | func (m *MenuState) isValidItemIndex(index int) bool {
50 | return index >= 0 && index < len(m.items)
51 | }
52 |
53 | func (m *MenuState) isSelectableItem(index int) bool {
54 | return m.isValidItemIndex(index) &&
55 | m.items[index].Type != menu.ItemSeparator &&
56 | m.items[index].Enabled ||
57 | !m.mouseOnSurface
58 | }
59 |
60 | func (m *MenuState) getCurrentItem() *menu.Item {
61 | if !m.isValidItemIndex(m.mouseY) {
62 | return nil
63 | }
64 | return &m.items[m.mouseY]
65 | }
66 |
67 | func (m *MenuState) navigateUp() {
68 | // Cancel any pending hover when navigating
69 | m.cancelHoverTimer()
70 |
71 | if m.mouseY > 0 {
72 | m.mouseY--
73 | // Skip separators
74 | for m.mouseY > 0 && m.items[m.mouseY].Type == menu.ItemSeparator {
75 | m.mouseY--
76 | }
77 | }
78 | }
79 |
80 | func (m *MenuState) navigateDown() {
81 | // Cancel any pending hover when navigating
82 | m.cancelHoverTimer()
83 |
84 | if m.mouseY < len(m.items)-1 {
85 | m.mouseY++
86 | // Skip separators
87 | for m.mouseY < len(m.items)-1 && m.items[m.mouseY].Type == menu.ItemSeparator {
88 | m.mouseY++
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/cmd/pawbar/suspend.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package main
8 |
9 | import (
10 | "context"
11 | "time"
12 |
13 | "github.com/godbus/dbus/v5"
14 | )
15 |
16 |
17 | type ResumeEvent struct{ Source string }
18 |
19 | func watchResume(ctx context.Context) <-chan ResumeEvent {
20 | out := make(chan ResumeEvent, 1)
21 |
22 | go func() {
23 | defer close(out)
24 |
25 | bus, err := dbus.ConnectSystemBus()
26 | if err != nil {
27 | return
28 | }
29 | defer bus.Close()
30 |
31 | logindOpts := []dbus.MatchOption{
32 | dbus.WithMatchSender("org.freedesktop.login1"),
33 | dbus.WithMatchInterface("org.freedesktop.login1.Manager"),
34 | dbus.WithMatchMember("PrepareForSleep"),
35 | }
36 | upowerOpts := []dbus.MatchOption{
37 | dbus.WithMatchSender("org.freedesktop.UPower"),
38 | dbus.WithMatchInterface("org.freedesktop.UPower"),
39 | dbus.WithMatchMember("Resuming"),
40 | }
41 |
42 | // TODO: properly handle errors
43 | _ = bus.AddMatchSignal(logindOpts...)
44 | _ = bus.AddMatchSignal(upowerOpts...)
45 | defer func() {
46 | _ = bus.RemoveMatchSignal(logindOpts...)
47 | _ = bus.RemoveMatchSignal(upowerOpts...)
48 | }()
49 |
50 | sigC := make(chan *dbus.Signal, 8)
51 | bus.Signal(sigC)
52 | defer bus.RemoveSignal(sigC)
53 |
54 | var last time.Time
55 | const debounce = 250 * time.Millisecond
56 |
57 | for {
58 | select {
59 | case <-ctx.Done():
60 | return
61 | case sig := <-sigC:
62 | if sig == nil {
63 | return
64 | }
65 |
66 | now := time.Now()
67 | if !last.IsZero() && now.Sub(last) < debounce {
68 | continue
69 | }
70 |
71 | switch sig.Name {
72 | case "org.freedesktop.login1.Manager.PrepareForSleep":
73 | // Body: [bool asleep]
74 | if len(sig.Body) == 1 {
75 | if asleep, _ := sig.Body[0].(bool); asleep {
76 | continue // going to sleep
77 | }
78 | last = now
79 | select { case out <- ResumeEvent{Source: "login1"}: default: }
80 | }
81 | case "org.freedesktop.UPower.Resuming":
82 | last = now
83 | select { case out <- ResumeEvent{Source: "UPower"}: default: }
84 | }
85 | }
86 | }
87 | }()
88 |
89 | return out
90 | }
91 |
--------------------------------------------------------------------------------
/internal/modules/custom/custom.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package custom
8 |
9 | import (
10 | "bytes"
11 |
12 | "git.sr.ht/~rockorager/vaxis"
13 | "github.com/nekorg/pawbar/internal/config"
14 | "github.com/nekorg/pawbar/internal/modules"
15 | )
16 |
17 | type CustomModule struct {
18 | receive chan bool
19 | send chan modules.Event
20 |
21 | opts Options
22 | initialOpts Options
23 | }
24 |
25 | func (mod *CustomModule) Dependencies() []string {
26 | return []string{}
27 | }
28 |
29 | func (mod *CustomModule) Name() string {
30 | return "custom"
31 | }
32 |
33 | func New() modules.Module {
34 | return &CustomModule{}
35 | }
36 |
37 | func (mod *CustomModule) Channels() (<-chan bool, chan<- modules.Event) {
38 | return mod.receive, mod.send
39 | }
40 |
41 | func (mod *CustomModule) Run() (<-chan bool, chan<- modules.Event, error) {
42 | mod.receive = make(chan bool)
43 | mod.send = make(chan modules.Event)
44 | mod.initialOpts = mod.opts
45 |
46 | go func() {
47 | for {
48 | select {
49 | case e := <-mod.send:
50 | switch ev := e.VaxisEvent.(type) {
51 | case vaxis.Mouse:
52 | if ev.EventType != vaxis.EventRelease {
53 | break
54 | }
55 | btn := config.ButtonName(ev)
56 |
57 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
58 | mod.receive <- true
59 | }
60 |
61 | case modules.FocusIn:
62 | if mod.opts.OnClick.HoverIn(&mod.opts) {
63 | mod.receive <- true
64 | }
65 |
66 | case modules.FocusOut:
67 | if mod.opts.OnClick.HoverOut(&mod.opts) {
68 | mod.receive <- true
69 | }
70 | }
71 | }
72 | }
73 | }()
74 |
75 | return mod.receive, mod.send, nil
76 | }
77 |
78 | func (mod *CustomModule) Render() []modules.EventCell {
79 | style := vaxis.Style{
80 | Foreground: mod.opts.Fg.Go(),
81 | Background: mod.opts.Bg.Go(),
82 | }
83 |
84 | var buf bytes.Buffer
85 | _ = mod.opts.Format.Execute(&buf, nil)
86 |
87 | rch := vaxis.Characters(buf.String())
88 | r := make([]modules.EventCell, len(rch))
89 |
90 | for i, ch := range rch {
91 | r[i] = modules.EventCell{C: vaxis.Cell{Character: ch, Style: style}, Mod: mod, MouseShape: mod.opts.Cursor.Go()}
92 | }
93 | return r
94 | }
95 |
--------------------------------------------------------------------------------
/internal/modules/powerProfiles/power.go:
--------------------------------------------------------------------------------
1 | package powerprofiles
2 |
3 | import (
4 | "bytes"
5 |
6 | "git.sr.ht/~rockorager/vaxis"
7 |
8 | "github.com/nekorg/pawbar/internal/config"
9 | "github.com/nekorg/pawbar/internal/menus/power"
10 | "github.com/nekorg/pawbar/internal/modules"
11 | )
12 |
13 | type powerProfileModule struct {
14 | receive chan bool
15 | send chan modules.Event
16 |
17 | opts Options
18 | initialOpts Options
19 | }
20 |
21 | func (mod *powerProfileModule) Dependencies() []string {
22 | return nil
23 | }
24 |
25 | func (mod *powerProfileModule) Channels() (<-chan bool, chan<- modules.Event) {
26 | return mod.receive, mod.send
27 | }
28 |
29 | func (mod *powerProfileModule) Name() string {
30 | return "powerprofiles"
31 | }
32 |
33 | func (mod *powerProfileModule) Run() (<-chan bool, chan<- modules.Event, error) {
34 | mod.receive = make(chan bool)
35 | mod.send = make(chan modules.Event)
36 | mod.initialOpts = mod.opts
37 |
38 | go func() {
39 | for {
40 | select {
41 | case e := <-mod.send:
42 | switch ev := e.VaxisEvent.(type) {
43 | case vaxis.Mouse:
44 | if ev.EventType != vaxis.EventPress {
45 | break
46 | }
47 | btn := config.ButtonName(ev)
48 |
49 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
50 | mod.receive <- true
51 | }
52 |
53 | switch ev.Button {
54 | case vaxis.MouseRightButton:
55 | go power.LaunchMenu(ev.XPixel/2, ev.YPixel/2)
56 | }
57 |
58 | case modules.FocusIn:
59 | if mod.opts.OnClick.HoverIn(&mod.opts) {
60 | mod.receive <- true
61 | }
62 |
63 | case modules.FocusOut:
64 | if mod.opts.OnClick.HoverOut(&mod.opts) {
65 | mod.receive <- true
66 | }
67 | }
68 | }
69 | }
70 | }()
71 |
72 | return mod.receive, mod.send, nil
73 | }
74 |
75 | func (mod *powerProfileModule) Render() []modules.EventCell {
76 | var s vaxis.Style
77 | s.Foreground = mod.opts.Fg.Go()
78 | s.Background = mod.opts.Bg.Go()
79 |
80 | var buf bytes.Buffer
81 | _ = mod.opts.Format.Execute(&buf, nil)
82 |
83 | chars := vaxis.Characters(buf.String())
84 | r := make([]modules.EventCell, len(chars))
85 |
86 | for i, ch := range chars {
87 | r[i] = modules.EventCell{
88 | C: vaxis.Cell{
89 | Character: ch,
90 | Style: s,
91 | },
92 | Metadata: "",
93 | Mod: mod,
94 | MouseShape: mod.opts.Cursor.Go(),
95 | }
96 | }
97 | return r
98 | }
99 |
--------------------------------------------------------------------------------
/internal/modules/ws/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package ws
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/config"
11 | "github.com/nekorg/pawbar/internal/lookup/colors"
12 | "github.com/nekorg/pawbar/internal/modules"
13 | )
14 |
15 | func init() {
16 | config.RegisterModule("ws", defaultOptions, func(o Options) (modules.Module, error) { return &Module{opts: o}, nil })
17 | }
18 |
19 | type ActiveOptions struct {
20 | Fg config.Color `yaml:"fg"`
21 | Bg config.Color `yaml:"bg"`
22 | }
23 |
24 | type UrgentOptions struct {
25 | Fg config.Color `yaml:"fg"`
26 | Bg config.Color `yaml:"bg"`
27 | }
28 |
29 | type SpecialOptions struct {
30 | Fg config.Color `yaml:"fg"`
31 | Bg config.Color `yaml:"bg"`
32 | }
33 |
34 | type Options struct {
35 | Fg config.Color `yaml:"fg"`
36 | Bg config.Color `yaml:"bg"`
37 | Cursor config.Cursor `yaml:"cursor"`
38 | Format config.Format `yaml:"format"`
39 | Special SpecialOptions `yaml:"special"`
40 | Active ActiveOptions `yaml:"active"`
41 | Urgent UrgentOptions `yaml:"urgent"`
42 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
43 | }
44 |
45 | type MouseOptions struct {
46 | Fg *config.Color `yaml:"fg"`
47 | Bg *config.Color `yaml:"bg"`
48 | Cursor *config.Cursor `yaml:"cursor"`
49 | Format *config.Format `yaml:"format"`
50 | }
51 |
52 | func defaultOptions() Options {
53 | fw, _ := config.NewTemplate("{{.WSID}}")
54 | spclClr, _ := colors.ParseColor("@special")
55 | actClr, _ := colors.ParseColor("@active")
56 | blkClr, _ := colors.ParseColor("@black")
57 | urgClr, _ := colors.ParseColor("@urgent")
58 |
59 | return Options{
60 | Format: config.Format{Template: fw},
61 | Special: SpecialOptions{
62 | Fg: config.Color(actClr),
63 | Bg: config.Color(spclClr),
64 | },
65 | Active: ActiveOptions{
66 | Fg: config.Color(blkClr),
67 | Bg: config.Color(actClr),
68 | },
69 | Urgent: UrgentOptions{
70 | Fg: config.Color(blkClr),
71 | Bg: config.Color(urgClr),
72 | },
73 | OnClick: config.MouseActions[MouseOptions]{
74 | Actions: map[string]*config.MouseAction[MouseOptions]{},
75 | },
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/internal/modules/bluetooth/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package bluetooth
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/config"
11 | "github.com/nekorg/pawbar/internal/lookup/colors"
12 | "github.com/nekorg/pawbar/internal/modules"
13 | )
14 |
15 | func init() {
16 | config.RegisterModule("bluetooth", defaultOptions, func(o Options) (modules.Module, error) { return &bluetoothModule{opts: o}, nil })
17 | }
18 |
19 | type NoConnectionOptions struct {
20 | Fg config.Color `yaml:"fg"`
21 | Bg config.Color `yaml:"bg"`
22 | Format config.Format `yaml:"format"`
23 | }
24 |
25 | type ConnectionOptions struct {
26 | Fg config.Color `yaml:"fg"`
27 | Bg config.Color `yaml:"bg"`
28 | Format config.Format `yaml:"format"`
29 | }
30 |
31 | type Options struct {
32 | Fg config.Color `yaml:"fg"`
33 | Bg config.Color `yaml:"bg"`
34 | Cursor config.Cursor `yaml:"cursor"`
35 | Format config.Format `yaml:"format"`
36 | Connection ConnectionOptions `yaml:"connection"`
37 | NoConnection NoConnectionOptions `yaml:"noconnection"`
38 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
39 | }
40 |
41 | type MouseOptions struct {
42 | Fg *config.Color `yaml:"fg"`
43 | Bg *config.Color `yaml:"bg"`
44 | Cursor *config.Cursor `yaml:"cursor"`
45 | Format *config.Format `yaml:"format"`
46 | }
47 |
48 | func defaultOptions() Options {
49 | fd, _ := config.NewTemplate("")
50 | fc, _ := config.NewTemplate("")
51 | fn, _ := config.NewTemplate("")
52 | fa, _ := config.NewTemplate(" {{.Device}}")
53 | noConClr, _ := colors.ParseColor("darkgray")
54 | return Options{
55 | Format: config.Format{Template: fd},
56 | NoConnection: NoConnectionOptions{
57 | Format: config.Format{Template: fn},
58 | Fg: config.Color(noConClr),
59 | },
60 | Connection: ConnectionOptions{
61 | Format: config.Format{Template: fc},
62 | },
63 | OnClick: config.MouseActions[MouseOptions]{
64 | Actions: map[string]*config.MouseAction[MouseOptions]{
65 | "left": {
66 | Configs: []MouseOptions{{Format: &config.Format{Template: fa}}},
67 | },
68 | },
69 | },
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/internal/modules/wifi/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package wifi
8 |
9 | import (
10 | "time"
11 |
12 | "github.com/nekorg/pawbar/internal/config"
13 | "github.com/nekorg/pawbar/internal/lookup/colors"
14 | "github.com/nekorg/pawbar/internal/modules"
15 | )
16 |
17 | func init() {
18 | config.RegisterModule("wifi", defaultOptions, func(o Options) (modules.Module, error) { return &wifiModule{opts: o}, nil })
19 | }
20 |
21 | type NoConnectionOptions struct {
22 | Fg config.Color `yaml:"fg"`
23 | Bg config.Color `yaml:"bg"`
24 | Format config.Format `yaml:"format"`
25 | }
26 |
27 | type Options struct {
28 | Fg config.Color `yaml:"fg"`
29 | Bg config.Color `yaml:"bg"`
30 | Cursor config.Cursor `yaml:"cursor"`
31 | Tick config.Duration `yaml:"tick"`
32 | Format config.Format `yaml:"format"`
33 | NoConnection NoConnectionOptions `yaml:"noconnection"`
34 | Icons []rune `yaml:"icons"`
35 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
36 | }
37 |
38 | type MouseOptions struct {
39 | Fg *config.Color `yaml:"fg"`
40 | Bg *config.Color `yaml:"bg"`
41 | Cursor *config.Cursor `yaml:"cursor"`
42 | Tick *config.Duration `yaml:"tick"`
43 | Format *config.Format `yaml:"format"`
44 | }
45 |
46 | func defaultOptions() Options {
47 | fw, _ := config.NewTemplate("{{.Icon}}")
48 | fs, _ := config.NewTemplate("{{.Interface}}")
49 | fn, _ := config.NewTemplate("")
50 | fl, _ := config.NewTemplate("{{.Icon}} {{.SSID}}")
51 | noConClr, _ := colors.ParseColor("darkgray")
52 | return Options{
53 | Format: config.Format{Template: fw},
54 | Tick: config.Duration(5 * time.Second),
55 | Icons: []rune{'', '', '', '', ''},
56 | NoConnection: NoConnectionOptions{
57 | Format: config.Format{Template: fn},
58 | Fg: config.Color(noConClr),
59 | },
60 | OnClick: config.MouseActions[MouseOptions]{
61 | Actions: map[string]*config.MouseAction[MouseOptions]{
62 | "hover": {
63 | Configs: []MouseOptions{{Format: &config.Format{Template: fs}}},
64 | },
65 | "left": {
66 | Configs: []MouseOptions{{Format: &config.Format{Template: fl}}},
67 | },
68 | },
69 | },
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/pkg/dbusmenukitty/menu/manager.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package menu
8 |
9 | import (
10 | "sync"
11 |
12 | "github.com/nekorg/katnip"
13 | "github.com/fxamacker/cbor/v2"
14 | )
15 |
16 | type MenuManager struct {
17 | panels []*katnip.Panel
18 | positions []Position
19 | mutex sync.RWMutex
20 | }
21 |
22 | var globalManager = &MenuManager{}
23 |
24 | func GetManager() *MenuManager {
25 | return globalManager
26 | }
27 |
28 | func (sm *MenuManager) AddPanel(panel *katnip.Panel, x, y int) {
29 | sm.mutex.Lock()
30 | defer sm.mutex.Unlock()
31 |
32 | sm.panels = append(sm.panels, panel)
33 | sm.positions = append(sm.positions, Position{X: x, Y: y})
34 | }
35 |
36 | func (sm *MenuManager) RemovePanel(panel *katnip.Panel) {
37 | sm.mutex.Lock()
38 | defer sm.mutex.Unlock()
39 |
40 | for i, p := range sm.panels {
41 | if p == panel {
42 | for j := i + 1; j < len(sm.panels); j++ {
43 | sm.panels[j].Stop()
44 | }
45 |
46 | sm.panels = sm.panels[:i]
47 | sm.positions = sm.positions[:i]
48 | break
49 | }
50 | }
51 | }
52 |
53 | func (sm *MenuManager) CloseAllSubmenus() {
54 | sm.mutex.Lock()
55 | defer sm.mutex.Unlock()
56 |
57 | if len(sm.panels) > 1 {
58 | for i := 1; i < len(sm.panels); i++ {
59 | closeMsg := Message{
60 | Type: MsgMenuClose,
61 | Payload: MessagePayload{},
62 | }
63 | enc := cbor.NewEncoder(sm.panels[i].Writer())
64 | enc.Encode(closeMsg)
65 | sm.panels[i].Stop()
66 | }
67 | sm.panels = sm.panels[:1]
68 | sm.positions = sm.positions[:1]
69 | }
70 | }
71 |
72 | func (sm *MenuManager) CloseAllMenus() {
73 |
74 | sm.mutex.Lock()
75 | defer sm.mutex.Unlock()
76 |
77 | for _, p := range sm.panels {
78 | closeMsg := Message{
79 | Type: MsgMenuClose,
80 | Payload: MessagePayload{},
81 | }
82 | enc := cbor.NewEncoder(p.Writer())
83 | enc.Encode(closeMsg)
84 | p.Stop()
85 | }
86 | sm.panels = nil
87 | sm.positions = nil
88 | }
89 |
90 | func (sm *MenuManager) GetNextPosition() (int, int) {
91 | sm.mutex.RLock()
92 | defer sm.mutex.RUnlock()
93 |
94 | if len(sm.positions) == 0 {
95 | return 0, 0
96 | }
97 |
98 | lastPos := sm.positions[len(sm.positions)-1]
99 | return lastPos.X + 200, lastPos.Y // Adjust spacing as needed
100 | }
101 |
102 | func (sm *MenuManager) HandlePanelExit(panel *katnip.Panel) {
103 | sm.RemovePanel(panel)
104 | }
105 |
--------------------------------------------------------------------------------
/docs/vitepress.config.mts:
--------------------------------------------------------------------------------
1 | import {
2 | defineConfig,
3 | } from 'vitepress'
4 |
5 | // https://vitepress.dev/reference/site-config
6 | export default defineConfig({
7 | title: "pawbar",
8 | description: "Kat vibes for your desktop",
9 | srcExclude: ['**/README.md'],
10 | cleanUrls: true,
11 | head: [
12 | [
13 | 'link',
14 | { rel: 'icon', type: 'image/svg+xml', href: '/pawbar.svg' }
15 | ],
16 | [
17 | 'link',
18 | {
19 | rel: 'stylesheet',
20 | href: 'https://fonts.googleapis.com/css2?family=Karla:wght@400;500;700&family=Source+Code+Pro:wght@400;500;700&display=swap'
21 | }
22 | ]
23 | ],
24 | themeConfig: {
25 | logo: '/pawbar.svg',
26 | outline: [2, 3],
27 | search: { provider: 'local' },
28 | // https://vitepress.dev/reference/default-theme-config
29 | nav: [
30 | { text: 'Docs', link: '/docs/getting-started', activeMatch: "/docs/" },
31 | ],
32 |
33 | sidebar: [
34 | {
35 | text: 'Getting Started',
36 | link: '/docs/getting-started',
37 | },
38 | {
39 | text: 'Configuration',
40 | link: '/docs/configuration',
41 | },
42 | {
43 | text: 'Modules',
44 | collapsed: false,
45 | base: '/docs/modules',
46 | link: '/',
47 | items: [
48 | { text: 'Backlight', link: '#backlight' },
49 | { text: 'Battery', link: '#battery' },
50 | { text: 'Bluetooth', link: '#bluetooth' },
51 | { text: 'Clock', link: '#clock' },
52 | { text: 'CPU', link: '#cpu' },
53 | { text: 'Custom', link: '#custom' },
54 | { text: 'Disk', link: '#disk' },
55 | { text: 'Idle Inhibitor', link: '#idleInhibitor' },
56 | { text: 'Locale', link: '#locale' },
57 | { text: 'Mpris', link: '#mpris' },
58 | { text: 'RAM', link: '#ram' },
59 | { text: 'Window Title', link: '#title' },
60 | { text: 'Tray', link: '#tray' },
61 | { text: 'Volume', link: '#volume' },
62 | { text: 'Wi-Fi', link: '#wifi' },
63 | { text: 'Workspace', link: '#ws' },
64 | ]
65 | }
66 | ],
67 |
68 | socialLinks: [
69 | { icon: 'github', link: 'https://github.com/codelif/pawbar' }
70 | ],
71 |
72 | editLink: {
73 | pattern: 'https://github.com/codelif/pawbar/edit/main/docs/:path',
74 | text: 'Edit this page on Github'
75 | },
76 |
77 | footer: {
78 | message: 'Released under the BSD-3-Clause License.',
79 | copyright: 'Copyright © 2025 Harsh Sharma'
80 | }
81 | }
82 | })
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/internal/lookup/units/byte.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package units
8 |
9 | import (
10 | "fmt"
11 | )
12 |
13 | const (
14 | Byte float64 = 1
15 |
16 | // IEC
17 | KiB = 1024 * Byte
18 | MiB = 1024 * KiB
19 | GiB = 1024 * MiB
20 | TiB = 1024 * GiB
21 |
22 | // SI (for those people...)
23 | KB = 1000 * Byte
24 | MB = 1000 * KB
25 | GB = 1000 * MB
26 | TB = 1000 * GB
27 | )
28 |
29 | // I don't think anyone with more than a PB/PiB of
30 | // disk space cares for this, so it is what it is
31 |
32 | type Unit struct {
33 | Div float64
34 | Name string
35 | }
36 |
37 | type System int
38 |
39 | const (
40 | IEC System = iota
41 | SI
42 | )
43 |
44 | func ParseSystem(s string) System {
45 | switch s {
46 | case "si", "SI", "metric", "decimal":
47 | return SI
48 | default:
49 | return IEC
50 | }
51 | }
52 |
53 | var (
54 | unitsIEC = []Unit{
55 | {TiB, "TiB"},
56 | {GiB, "GiB"},
57 | {MiB, "MiB"},
58 | {KiB, "KiB"},
59 | {Byte, "B"},
60 | }
61 |
62 | unitsSI = []Unit{
63 | {TB, "TB"},
64 | {GB, "GB"},
65 | {MB, "MB"},
66 | {KB, "KB"},
67 | {Byte, "B"},
68 | }
69 | )
70 |
71 | var parseTable = map[string]Unit{
72 | "b": {Byte, "B"}, "byte": {Byte, "B"}, "bytes": {Byte, "B"},
73 | "kib": {KiB, "KiB"}, "kibibyte": {KiB, "KiB"}, "kibibytes": {KiB, "KiB"},
74 | "mib": {MiB, "MiB"}, "mibibyte": {MiB, "MiB"}, "mibibytes": {MiB, "MiB"},
75 | "gib": {GiB, "GiB"}, "gibibyte": {GiB, "GiB"}, "gibibytes": {GiB, "GiB"},
76 | "tib": {TiB, "TiB"}, "tebibyte": {TiB, "TiB"}, "tebibytes": {TiB, "TiB"},
77 |
78 | "kb": {KB, "KB"}, "kilobyte": {KB, "KB"}, "kilobytes": {KB, "KB"},
79 | "mb": {MB, "MB"}, "megabyte": {MB, "MB"}, "megabytes": {MB, "MB"},
80 | "gb": {GB, "GB"}, "gigabyte": {GB, "GB"}, "gigabytes": {GB, "GB"},
81 | "tb": {TB, "TB"}, "terabyte": {TB, "TB"}, "terabytes": {TB, "TB"},
82 | }
83 |
84 | func table(sys System) []Unit {
85 | switch sys {
86 | case SI:
87 | return unitsSI
88 | default:
89 | return unitsIEC
90 | }
91 | }
92 |
93 | func Choose(bytes uint64, sys System) Unit {
94 | units := table(sys)
95 | for _, u := range units {
96 | if float64(bytes)/u.Div >= 1.0 {
97 | return u
98 | }
99 | }
100 |
101 | return units[len(units)-1]
102 | }
103 |
104 | func Format(v uint64, u Unit) float64 {
105 | return float64(v) / u.Div
106 | }
107 |
108 | func ParseUnit(s string) (Unit, error) {
109 | u, ok := parseTable[s]
110 | if !ok {
111 | return Unit{}, fmt.Errorf("cannot parse unit name %q", s)
112 | }
113 |
114 | return u, nil
115 | }
116 |
--------------------------------------------------------------------------------
/internal/modules/ram/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package ram
8 |
9 | import (
10 | "time"
11 |
12 | "github.com/nekorg/pawbar/internal/config"
13 | "github.com/nekorg/pawbar/internal/lookup/colors"
14 | "github.com/nekorg/pawbar/internal/lookup/icons"
15 | "github.com/nekorg/pawbar/internal/modules"
16 | )
17 |
18 | func init() {
19 | config.RegisterModule("ram", defaultOptions, func(o Options) (modules.Module, error) { return &RamModule{opts: o}, nil })
20 | }
21 |
22 | type ThresholdOptions struct {
23 | Percent config.Percent `yaml:"percent"`
24 | Direction config.Direction `yaml:"direction"`
25 | Fg config.Color `yaml:"fg"`
26 | Bg config.Color `yaml:"bg"`
27 | }
28 |
29 | type Options struct {
30 | Fg config.Color `yaml:"fg"`
31 | Bg config.Color `yaml:"bg"`
32 | Cursor config.Cursor `yaml:"cursor"`
33 | Tick config.Duration `yaml:"tick"`
34 | Format config.Format `yaml:"format"`
35 | Icon config.Icon `yaml:"icon"`
36 |
37 | UseSI bool `yaml:"use_si"`
38 | Scale config.Scale `yaml:"unit"`
39 |
40 | Thresholds []ThresholdOptions `yaml:"thresholds"`
41 |
42 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
43 | }
44 |
45 | type MouseOptions struct {
46 | Fg *config.Color `yaml:"fg"`
47 | Bg *config.Color `yaml:"bg"`
48 | Cursor *config.Cursor `yaml:"cursor"`
49 | Tick *config.Duration `yaml:"tick"`
50 | Format *config.Format `yaml:"format"`
51 | Icon *config.Icon `yaml:"icon"`
52 |
53 | UseSI *bool `yaml:"use_si"`
54 | Scale *config.Scale `yaml:"scale"`
55 | }
56 |
57 | func defaultOptions() Options {
58 | icon, _ := icons.Lookup("compass")
59 | f0, _ := config.NewTemplate("{{.Icon}} {{.UsedPercent}}%")
60 | f1, _ := config.NewTemplate("{{.Icon}} {{.Used | round 2}}/{{.Total | round 2}} {{.Unit}}")
61 | urgClr, _ := colors.ParseColor("@urgent")
62 | warClr, _ := colors.ParseColor("@warning")
63 | return Options{
64 | Format: config.Format{Template: f0},
65 | Tick: config.Duration(10 * time.Second),
66 | UseSI: false,
67 | Icon: config.Icon(icon),
68 | Thresholds: []ThresholdOptions{
69 | {
70 | Percent: 80,
71 | Direction: config.Direction(true),
72 | Fg: config.Color(warClr),
73 | },
74 | {
75 | Percent: 90,
76 | Direction: config.Direction(true),
77 | Fg: config.Color(urgClr),
78 | },
79 | },
80 | OnClick: config.MouseActions[MouseOptions]{
81 | Actions: map[string]*config.MouseAction[MouseOptions]{
82 | "left": {
83 | Configs: []MouseOptions{{Format: &config.Format{Template: f1}}},
84 | },
85 | },
86 | },
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/internal/modules/disk/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package disk
8 |
9 | import (
10 | "time"
11 |
12 | "github.com/nekorg/pawbar/internal/config"
13 | "github.com/nekorg/pawbar/internal/lookup/colors"
14 | "github.com/nekorg/pawbar/internal/lookup/icons"
15 | "github.com/nekorg/pawbar/internal/modules"
16 | )
17 |
18 | func init() {
19 | config.RegisterModule("disk", defaultOptions, func(o Options) (modules.Module, error) { return &DiskModule{opts: o}, nil })
20 | }
21 |
22 | type ThresholdOptions struct {
23 | Percent config.Percent `yaml:"percent"`
24 | Direction config.Direction `yaml:"direction"`
25 | Fg config.Color `yaml:"fg"`
26 | Bg config.Color `yaml:"bg"`
27 | }
28 |
29 | type Options struct {
30 | Fg config.Color `yaml:"fg"`
31 | Bg config.Color `yaml:"bg"`
32 | Cursor config.Cursor `yaml:"cursor"`
33 | Tick config.Duration `yaml:"tick"`
34 | Format config.Format `yaml:"format"`
35 | Icon config.Icon `yaml:"icon"`
36 |
37 | UseSI bool `yaml:"use_si"`
38 | Scale config.Scale `yaml:"unit"`
39 |
40 | Thresholds []ThresholdOptions `yaml:"thresholds"`
41 |
42 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
43 | }
44 |
45 | type MouseOptions struct {
46 | Fg *config.Color `yaml:"fg"`
47 | Bg *config.Color `yaml:"bg"`
48 | Cursor *config.Cursor `yaml:"cursor"`
49 | Tick *config.Duration `yaml:"tick"`
50 | Format *config.Format `yaml:"format"`
51 | Icon *config.Icon `yaml:"icon"`
52 |
53 | UseSI *bool `yaml:"use_si"`
54 | Scale *config.Scale `yaml:"scale"`
55 | }
56 |
57 | func defaultOptions() Options {
58 | icon, _ := icons.Lookup("disk")
59 | f0, _ := config.NewTemplate("{{.Icon}} {{.UsedPercent}}%")
60 | f1, _ := config.NewTemplate("{{.Icon}} {{.Used | round 2}}/{{.Total | round 2}} {{.Unit}}")
61 | urgClr, _ := colors.ParseColor("@urgent")
62 | warClr, _ := colors.ParseColor("@warning")
63 | return Options{
64 | Format: config.Format{Template: f0},
65 | Tick: config.Duration(10 * time.Second),
66 | UseSI: false,
67 | Icon: config.Icon(icon),
68 | Thresholds: []ThresholdOptions{
69 | {
70 | Percent: 80,
71 | Direction: config.Direction(true),
72 | Fg: config.Color(warClr),
73 | },
74 | {
75 | Percent: 90,
76 | Direction: config.Direction(true),
77 | Fg: config.Color(urgClr),
78 | },
79 | },
80 | OnClick: config.MouseActions[MouseOptions]{
81 | Actions: map[string]*config.MouseAction[MouseOptions]{
82 | "left": {
83 | Configs: []MouseOptions{{Format: &config.Format{Template: f1}}},
84 | },
85 | },
86 | },
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/pkg/dbusmenukitty/menu/types.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package menu
8 |
9 | import "github.com/codelif/xdgicons"
10 |
11 | const (
12 | ItemStandard string = "standard"
13 | ItemSeparator string = "separator"
14 | )
15 |
16 | const (
17 | ToggleNone string = ""
18 | ToggleCheckMark string = "checkmark"
19 | ToggleRadio string = "radio"
20 | )
21 |
22 | const (
23 | StateOff int = 0
24 | StateOn int = 1
25 | StateIndeterminate int = -1 // can be anything other than 0&1 but set to default value
26 | )
27 |
28 | type MessageType int
29 |
30 | const (
31 | MsgMenuUpdate MessageType = iota
32 | MsgMouseUpdate
33 | MsgItemClicked
34 | MsgItemHovered
35 | MsgSubmenuRequested
36 | MsgSubmenuCancelRequested
37 | MsgMenuClose
38 | )
39 |
40 | type Size struct {
41 | Rows, Cols, XPixels, YPixels int
42 | }
43 |
44 | type Position struct {
45 | X, Y, PX, PY int
46 | }
47 |
48 | type PPC struct {
49 | X, Y float64
50 | }
51 |
52 | type State struct {
53 | Size Size
54 | Position Position
55 | PPC PPC
56 | }
57 |
58 | type MessagePayload struct {
59 | Menu []Item
60 | ItemId int32
61 | State State
62 | X, Y int // for other things
63 | }
64 |
65 | type Message struct {
66 | Type MessageType
67 | Payload MessagePayload
68 | }
69 |
70 | type Label struct {
71 | Display string
72 | AccessKey rune
73 | AccessIndex int
74 | Found bool
75 | }
76 |
77 | type Item struct {
78 | Id int32
79 | Type string
80 | Label Label
81 | Enabled bool
82 | Visible bool
83 | IconName string
84 | Icon xdgicons.Icon // custom property for speeding up icon lookup (valid if IconName exists)
85 | IconData []byte
86 | Shortcut [][]string
87 | ToggleType string
88 | ToggleState int32
89 | HasChildren bool
90 | }
91 |
92 | func ParseLabel(label string) Label {
93 | runes := []rune(label)
94 | n := len(runes)
95 |
96 | var output []rune
97 | outPos := 0
98 | var result Label
99 |
100 | for i := 0; i < n; {
101 | if runes[i] == '_' {
102 | if i+1 < n && runes[i+1] == '_' {
103 | output = append(output, '_')
104 | outPos++
105 | i += 2
106 | } else {
107 | if !result.Found && i+1 < n {
108 | result.Found = true
109 | result.AccessKey = runes[i+1]
110 | result.AccessIndex = outPos
111 | output = append(output, runes[i+1])
112 | outPos++
113 | i += 2
114 | } else {
115 | i++
116 | }
117 | }
118 | } else {
119 | output = append(output, runes[i])
120 | outPos++
121 | i++
122 | }
123 | }
124 |
125 | result.Display = string(output)
126 | return result
127 | }
128 |
--------------------------------------------------------------------------------
/internal/modules/battery/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package battery
8 |
9 | import (
10 | "github.com/nekorg/pawbar/internal/config"
11 | "github.com/nekorg/pawbar/internal/lookup/colors"
12 | "github.com/nekorg/pawbar/internal/modules"
13 | )
14 |
15 | func init() {
16 | config.RegisterModule("battery", defaultOptions, func(o Options) (modules.Module, error) { return &Battery{opts: o}, nil })
17 | }
18 |
19 | type ThresholdOptions struct {
20 | Percent config.Percent `yaml:"percent"`
21 | Direction config.Direction `yaml:"direction"`
22 | Fg config.Color `yaml:"fg"`
23 | Bg config.Color `yaml:"bg"`
24 | }
25 |
26 | type DischargingOptions struct {
27 | Fg config.Color `yaml:"fg"`
28 | Bg config.Color `yaml:"bg"`
29 | Icons []rune `yaml:"icons"`
30 | }
31 |
32 | type ChargingOptions struct {
33 | Fg config.Color `yaml:"fg"`
34 | Bg config.Color `yaml:"bg"`
35 | Icons []rune `yaml:"icons"`
36 | }
37 |
38 | type ChargedOptions struct {
39 | Fg config.Color `yaml:"fg"`
40 | Bg config.Color `yaml:"bg"`
41 | Icon rune `yaml:"icon"`
42 | }
43 |
44 | type Options struct {
45 | Fg config.Color `yaml:"fg"`
46 | Bg config.Color `yaml:"bg"`
47 | Cursor config.Cursor `yaml:"cursor"`
48 | Format config.Format `yaml:"format"`
49 | Discharging DischargingOptions `yaml:"discharging"`
50 | Charging ChargingOptions `yaml:"charging"`
51 | Charged ChargedOptions `yaml:"charged"`
52 | Thresholds []ThresholdOptions `yaml:"thresholds"`
53 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
54 | }
55 |
56 | type MouseOptions struct {
57 | Fg *config.Color `yaml:"fg"`
58 | Bg *config.Color `yaml:"bg"`
59 | Cursor *config.Cursor `yaml:"cursor"`
60 | Format *config.Format `yaml:"format"`
61 | }
62 |
63 | func defaultOptions() Options {
64 | fv, _ := config.NewTemplate("{{.Icon}} {{.Percent}}%")
65 | fr, _ := config.NewTemplate("{{.Hours}} hrs {{ .Minutes}} mins")
66 | urgClr, _ := colors.ParseColor("@urgent")
67 | warClr, _ := colors.ParseColor("@warning")
68 | return Options{
69 | Format: config.Format{Template: fv},
70 | Discharging: DischargingOptions{
71 | Icons: []rune{'', '', '', '', '', '', '', '', '', '', ''},
72 | },
73 | Charging: ChargingOptions{
74 | Icons: []rune{'', '', '', '', '', '', '', '', '', '', ''},
75 | },
76 | Charged: ChargedOptions{
77 | Icon: '',
78 | },
79 | Thresholds: []ThresholdOptions{
80 | {
81 | Percent: 15,
82 | Fg: config.Color(urgClr),
83 | },
84 | {
85 | Percent: 30,
86 | Fg: config.Color(warClr),
87 | },
88 | },
89 | OnClick: config.MouseActions[MouseOptions]{
90 | Actions: map[string]*config.MouseAction[MouseOptions]{
91 | "hover": {
92 | Configs: []MouseOptions{{Format: &config.Format{Template: fr}}},
93 | },
94 | },
95 | },
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/internal/modules/clock/clock.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package clock
8 |
9 | import (
10 | "time"
11 |
12 | "git.sr.ht/~rockorager/vaxis"
13 | "github.com/itchyny/timefmt-go"
14 | "github.com/nekorg/pawbar/internal/config"
15 | "github.com/nekorg/pawbar/internal/menus/calendar"
16 | "github.com/nekorg/pawbar/internal/modules"
17 | )
18 |
19 | type ClockModule struct {
20 | receive chan bool
21 | send chan modules.Event
22 |
23 | opts Options
24 | initialOpts Options
25 |
26 | currentTickerInterval time.Duration
27 | ticker *time.Ticker
28 | }
29 |
30 | func (mod *ClockModule) Dependencies() []string {
31 | return nil
32 | }
33 |
34 | func (mod *ClockModule) Run() (<-chan bool, chan<- modules.Event, error) {
35 | mod.receive = make(chan bool)
36 | mod.send = make(chan modules.Event)
37 | mod.initialOpts = mod.opts
38 |
39 | go func() {
40 | mod.currentTickerInterval = mod.opts.Tick.Go()
41 | mod.ticker = time.NewTicker(mod.currentTickerInterval)
42 | defer mod.ticker.Stop()
43 | for {
44 | select {
45 | case <-mod.ticker.C:
46 | mod.receive <- true
47 | case e := <-mod.send:
48 | switch ev := e.VaxisEvent.(type) {
49 | case vaxis.Mouse:
50 | if ev.EventType != vaxis.EventPress {
51 | break
52 | }
53 | btn := config.ButtonName(ev)
54 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
55 | mod.receive <- true
56 | }
57 | mod.ensureTickInterval()
58 | switch ev.Button {
59 | case vaxis.MouseRightButton:
60 | go calendar.LaunchMenu(ev.XPixel/2, ev.YPixel/2)
61 | }
62 |
63 | case modules.FocusIn:
64 | if mod.opts.OnClick.HoverIn(&mod.opts) {
65 | mod.receive <- true
66 | }
67 | mod.ensureTickInterval()
68 |
69 | case modules.FocusOut:
70 | if mod.opts.OnClick.HoverOut(&mod.opts) {
71 | mod.receive <- true
72 | }
73 | mod.ensureTickInterval()
74 | }
75 | }
76 | }
77 | }()
78 |
79 | return mod.receive, mod.send, nil
80 | }
81 |
82 | func (mod *ClockModule) ensureTickInterval() {
83 | if mod.opts.Tick.Go() != mod.currentTickerInterval {
84 | mod.currentTickerInterval = mod.opts.Tick.Go()
85 | mod.ticker.Reset(mod.currentTickerInterval)
86 | }
87 | }
88 |
89 | func (mod *ClockModule) Render() []modules.EventCell {
90 | var s vaxis.Style
91 | s.Foreground = mod.opts.Fg.Go()
92 | s.Background = mod.opts.Bg.Go()
93 |
94 | rch := vaxis.Characters(timefmt.Format(time.Now(), mod.opts.Format))
95 | r := make([]modules.EventCell, len(rch))
96 | for i, ch := range rch {
97 | r[i] = modules.EventCell{
98 | C: vaxis.Cell{
99 | Character: ch,
100 | Style: s,
101 | },
102 | Metadata: "",
103 | Mod: mod,
104 | MouseShape: mod.opts.Cursor.Go(),
105 | }
106 | }
107 | return r
108 | }
109 |
110 | func (mod *ClockModule) Channels() (<-chan bool, chan<- modules.Event) {
111 | return mod.receive, mod.send
112 | }
113 |
114 | func (mod *ClockModule) Name() string {
115 | return "clock"
116 | }
117 |
--------------------------------------------------------------------------------
/internal/menus/calendar/tui/tui.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "os"
7 | "strings"
8 | "time"
9 |
10 | "git.sr.ht/~rockorager/vaxis"
11 | "github.com/nekorg/katnip"
12 | )
13 |
14 | var (
15 | now = time.Now()
16 | currYear = now.Year()
17 | currMonth = now.Month()
18 | )
19 |
20 | func inc() {
21 | if currMonth.String() == "December" {
22 | currYear += 1
23 | currMonth = time.Month(1)
24 | } else {
25 | currMonth = currMonth + 1
26 | }
27 | }
28 |
29 | func dec() {
30 | if currMonth.String() == "January" {
31 | currYear -= 1
32 | currMonth = time.Month(12)
33 | } else {
34 | currMonth = currMonth - 1
35 | }
36 | }
37 |
38 | func Panel(k *katnip.Kitty, rw io.ReadWriter) int {
39 | vx, err := vaxis.New(vaxis.Options{
40 | WithTTY: os.Stdout.Name(),
41 | EnableSGRPixels: true,
42 | })
43 | if err != nil {
44 | return 1
45 | }
46 | defer vx.Close()
47 |
48 | draw := func() {
49 | win := vx.Window()
50 | win.Clear()
51 | PrintMonthCal(currYear, currMonth)
52 |
53 | vx.Render()
54 | }
55 | draw()
56 |
57 | for ev := range vx.Events() {
58 | switch ev := ev.(type) {
59 | case vaxis.Key:
60 | if ev.EventType == vaxis.EventPress {
61 | switch ev.Keycode {
62 | case vaxis.KeyEsc:
63 | return 0
64 | case vaxis.KeyLeft, vaxis.KeyUp:
65 | dec()
66 | draw()
67 | case vaxis.KeyRight, vaxis.KeyDown:
68 | inc()
69 | draw()
70 | }
71 | }
72 | case vaxis.Mouse:
73 | switch ev.Button {
74 |
75 | case vaxis.MouseWheelDown:
76 | inc()
77 | draw()
78 |
79 | case vaxis.MouseWheelUp:
80 | dec()
81 | draw()
82 |
83 | // case vaxis.EventPress:
84 | // vx.Notify("press:", fmt.Sprintf("%d%d", ev.Col, ev.Row))
85 | }
86 | // case vaxis.FocusOut:
87 | // return 0
88 | case vaxis.Resize, vaxis.Redraw:
89 | draw()
90 | }
91 | }
92 | return 0
93 | }
94 |
95 | func PrintMonthCal(year int, month time.Month) {
96 | const width = 20
97 | title := fmt.Sprintf("%s %d", month, year)
98 | fmt.Println(center(title, width))
99 | fmt.Println("Su Mo Tu We Th Fr Sa")
100 |
101 | loc := time.Now().Location()
102 | first := time.Date(year, month, 1, 0, 0, 0, 0, loc)
103 | offset := int(first.Weekday())
104 | daysInMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
105 | day := 1
106 | for week := 0; week < 6; week++ {
107 | for weekday := 0; weekday < 7; weekday++ {
108 | cellIndex := week*7 + weekday
109 | if cellIndex < offset || day > daysInMonth {
110 | fmt.Print(" ")
111 | } else {
112 | if day == now.Day() && month == now.Month() && year == now.Year() {
113 | reverseVideo := "\033[7m"
114 | reset := "\033[0m"
115 | fmt.Printf("%s%2d%s ", reverseVideo, day, reset)
116 | } else if day == now.Day() {
117 | bgOn := "\033[48;5;243m"
118 | off := "\033[0m"
119 | fgOn := "\033[97m"
120 | fmt.Printf("%s%s%2d%s%s ", bgOn, fgOn, day, off, off)
121 | } else {
122 | fmt.Printf("%2d ", day)
123 | }
124 | day++
125 | }
126 | }
127 | }
128 | }
129 |
130 | func center(s string, width int) string {
131 | if len(s) >= width {
132 | return s
133 | }
134 | left := (width - len(s)) / 2
135 | return strings.Repeat(" ", left) + s
136 | }
137 |
--------------------------------------------------------------------------------
/internal/modules/cpu/config.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package cpu
8 |
9 | import (
10 | "text/template"
11 | "time"
12 |
13 | "github.com/nekorg/pawbar/internal/config"
14 | "github.com/nekorg/pawbar/internal/lookup/colors"
15 | "github.com/nekorg/pawbar/internal/modules"
16 | )
17 |
18 | // Dev NOTE:
19 | // - cpu:
20 | // tick: 10s
21 | // cursor: text
22 | // onmouse:
23 | // left:
24 | // notify: "wow"
25 | // run: ["pavucontrol"]
26 | // config:
27 | // - cursor: pointer
28 | // format: wow
29 | // - format: "{{.Percent}} this is shit"
30 | // fg: yellow
31 | // bg: blue
32 | // - tick: 1s
33 | //
34 | // In config:, it can be a list like above or just a single alternative:
35 | // left:
36 | // config:
37 | // fg: aliceblue
38 | // format: hello
39 | //
40 | // this will just alternate between this state and initial state
41 | // also note fields not defined (like bg, cursor, tick in above example)
42 | // use their initial values they don't carry from previous alternate
43 | //
44 | // all of this will be true for all options with config.OnClickActions field
45 | // (they also need to call the relevent OnClickAction function in the loop)
46 | // see internal/modules/cpu/cpu.go and internal/config/click.go for more info
47 |
48 | // you can also attach a Validate function to Options but try to avoid if you
49 | // can define a yaml type in internal/config/types.go
50 |
51 | func init() {
52 | config.RegisterModule("cpu", defaultOptions, func(o Options) (modules.Module, error) { return &CpuModule{opts: o}, nil })
53 | }
54 |
55 | type ThresholdOptions struct {
56 | Percent config.Percent `yaml:"percent"`
57 | For config.Duration `yaml:"for"`
58 | Fg config.Color `yaml:"fg"`
59 | Bg config.Color `yaml:"bg"`
60 | }
61 |
62 | type Options struct {
63 | Fg config.Color `yaml:"fg"`
64 | Bg config.Color `yaml:"bg"`
65 | Cursor config.Cursor `yaml:"cursor"`
66 | Tick config.Duration `yaml:"tick"`
67 | Format config.Format `yaml:"format"`
68 | Threshold ThresholdOptions `yaml:"threshold"`
69 | OnClick config.MouseActions[MouseOptions] `yaml:"onmouse"`
70 | }
71 |
72 | // these field names need to match exactly the
73 | // ones in Options (coz ya know, reflections ;)
74 | // kill me. But they do enable cleaner per-module config code
75 | // so it was worth it.
76 | // Also Rob Pike is such a goated guy
77 | // Read his wise words: https://go.dev/blog/laws-of-reflection
78 | type MouseOptions struct {
79 | Fg *config.Color `yaml:"fg"`
80 | Bg *config.Color `yaml:"bg"`
81 | Cursor *config.Cursor `yaml:"cursor"`
82 | Tick *config.Duration `yaml:"tick"`
83 | Format *config.Format `yaml:"format"`
84 | }
85 |
86 | func defaultOptions() Options {
87 | f, _ := template.New("format").Parse(" {{.Percent}}%")
88 | urgClr, _ := colors.ParseColor("@urgent")
89 | return Options{
90 | Format: config.Format{Template: f},
91 | Tick: config.Duration(3 * time.Second),
92 | Threshold: ThresholdOptions{
93 | Percent: 90,
94 | For: config.Duration(7 * time.Second),
95 | Fg: config.Color(urgClr),
96 | },
97 | OnClick: config.MouseActions[MouseOptions]{},
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # pawbar
2 | A kitten-panel based desktop panel for your desktop
3 |
4 | 
5 |
6 | ## Why?
7 | Due to the existence of modern terminal standards (especially in kitty), a tiny terminal windows support true colors, [text-sizing](https://sw.kovidgoyal.net/kitty/text-sizing-protocol/), [images](https://sw.kovidgoyal.net/kitty/graphics-protocol/), mouse support (clicking, dragging, hover and scrolling), etc. Which makes it a viable option for a status bar, instead of a GUI Toolkits (like GTK). Kitty enables this using its [panel](https://sw.kovidgoyal.net/kitty/kittens/panel/) mode/kitten, through which it offers all the customization/capabilities of a kitty terminal window but as a status bar on top of your screen.
8 |
9 |
10 | ## Installing
11 | > [!IMPORTANT]
12 | > You need to checkout the submodules before building `pawbar`, you can do one of the following:
13 | > - Clone the repository with
14 | > ```
15 | > git clone --recurse-submodules https://github.com/codelif/pawbar.git
16 | > cd pawbar
17 | > ```
18 | >
19 | > - Clone normally and checkout manually:
20 | > ```
21 | > git clone https://github.com/codelif/pawbar.git
22 | > cd pawbar
23 | > git submodule update --remote --init
24 | > ```
25 | A basic install script is there (you need to compile pawbar before running the script):
26 | ```sh
27 | go build ./cmd/pawbar
28 | ./install.sh
29 | ```
30 | Though fair caution it installs to `/usr/local/bin`
31 |
32 | > [!NOTE]
33 | > I will add other installation methods when I am satisfied with this project.
34 | ## Usage
35 | Run bar by calling the bar script after using the `install.sh` script:
36 | ```sh
37 | pawbar
38 | ```
39 |
40 |
41 | By default the bar is configured with only a clock and a battery. You can add modules by editing `$HOME/.config/pawbar/pawbar.yaml`.
42 |
43 | It has 16 modules (all customisable upto a certain extent,for now):
44 | - `backlight`: A screen brightness indicator (interactable)
45 | - `battery`: A battery module with dynamic icons and colors
46 | - `bluetooth`: A simple bluetooth conenction indicator (for now, without interactive menu)
47 | - `clock`: A simple date-time module (format changable on click)
48 | - `cpu`: CPU usage
49 | - `custom`: perform custom tasks, e.g. running at script, opening an app etc.
50 | - `disk`: Disk usage (format changable on click)
51 | - `idleInhibitor`: toggle screen off/lock actions as allowed/inhibited.
52 | - `locale`: Current locale
53 | - `mpris`: mpris player, with play/pause (interactable),artist,title.
54 | - `ram`: RAM usage (format changable on click)
55 | - `title`: A window class & title display ((hyprland/i3/sway)
56 | - `tray`: tray using nm-applet (with menu)
57 | - `volume`: A Volume level indicator (interactable)
58 | - `wifi`: A simple wifi conenction indicator (without menu, interatable on clicks)
59 | - `ws`: A dynamic workspace switcher (hyprland/i3/sway) with (with mouse events) (change workspace on click)
60 |
61 | - `space`: A single space
62 | - `sep`: A full height vertical bar and a space on either side
63 |
64 | A typical(my) config looks like:
65 | ```yaml
66 | left:
67 | - ws
68 | - title
69 |
70 | right:
71 | - battery
72 | - space
73 | - sep
74 | - space
75 | - clock
76 | ```
77 |
78 | ## Roadmap
79 | - [x] Running
80 | - [ ] Modules and Services:
81 | - [ ] bluetooth (with menu)
82 | - [ ] tray (more functional)
83 | - [ ] workspace and title for more WMs
84 | - [ ] power profiles
85 | - [ ] session controls (e.g. suspend)
86 | - [ ] calender addition to module(clock)
87 | - [ ] Suggest more
88 | - [ ] Extended module config
89 | - [ ] Extended bar config
90 | - [ ] Menu support
91 |
92 | ## Contribution
93 | Project is in very early stages, any contribution is very much appreciated.
94 |
--------------------------------------------------------------------------------
/internal/modules/cpu/cpu.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package cpu
8 |
9 | import (
10 | "bytes"
11 | "time"
12 |
13 | "git.sr.ht/~rockorager/vaxis"
14 | "github.com/nekorg/pawbar/internal/config"
15 | "github.com/nekorg/pawbar/internal/modules"
16 | "github.com/shirou/gopsutil/v3/cpu"
17 | )
18 |
19 | type CpuModule struct {
20 | receive chan bool
21 | send chan modules.Event
22 |
23 | opts Options
24 | initialOpts Options
25 |
26 | highStart time.Time
27 | highTriggered bool
28 |
29 | currentTickerInterval time.Duration
30 | ticker *time.Ticker
31 | }
32 |
33 | func (mod *CpuModule) Dependencies() []string {
34 | return nil
35 | }
36 |
37 | func (mod *CpuModule) Run() (<-chan bool, chan<- modules.Event, error) {
38 | mod.receive = make(chan bool)
39 | mod.send = make(chan modules.Event)
40 | mod.initialOpts = mod.opts
41 |
42 | go func() {
43 | mod.currentTickerInterval = mod.opts.Tick.Go()
44 | mod.ticker = time.NewTicker(mod.currentTickerInterval)
45 | defer mod.ticker.Stop()
46 | for {
47 | select {
48 | case <-mod.ticker.C:
49 | mod.receive <- true
50 | case e := <-mod.send:
51 | switch ev := e.VaxisEvent.(type) {
52 | case vaxis.Mouse:
53 | if ev.EventType != vaxis.EventPress {
54 | break
55 | }
56 | btn := config.ButtonName(ev)
57 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
58 | mod.receive <- true
59 | }
60 | mod.ensureTickInterval()
61 |
62 | case modules.FocusIn:
63 | if mod.opts.OnClick.HoverIn(&mod.opts) {
64 | mod.receive <- true
65 | }
66 | mod.ensureTickInterval()
67 |
68 | case modules.FocusOut:
69 | if mod.opts.OnClick.HoverOut(&mod.opts) {
70 | mod.receive <- true
71 | }
72 | mod.ensureTickInterval()
73 | }
74 | }
75 | }
76 | }()
77 |
78 | return mod.receive, mod.send, nil
79 | }
80 |
81 | func (mod *CpuModule) ensureTickInterval() {
82 | if mod.opts.Tick.Go() != mod.currentTickerInterval {
83 | mod.currentTickerInterval = mod.opts.Tick.Go()
84 | mod.ticker.Reset(mod.currentTickerInterval)
85 | }
86 | }
87 |
88 | func (mod *CpuModule) Render() []modules.EventCell {
89 | percent, err := cpu.Percent(0, false)
90 | if err != nil || len(percent) == 0 {
91 | return nil
92 | }
93 | usage := int(percent[0])
94 |
95 | threshold := mod.opts.Threshold.Percent.Go()
96 | if usage > threshold {
97 | if mod.highStart.IsZero() {
98 | mod.highStart = time.Now()
99 | } else if !mod.highTriggered && time.Since(mod.highStart) >= mod.opts.Threshold.For.Go() {
100 | mod.highTriggered = true
101 | }
102 | } else {
103 | mod.highStart = time.Time{}
104 | mod.highTriggered = false
105 | }
106 |
107 | style := vaxis.Style{}
108 | if mod.highTriggered {
109 | style.Foreground = mod.opts.Threshold.Fg.Go()
110 | style.Background = mod.opts.Threshold.Bg.Go()
111 |
112 | } else {
113 | style.Foreground = mod.opts.Fg.Go()
114 | style.Background = mod.opts.Bg.Go()
115 | }
116 |
117 | var buf bytes.Buffer
118 | _ = mod.opts.Format.Execute(&buf, struct{ Percent int }{usage})
119 |
120 | rch := vaxis.Characters(buf.String())
121 | r := make([]modules.EventCell, len(rch))
122 |
123 | for i, ch := range rch {
124 | r[i] = modules.EventCell{C: vaxis.Cell{Character: ch, Style: style}, Mod: mod, MouseShape: mod.opts.Cursor.Go()}
125 | }
126 | return r
127 | }
128 |
129 | func (mod *CpuModule) Channels() (<-chan bool, chan<- modules.Event) {
130 | return mod.receive, mod.send
131 | }
132 |
133 | func (mod *CpuModule) Name() string {
134 | return "cpu"
135 | }
136 |
--------------------------------------------------------------------------------
/internal/menus/power/tui/tui.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "os"
7 | "time"
8 |
9 | "git.sr.ht/~rockorager/vaxis"
10 | "github.com/godbus/dbus/v5"
11 | "github.com/nekorg/katnip"
12 | )
13 |
14 | func Panel(k *katnip.Kitty, rw io.ReadWriter) int {
15 | vx, err := vaxis.New(vaxis.Options{
16 | WithTTY: os.Stdout.Name(),
17 | EnableSGRPixels: true,
18 | })
19 | if err != nil {
20 | return 1
21 | }
22 | defer vx.Close()
23 | win := vx.Window()
24 |
25 | conn, obj, err := connect()
26 | if err != nil {
27 | fmt.Errorf("error in connection: %s", err)
28 | }
29 | defer conn.Close()
30 |
31 | draw(-1, win, getProfile(obj))
32 | vx.Render()
33 | sel := -1
34 | redraw := func() {
35 | draw(sel, win, getProfile(obj))
36 | vx.Render()
37 | }
38 |
39 | for ev := range vx.Events() {
40 | switch ev := ev.(type) {
41 | case vaxis.Key:
42 | if ev.EventType == vaxis.EventPress {
43 | switch ev.Keycode {
44 | case vaxis.KeyEsc:
45 | return 0
46 | case vaxis.KeyUp:
47 | case vaxis.KeyDown:
48 | }
49 | }
50 | case vaxis.Mouse:
51 | newSel := -1
52 | if ev.Row >= 0 && ev.Row < 3 {
53 | newSel = ev.Row
54 | }
55 | if newSel != sel {
56 | sel = newSel
57 | redraw()
58 | }
59 | if ev.EventType == vaxis.EventLeave {
60 | sel = -1
61 | redraw()
62 | }
63 |
64 | if ev.Button == vaxis.MouseLeftButton && ev.EventType == vaxis.EventPress {
65 | switch sel {
66 | case 0:
67 | setProfile("performance", obj)
68 | redraw()
69 | case 1:
70 | setProfile("balanced", obj)
71 | redraw()
72 | case 2:
73 | setProfile("power-saver", obj)
74 | redraw()
75 | }
76 | time.Sleep(300 * time.Millisecond)
77 | return 0
78 | }
79 | }
80 | }
81 | return 0
82 | }
83 |
84 | func connect() (*dbus.Conn, dbus.BusObject, error) {
85 | conn, err := dbus.ConnectSystemBus()
86 | if err != nil {
87 | return nil, nil, fmt.Errorf("failed to connect system bus: %w", err)
88 | }
89 |
90 | obj := conn.Object(
91 | "org.freedesktop.UPower.PowerProfiles",
92 | "/org/freedesktop/UPower/PowerProfiles",
93 | )
94 | return conn, obj, nil
95 | }
96 |
97 | func setProfile(profile string, obj dbus.BusObject) {
98 | call := obj.Call("org.freedesktop.DBus.Properties.Set", 0,
99 | "org.freedesktop.UPower.PowerProfiles",
100 | "ActiveProfile",
101 | dbus.MakeVariant(profile),
102 | )
103 | if call.Err != nil {
104 | fmt.Errorf("error in setting ActiveProfile: %s", call.Err)
105 | }
106 | }
107 |
108 | func getProfile(obj dbus.BusObject) string {
109 | var v dbus.Variant
110 | err := obj.Call("org.freedesktop.DBus.Properties.Get", 0,
111 | "org.freedesktop.UPower.PowerProfiles",
112 | "ActiveProfile",
113 | ).Store(&v)
114 | if err != nil {
115 | fmt.Errorf("error in getting ActiveProfile: %s", err)
116 | }
117 | return v.Value().(string)
118 | }
119 |
120 | func draw(sel int, win vaxis.Window, mode string) {
121 | win.Clear()
122 |
123 | normal := vaxis.Style{Foreground: vaxis.ColorWhite}
124 | highlighted := vaxis.Style{
125 | Foreground: vaxis.ColorWhite,
126 | Background: vaxis.ColorGray,
127 | }
128 |
129 | items := []string{"Performance", "Balanced", "Power-saving"}
130 | modeIndex := map[string]int{
131 | "performance": 0,
132 | "balanced": 1,
133 | "power-saver": 2,
134 | }
135 |
136 | active := -1
137 | if idx, ok := modeIndex[mode]; ok {
138 | active = idx
139 | }
140 |
141 | width, _ := win.Size()
142 | for i, text := range items {
143 | style := normal
144 | prefix := " "
145 | if i == sel {
146 | style = highlighted
147 | }
148 | if i == active {
149 | prefix = " "
150 | }
151 | line := fmt.Sprintf("%-*s", width, prefix+text)
152 | win.Println(i, vaxis.Segment{
153 | Text: line,
154 | Style: style,
155 | })
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/internal/services/pulse/pulse.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package pulse
8 |
9 | import (
10 | "fmt"
11 | "time"
12 |
13 | "github.com/nekorg/pawbar/internal/services"
14 | "github.com/codelif/pulseaudio"
15 | )
16 |
17 | func Register() (*PulseService, bool) {
18 | s, ok := services.Ensure("pulse", func() services.Service { return &PulseService{} }).(*PulseService)
19 | return s, ok
20 | }
21 |
22 | func GetService() (*PulseService, bool) {
23 | if s, ok := services.ServiceRegistry["pulse"].(*PulseService); ok {
24 | return s, true
25 | }
26 | return nil, false
27 | }
28 |
29 | type PulseService struct {
30 | running bool
31 | exit chan bool
32 | listeners []chan<- SinkEvent
33 | client *pulseaudio.Client
34 | }
35 |
36 | type SinkEvent struct {
37 | Sink string
38 | Volume float64
39 | Muted bool
40 | }
41 |
42 | func (p *PulseService) Name() string { return "pulse" }
43 |
44 | func (p *PulseService) IssueListener() <-chan SinkEvent {
45 | l := make(chan SinkEvent, 10)
46 | p.listeners = append(p.listeners, l)
47 |
48 | return l
49 | }
50 |
51 | func (p *PulseService) Start() error {
52 | if p.running {
53 | return nil
54 | }
55 |
56 | client, err := pulseaudio.NewClient("")
57 | if err != nil {
58 | return err
59 | }
60 | p.client = client
61 |
62 | events, err := client.Events()
63 | if err != nil {
64 | return err
65 | }
66 |
67 | p.exit = make(chan bool)
68 | p.running = true
69 |
70 | go func() {
71 | for p.running {
72 | select {
73 | case e := <-events:
74 | if e.Op == pulseaudio.EvChange && (e.Facility == pulseaudio.EvSink || e.Facility == pulseaudio.EvSource) {
75 | sink, err := p.GetDefaultSinkInfo()
76 | if err != nil {
77 | continue
78 | }
79 | for _, ch := range p.listeners {
80 | ch <- sink
81 | }
82 | }
83 | case <-p.exit:
84 | p.running = false
85 | }
86 | }
87 | }()
88 |
89 | return nil
90 | }
91 |
92 | func (p *PulseService) GetDefaultSink() (pulseaudio.Sink, error) {
93 | if !p.running {
94 | return pulseaudio.Sink{}, fmt.Errorf("pulse service not running")
95 | }
96 | serverInfo, err := p.client.ServerInfo()
97 | if err != nil {
98 | return pulseaudio.Sink{}, err
99 | }
100 |
101 | sinks, err := p.client.Sinks()
102 | if err != nil {
103 | return pulseaudio.Sink{}, err
104 | }
105 |
106 | for _, sink := range sinks {
107 | if sink.Name != serverInfo.DefaultSink {
108 | continue
109 | }
110 |
111 | return sink, nil
112 | }
113 |
114 | return pulseaudio.Sink{}, fmt.Errorf("default sink '%s' not found", serverInfo.DefaultSink)
115 | }
116 |
117 | func (p *PulseService) Stop() error {
118 | if !p.running {
119 | return nil
120 | }
121 |
122 | select {
123 | case <-time.After(2 * time.Second):
124 | return fmt.Errorf("could not stop")
125 | case p.exit <- true:
126 | p.running = false
127 | }
128 |
129 | return nil
130 | }
131 |
132 | func (p *PulseService) GetDefaultSinkInfo() (SinkEvent, error) {
133 | if !p.running {
134 | return SinkEvent{}, fmt.Errorf("pulse service not running")
135 | }
136 |
137 | sink, err := p.GetDefaultSink()
138 | if err != nil {
139 | return SinkEvent{}, err
140 | }
141 |
142 | return SinkEvent{
143 | Sink: sink.Name,
144 | Volume: float64(float32(sink.Cvolume[0])/0xffff) * 100,
145 | Muted: sink.Muted,
146 | }, nil
147 | }
148 |
149 | func (p *PulseService) SetSinkVolume(sink string, volume float64) error {
150 | if !p.running {
151 | return fmt.Errorf("pulse service not running")
152 | }
153 | return p.SetSinkVolume(sink, volume)
154 | }
155 |
156 | func (p *PulseService) SetSinkMute(sink string, mute bool) error {
157 | if !p.running {
158 | return fmt.Errorf("pulse service not running")
159 | }
160 | return p.SetSinkMute(sink, mute)
161 | }
162 |
--------------------------------------------------------------------------------
/internal/modules/tray/tray.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package tray
8 |
9 | import (
10 | "bytes"
11 | "strconv"
12 |
13 | "git.sr.ht/~rockorager/vaxis"
14 | "github.com/nekorg/pawbar/internal/config"
15 | "github.com/nekorg/pawbar/internal/modules"
16 | "github.com/nekorg/pawbar/internal/services/sni"
17 | "github.com/nekorg/pawbar/pkg/dbusmenukitty"
18 | "gopkg.in/yaml.v3"
19 | )
20 |
21 | func init() {
22 | config.Register("tray", func(n *yaml.Node) (modules.Module, error) {
23 | // no options yet
24 | return &Module{}, nil
25 | })
26 | }
27 |
28 | type Module struct {
29 | svc *sni.Service
30 | receive chan bool
31 | send chan modules.Event
32 | lastList []sni.Item
33 | }
34 |
35 | func (m *Module) Name() string { return "tray" }
36 | func (m *Module) Dependencies() []string { return []string{"sni"} }
37 | func (m *Module) Channels() (<-chan bool, chan<- modules.Event) { return m.receive, m.send }
38 |
39 | func (m *Module) Run() (<-chan bool, chan<- modules.Event, error) {
40 | svc, ok := sni.Register()
41 | if !ok {
42 | return nil, nil, nil
43 | }
44 | m.svc = svc
45 | m.receive = make(chan bool, 4)
46 | m.send = make(chan modules.Event, 8)
47 |
48 | // Subscribe to SNI updates
49 | evs := svc.IssueListener()
50 | m.lastList = svc.Items()
51 |
52 | go func() {
53 | for {
54 | select {
55 | case <-evs:
56 | m.lastList = svc.Items()
57 | m.receive <- true
58 | case e := <-m.send:
59 | switch ev := e.VaxisEvent.(type) {
60 | case vaxis.Mouse:
61 | if ev.EventType != vaxis.EventPress {
62 | break
63 | }
64 | // Which “cell” (item) did we click? We encode metadata = index
65 | idx, _ := strconv.Atoi(e.Cell.Metadata)
66 | if idx < 0 || idx >= len(m.lastList) {
67 | break
68 | }
69 | item := m.lastList[idx]
70 | switch ev.Button {
71 | case vaxis.MouseLeftButton:
72 | _ = svc.Activate(item, int32(ev.XPixel), int32(ev.YPixel))
73 | case vaxis.MouseMiddleButton:
74 | _ = svc.SecondaryActivate(item, int32(ev.XPixel), int32(ev.YPixel))
75 | case vaxis.MouseRightButton:
76 | // Prefer item.ContextMenu if it exists; if menu path is published, open our TUI popup
77 | if item.MenuPath != "" {
78 | // TODO: this assumes 2x scale, use pkg/monitor to determine correct scale.
79 | go dbusmenukitty.LaunchMenu(ev.XPixel/2, ev.YPixel/2)
80 | } else {
81 | _ = svc.ContextMenu(item, int32(ev.XPixel), int32(ev.YPixel))
82 | }
83 | case vaxis.MouseWheelUp:
84 | _ = svc.Scroll(item, +120, "vertical")
85 | case vaxis.MouseWheelDown:
86 | _ = svc.Scroll(item, -120, "vertical")
87 | }
88 | }
89 | }
90 | }
91 | }()
92 |
93 | return m.receive, m.send, nil
94 | }
95 |
96 | func (m *Module) Render() []modules.EventCell {
97 | list := m.lastList
98 | if len(list) == 0 {
99 | return nil
100 | }
101 | // MVP text form: [icon-like]Title or Id
102 | // You can swap ’labelFor’ to prefer IconName, Title, Id, etc.
103 | labelFor := func(it sni.Item) string {
104 | if it.IconName != "" {
105 | return it.IconName // simple and compact
106 | }
107 | if it.Title != "" {
108 | return it.Title
109 | }
110 | if it.Id != "" {
111 | return it.Id
112 | }
113 | return "?"
114 | }
115 |
116 | var out []modules.EventCell
117 | style := vaxis.Style{} // inherit bar defaults
118 | for i, it := range list {
119 | // Add a space separator between items
120 | if i != 0 {
121 | out = append(out, modules.ECSPACE)
122 | }
123 | var buf bytes.Buffer
124 | buf.WriteString(labelFor(it))
125 | for _, ch := range vaxis.Characters(buf.String()) {
126 | out = append(out, modules.EventCell{
127 | C: vaxis.Cell{Character: ch, Style: style},
128 | Metadata: strconv.Itoa(i), // index for click routing
129 | Mod: m,
130 | // use pointer cursor to hint clickability
131 | MouseShape: vaxis.MouseShapeDefault,
132 | })
133 | }
134 | }
135 | return out
136 | }
137 |
--------------------------------------------------------------------------------
/pkg/dbusmenukitty/tui/msghandler.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package tui
8 |
9 | import (
10 | "time"
11 |
12 | "github.com/nekorg/pawbar/pkg/dbusmenukitty/menu"
13 | "github.com/fxamacker/cbor/v2"
14 | )
15 |
16 | type MessageHandler struct {
17 | encoder *cbor.Encoder
18 | state *MenuState
19 | }
20 |
21 | func NewMessageHandler(encoder *cbor.Encoder, state *MenuState) *MessageHandler {
22 | return &MessageHandler{
23 | encoder: encoder,
24 | state: state,
25 | }
26 | }
27 |
28 | func (h *MessageHandler) sendMessage(msgType menu.MessageType, itemId int32, pixelX, pixelY int) {
29 | msg := menu.Message{
30 | Type: msgType,
31 | Payload: menu.MessagePayload{
32 | ItemId: itemId,
33 | X: pixelX,
34 | Y: pixelY,
35 | State: menu.State{
36 | Position: menu.Position{
37 | X: h.state.mouseX,
38 | Y: h.state.mouseY,
39 | PX: h.state.mousePixelX,
40 | PY: h.state.mousePixelY,
41 | },
42 | Size: h.state.size,
43 | PPC: h.state.ppc,
44 | },
45 | },
46 | }
47 | h.encoder.Encode(msg)
48 | }
49 |
50 | func (h *MessageHandler) handleItemHover(item *menu.Item) {
51 | h.sendMessage(menu.MsgItemHovered, item.Id, 0, 0)
52 | h.state.lastMouseY = h.state.mouseY
53 |
54 | // Only start timer for submenu items that don't already have an active timer
55 | if item.HasChildren && item.Enabled {
56 | // Only set new timer if this is a different item or no timer is active
57 | if h.state.hoverItemId != item.Id {
58 | // If we had a different submenu item, cancel it first
59 | if h.state.hoverItemId != 0 {
60 | h.handleSubmenuCancel()
61 | }
62 |
63 | h.state.hoverItemId = item.Id
64 | capturedItemId := item.Id
65 | capturedMouseY := h.state.mouseY
66 |
67 | h.state.hoverTimer = time.AfterFunc(hoverActivationTimeout, func() {
68 | // Only proceed if we're still hovering the same item at same position
69 | if h.state.hoverItemId == capturedItemId &&
70 | h.state.mouseY == capturedMouseY &&
71 | h.state.mouseOnSurface {
72 | h.sendMessage(menu.MsgSubmenuRequested, item.Id,
73 | 0, h.state.mouseY)
74 | }
75 | })
76 | }
77 | // If it's the same item, do nothing - keep existing state
78 | } else {
79 | // Moving to non-submenu item - cancel any active submenu
80 | if h.state.hoverItemId != 0 {
81 | h.handleSubmenuCancel()
82 | }
83 | }
84 | }
85 |
86 | func (h *MessageHandler) handleSubmenuCancel() {
87 | if h.state.hoverItemId != 0 {
88 | h.sendMessage(menu.MsgSubmenuCancelRequested, h.state.hoverItemId, 0, 0)
89 | h.state.hoverItemId = 0
90 | }
91 | }
92 |
93 | func (h *MessageHandler) handleItemClick(item *menu.Item) {
94 | id := item.Id
95 | if !h.state.mouseOnSurface {
96 | id = -1
97 | }
98 | h.sendMessage(menu.MsgItemClicked, id, 0, 0)
99 | }
100 |
101 | func (h *MessageHandler) handleMouseMotion(col, row int) {
102 | if h.state.mousePixelX < 0 || h.state.mousePixelY < 0 {
103 | return
104 | }
105 |
106 |
107 | h.state.mouseX = col
108 | // If mouse hasn't actually moved to a different row, ignore
109 | if h.state.mouseY == row && h.state.mouseOnSurface {
110 | return
111 | }
112 |
113 | prevMouseY := h.state.mouseY
114 | h.state.mouseOnSurface = true
115 | h.state.mouseY = row
116 |
117 | // Cancel any pending timer (but keep hoverItemId for submenu tracking)
118 | h.state.cancelHoverTimer()
119 |
120 | // If we moved to a different item, handle the transition
121 | if prevMouseY != row {
122 | // Handle hover for valid items
123 | if h.state.isSelectableItem(h.state.mouseY) {
124 | h.handleItemHover(&h.state.items[h.state.mouseY])
125 | } else {
126 | // Hovering over separator or invalid area - cancel any submenu
127 | if h.state.hoverItemId != 0 {
128 | h.handleSubmenuCancel()
129 | }
130 | h.state.lastMouseY = -1
131 | }
132 | }
133 | }
134 |
135 | func (h *MessageHandler) handleKeyNavigation(keyPressed bool) {
136 | if !keyPressed || !h.state.isValidItemIndex(h.state.mouseY) {
137 | return
138 | }
139 |
140 | h.state.cancelHoverTimer()
141 | currentItem := &h.state.items[h.state.mouseY]
142 | h.handleItemHover(currentItem)
143 | }
144 |
--------------------------------------------------------------------------------
/internal/modules/title/title.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package title
8 |
9 | import (
10 | "bytes"
11 | "fmt"
12 | "os"
13 |
14 | "git.sr.ht/~rockorager/vaxis"
15 | "github.com/nekorg/pawbar/internal/config"
16 | "github.com/nekorg/pawbar/internal/modules"
17 | "github.com/nekorg/pawbar/internal/services/hypr"
18 | "github.com/nekorg/pawbar/internal/services/i3"
19 | )
20 |
21 | type Window struct {
22 | Title string
23 | Class string
24 | }
25 |
26 | type backend interface {
27 | Window() Window
28 | Events() <-chan struct{}
29 | }
30 |
31 | type Module struct {
32 | b backend
33 | receive chan bool
34 | send chan modules.Event
35 |
36 | opts Options
37 | initialOpts Options
38 | }
39 |
40 | func New() modules.Module { return &Module{} }
41 |
42 | func (mod *Module) Name() string { return "title" }
43 | func (mod *Module) Dependencies() []string { return nil }
44 | func (mod *Module) Channels() (<-chan bool, chan<- modules.Event) { return mod.receive, mod.send }
45 |
46 | func (mod *Module) Run() (<-chan bool, chan<- modules.Event, error) {
47 | err := mod.selectBackend()
48 | if err != nil {
49 | return nil, nil, err
50 | }
51 |
52 | mod.receive = make(chan bool)
53 | mod.send = make(chan modules.Event)
54 | mod.initialOpts = mod.opts
55 |
56 | go func() {
57 | render := mod.b.Events()
58 | for {
59 | select {
60 | case e := <-mod.send:
61 | switch ev := e.VaxisEvent.(type) {
62 | case vaxis.Mouse:
63 | if ev.EventType != vaxis.EventPress {
64 | break
65 | }
66 | btn := config.ButtonName(ev)
67 |
68 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
69 | mod.receive <- true
70 | }
71 | case modules.FocusIn:
72 | if mod.opts.OnClick.HoverIn(&mod.opts) {
73 | mod.receive <- true
74 | }
75 |
76 | case modules.FocusOut:
77 | if mod.opts.OnClick.HoverOut(&mod.opts) {
78 | mod.receive <- true
79 | }
80 | }
81 |
82 | case <-render:
83 | mod.receive <- true
84 | }
85 | }
86 | }()
87 |
88 | return mod.receive, mod.send, nil
89 | }
90 |
91 | func (mod *Module) selectBackend() error {
92 | if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
93 | svc, ok := hypr.Register()
94 | if !ok {
95 | return fmt.Errorf("Could not start hypr service.")
96 | }
97 | mod.b = newHyprBackend(svc)
98 | } else if os.Getenv("I3SOCK") != "" || os.Getenv("SWAYSOCK") != "" {
99 | svc, ok := i3.Register()
100 | if !ok {
101 | return fmt.Errorf("Could not start i3 service.")
102 | }
103 | mod.b = newI3Backend(svc)
104 | } else {
105 | return fmt.Errorf("Could not find a wm backend for current environment.")
106 | }
107 |
108 | return nil
109 | }
110 |
111 | func (mod *Module) Render() []modules.EventCell {
112 | win := mod.b.Window()
113 | var cells []modules.EventCell
114 |
115 | if win.Class != "" {
116 | style := vaxis.Style{
117 | Foreground: mod.opts.Class.Fg.Go(),
118 | Background: mod.opts.Class.Bg.Go(),
119 | }
120 |
121 | var buf bytes.Buffer
122 | _ = mod.opts.Class.Format.Execute(&buf, struct{ Class string }{
123 | Class: " " + win.Class + " ",
124 | })
125 |
126 | for _, ch := range vaxis.Characters(buf.String()) {
127 | cells = append(cells, modules.EventCell{
128 | C: vaxis.Cell{
129 | Character: ch,
130 | Style: style,
131 | },
132 | Mod: mod,
133 | MouseShape: vaxis.MouseShapeDefault,
134 | })
135 | }
136 | }
137 |
138 | if win.Title != "" && win.Class != "" {
139 | style := vaxis.Style{
140 | Foreground: mod.opts.Title.Fg.Go(),
141 | Background: mod.opts.Title.Bg.Go(),
142 | }
143 |
144 | var buf bytes.Buffer
145 | _ = mod.opts.Title.Format.Execute(&buf, struct{ Title string }{
146 | Title: win.Title,
147 | })
148 | cells = append(cells, modules.EventCell{C: vaxis.Cell{Character: vaxis.Character{Grapheme: " ", Width: 1}}, Mod: mod})
149 | for _, ch := range vaxis.Characters(buf.String()) {
150 | cells = append(cells, modules.EventCell{
151 | C: vaxis.Cell{
152 | Character: ch,
153 | Style: style,
154 | },
155 | Mod: mod,
156 | MouseShape: vaxis.MouseShapeDefault,
157 | })
158 | }
159 | }
160 |
161 | return cells
162 | }
163 |
--------------------------------------------------------------------------------
/internal/modules/disk/disk.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package disk
8 |
9 | import (
10 | "bytes"
11 | "time"
12 |
13 | "git.sr.ht/~rockorager/vaxis"
14 | "github.com/nekorg/pawbar/internal/config"
15 | "github.com/nekorg/pawbar/internal/lookup/units"
16 | "github.com/nekorg/pawbar/internal/modules"
17 | "github.com/nekorg/pawbar/internal/utils"
18 | "github.com/shirou/gopsutil/v3/disk"
19 | )
20 |
21 | type DiskModule struct {
22 | receive chan bool
23 | send chan modules.Event
24 | opts Options
25 | initialOpts Options
26 | currentTickerInterval time.Duration
27 | ticker *time.Ticker
28 | }
29 |
30 | func (mod *DiskModule) Dependencies() []string { return nil }
31 |
32 | func (mod *DiskModule) Run() (<-chan bool, chan<- modules.Event, error) {
33 | mod.receive = make(chan bool)
34 | mod.send = make(chan modules.Event)
35 | mod.initialOpts = mod.opts
36 |
37 | go func() {
38 | mod.currentTickerInterval = mod.opts.Tick.Go()
39 | mod.ticker = time.NewTicker(mod.currentTickerInterval)
40 | defer mod.ticker.Stop()
41 |
42 | for {
43 | select {
44 | case <-mod.ticker.C:
45 | mod.receive <- true
46 | case e := <-mod.send:
47 | switch ev := e.VaxisEvent.(type) {
48 | case vaxis.Mouse:
49 | if ev.EventType != vaxis.EventPress {
50 | break
51 | }
52 | btn := config.ButtonName(ev)
53 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
54 | mod.receive <- true
55 | }
56 | mod.ensureTickInterval()
57 | case modules.FocusIn:
58 | if mod.opts.OnClick.HoverIn(&mod.opts) {
59 | mod.receive <- true
60 | }
61 | mod.ensureTickInterval()
62 | case modules.FocusOut:
63 | if mod.opts.OnClick.HoverOut(&mod.opts) {
64 | mod.receive <- true
65 | }
66 | mod.ensureTickInterval()
67 | }
68 | }
69 | }
70 | }()
71 |
72 | return mod.receive, mod.send, nil
73 | }
74 |
75 | func (mod *DiskModule) ensureTickInterval() {
76 | if d := mod.opts.Tick.Go(); d != mod.currentTickerInterval {
77 | mod.currentTickerInterval = d
78 | mod.ticker.Reset(d)
79 | }
80 | }
81 |
82 | func pickThreshold(p int, th []ThresholdOptions) *ThresholdOptions {
83 | for _, t := range th {
84 | matchUp := t.Direction.IsUp() && p >= t.Percent.Go()
85 | matchDown := !t.Direction.IsUp() && p <= t.Percent.Go()
86 | if matchUp || matchDown {
87 | return &t
88 | }
89 | }
90 | return nil
91 | }
92 |
93 | func (mod *DiskModule) Render() []modules.EventCell {
94 | du, err := disk.Usage("/")
95 | if err != nil {
96 | return nil
97 | }
98 |
99 | usedPercent := int(du.UsedPercent)
100 | freePercent := 100 - usedPercent
101 |
102 | system := units.IEC
103 | if mod.opts.UseSI {
104 | system = units.SI
105 | }
106 |
107 | unit := mod.opts.Scale.Unit
108 | if mod.opts.Scale.Dynamic || mod.opts.Scale.Unit.Name == "" {
109 | unit = units.Choose(du.Total, system)
110 | }
111 |
112 | usedAbs := units.Format(du.Used, unit)
113 | freeAbs := units.Format(du.Free, unit)
114 | totalAbs := units.Format(du.Total, unit)
115 |
116 | usage := usedPercent
117 | style := vaxis.Style{}
118 |
119 | t := pickThreshold(usage, mod.opts.Thresholds)
120 |
121 | if t != nil {
122 | style.Foreground = t.Fg.Go()
123 | style.Background = t.Bg.Go()
124 | } else {
125 | style.Foreground = mod.opts.Fg.Go()
126 | style.Background = mod.opts.Bg.Go()
127 | }
128 |
129 | var buf bytes.Buffer
130 | err = mod.opts.Format.Execute(&buf, struct {
131 | Used, Free, Total float64
132 | UsedPercent, FreePercent int
133 | Unit, Icon string
134 | }{
135 | usedAbs, freeAbs, totalAbs,
136 | usedPercent, freePercent,
137 | unit.Name, mod.opts.Icon.Go(),
138 | })
139 | if err != nil {
140 | utils.Logger.Printf("fixme: disk: template error: %v\n", err)
141 | }
142 |
143 | rch := vaxis.Characters(buf.String())
144 | out := make([]modules.EventCell, len(rch))
145 | for i, ch := range rch {
146 | out[i] = modules.EventCell{
147 | C: vaxis.Cell{Character: ch, Style: style},
148 | Mod: mod,
149 | MouseShape: mod.opts.Cursor.Go(),
150 | }
151 | }
152 | return out
153 | }
154 |
155 | func (mod *DiskModule) Channels() (<-chan bool, chan<- modules.Event) {
156 | return mod.receive, mod.send
157 | }
158 |
159 | func (mod *DiskModule) Name() string { return "disk" }
160 |
--------------------------------------------------------------------------------
/internal/modules/battery/battery.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package battery
8 |
9 | import (
10 | "bytes"
11 |
12 | "git.sr.ht/~rockorager/vaxis"
13 | "github.com/nekorg/pawbar/internal/config"
14 | "github.com/nekorg/pawbar/internal/lookup/icons"
15 | "github.com/nekorg/pawbar/internal/modules"
16 | "github.com/nekorg/pawbar/internal/utils"
17 | )
18 |
19 | func New() modules.Module {
20 | return &Battery{}
21 | }
22 |
23 | type Battery struct {
24 | receive chan bool
25 | send chan modules.Event
26 |
27 | opts Options
28 | initialOpts Options
29 |
30 | device UPowerDevice
31 | }
32 |
33 | func (mod *Battery) Dependencies() []string {
34 | return []string{}
35 | }
36 |
37 | func (mod *Battery) Run() (<-chan bool, chan<- modules.Event, error) {
38 | mod.send = make(chan modules.Event)
39 | mod.receive = make(chan bool)
40 | mod.initialOpts = mod.opts
41 |
42 | upower, uch, err := ConnectUPower()
43 | if err != nil {
44 | return nil, nil, err
45 | }
46 |
47 | mod.device, _ = GetDisplayDevice(upower)
48 |
49 | go func() {
50 | defer upower.Close()
51 | for {
52 | select {
53 | case sig := <-uch:
54 | HandleSignal(sig, &mod.device)
55 | mod.receive <- true
56 | case e := <-mod.send:
57 | switch ev := e.VaxisEvent.(type) {
58 | case vaxis.Mouse:
59 | if ev.EventType != vaxis.EventPress {
60 | continue
61 | }
62 | btn := config.ButtonName(ev)
63 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
64 | mod.receive <- true
65 | }
66 |
67 | case modules.FocusIn:
68 | if mod.opts.OnClick.HoverIn(&mod.opts) {
69 | mod.receive <- true
70 | }
71 |
72 | case modules.FocusOut:
73 | if mod.opts.OnClick.HoverOut(&mod.opts) {
74 | mod.receive <- true
75 | }
76 | }
77 | }
78 | }
79 | }()
80 |
81 | return mod.receive, mod.send, nil
82 | }
83 |
84 | func pickThreshold(p int, th []ThresholdOptions) *ThresholdOptions {
85 | for _, t := range th {
86 | matchUp := t.Direction.IsUp() && p >= t.Percent.Go()
87 | matchDown := !t.Direction.IsUp() && p <= t.Percent.Go()
88 | if matchUp || matchDown {
89 | return &t
90 | }
91 | }
92 | return nil
93 | }
94 |
95 | func (mod *Battery) Render() []modules.EventCell {
96 | percent := int(mod.device.Percentage)
97 | style := vaxis.Style{}
98 |
99 | icon := ' '
100 | eta := 0
101 |
102 | if mod.device.State == StateCharging || mod.device.State == StateFullyCharged {
103 | icon = icons.Choose(mod.opts.Charging.Icons, percent)
104 | eta = int(mod.device.TimeToFull)
105 | style.Foreground = mod.opts.Charging.Fg.Go()
106 | style.Background = mod.opts.Charging.Bg.Go()
107 | }
108 |
109 | if mod.device.State == StateDischarging {
110 | icon = icons.Choose(mod.opts.Discharging.Icons, percent)
111 | eta = int(mod.device.TimeToEmpty)
112 | style.Foreground = mod.opts.Discharging.Fg.Go()
113 | style.Background = mod.opts.Discharging.Bg.Go()
114 | }
115 |
116 | t := pickThreshold(percent, mod.opts.Thresholds)
117 | if t != nil {
118 | style.Foreground = t.Fg.Go()
119 | style.Background = t.Bg.Go()
120 | }
121 |
122 | if mod.device.State == StateFullyCharged {
123 | style.Foreground = mod.opts.Charged.Fg.Go()
124 | style.Background = mod.opts.Charged.Bg.Go()
125 | icon = mod.opts.Charged.Icon
126 | }
127 |
128 | // TODO: make config items implement IsZeroer
129 | // to save my soul
130 | if mod.opts.Fg.Go() != vaxis.Color(0) {
131 | style.Foreground = mod.opts.Fg.Go()
132 | }
133 | if mod.opts.Bg.Go() != vaxis.Color(0) {
134 | style.Background = mod.opts.Bg.Go()
135 | }
136 |
137 | var buf bytes.Buffer
138 |
139 | err := mod.opts.Format.Execute(&buf, struct {
140 | Icon string
141 | Percent int
142 | Hours int
143 | Minutes int
144 | }{
145 | Icon: string(icon),
146 | Percent: percent,
147 | Hours: eta / 3600,
148 | Minutes: (eta / 60) % 60,
149 | })
150 | if err != nil {
151 | utils.Logger.Printf("battery: render error: %v", err)
152 | }
153 |
154 | rch := vaxis.Characters(buf.String())
155 | r := make([]modules.EventCell, len(rch))
156 |
157 | for i, ch := range rch {
158 | r[i] = modules.EventCell{C: vaxis.Cell{Character: ch, Style: style}, Mod: mod, MouseShape: mod.opts.Cursor.Go()}
159 | }
160 | return r
161 | }
162 |
163 | func (mod *Battery) Channels() (<-chan bool, chan<- modules.Event) {
164 | return mod.receive, mod.send
165 | }
166 |
167 | func (mod *Battery) Name() string {
168 | return "battery"
169 | }
170 |
--------------------------------------------------------------------------------
/pkg/dbusmenukitty/tui/renderer.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package tui
8 |
9 | import (
10 | "bytes"
11 | "fmt"
12 | "image"
13 | "image/color"
14 | "image/png"
15 | l "log"
16 | "os"
17 | "path/filepath"
18 | "strings"
19 |
20 | "git.sr.ht/~rockorager/vaxis"
21 | "git.sr.ht/~rockorager/vaxis/log"
22 | "github.com/codelif/gorsvg"
23 | "github.com/nekorg/pawbar/pkg/dbusmenukitty/menu"
24 | "github.com/codelif/xdgicons"
25 | "github.com/codelif/xdgicons/missing"
26 | "golang.org/x/image/colornames"
27 | )
28 |
29 | type Renderer struct {
30 | win vaxis.Window
31 | }
32 |
33 | func NewRenderer(win vaxis.Window) *Renderer {
34 | return &Renderer{win: win}
35 | }
36 |
37 | func (r *Renderer) drawBackground(row int, style vaxis.Style) {
38 | w, _ := r.win.Size()
39 | r.win.Println(row, vaxis.Segment{
40 | Text: strings.Repeat(" ", w),
41 | Style: style,
42 | })
43 | }
44 |
45 | func (r *Renderer) getItemStyle(item *menu.Item, row int, state *MenuState) vaxis.Style {
46 | var style vaxis.Style
47 |
48 | if row == state.mouseY && state.mouseOnSurface {
49 | if state.mousePressed {
50 | style.Background = vaxis.ColorBlue
51 | } else {
52 | style.Background = vaxis.ColorGray
53 | }
54 | }
55 |
56 | if !item.Enabled {
57 | style.Background = 0
58 | style.Attribute |= vaxis.AttrDim
59 | }
60 |
61 | return style
62 | }
63 |
64 | func (r *Renderer) renderIcon(item *menu.Item, row int, defaultColor color.Color) (prefixAdd string) {
65 | var img image.Image
66 | var err error
67 |
68 | if item.IconData != nil {
69 | img, err = png.Decode(bytes.NewReader(item.IconData))
70 | if err != nil {
71 | log.Trace("png decode error:", err)
72 | img = missing.GenerateMissingIconBroken(iconSize, defaultColor)
73 | }
74 | } else if item.IconName != "" {
75 | img, err = renderIcon(item.Icon, defaultColor)
76 | if err != nil {
77 | img = missing.GenerateMissingIcon(iconSize, defaultColor)
78 | }
79 | } else {
80 | return ""
81 | }
82 |
83 | kimg := r.win.Vx.NewKittyGraphic(img)
84 | kimg.Resize(iconCellWidth, iconCellHeight)
85 | iw, ih := kimg.CellSize()
86 | log.Trace("kitty image size: %d, %d", iw, ih)
87 | kimg.Draw(r.win.New(menuPadding, row, iw, ih))
88 |
89 | return strings.Repeat(" ", iconSpacing)
90 | }
91 |
92 | func (r *Renderer) drawItem(item *menu.Item, row int, state *MenuState, showIcons bool) {
93 | style := r.getItemStyle(item, row, state)
94 | defaultColor := fgColor
95 |
96 | if !item.Enabled {
97 | defaultColor = colornames.Gray
98 | }
99 |
100 | // Draw background
101 | r.drawBackground(row, style)
102 |
103 | // Build prefix
104 | prefix := strings.Repeat(" ", menuPadding)
105 | if item.HasChildren {
106 | prefix = string(arrowHeads[0]) + prefix[1:]
107 | }
108 |
109 | // Add icon if present and rendering full menu
110 | if showIcons {
111 | prefix += r.renderIcon(item, row, defaultColor)
112 | } else if item.IconData != nil || item.IconName != "" {
113 | // Reserve space for icon in fast draw
114 | prefix += strings.Repeat(" ", iconSpacing)
115 | }
116 |
117 | // Draw text
118 | suffix := strings.Repeat(" ", menuPadding)
119 | text := prefix + item.Label.Display + suffix
120 | r.win.Println(row, vaxis.Segment{Text: text, Style: style})
121 | }
122 |
123 | func (r *Renderer) drawSeparator(row int) {
124 | w, _ := r.win.Size()
125 | r.win.Println(row, vaxis.Segment{
126 | Text: strings.Repeat("─", w),
127 | Style: vaxis.Style{Attribute: vaxis.AttrDim},
128 | })
129 | }
130 |
131 | func (r *Renderer) drawMenu(items []menu.Item, state *MenuState, showIcons bool) {
132 | for i, item := range items {
133 | if item.Type == menu.ItemSeparator {
134 | r.drawSeparator(i)
135 | } else {
136 | r.drawItem(&item, i, state, showIcons)
137 | }
138 | }
139 | }
140 |
141 | func renderIcon(icon xdgicons.Icon, c color.Color) (image.Image, error) {
142 | l.Printf("%v\n", icon)
143 |
144 | if icon.Path == "" {
145 | return nil, fmt.Errorf("no file path provided")
146 | }
147 |
148 | isSymbolic := strings.HasSuffix(icon.Name, "-symbolic")
149 | ext := strings.ToLower(filepath.Ext(icon.Path))
150 |
151 | f, err := os.Open(icon.Path)
152 | if err != nil {
153 | return nil, fmt.Errorf("error opening file %s: %w", icon.Path, err)
154 | }
155 | defer f.Close()
156 |
157 | switch ext {
158 | case ".svg":
159 | if isSymbolic {
160 | return gorsvg.DecodeWithColor(f, 48, 48, c)
161 | }
162 | return gorsvg.Decode(f, 48, 48)
163 |
164 | case ".png":
165 | return png.Decode(f)
166 |
167 | default:
168 | return nil, fmt.Errorf("unsupported image format: %s", ext)
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/internal/modules/locale/locale.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package locale
8 |
9 | import (
10 | "bytes"
11 | "os"
12 | "strings"
13 | "time"
14 |
15 | "git.sr.ht/~rockorager/vaxis"
16 | "github.com/nekorg/pawbar/internal/config"
17 | "github.com/nekorg/pawbar/internal/modules"
18 | )
19 |
20 | func New() modules.Module {
21 | return &LocaleModule{}
22 | }
23 |
24 | type LocaleModule struct {
25 | receive chan bool
26 | send chan modules.Event
27 |
28 | opts Options
29 | initialOpts Options
30 |
31 | currentTickerInterval time.Duration
32 | ticker *time.Ticker
33 | }
34 |
35 | func (mod *LocaleModule) Dependencies() []string {
36 | return nil
37 | }
38 |
39 | func (mod *LocaleModule) splitLocale(locale string) (string, string) {
40 | formattedLocale, _, _ := strings.Cut(locale, ".")
41 | formattedLocale = strings.ReplaceAll(formattedLocale, "-", "_")
42 | language, territory, _ := strings.Cut(formattedLocale, "_")
43 | return language, territory
44 | }
45 |
46 | func (mod *LocaleModule) splitLocales(locales string) []string {
47 | return strings.Split(locales, ":")
48 | }
49 |
50 | func (mod *LocaleModule) getLangFromEnv() string {
51 | locale := ""
52 | for _, env := range [...]string{"LC_ALL", "LC_MESSAGES", "LANG"} {
53 | locale = os.Getenv(env)
54 | if len(locale) > 0 {
55 | break
56 | }
57 | }
58 |
59 | if locale == "C" || locale == "POSIX" {
60 | return locale
61 | }
62 | languages := os.Getenv("LANGUAGE")
63 | if len(languages) > 0 {
64 | return languages
65 | }
66 |
67 | return locale
68 | }
69 |
70 | func (mod *LocaleModule) getUnixLocales() []string {
71 | locale := mod.getLangFromEnv()
72 | if locale == "C" || locale == "POSIX" || len(locale) == 0 {
73 | return nil
74 | }
75 |
76 | return mod.splitLocales(locale)
77 | }
78 |
79 | func (mod *LocaleModule) GetLocale() (string, error) {
80 | unixLocales := mod.getUnixLocales()
81 | if unixLocales == nil {
82 | return "", nil
83 | }
84 |
85 | language, region := mod.splitLocale(unixLocales[0])
86 | locale := language
87 | if len(region) > 0 {
88 | locale = strings.Join([]string{language, region}, "-")
89 | }
90 |
91 | return locale, nil
92 | }
93 |
94 | func (mod *LocaleModule) Run() (<-chan bool, chan<- modules.Event, error) {
95 | mod.receive = make(chan bool)
96 | mod.send = make(chan modules.Event)
97 | mod.initialOpts = mod.opts
98 |
99 | go func() {
100 | mod.currentTickerInterval = mod.opts.Tick.Go()
101 | mod.ticker = time.NewTicker(mod.currentTickerInterval)
102 | defer mod.ticker.Stop()
103 | for {
104 | select {
105 | case <-mod.ticker.C:
106 | mod.receive <- true
107 | case e := <-mod.send:
108 | switch ev := e.VaxisEvent.(type) {
109 | case vaxis.Mouse:
110 | if ev.EventType != vaxis.EventPress {
111 | break
112 | }
113 | btn := config.ButtonName(ev)
114 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
115 | mod.receive <- true
116 | }
117 | mod.ensureTickInterval()
118 |
119 | case modules.FocusIn:
120 | if mod.opts.OnClick.HoverIn(&mod.opts) {
121 | mod.receive <- true
122 | }
123 | mod.ensureTickInterval()
124 |
125 | case modules.FocusOut:
126 | if mod.opts.OnClick.HoverOut(&mod.opts) {
127 | mod.receive <- true
128 | }
129 | mod.ensureTickInterval()
130 | }
131 | }
132 | }
133 | }()
134 |
135 | return mod.receive, mod.send, nil
136 | }
137 |
138 | func (mod *LocaleModule) ensureTickInterval() {
139 | if mod.opts.Tick.Go() != mod.currentTickerInterval {
140 | mod.currentTickerInterval = mod.opts.Tick.Go()
141 | mod.ticker.Reset(mod.currentTickerInterval)
142 | }
143 | }
144 |
145 | func (mod *LocaleModule) Render() []modules.EventCell {
146 | locale, err := mod.GetLocale()
147 | if err != nil {
148 | return nil
149 | }
150 |
151 | style := vaxis.Style{
152 | Foreground: mod.opts.Fg.Go(),
153 | Background: mod.opts.Bg.Go(),
154 | }
155 |
156 | data := struct {
157 | Locale string
158 | }{
159 | Locale: locale,
160 | }
161 |
162 | var buf bytes.Buffer
163 | _ = mod.opts.Format.Execute(&buf, data)
164 |
165 | rch := vaxis.Characters(buf.String())
166 | r := make([]modules.EventCell, len(rch))
167 |
168 | for i, ch := range rch {
169 | r[i] = modules.EventCell{C: vaxis.Cell{Character: ch, Style: style}, Mod: mod, MouseShape: mod.opts.Cursor.Go()}
170 | }
171 | return r
172 | }
173 |
174 | func (mod *LocaleModule) Channels() (<-chan bool, chan<- modules.Event) {
175 | return mod.receive, mod.send
176 | }
177 |
178 | func (mod *LocaleModule) Name() string {
179 | return "locale"
180 | }
181 |
--------------------------------------------------------------------------------
/internal/modules/volume/volume.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package volume
8 |
9 | import (
10 | "bytes"
11 | "errors"
12 |
13 | "git.sr.ht/~rockorager/vaxis"
14 | "github.com/nekorg/pawbar/internal/config"
15 | "github.com/nekorg/pawbar/internal/modules"
16 | "github.com/nekorg/pawbar/internal/services/pulse"
17 | "github.com/nekorg/pawbar/internal/utils"
18 | )
19 |
20 | type VolumeModule struct {
21 | receive chan bool
22 | send chan modules.Event
23 | svc *pulse.PulseService
24 | sink string
25 | Volume float64
26 | Muted bool
27 | events <-chan pulse.SinkEvent
28 | opts Options
29 | initialOpts Options
30 | }
31 |
32 | func New() modules.Module {
33 | return &VolumeModule{}
34 | }
35 |
36 | func (mod *VolumeModule) Name() string {
37 | return "volume"
38 | }
39 |
40 | func (mod *VolumeModule) Dependencies() []string {
41 | return []string{"pulse"}
42 | }
43 |
44 | func (mod *VolumeModule) Run() (<-chan bool, chan<- modules.Event, error) {
45 | svc, ok := pulse.Register()
46 | if !ok {
47 | return nil, nil, errors.New("pulse service not available")
48 | }
49 | if err := svc.Start(); err != nil {
50 | return nil, nil, err
51 | }
52 |
53 | sink, err := svc.GetDefaultSinkInfo()
54 | if err != nil {
55 | return nil, nil, err
56 | }
57 |
58 | mod.svc = svc
59 | mod.sink = sink.Sink
60 | mod.Volume = sink.Volume
61 | mod.Muted = sink.Muted
62 |
63 | mod.receive = make(chan bool)
64 | mod.send = make(chan modules.Event)
65 | mod.events = svc.IssueListener()
66 | mod.initialOpts = mod.opts
67 |
68 | go func() {
69 | for {
70 | select {
71 | case e := <-mod.send:
72 | switch ev := e.VaxisEvent.(type) {
73 | case vaxis.Mouse:
74 | if ev.EventType != vaxis.EventPress {
75 | break
76 | }
77 | btn := config.ButtonName(ev)
78 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
79 | mod.receive <- true
80 | }
81 | case modules.FocusIn:
82 | if mod.opts.OnClick.HoverIn(&mod.opts) {
83 | mod.receive <- true
84 | }
85 |
86 | case modules.FocusOut:
87 | if mod.opts.OnClick.HoverOut(&mod.opts) {
88 | mod.receive <- true
89 | }
90 | }
91 |
92 | case e := <-mod.events:
93 | mod.Volume = e.Volume
94 | mod.Muted = e.Muted
95 | mod.receive <- true
96 | }
97 | }
98 | }()
99 |
100 | return mod.receive, mod.send, nil
101 | }
102 |
103 | // in case of tui control scollup/down etc
104 |
105 | // func (mod *VolumeModule) Change(delta float64) error {
106 | // v := mod.Volume + delta
107 | // if v < 0 {
108 | // v = 0
109 | // }
110 | // if v > 1 {
111 | // v = 1
112 | // }
113 | // if err := mod.svc.SetSinkVolume(mod.sink, v); err != nil {
114 | // return err
115 | // }
116 | // mod.Volume = v
117 | // return nil
118 | // }
119 | //
120 | // func (mod *VolumeModule) ToggleMute() error {
121 | // newMute := !mod.Muted
122 | // if err := mod.svc.SetSinkMute(mod.sink, newMute); err != nil {
123 | // return err
124 | // }
125 | // mod.Muted = newMute
126 | // return nil
127 | // }
128 |
129 | func (mod *VolumeModule) Channels() (<-chan bool, chan<- modules.Event) {
130 | return mod.receive, mod.send
131 | }
132 |
133 | func (mod *VolumeModule) Render() []modules.EventCell {
134 | style := vaxis.Style{}
135 |
136 | if mod.Muted {
137 |
138 | style.Foreground = mod.opts.Muted.Fg.Go()
139 | style.Background = mod.opts.Muted.Bg.Go()
140 |
141 | text := mod.opts.Muted.MuteFormat
142 | rch := vaxis.Characters(text)
143 | r := make([]modules.EventCell, len(rch))
144 |
145 | for i, ch := range rch {
146 | r[i] = modules.EventCell{
147 | C: vaxis.Cell{Character: ch, Style: style},
148 | Mod: mod,
149 | MouseShape: mod.opts.Cursor.Go(),
150 | }
151 | }
152 | return r
153 | } else {
154 |
155 | style.Foreground = mod.opts.Fg.Go()
156 | style.Background = mod.opts.Bg.Go()
157 |
158 | vol := int(mod.Volume)
159 | icons := mod.opts.Icons
160 | idx := utils.Clamp(vol*len(icons)/100, 0, len(icons)-1)
161 | icon := icons[idx]
162 | data := struct {
163 | Icon string
164 | Percent int
165 | }{
166 | Icon: string(icon),
167 | Percent: vol,
168 | }
169 |
170 | var buf bytes.Buffer
171 | _ = mod.opts.Format.Execute(&buf, data)
172 | rch := vaxis.Characters(buf.String())
173 | r := make([]modules.EventCell, len(rch))
174 | for i, ch := range rch {
175 | r[i] = modules.EventCell{
176 | C: vaxis.Cell{Character: ch, Style: style},
177 | Mod: mod,
178 | MouseShape: mod.opts.Cursor.Go(),
179 | }
180 | }
181 | return r
182 |
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/internal/tui/helpers.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package tui
8 |
9 | import (
10 | "git.sr.ht/~rockorager/vaxis"
11 | "github.com/nekorg/pawbar/internal/modules"
12 | )
13 |
14 | func anchorOf(s string) anchor {
15 | switch s {
16 | case "left":
17 | return left
18 | case "middle":
19 | return middle
20 | default:
21 | return right
22 | }
23 | }
24 |
25 | func refreshModMap(l, m, r []modules.Module) {
26 | for _, mod := range append(append(l, m...), r...) {
27 | modMap[mod] = mod.Render()
28 | }
29 | }
30 |
31 | // flattens modules into blocks sorted by priority.
32 | func buildBlocks() []block {
33 | cells := map[string][]modules.EventCell{
34 | "left": flatten(leftModules),
35 | "middle": flatten(middleModules),
36 | "right": flatten(rightModules),
37 | }
38 |
39 | blocks := make([]block, 0, 3)
40 | for _, name := range truncOrder {
41 | blocks = append(blocks, block{
42 | cells: cells[name],
43 | side: anchorOf(name),
44 | })
45 | }
46 | return blocks
47 | }
48 |
49 | func stringToEC(s string) []modules.EventCell {
50 | ecs := make([]modules.EventCell, 0, len(s))
51 | for _, c := range vaxis.Characters(s) {
52 | ecs = append(ecs, modules.EventCell{C: vaxis.Cell{Character: c}})
53 | }
54 |
55 | return ecs
56 | }
57 |
58 | // writes cell and adds padding for grapheme's with >1 width
59 | // returns x + {grapheme width}
60 | func writeCell(win vaxis.Window, x int, c modules.EventCell) int {
61 | if x+c.C.Width > width {
62 | return x + c.C.Width
63 | }
64 | win.SetCell(x, 0, c.C)
65 | state[x] = c
66 |
67 | for w := 1; w < c.C.Width; w++ {
68 | empty := vaxis.Cell{Style: c.C.Style}
69 | win.SetCell(x+w, 0, empty)
70 | state[x+w] = modules.EventCell{
71 | C: empty,
72 | Metadata: c.Metadata,
73 | Mod: c.Mod,
74 | MouseShape: c.MouseShape,
75 | }
76 | }
77 | return x + c.C.Width
78 | }
79 |
80 | func flatten(mods []modules.Module) []modules.EventCell {
81 | // each module will probably require more than 3 cells
82 | // ws with 1 workspace requires 3, so most of them will
83 | // take more than that right? right? (foreshadowing)
84 | out := make([]modules.EventCell, 0, len(mods)*3)
85 | for _, m := range mods {
86 | out = append(out, modMap[m]...)
87 | }
88 | return out
89 | }
90 |
91 | // this keeps account for grapheme widths
92 | // so this is safe for anchor calculations; probably?
93 | func totalWidth(cells []modules.EventCell) int {
94 | w := 0
95 | for _, c := range cells {
96 | w += c.C.Width
97 | }
98 | return w
99 | }
100 |
101 | // also adds ellipsis at the start. ellipsis huh, weird word
102 | func trimStart(cells []modules.EventCell, w int, ellipsis bool) []modules.EventCell {
103 | if w <= 0 {
104 | return nil
105 | }
106 | if totalWidth(cells) <= w {
107 | return cells
108 | }
109 | if ellipsis {
110 | if ellipsisWidth >= w {
111 | return nil
112 | }
113 | w -= ellipsisWidth
114 | }
115 | acc := 0
116 | end := 0
117 | for ; end < len(cells) && acc < w; end++ {
118 | acc += totalWidth(cells[end : end+1])
119 | }
120 | trim := cells[:end]
121 | if ellipsis {
122 | trim = append(trim, ellipsisCells...)
123 | }
124 | return trim
125 | }
126 |
127 | // also adds ellipsis at the end. ellipsis huh, indeed a weird word
128 | func trimEnd(cells []modules.EventCell, w int, ellipsis bool) []modules.EventCell {
129 | if w <= 0 {
130 | return nil
131 | }
132 | if totalWidth(cells) <= w {
133 | return cells
134 | }
135 | if ellipsis {
136 | if ellipsisWidth >= w {
137 | return nil
138 | }
139 | w -= ellipsisWidth
140 | }
141 | acc := 0
142 | start := len(cells)
143 | for start > 0 && acc < w {
144 | start--
145 | acc += totalWidth(cells[start : start+1])
146 | }
147 | trim := cells[start:]
148 | if ellipsis {
149 | trim = append(clone(ellipsisCells), trim...)
150 | }
151 | return trim
152 | }
153 |
154 | // also adds ellipsis at the end and start. huh weird, where have I seen thi-
155 | func trimMiddle(cells []modules.EventCell, w int, ellipsis bool) []modules.EventCell {
156 | if w <= 0 || totalWidth(cells) <= w {
157 | return cells
158 | }
159 | if ellipsis {
160 | ellW := ellipsisWidth * 2
161 | if ellW >= w {
162 | return nil
163 | }
164 | w -= ellW
165 | }
166 |
167 | left, right := 0, len(cells)-1
168 | cur := totalWidth(cells)
169 | for cur > w && left < right {
170 | cur -= cells[left].C.Width
171 | left++
172 | if cur > w && left < right {
173 | cur -= cells[right].C.Width
174 | right--
175 | }
176 | }
177 | trimmed := cells[left : right+1]
178 | if ellipsis {
179 | trimmed = append(
180 | append(clone(ellipsisCells), trimmed...),
181 | ellipsisCells...,
182 | )
183 | }
184 | return trimmed
185 | }
186 |
187 | func clone(src []modules.EventCell) []modules.EventCell {
188 | out := make([]modules.EventCell, len(src))
189 | copy(out, src)
190 | return out
191 | }
192 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/styles.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) 2025 Nekorg All rights reserved.
3 | * Use of this source code is governed by a BSD-style
4 | * license that can be found in the LICENSE file.
5 | *
6 | * SPDX-License-Identifier: bsd
7 | */
8 |
9 | /* .vitepress/theme/custom.css */
10 |
11 | /* :root { */
12 | /* --vp-font-family-base: 'Karla', system-ui, sans-serif; */
13 | /* --vp-font-family-mono: 'Source Code Pro', ui-monospace, monospace; */
14 | /* } */
15 | /* ===== Base (light) ===================================================== */
16 | :root {
17 |
18 | /* Palette */
19 | --paw-brown-900: #1a1412;
20 | --paw-brown-800: #2a2a2a;
21 | --paw-brown-700: #3b2c28; /* deep */
22 | --paw-brown-600: #5b3a2e; /* warm */
23 | --paw-tan-500: #c9a646; /* darker tan */
24 | --paw-tan-400: #d2b47c; /* lighter support */
25 | --paw-ivory: #faf7f2; /* paper-like background */
26 |
27 | /* === Foreground brand: Bronze === */
28 | --vp-c-brand-1: #a97142; /* bronze base */
29 | --vp-c-brand-2: #8f5d33; /* darker bronze (hover/active) */
30 | --vp-c-brand-3: #6f4827; /* outlines/borders */
31 |
32 | /* UI text & surfaces (light) */
33 | --vp-c-bg: var(--paw-ivory);
34 | --vp-c-bg-soft: #f3efe7;
35 | --vp-c-bg-mute: #ece5d9;
36 | --vp-c-text-1: #2c241f; /* main text */
37 | --vp-c-text-2: #4b4038; /* secondary */
38 | --vp-c-text-3: #6c5f55; /* tertiary */
39 |
40 | /* Borders & rings */
41 | --vp-c-divider: #e5dccd;
42 | --vp-c-gutter: #e9e0d2;
43 |
44 | /* Code blocks */
45 | --vp-code-block-bg: #f7f2ea;
46 | --vp-code-block-border: #eadfcf;
47 | --vp-code-line-highlight: #efe6d7;
48 | --vp-code-copy-code-bg: #efe6d7;
49 |
50 | /* Callouts (custom blocks) */
51 | --vp-c-tip-1: #f0eadc;
52 | --vp-c-tip-2: var(--vp-c-brand-1);
53 | --vp-c-warning-1:#f6edd5;
54 | --vp-c-warning-2:#caa03a;
55 | --vp-c-danger-1: #f6e6e4;
56 | --vp-c-danger-2: #b25a4a;
57 |
58 | /* Home hero (bronze gradient over browns) */
59 | --vp-home-hero-name-color: transparent;
60 | --vp-home-hero-name-background: -webkit-linear-gradient(
61 | 120deg,
62 | var(--vp-c-brand-1) 0%,
63 | var(--paw-brown-600) 55%,
64 | var(--paw-brown-700) 100%
65 | );
66 | --vp-home-hero-image-background-image: linear-gradient(
67 | -45deg,
68 | var(--paw-brown-700) 48%,
69 | var(--vp-c-brand-1) 52%
70 | );
71 | --vp-home-hero-image-filter: blur(60px);
72 | }
73 |
74 | /* Scale glow on wider screens */
75 | @media (min-width: 640px) {
76 | :root { --vp-home-hero-image-filter: blur(74px); }
77 | }
78 | @media (min-width: 960px) {
79 | :root { --vp-home-hero-image-filter: blur(88px); }
80 | }
81 |
82 | /* Image framing */
83 | .VPHero .VPImage {
84 | filter: drop-shadow(-2px 4px 8px rgba(42, 42, 42, 0.25));
85 | padding: 14px;
86 | }
87 |
88 | /* Buttons & links */
89 | .VPButton.VPButton--brand {
90 | --vp-button-brand-bg: var(--vp-c-brand-1);
91 | --vp-button-brand-hover-bg: var(--vp-c-brand-2);
92 | --vp-button-brand-border: transparent;
93 | --vp-button-brand-text: #f6f8fa; /* ensure contrast */
94 | }
95 | a { color: var(--vp-c-brand-1); }
96 | a:hover { color: var(--vp-c-brand-2); }
97 |
98 | /* Code block polish */
99 | .vp-doc .shiki, .vp-doc pre code {
100 | background: var(--vp-code-block-bg) !important;
101 | border: 1px solid var(--vp-code-block-border);
102 | }
103 |
104 | /* Custom block accents */
105 | .custom-block.tip { border-left-color: var(--vp-c-tip-2); }
106 | .custom-block.warning { border-left-color: var(--vp-c-warning-2); }
107 | .custom-block.danger { border-left-color: var(--vp-c-danger-2); }
108 |
109 | /* ===== Dark mode ======================================================== */
110 | :root.dark {
111 | --vp-c-bg: #12100e;
112 | --vp-c-bg-soft: #171412;
113 | --vp-c-bg-mute: #1d1916;
114 |
115 | --vp-c-text-1: #f2ece6;
116 | --vp-c-text-2: #daccbf;
117 | --vp-c-text-3: #b7a898;
118 |
119 | --vp-c-divider: #2b241f;
120 | --vp-c-gutter: #26211e;
121 |
122 | /* Bronze foreground (lighter for contrast on dark) */
123 | --vp-c-brand-1: #c08953;
124 | --vp-c-brand-2: #a97142;
125 | --vp-c-brand-3: #8f5d33;
126 |
127 | /* Code blocks (dark) */
128 | --vp-code-block-bg: #1a1613;
129 | --vp-code-block-border: #2a231e;
130 | --vp-code-line-highlight: #221d19;
131 | --vp-code-copy-code-bg: #221d19;
132 |
133 | /* Custom blocks (dark) */
134 | --vp-c-tip-1: #1e1a16;
135 | --vp-c-tip-2: var(--vp-c-brand-1);
136 | --vp-c-warning-1: #211b12;
137 | --vp-c-warning-2: #caa03a;
138 | --vp-c-danger-1: #231716;
139 | --vp-c-danger-2: #c57364;
140 |
141 | /* Hero in dark: bronze over deep brown */
142 | --vp-home-hero-name-background: -webkit-linear-gradient(
143 | 120deg,
144 | var(--vp-c-brand-1) 0%,
145 | #6d4a3b 55%,
146 | #3b2c28 100%
147 | );
148 | --vp-home-hero-image-background-image: linear-gradient(
149 | -45deg,
150 | #2b211d 48%,
151 | var(--vp-c-brand-1) 52%
152 | );
153 | --vp-home-hero-image-filter: blur(64px);
154 | }
155 |
156 |
--------------------------------------------------------------------------------
/internal/modules/idleInhibitor/idleInhibitor.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package idleinhibitor
8 |
9 | import (
10 | "bytes"
11 | "fmt"
12 |
13 | "github.com/godbus/dbus/v5"
14 |
15 | "git.sr.ht/~rockorager/vaxis"
16 | "github.com/nekorg/pawbar/internal/config"
17 | "github.com/nekorg/pawbar/internal/modules"
18 | )
19 |
20 | const (
21 | portalBusName = "org.freedesktop.portal.Desktop"
22 | portalObjectPath = "/org/freedesktop/portal/desktop"
23 | ifaceInhibit = "org.freedesktop.portal.Inhibit"
24 | ifaceRequest = "org.freedesktop.portal.Request"
25 | flagIdle = 8
26 | )
27 |
28 | type Format int
29 |
30 | func (f *Format) toggle() { *f ^= 1 }
31 |
32 | const (
33 | FormatIdle Format = iota
34 | FormatInhibit
35 | )
36 |
37 | type IdleModule struct {
38 | receive chan bool
39 | send chan modules.Event
40 | format Format
41 | bus *dbus.Conn
42 | handle dbus.ObjectPath
43 | opts Options
44 | initialOpts Options
45 | }
46 |
47 | func (mod *IdleModule) Dependencies() []string {
48 | return []string{}
49 | }
50 |
51 | func (mod *IdleModule) Name() string {
52 | return "idleinhibitor"
53 | }
54 |
55 | func New() modules.Module {
56 | return &IdleModule{}
57 | }
58 |
59 | func (mod *IdleModule) Channels() (<-chan bool, chan<- modules.Event) {
60 | return mod.receive, mod.send
61 | }
62 |
63 | func (mod *IdleModule) setConnection() error {
64 | bus, err := dbus.SessionBus()
65 | if err != nil {
66 | return fmt.Errorf("Failed to connect to session bus: %v\n", err)
67 | }
68 | mod.bus = bus
69 | return nil
70 | }
71 |
72 | func (mod *IdleModule) inhibitIdle() error {
73 | obj := mod.bus.Object(portalBusName, dbus.ObjectPath(portalObjectPath))
74 |
75 | call := obj.Call(ifaceInhibit+".Inhibit", 0, "", uint32(flagIdle), map[string]dbus.Variant{})
76 | if call.Err != nil {
77 | return fmt.Errorf("Inhibit call failed: %v\n", call.Err)
78 | }
79 |
80 | var handle dbus.ObjectPath
81 | if err := call.Store(&handle); err != nil {
82 | return fmt.Errorf("Failed to parse handle: %v\n", err)
83 | }
84 | mod.handle = handle
85 | return nil
86 | }
87 |
88 | func (mod *IdleModule) closeRequest() error {
89 | req := mod.bus.Object(portalBusName, mod.handle)
90 | closeCall := req.Call(ifaceRequest+".Close", 0)
91 | if closeCall.Err != nil {
92 | return fmt.Errorf("Failed to remove inhibition: %v\n", closeCall.Err)
93 | }
94 | return nil
95 | }
96 |
97 | func (mod *IdleModule) Run() (<-chan bool, chan<- modules.Event, error) {
98 | mod.receive = make(chan bool)
99 | mod.send = make(chan modules.Event)
100 | mod.initialOpts = mod.opts
101 | err := mod.setConnection()
102 | if err != nil {
103 | return nil, nil, err
104 | }
105 | go func() {
106 | for {
107 | select {
108 | case e := <-mod.send:
109 | switch ev := e.VaxisEvent.(type) {
110 | case vaxis.Mouse:
111 | if ev.EventType != vaxis.EventPress {
112 | break
113 | }
114 | btn := config.ButtonName(ev)
115 |
116 | if btn == "left" {
117 | mod.format.toggle()
118 | mod.stateFunc()
119 | mod.receive <- true
120 | }
121 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
122 | mod.receive <- true
123 | }
124 |
125 | case modules.FocusIn:
126 | if mod.opts.OnClick.HoverIn(&mod.opts) {
127 | mod.receive <- true
128 | }
129 |
130 | case modules.FocusOut:
131 | if mod.opts.OnClick.HoverOut(&mod.opts) {
132 | mod.receive <- true
133 | }
134 | }
135 | }
136 | }
137 | }()
138 |
139 | return mod.receive, mod.send, nil
140 | }
141 |
142 | func (mod *IdleModule) stateFunc() error {
143 | switch mod.format {
144 | case FormatInhibit:
145 | err := mod.inhibitIdle()
146 | if err != nil {
147 | return err
148 | }
149 | case FormatIdle:
150 | err := mod.closeRequest()
151 | if err != nil {
152 | return err
153 | }
154 | }
155 | return fmt.Errorf("invalid state caught")
156 | }
157 |
158 | func (mod *IdleModule) Render() []modules.EventCell {
159 | style := vaxis.Style{
160 | Foreground: mod.opts.Fg.Go(),
161 | Background: mod.opts.Bg.Go(),
162 | }
163 | var tlp config.Format
164 | switch mod.format {
165 | case FormatIdle:
166 | tlp = mod.opts.Format
167 | case FormatInhibit:
168 | tlp = mod.opts.Inhibit.Format
169 | style.Foreground = mod.opts.Inhibit.Fg.Go()
170 | style.Background = mod.opts.Inhibit.Bg.Go()
171 |
172 | }
173 | var buf bytes.Buffer
174 | if err := tlp.Execute(&buf, nil); err != nil {
175 | return nil
176 | }
177 |
178 | chars := vaxis.Characters(buf.String())
179 | r := make([]modules.EventCell, len(chars))
180 | for i, ch := range chars {
181 | r[i] = modules.EventCell{
182 | C: vaxis.Cell{
183 | Character: ch,
184 | Style: style,
185 | },
186 | Mod: mod,
187 | MouseShape: mod.opts.Cursor.Go(),
188 | }
189 | }
190 | return r
191 | }
192 |
--------------------------------------------------------------------------------
/internal/modules/ws/ws.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package ws
8 |
9 | import (
10 | "bytes"
11 | "fmt"
12 | "os"
13 | "strconv"
14 |
15 | "git.sr.ht/~rockorager/vaxis"
16 | "github.com/nekorg/pawbar/internal/config"
17 | "github.com/nekorg/pawbar/internal/modules"
18 | "github.com/nekorg/pawbar/internal/services/hypr"
19 | "github.com/nekorg/pawbar/internal/services/i3"
20 | )
21 |
22 | type Workspace struct {
23 | ID int
24 | Name string
25 | Active bool
26 | Urgent bool
27 | Special bool
28 | }
29 |
30 | type Format int
31 |
32 | func (f *Format) toggle() { *f ^= 1 }
33 |
34 | const (
35 | FormatAll Format = iota
36 | FormatCurr
37 | )
38 |
39 | type backend interface {
40 | List() []Workspace
41 | Events() <-chan struct{}
42 | Goto(name string)
43 | }
44 |
45 | type Module struct {
46 | b backend
47 | receive chan bool
48 | send chan modules.Event
49 | bname string
50 | format Format
51 |
52 | opts Options
53 | initialOpts Options
54 | }
55 |
56 | func New() modules.Module { return &Module{} }
57 |
58 | func (mod *Module) Name() string { return "ws" }
59 | func (mod *Module) Dependencies() []string { return nil }
60 | func (mod *Module) Channels() (<-chan bool, chan<- modules.Event) { return mod.receive, mod.send }
61 |
62 | func (mod *Module) Run() (<-chan bool, chan<- modules.Event, error) {
63 | err := mod.selectBackend()
64 | if err != nil {
65 | return nil, nil, err
66 | }
67 |
68 | mod.receive = make(chan bool)
69 | mod.send = make(chan modules.Event)
70 | mod.initialOpts = mod.opts
71 |
72 | go func() {
73 | render := mod.b.Events()
74 | for {
75 | select {
76 | case e := <-mod.send:
77 | switch ev := e.VaxisEvent.(type) {
78 | case vaxis.Mouse:
79 | if ev.EventType != vaxis.EventPress {
80 | break
81 | }
82 | btn := config.ButtonName(ev)
83 |
84 | if btn == "left" {
85 | go mod.b.Goto(e.Cell.Metadata)
86 | }
87 | if btn == "right" {
88 | mod.format.toggle()
89 | mod.receive <- true
90 | }
91 |
92 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
93 | mod.receive <- true
94 | }
95 |
96 | case modules.FocusIn:
97 | if mod.opts.OnClick.HoverIn(&mod.opts) {
98 | mod.receive <- true
99 | }
100 |
101 | case modules.FocusOut:
102 | if mod.opts.OnClick.HoverOut(&mod.opts) {
103 | mod.receive <- true
104 | }
105 | }
106 | case <-render:
107 | mod.receive <- true
108 | }
109 | }
110 | }()
111 |
112 | return mod.receive, mod.send, nil
113 | }
114 |
115 | func (mod *Module) selectBackend() error {
116 | if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
117 | svc, ok := hypr.Register()
118 | if !ok {
119 | return fmt.Errorf("Could not start hypr service.")
120 | }
121 | mod.b = newHyprBackend(svc)
122 | mod.bname = "hypr"
123 | } else if os.Getenv("I3SOCK") != "" || os.Getenv("SWAYSOCK") != "" {
124 | svc, ok := i3.Register()
125 | if !ok {
126 | return fmt.Errorf("Could not start i3 service.")
127 | }
128 | mod.b = newI3Backend(svc)
129 | mod.bname = "i3"
130 | } else {
131 | return fmt.Errorf("Could not find a wm backend for current environment.")
132 | }
133 |
134 | return nil
135 | }
136 |
137 | func (mod *Module) Render() []modules.EventCell {
138 | data := struct{ WSID string }{}
139 | format := mod.opts.Format
140 |
141 | var toRender []Workspace
142 | switch mod.format {
143 | case FormatAll:
144 | toRender = mod.b.List()
145 |
146 | case FormatCurr:
147 | for _, w := range mod.b.List() {
148 | if w.Active {
149 | toRender = []Workspace{w}
150 | break
151 | }
152 | }
153 |
154 | default:
155 | toRender = mod.b.List()
156 | }
157 |
158 | var cells []modules.EventCell
159 | for _, w := range toRender {
160 | wsName := w.Name
161 | if w.Special {
162 | wsName = "S"
163 | }
164 | var meta string
165 | if mod.bname == "hypr" {
166 | meta = strconv.Itoa(w.ID)
167 | } else {
168 | meta = wsName
169 | }
170 | style := vaxis.Style{
171 | Foreground: mod.opts.Fg.Go(),
172 | Background: mod.opts.Bg.Go(),
173 | }
174 | switch {
175 | case w.Special:
176 | style.Foreground = mod.opts.Special.Fg.Go()
177 | style.Background = mod.opts.Special.Bg.Go()
178 | case w.Active:
179 | style.Foreground = mod.opts.Active.Fg.Go()
180 | style.Background = mod.opts.Active.Bg.Go()
181 | case w.Urgent:
182 | style.Foreground = mod.opts.Urgent.Fg.Go()
183 | style.Background = mod.opts.Urgent.Bg.Go()
184 | }
185 | data.WSID = " " + wsName + " "
186 | var buf bytes.Buffer
187 | if err := format.Execute(&buf, data); err != nil {
188 | continue
189 | }
190 |
191 | // split into cells
192 | for _, ch := range vaxis.Characters(buf.String()) {
193 | cells = append(cells, modules.EventCell{
194 | C: vaxis.Cell{
195 | Character: ch,
196 | Style: style,
197 | },
198 | Metadata: meta,
199 | Mod: mod,
200 | MouseShape: vaxis.MouseShapeClickable,
201 | })
202 | }
203 | }
204 |
205 | return cells
206 | }
207 |
--------------------------------------------------------------------------------
/docs/docs/configuration.md:
--------------------------------------------------------------------------------
1 | ---
2 | next:
3 | text: 'Modules'
4 | link: '/docs/modules'
5 | ---
6 | # Configuration
7 |
8 | Configuration file is divided into 4 sections:
9 | - `bar`: used for general bar configuration
10 | - `left`: left anchored modules
11 | - `middle`: centered modules
12 | - `right`: right anchored modules
13 |
14 | # `bar`
15 | Has three options:
16 | - `truncate_priority`
17 | - `enable_ellipsis`
18 | - `ellipsis`
19 | ## `truncate_priority`
20 | Sets content priority on overlap between anchored modules.
21 |
22 | Default:
23 | ```yaml
24 | truncate_priority:
25 | - right
26 | - left
27 | - middle
28 | ```
29 |
30 | For example, in the config
31 | ```yaml
32 | left:
33 | - title
34 | middle:
35 | - clock
36 | ```
37 |
38 | if the content of the title reaches clock then by default clock will be under the title.
39 |
40 | one set of anchor can be given priority over the other with this
41 |
42 | ## `enable_ellipsis`
43 | Enables adding ellipsis inside truncated content
44 |
45 | Adds ellipsis at the point of overlap. The ellipsis uses the space of the anchor being truncated.
46 |
47 | Default:
48 | ```yaml
49 | enable_ellipsis: true
50 | ```
51 |
52 | ## `ellipsis`
53 | Sets the ellipsis content.
54 |
55 | Default:
56 | ```yaml
57 | ellipsis: "…"
58 | ```
59 |
60 | # Modules
61 |
62 | There are **16** modules currently:
63 | - `backlight`
64 | - `battery`
65 | - `bluetooth`
66 | - `clock`
67 | - `cpu`
68 | - `custom`
69 | - `disk`
70 | - `idleInhibitor`
71 | - `locale`
72 | - `mpris`
73 | - `ram`
74 | - `title`
75 | - `tray`
76 | - `volume`
77 | - `wifi`
78 | - `ws`
79 |
80 |
81 | Each module must be anchored in one of **3** ways:
82 | - `left`
83 | - `middle`
84 | - `right`
85 |
86 | like this in config file:
87 | ```yaml
88 | left:
89 | - ws
90 | - title
91 | middle:
92 | - clock
93 | right:
94 | - tray
95 | - sep
96 | - disk
97 | ```
98 |
99 | The modules are listed in a way such that top maps to left and bottom maps to right.\
100 | So in the example above, tray will be the leftmost module in the right anchor and disk be the rightmost
101 |
102 | Each modules has some configuration options, but some common configuration options are available in most modules like:
103 | - `fg`
104 | - `bg`
105 | - `format`
106 | - `cursor`
107 | - `onmouse`
108 |
109 | ## `fg`
110 | Set foreground color.
111 | \
112 | Changes color for text, icons, etc
113 |
114 | Example:
115 | ```yaml
116 | fg: aliceblue
117 | ```
118 |
119 | ### Colors
120 | Colors can set using **4** different methods:
121 | - [CSS Named Colors](https://developer.mozilla.org/en-US/docs/Web/CSS/named-color): Set the name directly like `fg: rebeccapurple`
122 | - Hex Codes: `fg: #1A553B` or `fg: #FFF`
123 | - RGB Codes: `fg: rgb(234,98,102)`
124 | - Predefined Variables: `fg: @urgent`, `fg: @good`, `fg: @color112`
125 |
126 | ## `bg`
127 | Set background color.
128 |
129 | Example:
130 | ```yaml
131 | bg: saddlebrown
132 | ```
133 |
134 | Refer [Colors](#colors) for how to set colors
135 |
136 | ## `format`
137 | Sets display format for the module
138 |
139 | Apart from `clock`, most modules uses go's [text/template format syntax](https://pkg.go.dev/text/template)
140 |
141 | For basic configuration it is enough to know, each module has its set of keywords, like `backlight` may have:
142 | - `Icon`: icon
143 | - `Percent`: percent of backlight
144 | - `Now`: current brightness units
145 | - `Max`: maximum brightness units
146 |
147 | Now, these can be escaped using the syntax {{.Keyword}} \
148 | Examples:
149 | ```yaml
150 | format: "{{.Icon}} {{.Percent}}%"
151 | ```
152 |
153 | ```yaml
154 | format: "hallo: {{.Icon}}: {{.Now}}/{{.Max}}"
155 | ```
156 |
157 | templates are really powerful and you can do a bunch of cool stuff with it. Check out the link above to know more.
158 |
159 | ## `cursor`
160 | Sets cursor shown while hovering over the module.
161 |
162 | ```yaml
163 | cursor: pointer
164 | ```
165 |
166 | Available cursor shapes can be referred from [here](https://developer.mozilla.org/en-US/docs/Web/CSS/cursor)
167 |
168 | ## onmouse
169 | Sets dynamic behavior on mouse interaction
170 |
171 | Supports setting behaviour on 7 types of mouse interaction:
172 | - `left`: Left click
173 | - `right`: Right click
174 | - `middle`: Middle click
175 | - `wheel-up`: Scroll up (touchpad or mouse)
176 | - `wheel-down`: Scroll down
177 | - `wheel-left`: Scroll left
178 | - `wheel-right`: Scroll right
179 | - `hover`: Hover over the module, only triggers when you enter the module space
180 |
181 | Each type of interaction has **3** actions it can perform when triggered:
182 | - `run`: Execute a command
183 | - `notify`: Send a desktop notification
184 | - `config`: Change config dynamically
185 |
186 | ### `run`
187 | Execute a command
188 |
189 | Example:
190 | ```yaml
191 | onmouse:
192 | left:
193 | run: "pavucontrol"
194 | ```
195 |
196 | ### `notify`
197 | Send a desktop notification
198 |
199 | Example:
200 | ```yaml
201 | onmouse:
202 | middle:
203 | notify: "Hey I am pawbar."
204 | ```
205 |
206 | ### `config`
207 | List of configs for this module to cycle through each time this type of interaction is triggered.
208 |
209 | Example:
210 | ```yaml
211 | onmouse:
212 | left:
213 | config:
214 | - fg: blue # conf 1
215 | bg: green
216 | - fg: red # conf 2
217 | bg: white
218 | ```
219 |
220 | On each left click, this will cycle through the base config, conf 1, conf 2
221 |
222 | Each module will have its set of allowed config options in onmouse interactions, but most will have atleast `fg`, `bg`, `format`, `cursor`
223 |
224 |
--------------------------------------------------------------------------------
/internal/modules/ws/backend_hypr.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package ws
8 |
9 | import (
10 | "sort"
11 | "strconv"
12 | "strings"
13 | "sync"
14 |
15 | "github.com/nekorg/pawbar/internal/services/hypr"
16 | )
17 |
18 | type hyprBackend struct {
19 | svc *hypr.Service
20 | ev chan hypr.HyprEvent
21 | ws map[int]*Workspace
22 | mu sync.RWMutex
23 | sig chan struct{}
24 | }
25 |
26 | func newHyprBackend(s *hypr.Service) backend {
27 | b := &hyprBackend{
28 | svc: s,
29 | ev: make(chan hypr.HyprEvent),
30 | ws: make(map[int]*Workspace),
31 | sig: make(chan struct{}, 1),
32 | }
33 |
34 | b.refreshWorkspaceCache()
35 |
36 | for _, e := range []string{"workspacev2", "focusedmonv2", "createworkspacev2", "destroyworkspacev2", "activespecial", "renameworkspace", "urgent"} {
37 | b.svc.RegisterChannel(e, b.ev)
38 | }
39 |
40 | go b.loop()
41 | return b
42 | }
43 |
44 | func (b *hyprBackend) loop() {
45 | for e := range b.ev {
46 | if !b.validate(e) {
47 | b.refreshWorkspaceCache()
48 | }
49 |
50 | if b.handleEvent(e) {
51 | b.signal()
52 | }
53 | }
54 | }
55 |
56 | func (b *hyprBackend) refreshWorkspaceCache() {
57 | b.mu.Lock()
58 | defer b.mu.Unlock()
59 | b.ws = make(map[int]*Workspace)
60 |
61 | workspaces := hypr.GetWorkspaces()
62 |
63 | for _, w := range workspaces {
64 | b.ws[w.Id] = &Workspace{
65 | ID: w.Id,
66 | Name: w.Name,
67 | Active: w.Id == hypr.GetActiveWorkspace().Id,
68 | Special: strings.HasPrefix(w.Name, "special:"),
69 | }
70 | }
71 | }
72 |
73 | func (b *hyprBackend) signal() {
74 | select {
75 | case b.sig <- struct{}{}:
76 | default:
77 | }
78 | }
79 |
80 | func (b *hyprBackend) validate(e hypr.HyprEvent) bool {
81 | b.mu.Lock()
82 | defer b.mu.Unlock()
83 | switch e.Event {
84 | case "workspacev2":
85 | id, _ := strconv.Atoi(e.Data[:strings.IndexRune(e.Data, ',')])
86 | _, ok := b.ws[id]
87 | return ok
88 |
89 | case "focusedmonv2":
90 | id, _ := strconv.Atoi(e.Data[strings.LastIndex(e.Data, ",")+1:])
91 | _, ok := b.ws[id]
92 | return ok
93 |
94 | case "createworkspacev2":
95 | id, _ := strconv.Atoi(e.Data[:strings.IndexRune(e.Data, ',')])
96 | _, ok := b.ws[id]
97 | return !ok
98 |
99 | case "destroyworkspacev2":
100 | id, _ := strconv.Atoi(e.Data[:strings.IndexRune(e.Data, ',')])
101 | _, ok := b.ws[id]
102 | return ok
103 |
104 | case "renameworkspace":
105 | id, _ := strconv.Atoi(e.Data[:strings.IndexRune(e.Data, ',')])
106 | _, ok := b.ws[id]
107 | return ok
108 | }
109 |
110 | return true
111 | }
112 |
113 | func (b *hyprBackend) handleEvent(e hypr.HyprEvent) bool {
114 | b.mu.Lock()
115 | defer b.mu.Unlock()
116 | switch e.Event {
117 | case "workspacev2":
118 | id, _ := strconv.Atoi(e.Data[:strings.IndexRune(e.Data, ',')])
119 | b.setActiveWorkspace(id)
120 | case "createworkspacev2":
121 | id_str, name, _ := strings.Cut(e.Data, ",")
122 | id, _ := strconv.Atoi(id_str)
123 | b.createWorkspace(id, name)
124 | case "destroyworkspacev2":
125 | id, _ := strconv.Atoi(e.Data[:strings.IndexRune(e.Data, ',')])
126 | b.destroyWorkspace(id)
127 | case "activespecial":
128 | b.activateSpecialWorkspace(e.Data[:strings.IndexRune(e.Data, ',')])
129 | case "urgent":
130 | b.setWorkspaceUrgent(e.Data)
131 | case "renameworkspace":
132 | idr, name, _ := strings.Cut(e.Data, ",")
133 | id, _ := strconv.Atoi(idr)
134 | b.renameWorkspace(id, name)
135 | default:
136 | return false
137 | }
138 | return true
139 | }
140 |
141 | func (b *hyprBackend) renameWorkspace(id int, name string) {
142 | for _, w := range b.ws {
143 | if w.ID == id {
144 | w.Name = name
145 | }
146 | }
147 | }
148 |
149 | func (b *hyprBackend) setActiveWorkspace(id int) {
150 | for _, w := range b.ws {
151 | if !w.Special {
152 | w.Active = false
153 | }
154 | }
155 |
156 | b.ws[id].Active = true
157 | b.ws[id].Urgent = false
158 | }
159 |
160 | func (b *hyprBackend) createWorkspace(id int, name string) {
161 | b.ws[id] = &Workspace{
162 | ID: id,
163 | Name: name,
164 | Active: false,
165 | Special: strings.HasPrefix(name, "special:"),
166 | }
167 | }
168 |
169 | func (b *hyprBackend) destroyWorkspace(id int) {
170 | delete(b.ws, id)
171 | }
172 |
173 | func (b *hyprBackend) activateSpecialWorkspace(name string) {
174 | active := name != ""
175 |
176 | for _, w := range b.ws {
177 | if w.Special {
178 | w.Active = active
179 | }
180 | }
181 | }
182 |
183 | func (b *hyprBackend) setWorkspaceUrgent(address string) {
184 | clients := hypr.GetClients()
185 |
186 | activeId := 0
187 | for _, w := range b.ws {
188 | if w.Active && !w.Special {
189 | activeId = w.ID
190 | }
191 | }
192 |
193 | for _, client := range clients {
194 | client_address, _ := strings.CutPrefix(client.Address, "0x")
195 | if client_address == address && client.Workspace.Id != activeId {
196 | b.ws[client.Workspace.Id].Urgent = true
197 | }
198 | }
199 | }
200 |
201 | func (b *hyprBackend) List() []Workspace {
202 | b.mu.RLock()
203 | defer b.mu.RUnlock()
204 | ws := make([]Workspace, 0, len(b.ws))
205 | for _, v := range b.ws {
206 | ws = append(ws, Workspace{v.ID, v.Name, v.Active, v.Urgent, v.Special})
207 | }
208 | sort.Slice(ws, func(a, b int) bool { return ws[a].ID < ws[b].ID })
209 | return ws
210 | }
211 | func (b *hyprBackend) Events() <-chan struct{} { return b.sig }
212 | func (b *hyprBackend) Goto(name string) { hypr.GoToWorkspace(name) }
213 |
--------------------------------------------------------------------------------
/internal/services/hypr/hypr.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package hypr
8 |
9 | import (
10 | "bufio"
11 | "encoding/json"
12 | "fmt"
13 | "net"
14 | "os"
15 | "path"
16 | "strings"
17 |
18 | "github.com/nekorg/pawbar/internal/services"
19 | )
20 |
21 | func Register() (*Service, bool) {
22 | if s, ok := services.Ensure("hypr", func() services.Service { return &Service{} }).(*Service); ok {
23 | return s, true
24 | }
25 | return nil, false
26 | }
27 |
28 | type Service struct {
29 | callbacks map[string][]chan<- HyprEvent
30 | running bool
31 | }
32 |
33 | func (h *Service) Name() string { return "hypr" }
34 |
35 | func (h *Service) Start() error {
36 | if h.running {
37 | return nil
38 | }
39 |
40 | if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
41 | return fmt.Errorf("Hyprland is not running.")
42 | }
43 |
44 | h.callbacks = make(map[string][]chan<- HyprEvent)
45 | go h.run()
46 | h.running = true
47 | return nil
48 | }
49 |
50 | func (h *Service) Stop() error {
51 | return nil
52 | }
53 |
54 | func (h *Service) RegisterChannel(event string, ch chan<- HyprEvent) {
55 | h.callbacks[event] = append(h.callbacks[event], ch)
56 | }
57 |
58 | func (h *Service) run() {
59 | _, sockaddr2 := GetHyprSocketAddrs()
60 |
61 | sock2, err := net.Dial("unix", sockaddr2)
62 | if err != nil {
63 | panic(err)
64 | }
65 | defer sock2.Close()
66 |
67 | scanner := bufio.NewScanner(sock2)
68 | for scanner.Scan() {
69 | e := NewHyprEvent(scanner.Text())
70 | c, ok := h.callbacks[e.Event]
71 | if ok {
72 | for _, ch := range c {
73 | ch <- e
74 | }
75 | }
76 | }
77 | }
78 |
79 | func GetHyprSocketAddrs() (string, string) {
80 | instance_signature := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
81 | runtime_dir := os.Getenv("XDG_RUNTIME_DIR")
82 | socket_addr := path.Join(runtime_dir, "/hypr", instance_signature)
83 |
84 | return path.Join(socket_addr, "/.socket.sock"), path.Join(socket_addr, "/.socket2.sock")
85 | }
86 |
87 | type HyprEvent struct {
88 | Event string
89 | Data string
90 | }
91 |
92 | func NewHyprEvent(s string) HyprEvent {
93 | e, d, _ := strings.Cut(s, ">>")
94 | return HyprEvent{e, strings.Trim(d, " \n")}
95 | }
96 |
97 | type Workspace struct {
98 | Id int `json:"id"`
99 | Name string `json:"name"`
100 | Monitor string `json:"monitor"`
101 | MonitorID int `json:"monitorID"`
102 | Windows int `json:"windows"`
103 | Hasfullscreen bool `json:"hasfullscreen"`
104 | Lastwindow string `json:"lastwindow"`
105 | Lastwindowtitle string `json:"lastwindowtitle"`
106 | }
107 |
108 | func GetWorkspaces() []Workspace {
109 | sockaddr1, _ := GetHyprSocketAddrs()
110 | sock, err := net.Dial("unix", sockaddr1)
111 | if err != nil {
112 | panic(err)
113 | }
114 | defer sock.Close()
115 | scanner := json.NewDecoder(sock)
116 |
117 | sock.Write([]byte("-j/workspaces"))
118 | var o []Workspace
119 |
120 | err = scanner.Decode(&o)
121 | if err != nil {
122 | panic(err)
123 | }
124 | return o
125 | }
126 |
127 | func GetActiveWorkspace() Workspace {
128 | sockaddr1, _ := GetHyprSocketAddrs()
129 | sock, err := net.Dial("unix", sockaddr1)
130 | if err != nil {
131 | panic(err)
132 | }
133 | defer sock.Close()
134 | scanner := json.NewDecoder(sock)
135 |
136 | sock.Write([]byte("-j/activeworkspace"))
137 | var o Workspace
138 |
139 | err = scanner.Decode(&o)
140 | if err != nil {
141 | panic(err)
142 | }
143 | return o
144 | }
145 |
146 | type ClientWS struct {
147 | Id int
148 | Name string
149 | }
150 |
151 | type Client struct {
152 | Address string `json:"address"`
153 | Mapped bool `json:"mapped"`
154 | Hidden bool `json:"hidden"`
155 | At []int `json:"at"`
156 | Size []int `json:"size"`
157 | Workspace ClientWS `json:"workspace"`
158 | Floating bool `json:"floating"`
159 | Pseudo bool `json:"pseudo"`
160 | Monitor int `json:"monitor"`
161 | Class string `json:"class"`
162 | Title string `json:"title"`
163 | InitialClass string `json:"initialClass"`
164 | InitialTitle string `json:"initialTitle"`
165 | Pid int `json:"pid"`
166 | Xwayland bool `json:"xwayland"`
167 | Pinned bool `json:"pinned"`
168 | Fullscreen int `json:"fullscreen"`
169 | FullscreenClient int `json:"fullscreenClient"`
170 | Grouped interface{} `json:"grouped"`
171 | Tags interface{} `json:"tags"`
172 | Swallowing string `json:"swallowing"`
173 | FocusHistoryID int `json:"focusHistoryID"`
174 | InhibitingIdle bool `json:"inhibitingIdle"`
175 | }
176 |
177 | func GetClients() []Client {
178 | sockaddr1, _ := GetHyprSocketAddrs()
179 | sock, err := net.Dial("unix", sockaddr1)
180 | if err != nil {
181 | panic(err)
182 | }
183 | defer sock.Close()
184 | scanner := json.NewDecoder(sock)
185 |
186 | sock.Write([]byte("-j/clients"))
187 | var o []Client
188 |
189 | err = scanner.Decode(&o)
190 | if err != nil {
191 | panic(err)
192 | }
193 | return o
194 | }
195 |
196 | func GoToWorkspace(name string) {
197 | sockaddr1, _ := GetHyprSocketAddrs()
198 | sock, err := net.Dial("unix", sockaddr1)
199 | if err != nil {
200 | panic(err)
201 | }
202 | defer sock.Close()
203 |
204 | sock.Write([]byte("/dispatch workspace " + name))
205 | }
206 |
--------------------------------------------------------------------------------
/pkg/dbusmenukitty/tui/tui.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package tui
8 |
9 | import (
10 | "image/color"
11 | "io"
12 | "time"
13 |
14 | l "log"
15 |
16 | "git.sr.ht/~rockorager/vaxis"
17 | "git.sr.ht/~rockorager/vaxis/log"
18 | "github.com/fxamacker/cbor/v2"
19 | "github.com/nekorg/katnip"
20 | "github.com/nekorg/pawbar/pkg/dbusmenukitty/menu"
21 | )
22 |
23 | const (
24 | hoverActivationTimeout = 200 * time.Millisecond
25 | iconSize = 32
26 | iconCellWidth = 2
27 | iconCellHeight = 1
28 | menuPadding = 2
29 | iconSpacing = 2
30 | )
31 |
32 | var (
33 | fgColor color.Color
34 | arrowHeads = []rune{'◄', '►'}
35 | )
36 |
37 | func Leaf(k *katnip.Kitty, rw io.ReadWriter) int {
38 | // device := "/dev/pts/8"
39 | // Fd, err := os.OpenFile(device, os.O_WRONLY, 0o620)
40 | // if err == nil {
41 | // log.SetOutput(Fd)
42 | // log.SetLevel(log.LevelTrace)
43 | // }
44 | dec := cbor.NewDecoder(rw)
45 | enc := cbor.NewEncoder(rw)
46 |
47 | // Disable logging
48 | l.SetOutput(io.Discard)
49 | // log.SetOutput(io.Discard)
50 | // log.SetLevel(log.LevelInfo)
51 |
52 | // Setup message queue
53 | msgQueue := make(chan menu.Message, 10)
54 | go func() {
55 | var msg menu.Message
56 | for {
57 | if err := dec.Decode(&msg); err == nil {
58 | msgQueue <- msg
59 | }
60 | }
61 | }()
62 |
63 | // Initialize vaxis
64 | vx, err := vaxis.New(vaxis.Options{EnableSGRPixels: true})
65 | if err != nil {
66 | return 1
67 | }
68 |
69 | // Query and store foreground color for icon rendering
70 | c := vx.QueryForeground()
71 | rgb := c.Params()
72 | fgColor = color.RGBA{rgb[0], rgb[1], rgb[2], 255}
73 |
74 | // Initialize state and handlers
75 | state := NewMenuState()
76 | messageHandler := NewMessageHandler(enc, state)
77 |
78 | win := vx.Window()
79 | renderer := NewRenderer(win)
80 |
81 | {
82 | winSize := vx.Size()
83 | state.size = menu.Size{
84 | Cols: winSize.Cols,
85 | Rows: winSize.Rows,
86 | XPixels: winSize.XPixel,
87 | YPixels: winSize.YPixel,
88 | }
89 | state.ppc = menu.PPC{
90 | X: float64(winSize.XPixel) / float64(winSize.Cols),
91 | Y: float64(winSize.YPixel) / float64(winSize.Rows),
92 | }
93 | }
94 |
95 | k.Show()
96 |
97 | for {
98 | select {
99 | case ev := <-vx.Events():
100 | switch ev := ev.(type) {
101 | case vaxis.Redraw:
102 | vx.Render()
103 |
104 | case vaxis.Resize:
105 | win = vx.Window()
106 | renderer = NewRenderer(win)
107 | win.Clear()
108 | state.size = menu.Size{
109 | Cols: ev.Cols,
110 | Rows: ev.Rows,
111 | XPixels: ev.XPixel,
112 | YPixels: ev.YPixel,
113 | }
114 |
115 | state.ppc = menu.PPC{
116 | X: float64(ev.XPixel) / float64(ev.Cols),
117 | Y: float64(ev.YPixel) / float64(ev.Rows),
118 | }
119 |
120 | log.Debug("dbusmenukitty: %d, %d\n", state.size.Cols, state.size.Rows)
121 | renderer.drawMenu(state.items, state, true)
122 | vx.Render()
123 |
124 | case vaxis.Mouse:
125 | l.Printf("%#v\n", ev)
126 | state.mousePixelX, state.mousePixelY = ev.XPixel, ev.YPixel
127 | switch ev.EventType {
128 | case vaxis.EventLeave:
129 | state.mouseOnSurface = false
130 |
131 | case vaxis.EventMotion:
132 | messageHandler.handleMouseMotion(ev.Col, ev.Row)
133 |
134 | case vaxis.EventPress:
135 | if ev.Button == vaxis.MouseLeftButton {
136 | state.mousePressed = true
137 | }
138 |
139 | case vaxis.EventRelease:
140 | if ev.Button == vaxis.MouseLeftButton {
141 | state.mousePressed = false
142 | if state.isSelectableItem(state.mouseY) {
143 | messageHandler.handleItemClick(&state.items[state.mouseY])
144 | }
145 | }
146 | }
147 |
148 | renderer.drawMenu(state.items, state, false) // Fast draw (text only)
149 | vx.Render()
150 |
151 | case vaxis.Key:
152 | if ev.EventType == vaxis.EventPress {
153 | switch ev.Keycode {
154 | case vaxis.KeyEsc, vaxis.KeyLeft:
155 | return 0
156 |
157 | case vaxis.KeyEnter:
158 | if state.isSelectableItem(state.mouseY) {
159 | messageHandler.handleItemClick(&state.items[state.mouseY])
160 | }
161 |
162 | case vaxis.KeyUp:
163 | state.navigateUp()
164 | messageHandler.handleKeyNavigation(true)
165 | renderer.drawMenu(state.items, state, false)
166 | vx.Render()
167 |
168 | case vaxis.KeyDown:
169 | state.navigateDown()
170 | messageHandler.handleKeyNavigation(true)
171 | renderer.drawMenu(state.items, state, false)
172 | vx.Render()
173 |
174 | case vaxis.KeyRight:
175 | if item := state.getCurrentItem(); item != nil && item.HasChildren && item.Enabled {
176 | messageHandler.sendMessage(menu.MsgSubmenuRequested, item.Id,
177 | state.mousePixelX, state.mousePixelY)
178 | }
179 | }
180 | }
181 | }
182 |
183 | case msg := <-msgQueue:
184 | switch msg.Type {
185 | case menu.MsgMenuClose:
186 | return 0
187 |
188 | case menu.MsgMenuUpdate:
189 | state.items = msg.Payload.Menu
190 | maxHorizontalLength := menu.MaxLengthLabel(state.items) + 4
191 | maxVerticalLength := len(state.items)
192 |
193 | log.Info("leaf: %d %d actual: %d %d\n", maxHorizontalLength, maxVerticalLength, state.size.Cols, state.size.Rows)
194 |
195 | win.Clear()
196 | renderer.drawMenu(state.items, state, true)
197 | vx.Render()
198 |
199 | if state.size.Cols != maxHorizontalLength || state.size.Rows != maxVerticalLength {
200 | log.Info("resizing window to fit menu")
201 | k.Resize(maxHorizontalLength, maxVerticalLength)
202 | continue
203 | }
204 | }
205 | }
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/go,linux
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,linux
3 |
4 | ### Go ###
5 | # If you prefer the allow list template instead of the deny list, see community template:
6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
7 | #
8 | # Binaries for programs and plugins
9 | *.exe
10 | *.exe~
11 | *.dll
12 | *.so
13 | *.dylib
14 | /pawbar
15 | !cmd/pawbar
16 |
17 | # Test binary, built with `go test -c`
18 | *.test
19 |
20 | # Output of the go coverage tool, specifically when used with LiteIDE
21 | *.out
22 |
23 | # Dependency directories (remove the comment below to include it)
24 | # vendor/
25 |
26 | # Go workspace file
27 | go.work
28 |
29 | ### Linux ###
30 | *~
31 |
32 | # temporary files which can be created if a process still has a handle open of a deleted file
33 | .fuse_hidden*
34 |
35 | # KDE directory preferences
36 | .directory
37 |
38 | # Linux trash folder which might appear on any partition or disk
39 | .Trash-*
40 |
41 | # .nfs files are created when an open file is removed but is still being accessed
42 | .nfs*
43 |
44 | # End of https://www.toptal.com/developers/gitignore/api/go,linux
45 | # Nix
46 | result
47 | # Created by https://www.toptal.com/developers/gitignore/api/macos,windows,node,vue,vuejs
48 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,node,vue,vuejs
49 |
50 | ### macOS ###
51 | # General
52 | .DS_Store
53 | .AppleDouble
54 | .LSOverride
55 |
56 | # Icon must end with two \r
57 | Icon
58 |
59 | # Thumbnails
60 | ._*
61 |
62 | # Files that might appear in the root of a volume
63 | .DocumentRevisions-V100
64 | .fseventsd
65 | .Spotlight-V100
66 | .TemporaryItems
67 | .Trashes
68 | .VolumeIcon.icns
69 | .com.apple.timemachine.donotpresent
70 |
71 | # Directories potentially created on remote AFP share
72 | .AppleDB
73 | .AppleDesktop
74 | Network Trash Folder
75 | Temporary Items
76 | .apdisk
77 |
78 | ### macOS Patch ###
79 | # iCloud generated files
80 | *.icloud
81 |
82 | ### Node ###
83 | # Logs
84 | logs
85 | *.log
86 | npm-debug.log*
87 | yarn-debug.log*
88 | yarn-error.log*
89 | lerna-debug.log*
90 | .pnpm-debug.log*
91 |
92 | # Diagnostic reports (https://nodejs.org/api/report.html)
93 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
94 |
95 | # Runtime data
96 | pids
97 | *.pid
98 | *.seed
99 | *.pid.lock
100 |
101 | # Directory for instrumented libs generated by jscoverage/JSCover
102 | lib-cov
103 |
104 | # Coverage directory used by tools like istanbul
105 | coverage
106 | *.lcov
107 |
108 | # nyc test coverage
109 | .nyc_output
110 |
111 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
112 | .grunt
113 |
114 | # Bower dependency directory (https://bower.io/)
115 | bower_components
116 |
117 | # node-waf configuration
118 | .lock-wscript
119 |
120 | # Compiled binary addons (https://nodejs.org/api/addons.html)
121 | build/Release
122 |
123 | # Dependency directories
124 | node_modules/
125 | jspm_packages/
126 |
127 | # Snowpack dependency directory (https://snowpack.dev/)
128 | web_modules/
129 |
130 | # TypeScript cache
131 | *.tsbuildinfo
132 |
133 | # Optional npm cache directory
134 | .npm
135 |
136 | # Optional eslint cache
137 | .eslintcache
138 |
139 | # Optional stylelint cache
140 | .stylelintcache
141 |
142 | # Microbundle cache
143 | .rpt2_cache/
144 | .rts2_cache_cjs/
145 | .rts2_cache_es/
146 | .rts2_cache_umd/
147 |
148 | # Optional REPL history
149 | .node_repl_history
150 |
151 | # Output of 'npm pack'
152 | *.tgz
153 |
154 | # Yarn Integrity file
155 | .yarn-integrity
156 |
157 | # dotenv environment variable files
158 | .env
159 | .env.development.local
160 | .env.test.local
161 | .env.production.local
162 | .env.local
163 |
164 | # parcel-bundler cache (https://parceljs.org/)
165 | .cache
166 | .parcel-cache
167 |
168 | # Next.js build output
169 | .next
170 | out
171 |
172 | # Nuxt.js build / generate output
173 | .nuxt
174 | dist
175 |
176 | # Gatsby files
177 | .cache/
178 | # Comment in the public line in if your project uses Gatsby and not Next.js
179 | # https://nextjs.org/blog/next-9-1#public-directory-support
180 | # public
181 |
182 | # vuepress build output
183 | .vuepress/dist
184 |
185 | # vuepress v2.x temp and cache directory
186 | .temp
187 |
188 | # Docusaurus cache and generated files
189 | .docusaurus
190 |
191 | # Serverless directories
192 | .serverless/
193 |
194 | # FuseBox cache
195 | .fusebox/
196 |
197 | # DynamoDB Local files
198 | .dynamodb/
199 |
200 | # TernJS port file
201 | .tern-port
202 |
203 | # Stores VSCode versions used for testing VSCode extensions
204 | .vscode-test
205 |
206 | # yarn v2
207 | .yarn/cache
208 | .yarn/unplugged
209 | .yarn/build-state.yml
210 | .yarn/install-state.gz
211 | .pnp.*
212 |
213 | ### Node Patch ###
214 | # Serverless Webpack directories
215 | .webpack/
216 |
217 | # Optional stylelint cache
218 |
219 | # SvelteKit build / generate output
220 | .svelte-kit
221 |
222 | ### Vue ###
223 | # gitignore template for Vue.js projects
224 | #
225 | # Recommended template: Node.gitignore
226 |
227 | # TODO: where does this rule come from?
228 | docs/_book
229 |
230 | # TODO: where does this rule come from?
231 | test/
232 |
233 | ### Vuejs ###
234 | # Recommended template: Node.gitignore
235 |
236 | dist/
237 | npm-debug.log
238 | yarn-error.log
239 |
240 | ### Windows ###
241 | # Windows thumbnail cache files
242 | Thumbs.db
243 | Thumbs.db:encryptable
244 | ehthumbs.db
245 | ehthumbs_vista.db
246 |
247 | # Dump file
248 | *.stackdump
249 |
250 | # Folder config file
251 | [Dd]esktop.ini
252 |
253 | # Recycle Bin used on file shares
254 | $RECYCLE.BIN/
255 |
256 | # Windows Installer files
257 | *.cab
258 | *.msi
259 | *.msix
260 | *.msm
261 | *.msp
262 |
263 | # Windows shortcuts
264 | *.lnk
265 |
266 | # End of https://www.toptal.com/developers/gitignore/api/macos,windows,node,vue,vuejs
267 |
268 | **/.vitepress/dist
269 | **/.vitepress/cache
270 |
--------------------------------------------------------------------------------
/internal/tui/renderer.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package tui
8 |
9 | import (
10 | "git.sr.ht/~rockorager/vaxis"
11 | "github.com/nekorg/pawbar/internal/config"
12 | "github.com/nekorg/pawbar/internal/modules"
13 | )
14 |
15 | var (
16 | modMap = make(map[modules.Module][]modules.EventCell)
17 | state []modules.EventCell
18 | leftModules []modules.Module
19 | middleModules []modules.Module
20 | rightModules []modules.Module
21 | width, height int
22 |
23 | truncOrder []string
24 | useEllipsis bool
25 | ellipsisCells []modules.EventCell
26 | ellipsisWidth int
27 | )
28 |
29 | type anchor int
30 |
31 | const (
32 | left anchor = iota
33 | middle
34 | right
35 | )
36 |
37 | type block struct {
38 | cells []modules.EventCell
39 | side anchor
40 | }
41 |
42 | func State() []modules.EventCell {
43 | return state
44 | }
45 |
46 | // can be called again
47 | func Init(w, h int, l, m, r []modules.Module, barCfg config.BarSettings) {
48 | width = w
49 | height = h
50 |
51 | leftModules = l
52 | middleModules = m
53 | rightModules = r
54 |
55 | truncOrder = barCfg.TruncatePriority
56 | useEllipsis = barCfg.EnableEllipsis == nil || *barCfg.EnableEllipsis
57 | ellipsisCells = stringToEC(barCfg.Ellipsis)
58 | ellipsisWidth = totalWidth(ellipsisCells)
59 |
60 | state = make([]modules.EventCell, width+1) // sometimes kitty can report mouse events outside reported width, like at the edge, idk why.
61 | refreshModMap(leftModules, middleModules, rightModules)
62 | }
63 |
64 | func Resize(w, h int) {
65 | width = w
66 | height = h
67 |
68 | state = make([]modules.EventCell, width+1)
69 | }
70 |
71 | func FullRender(win vaxis.Window) {
72 | refreshModMap(leftModules, middleModules, rightModules)
73 | render(win)
74 | }
75 |
76 | func PartialRender(win vaxis.Window, m modules.Module) {
77 | modMap[m] = m.Render()
78 | render(win)
79 | }
80 |
81 | func render(win vaxis.Window) {
82 | for i := range width {
83 | state[i] = modules.ECSPACE
84 | }
85 | win.Clear()
86 |
87 | blocks := buildBlocks()
88 | occ := make([]bool, width)
89 |
90 | mark := func(x, w int) {
91 | for i := 0; i < w && x+i < width; i++ {
92 | occ[x+i] = true
93 | }
94 | }
95 |
96 | for _, block := range blocks {
97 | if len(block.cells) == 0 {
98 | continue
99 | }
100 |
101 | fullW := totalWidth(block.cells)
102 | switch block.side {
103 | case left:
104 | free := 0
105 | for free < width && !occ[free] {
106 | free++
107 | }
108 | visible := block.cells
109 | if fullW > free {
110 | visible = trimStart(block.cells, free, useEllipsis)
111 | }
112 | if len(visible) == 0 {
113 | break
114 | }
115 | x := 0
116 | for _, r := range visible {
117 | next := writeCell(win, x, r)
118 | mark(x, next-x)
119 | x = next
120 | }
121 |
122 | case middle:
123 | start := (width - fullW) / 2
124 | if start < 0 {
125 | start = 0
126 | }
127 | end := start + fullW
128 |
129 | firstOcc, lastOcc := -1, -1
130 | for i := start; i < end && i < width; i++ {
131 | if occ[i] {
132 | if firstOcc == -1 {
133 | firstOcc = i
134 | }
135 | lastOcc = i
136 | }
137 | }
138 | if firstOcc == -1 {
139 | x := start
140 | for _, r := range block.cells {
141 | next := writeCell(win, x, r)
142 | mark(x, next-x)
143 | x = next
144 | }
145 | break
146 | }
147 |
148 | var visible []modules.EventCell
149 | var drawAt int
150 |
151 | ellW := 0
152 | if useEllipsis {
153 | ellW = ellipsisWidth
154 | }
155 | switch {
156 | case firstOcc == start && lastOcc == end-1:
157 | gapStart := 0
158 | for gapStart < width && occ[gapStart] {
159 | gapStart++
160 | }
161 | gapEnd := width - 1
162 | for gapEnd >= 0 && occ[gapEnd] {
163 | gapEnd--
164 | }
165 | gapLen := gapEnd - gapStart + 1
166 | if gapLen <= 0 {
167 | return
168 | }
169 | space := gapLen
170 | if space-2*ellW > 0 {
171 | visible := trimMiddle(block.cells, space, useEllipsis)
172 |
173 | drawAt := gapStart + (gapLen-totalWidth(visible))/2
174 | x := drawAt
175 | for _, r := range visible {
176 | next := writeCell(win, x, r)
177 | mark(x, next-x)
178 | x = next
179 | }
180 | }
181 | break
182 | case firstOcc == start:
183 | free := end - lastOcc - 1
184 | space := free
185 | if useEllipsis {
186 | space -= ellW
187 | }
188 | if space <= 0 {
189 | break
190 | }
191 | visible = trimEnd(block.cells, space, false)
192 | if useEllipsis {
193 | visible = append(clone(ellipsisCells), visible...)
194 | }
195 | drawAt = end - totalWidth(visible)
196 |
197 | case lastOcc == end-1 || firstOcc > start:
198 | free := firstOcc - start
199 | space := free
200 | if useEllipsis {
201 | space -= ellW
202 | }
203 | if space <= 0 {
204 | break
205 | }
206 | visible = trimStart(block.cells, space, false)
207 | if useEllipsis {
208 | visible = append(visible, ellipsisCells...)
209 | }
210 | drawAt = start
211 |
212 | default:
213 | break
214 | }
215 |
216 | x := drawAt
217 | for _, r := range visible {
218 | next := writeCell(win, x, r)
219 | mark(x, next-x)
220 | x = next
221 | }
222 | case right:
223 | free := 0
224 | for i := width - 1; i >= 0 && !occ[i]; i-- {
225 | free++
226 | }
227 | visible := block.cells
228 | if fullW > free {
229 | visible = trimEnd(block.cells, free, useEllipsis)
230 | }
231 | if len(visible) == 0 {
232 | break
233 | }
234 | renderW := totalWidth(visible)
235 | start := width - renderW
236 | x := start
237 | for _, r := range visible {
238 | next := writeCell(win, x, r)
239 | mark(x, next-x)
240 | x = next
241 | }
242 | }
243 | }
244 | }
245 |
--------------------------------------------------------------------------------
/internal/modules/ram/ram.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package ram
8 |
9 | import (
10 | "bufio"
11 | "bytes"
12 | "errors"
13 | "os"
14 | "strconv"
15 | "strings"
16 | "time"
17 |
18 | "git.sr.ht/~rockorager/vaxis"
19 | "github.com/nekorg/pawbar/internal/config"
20 | "github.com/nekorg/pawbar/internal/lookup/units"
21 | "github.com/nekorg/pawbar/internal/modules"
22 | )
23 |
24 | type virtualMemoryStat struct {
25 | Total uint64
26 | Available uint64
27 | Used uint64
28 | UsedPercent float64
29 | }
30 |
31 | func virtualMemory() (*virtualMemoryStat, error) {
32 | f, err := os.Open("/proc/meminfo")
33 | if err != nil {
34 | return nil, err
35 | }
36 | defer f.Close()
37 |
38 | var memTotal, memAvailable uint64
39 |
40 | scanner := bufio.NewScanner(f)
41 | for scanner.Scan() {
42 | line := scanner.Text()
43 | if strings.HasPrefix(line, "MemTotal:") {
44 | fields := strings.Fields(line)
45 | if len(fields) < 2 {
46 | return nil, errors.New("malformed MemTotal line in /proc/meminfo")
47 | }
48 | v, err := strconv.ParseUint(fields[1], 10, 64)
49 | if err != nil {
50 | return nil, err
51 | }
52 | memTotal = v * 1024
53 | }
54 | if strings.HasPrefix(line, "MemAvailable:") {
55 | fields := strings.Fields(line)
56 | if len(fields) < 2 {
57 | return nil, errors.New("malformed MemAvailable line in /proc/meminfo")
58 | }
59 | v, err := strconv.ParseUint(fields[1], 10, 64)
60 | if err != nil {
61 | return nil, err
62 | }
63 | memAvailable = v * 1024
64 | }
65 | if memTotal != 0 && memAvailable != 0 {
66 | break
67 | }
68 | }
69 |
70 | if err := scanner.Err(); err != nil {
71 | return nil, err
72 | }
73 |
74 | if memTotal == 0 {
75 | return nil, errors.New("MemTotal not found in /proc/meminfo")
76 | }
77 |
78 | used := memTotal - memAvailable
79 | usedPercent := float64(used) * 100.0 / float64(memTotal)
80 |
81 | return &virtualMemoryStat{
82 | Total: memTotal,
83 | Available: memAvailable,
84 | Used: used,
85 | UsedPercent: usedPercent,
86 | }, nil
87 | }
88 |
89 | type RamModule struct {
90 | receive chan bool
91 | send chan modules.Event
92 |
93 | opts Options
94 | initialOpts Options
95 |
96 | currentTickerInterval time.Duration
97 | ticker *time.Ticker
98 | }
99 |
100 | func (mod *RamModule) Dependencies() []string {
101 | return nil
102 | }
103 |
104 | func (mod *RamModule) Run() (<-chan bool, chan<- modules.Event, error) {
105 | mod.receive = make(chan bool)
106 | mod.send = make(chan modules.Event)
107 | mod.initialOpts = mod.opts
108 |
109 | go func() {
110 | mod.currentTickerInterval = mod.opts.Tick.Go()
111 | mod.ticker = time.NewTicker(mod.currentTickerInterval)
112 | defer mod.ticker.Stop()
113 | for {
114 | select {
115 | case <-mod.ticker.C:
116 | mod.receive <- true
117 | case e := <-mod.send:
118 | switch ev := e.VaxisEvent.(type) {
119 | case vaxis.Mouse:
120 | if ev.EventType != vaxis.EventPress {
121 | break
122 | }
123 | btn := config.ButtonName(ev)
124 | if mod.opts.OnClick.Dispatch(btn, &mod.initialOpts, &mod.opts) {
125 | mod.receive <- true
126 | }
127 | mod.ensureTickInterval()
128 |
129 | case modules.FocusIn:
130 | if mod.opts.OnClick.HoverIn(&mod.opts) {
131 | mod.receive <- true
132 | }
133 | mod.ensureTickInterval()
134 |
135 | case modules.FocusOut:
136 | if mod.opts.OnClick.HoverOut(&mod.opts) {
137 | mod.receive <- true
138 | }
139 | mod.ensureTickInterval()
140 | }
141 | }
142 | }
143 | }()
144 |
145 | return mod.receive, mod.send, nil
146 | }
147 |
148 | func (mod *RamModule) ensureTickInterval() {
149 | if mod.opts.Tick.Go() != mod.currentTickerInterval {
150 | mod.currentTickerInterval = mod.opts.Tick.Go()
151 | mod.ticker.Reset(mod.currentTickerInterval)
152 | }
153 | }
154 |
155 | func pickThreshold(p int, th []ThresholdOptions) *ThresholdOptions {
156 | for _, t := range th {
157 | matchUp := t.Direction.IsUp() && p >= t.Percent.Go()
158 | matchDown := !t.Direction.IsUp() && p <= t.Percent.Go()
159 | if matchUp || matchDown {
160 | return &t
161 | }
162 | }
163 | return nil
164 | }
165 |
166 | func (mod *RamModule) Render() []modules.EventCell {
167 | v, err := virtualMemory()
168 | if err != nil {
169 | return nil
170 | }
171 | system := units.IEC
172 | if mod.opts.UseSI {
173 | system = units.SI
174 | }
175 |
176 | unit := mod.opts.Scale.Unit
177 | if mod.opts.Scale.Dynamic || mod.opts.Scale.Unit.Name == "" {
178 | unit = units.Choose(v.Total, system)
179 | }
180 |
181 | usedAbs := units.Format(v.Used, unit)
182 | freeAbs := units.Format(v.Available, unit)
183 | totalAbs := units.Format(v.Total, unit)
184 |
185 | usedPercent := int(v.UsedPercent)
186 | freePercent := 100 - usedPercent
187 |
188 | usage := usedPercent
189 | style := vaxis.Style{}
190 |
191 | t := pickThreshold(usage, mod.opts.Thresholds)
192 |
193 | if t != nil {
194 | style.Foreground = t.Fg.Go()
195 | style.Background = t.Bg.Go()
196 | } else {
197 | style.Foreground = mod.opts.Fg.Go()
198 | style.Background = mod.opts.Bg.Go()
199 | }
200 |
201 | var buf bytes.Buffer
202 |
203 | err = mod.opts.Format.Execute(&buf, struct {
204 | Used, Free, Total float64
205 | UsedPercent, FreePercent int
206 | Unit, Icon string
207 | }{
208 | usedAbs, freeAbs, totalAbs,
209 | usedPercent, freePercent,
210 | unit.Name, mod.opts.Icon.Go(),
211 | })
212 |
213 | rch := vaxis.Characters(buf.String())
214 | r := make([]modules.EventCell, len(rch))
215 |
216 | for i, ch := range rch {
217 | r[i] = modules.EventCell{C: vaxis.Cell{Character: ch, Style: style}, Mod: mod, MouseShape: mod.opts.Cursor.Go()}
218 | }
219 | return r
220 | }
221 |
222 | func (mod *RamModule) Channels() (<-chan bool, chan<- modules.Event) {
223 | return mod.receive, mod.send
224 | }
225 |
226 | func (mod *RamModule) Name() string {
227 | return "ram"
228 | }
229 |
--------------------------------------------------------------------------------
/internal/config/types.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2025 Nekorg All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 | //
5 | // SPDX-License-Identifier: bsd
6 |
7 | package config
8 |
9 | import (
10 | "fmt"
11 | "strings"
12 | "text/template"
13 | "time"
14 |
15 | "git.sr.ht/~rockorager/vaxis"
16 | "github.com/nekorg/pawbar/internal/lookup/colors"
17 | "github.com/nekorg/pawbar/internal/lookup/icons"
18 | "github.com/nekorg/pawbar/internal/lookup/units"
19 | "github.com/nekorg/pawbar/internal/modules"
20 | "gopkg.in/yaml.v3"
21 | )
22 |
23 | type BarSettings struct {
24 | TruncatePriority []string `yaml:"truncate_priority"`
25 | EnableEllipsis *bool `yaml:"enable_ellipsis"`
26 | Ellipsis string `yaml:"ellipsis"`
27 | }
28 |
29 | func (b *BarSettings) UnmarshalYAML(n *yaml.Node) error {
30 | type plain BarSettings
31 | if err := n.Decode((*plain)(b)); err != nil {
32 | return err
33 | }
34 |
35 | if len(b.TruncatePriority) != 3 {
36 | return fmt.Errorf("truncate_priority: exactly 3 anchors needed, %d provided", len(b.TruncatePriority))
37 | }
38 |
39 | set := map[string]bool{"left": false, "middle": false, "right": false}
40 | for _, a := range b.TruncatePriority {
41 | if _, ok := set[a]; !ok {
42 | return fmt.Errorf(`truncate_priority: invalid anchor %q, valid options are: ["left", "middle", "right"]`, a)
43 | }
44 | if set[a] {
45 | return fmt.Errorf("truncate_priority: %q listed twice", a)
46 | }
47 | set[a] = true
48 | }
49 | return nil
50 | }
51 |
52 | func (b *BarSettings) FillDefaults() {
53 | if len(b.TruncatePriority) == 0 {
54 | b.TruncatePriority = []string{"right", "left", "middle"}
55 | }
56 | if b.EnableEllipsis == nil {
57 | t := true
58 | b.EnableEllipsis = &t
59 | }
60 |
61 | if b.Ellipsis == "" {
62 | b.Ellipsis = modules.ECELLIPSIS.C.Grapheme
63 | }
64 | }
65 |
66 | type BarConfig struct {
67 | Bar BarSettings `yaml:"bar"`
68 | Left []ModuleSpec `yaml:"left"`
69 | Middle []ModuleSpec `yaml:"middle"`
70 | Right []ModuleSpec `yaml:"right"`
71 | }
72 |
73 | type ModuleSpec struct {
74 | Name string
75 | Params *yaml.Node
76 | }
77 |
78 | func (m *ModuleSpec) UnmarshalYAML(n *yaml.Node) error {
79 | switch n.Kind {
80 | case yaml.ScalarNode:
81 | m.Name = n.Value
82 | case yaml.MappingNode:
83 | if len(n.Content) != 2 {
84 | return fmt.Errorf("module mapping must have")
85 | }
86 | m.Name = n.Content[0].Value
87 | m.Params = n.Content[1]
88 | default:
89 | return fmt.Errorf("invalid module spec")
90 |
91 | }
92 | return nil
93 | }
94 |
95 | type Duration time.Duration
96 |
97 | func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
98 | var s string
99 | if err := n.Decode(&s); err != nil {
100 | return err
101 | }
102 |
103 | td, err := time.ParseDuration(s)
104 | if err != nil {
105 | return fmt.Errorf("invalid duration %q: %w", s, err)
106 | }
107 |
108 | *d = Duration(td)
109 | return nil
110 | }
111 |
112 | func (d Duration) Go() time.Duration { return time.Duration(d) }
113 |
114 | type Format struct {
115 | *template.Template
116 | }
117 |
118 | func (t *Format) UnmarshalYAML(n *yaml.Node) error {
119 | var s string
120 | if err := n.Decode(&s); err != nil {
121 | return err
122 | }
123 |
124 | tmpl, err := NewTemplate(s)
125 | if err != nil {
126 | return err
127 | }
128 |
129 | t.Template = tmpl
130 | return nil
131 | }
132 |
133 | func (f Format) Go() *template.Template { return f.Template }
134 |
135 | type Color vaxis.Color
136 |
137 | func (c *Color) UnmarshalYAML(n *yaml.Node) error {
138 | var s string
139 | if err := n.Decode(&s); err != nil {
140 | return err
141 | }
142 |
143 | col, err := colors.ParseColor(s)
144 | if err != nil {
145 | return err
146 | }
147 | *c = Color(col)
148 |
149 | return nil
150 | }
151 |
152 | func (c Color) Go() vaxis.Color { return vaxis.Color(c) }
153 |
154 | type Percent int
155 |
156 | func (p *Percent) UnmarshalYAML(n *yaml.Node) error {
157 | var s int
158 | if err := n.Decode(&s); err != nil {
159 | return err
160 | }
161 |
162 | if s < 0 || s > 100 {
163 | return fmt.Errorf("percentage should be between 0-100")
164 | }
165 | *p = Percent(s)
166 | return nil
167 | }
168 |
169 | func (p Percent) Go() int { return int(p) }
170 |
171 | type Cursor vaxis.MouseShape
172 |
173 | func (c *Cursor) UnmarshalYAML(n *yaml.Node) error {
174 | var s string
175 | if err := n.Decode(&s); err != nil {
176 | return err
177 | }
178 |
179 | cur, err := ParseCursor(s)
180 | if err != nil {
181 | return err
182 | }
183 |
184 | *c = Cursor(cur)
185 | return nil
186 | }
187 |
188 | func (c Cursor) Go() vaxis.MouseShape { return vaxis.MouseShape(c) }
189 |
190 | type Scale struct {
191 | Dynamic bool
192 | Unit units.Unit
193 | }
194 |
195 | func (s *Scale) UnmarshalYAML(n *yaml.Node) error {
196 | var raw string
197 | if err := n.Decode(&raw); err != nil {
198 | return err
199 | }
200 |
201 | raw = strings.ToLower(strings.TrimSpace(raw))
202 |
203 | switch raw {
204 | case "", "auto", "dynamic", "adaptive":
205 | s.Dynamic = true
206 | return nil
207 | default:
208 | u, err := units.ParseUnit(raw)
209 | if err != nil {
210 | return err
211 | }
212 | s.Dynamic = false
213 | s.Unit = u
214 | return nil
215 | }
216 | }
217 |
218 | type Icon string
219 |
220 | func (i *Icon) UnmarshalYAML(n *yaml.Node) error {
221 | var raw string
222 | if err := n.Decode(&raw); err != nil {
223 | return err
224 | }
225 |
226 | *i = Icon(icons.Resolve(raw))
227 | return nil
228 | }
229 |
230 | func (i Icon) Go() string { return string(i) }
231 |
232 | type Direction bool
233 |
234 | func (d *Direction) UnmarshalYAML(n *yaml.Node) error {
235 | var raw string
236 | if err := n.Decode(&raw); err != nil {
237 | return err
238 | }
239 | raw = strings.ToLower(raw)
240 | switch raw {
241 | case "u", "up", "upward", "upwards":
242 | *d = true
243 | case "", "d", "down", "downward", "downwards":
244 | *d = false
245 | default:
246 | return fmt.Errorf("%q is not a valid direction. valid options are [%q, %q]", raw, "up", "down")
247 | }
248 | return nil
249 | }
250 | func (d Direction) IsUp() bool { return bool(d) }
251 | func (d Direction) Go() bool { return bool(d) }
252 |
--------------------------------------------------------------------------------