├── 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 | ![image](https://github.com/user-attachments/assets/b8cdfd44-ca66-45df-a8eb-d8142d0e4ffb) 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 | --------------------------------------------------------------------------------