├── .github └── workflows │ ├── aur.yml │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── cmd ├── hyprpanel-client │ ├── audio.go │ ├── clock.go │ ├── hud.go │ ├── main.go │ ├── menu_xml.go │ ├── module.go │ ├── notification_item.go │ ├── notifications.go │ ├── pager.go │ ├── pager_client.go │ ├── pager_workspace.go │ ├── panel.go │ ├── power.go │ ├── session.go │ ├── spacer.go │ ├── systray.go │ ├── systray_inhibitor.go │ ├── systray_item.go │ ├── taskbar.go │ ├── taskbar_item.go │ └── utils.go └── hyprpanel │ ├── host.go │ ├── main.go │ └── util.go ├── config ├── config.go └── default.json ├── go.mod ├── go.sum ├── internal ├── applications │ └── applications.go ├── audio │ └── audio.go ├── dbus │ ├── brightness.go │ ├── dbus.go │ ├── fdo.go │ ├── global_shortcuts.go │ ├── interfaces │ │ ├── com.canonical.dbusmenu.xml │ │ ├── org.freedesktop.Notifications.xml │ │ ├── org.freedesktop.UPower.Device.xml │ │ ├── org.freedesktop.UPower.xml │ │ ├── org.freedesktop.portal.GlobalShortcuts.xml │ │ ├── org.kde.StatusNotifierItem.xml │ │ └── org.kde.StatusNotifierWatcher.xml │ ├── menu.go │ ├── notifications.go │ ├── portal.go │ ├── power.go │ ├── sni.go │ └── snw.go ├── gtk4-layer-shell │ └── gtk4-layer-shell.go ├── hypripc │ ├── client.go │ ├── hypripc.go │ ├── monitor.go │ └── workspace.go └── panelplugin │ ├── grpc.go │ ├── interface.go │ └── panelplugin.go ├── proto ├── buf.gen.yaml ├── buf.yaml ├── doc │ └── hyprpanel │ │ ├── config │ │ └── v1 │ │ │ └── doc.md │ │ ├── event │ │ └── v1 │ │ │ └── doc.md │ │ ├── module │ │ └── v1 │ │ │ └── doc.md │ │ └── v1 │ │ └── doc.md ├── hyprpanel │ ├── config │ │ └── v1 │ │ │ ├── config.pb.go │ │ │ └── config.proto │ ├── event │ │ └── v1 │ │ │ ├── const.go │ │ │ ├── event.pb.go │ │ │ ├── event.proto │ │ │ ├── eventv1.go │ │ │ └── utils.go │ ├── module │ │ └── v1 │ │ │ ├── module.pb.go │ │ │ └── module.proto │ └── v1 │ │ ├── hyprpanel.pb.go │ │ ├── hyprpanel.proto │ │ └── hyprpanel_grpc.pb.go └── proto.go ├── style ├── default.css └── style.go └── wl ├── app.go ├── hyprland_toplevel_export.go └── toplevel_management.go /.github/workflows/aur.yml: -------------------------------------------------------------------------------- 1 | name: AUR 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | concurrency: 11 | group: "aur" 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | aur: 16 | environment: 17 | name: aur-update 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: AUR update (hyprpanel) 21 | uses: ATiltedTree/create-aur-release@v1.1.0 22 | with: 23 | package_name: hyprpanel 24 | ssh_private_key: ${{ secrets.AUR_KEY }} 25 | commit_username: ${{ secrets.AUR_COMMIT_USER }} 26 | commit_email: ${{ secrets.AUR_COMMIT_EMAIL }} 27 | - name: AUR update (hyprpanel-bin) 28 | uses: ATiltedTree/create-aur-release@v1.1.0 29 | with: 30 | package_name: hyprpanel-bin 31 | ssh_private_key: ${{ secrets.AUR_KEY }} 32 | commit_username: ${{ secrets.AUR_COMMIT_USER }} 33 | commit_email: ${{ secrets.AUR_COMMIT_EMAIL }} -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 11 | pull-requests: read 12 | 13 | jobs: 14 | golangci: 15 | name: Lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Setup Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: "^1.24.0" 24 | cache: false 25 | - name: Lint 26 | uses: golangci/golangci-lint-action@v7 27 | with: 28 | # Require: The version of golangci-lint to use. 29 | # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. 30 | # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. 31 | version: v2.0.2 32 | 33 | # Optional: working directory, useful for monorepos 34 | # working-directory: somedir 35 | 36 | # Optional: golangci-lint command line arguments. 37 | # 38 | # Note: By default, the `.golangci.yml` file should be at the root of the repository. 39 | # The location of the configuration file can be changed by using `--config=` 40 | # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 41 | 42 | # Optional: show only new issues if it's a pull request. The default value is `false`. 43 | only-new-issues: true 44 | 45 | # Optional: if set to true, then all caching functionality will be completely disabled, 46 | # takes precedence over all other caching options. 47 | # skip-cache: true 48 | 49 | # Optional: if set to true, then the action won't cache or restore ~/go/pkg. 50 | # skip-pkg-cache: true 51 | 52 | # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. 53 | # skip-build-cache: true 54 | 55 | # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. 56 | # install-mode: "goinstall" 57 | - name: Setup buf 58 | uses: bufbuild/buf-setup-action@v1.50.0 59 | with: 60 | github_token: ${{ secrets.GITHUB_TOKEN }} 61 | - name: Go generate 62 | run: go generate ./... 63 | - name: Check outdated protobuf 64 | run: | 65 | if [ -z "$(git status --porcelain)" ]; then 66 | exit 0 67 | else 68 | exit 1 69 | fi 70 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | # run only against tags 6 | tags: 7 | - "*" 8 | 9 | permissions: 10 | contents: write 11 | # packages: write 12 | # issues: write 13 | 14 | jobs: 15 | release: 16 | environment: 17 | name: release 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Setup Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: "^1.24.0" 28 | cache: false 29 | - name: Lint 30 | uses: golangci/golangci-lint-action@v7 31 | with: 32 | version: v2.0.2 33 | - name: Setup buf 34 | uses: bufbuild/buf-setup-action@v1.50.0 35 | with: 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | - name: Go generate 38 | run: go generate ./... 39 | - name: Check outdated protobuf 40 | run: | 41 | if [ -z "$(git status --porcelain)" ]; then 42 | exit 0 43 | else 44 | exit 1 45 | fi 46 | - name: Run GoReleaser 47 | uses: goreleaser/goreleaser-action@v5 48 | with: 49 | # either 'goreleaser' (default) or 'goreleaser-pro' 50 | distribution: goreleaser 51 | version: latest 52 | args: release --clean 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | ./cmd/hyprpanel/hyprpanel 3 | ./cmd/hyprpanel-client/hyprpanel-client 4 | 5 | dist/ 6 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 1 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - id: hyprpanel 20 | env: 21 | - CGO_ENABLED=0 22 | goos: 23 | - linux 24 | goarch: 25 | - amd64 26 | - arm64 27 | main: ./cmd/hyprpanel 28 | binary: hyprpanel 29 | - id: hyprpanel-client 30 | env: 31 | - CGO_ENABLED=0 32 | goos: 33 | - linux 34 | goarch: 35 | - amd64 36 | - arm64 37 | main: ./cmd/hyprpanel-client 38 | binary: hyprpanel-client 39 | 40 | archives: 41 | - format: tar.gz 42 | # this name template makes the OS and Arch compatible with the results of `uname`. 43 | name_template: >- 44 | {{ .ProjectName }}_ 45 | {{- title .Os }}_ 46 | {{- if eq .Arch "amd64" }}x86_64 47 | {{- else if eq .Arch "386" }}i386 48 | {{- else }}{{ .Arch }}{{ end }} 49 | {{- if .Arm }}v{{ .Arm }}{{ end }} 50 | 51 | changelog: 52 | sort: asc 53 | filters: 54 | exclude: 55 | - "^docs:" 56 | - "^test:" 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Peter Fern 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /cmd/hyprpanel-client/clock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/jwijenbergh/puregotk/v4/gdk" 8 | "github.com/jwijenbergh/puregotk/v4/glib" 9 | "github.com/jwijenbergh/puregotk/v4/gtk" 10 | configv1 "github.com/pdf/hyprpanel/proto/hyprpanel/config/v1" 11 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 12 | "github.com/pdf/hyprpanel/style" 13 | ) 14 | 15 | type clock struct { 16 | *refTracker 17 | *api 18 | cfg *modulev1.Clock 19 | container *gtk.Box 20 | timeLabel *gtk.Label 21 | dateLabel *gtk.Label 22 | revealer *gtk.Revealer 23 | popover *gtk.Popover 24 | tooltip string 25 | 26 | updateCallback glib.SourceFunc 27 | } 28 | 29 | func (c *clock) build(container *gtk.Box) error { 30 | c.container = gtk.NewBox(c.orientation, 0) 31 | now := time.Now() 32 | c.timeLabel = gtk.NewLabel(now.Format(c.cfg.TimeFormat)) 33 | c.dateLabel = gtk.NewLabel(c.cfg.DateFormat) 34 | 35 | c.container.Append(&c.timeLabel.Widget) 36 | c.container.Append(&c.dateLabel.Widget) 37 | 38 | if c.orientation == gtk.OrientationHorizontalValue { 39 | c.container.SetSizeRequest(-1, int(c.panelCfg.Size)) 40 | c.timeLabel.SetMarginEnd(8) 41 | } else { 42 | c.container.SetSizeRequest(int(c.panelCfg.Size), -1) 43 | c.timeLabel.SetMarginBottom(8) 44 | } 45 | 46 | c.timeLabel.SetName(style.ClockTimeID) 47 | c.dateLabel.SetName(style.ClockDateID) 48 | c.container.SetName(style.ClockID) 49 | c.container.AddCssClass(style.ModuleClass) 50 | 51 | calendar := gtk.NewCalendar() 52 | c.AddRef(calendar.Unref) 53 | calendar.SetName(style.ClockCalendarID) 54 | c.revealer = gtk.NewRevealer() 55 | c.revealer.SetChild(&calendar.Widget) 56 | c.popover = gtk.NewPopover() 57 | c.popover.SetChild(&c.revealer.Widget) 58 | 59 | closedCb := func(_ gtk.Popover) { 60 | c.revealer.SetRevealChild(false) 61 | } 62 | c.popover.ConnectClosed(&closedCb) 63 | 64 | switch c.panelCfg.Edge { 65 | case configv1.Edge_EDGE_TOP: 66 | c.popover.SetPosition(gtk.PosBottomValue) 67 | c.revealer.SetTransitionType(gtk.RevealerTransitionTypeSlideDownValue) 68 | case configv1.Edge_EDGE_RIGHT: 69 | c.popover.SetPosition(gtk.PosLeftValue) 70 | c.revealer.SetTransitionType(gtk.RevealerTransitionTypeSlideLeftValue) 71 | case configv1.Edge_EDGE_BOTTOM: 72 | c.popover.SetPosition(gtk.PosTopValue) 73 | c.revealer.SetTransitionType(gtk.RevealerTransitionTypeSlideUpValue) 74 | case configv1.Edge_EDGE_LEFT: 75 | c.popover.SetPosition(gtk.PosRightValue) 76 | c.revealer.SetTransitionType(gtk.RevealerTransitionTypeSlideRightValue) 77 | } 78 | 79 | c.container.Append(&c.popover.Widget) 80 | 81 | clickController := gtk.NewGestureClick() 82 | clickCb := func(ctrl gtk.GestureClick, nPress int, x, y float64) { 83 | if ctrl.GetCurrentButton() == uint(gdk.BUTTON_PRIMARY) { 84 | calendar.SelectDay(glib.NewDateTimeNowLocal()) 85 | c.popover.Popup() 86 | c.revealer.SetRevealChild(true) 87 | } 88 | } 89 | c.AddRef(func() { 90 | unrefCallback(&clickCb) 91 | }) 92 | clickController.ConnectReleased(&clickCb) 93 | c.container.AddController(&clickController.EventController) 94 | 95 | container.Append(&c.container.Widget) 96 | 97 | glib.IdleAdd(&c.updateCallback, 0) 98 | 99 | go c.watch() 100 | 101 | return nil 102 | } 103 | 104 | func (c *clock) update() error { 105 | now := time.Now() 106 | c.timeLabel.SetLabel(now.Format(c.cfg.TimeFormat)) 107 | c.dateLabel.SetLabel(now.Format(c.cfg.DateFormat)) 108 | var tooltip strings.Builder 109 | tooltip.WriteString(``) 110 | tooltip.WriteString(now.Format(c.cfg.TooltipTimeFormat)) 111 | tooltip.WriteString(` `) 112 | tooltip.WriteString(now.Format(c.cfg.TooltipDateFormat)) 113 | tooltip.WriteString(` (Local)`) 114 | for _, region := range c.cfg.AdditionalRegions { 115 | tooltip.WriteString("\r") 116 | loc, err := time.LoadLocation(region) 117 | if err != nil { 118 | continue 119 | } 120 | rtime := now.In(loc) 121 | tooltip.WriteString(``) 122 | tooltip.WriteString(rtime.Format(c.cfg.TooltipTimeFormat)) 123 | tooltip.WriteString(` `) 124 | tooltip.WriteString(rtime.Format(c.cfg.TooltipDateFormat)) 125 | tooltip.WriteString(``) 126 | tooltip.WriteString(` (`) 127 | tooltip.WriteString(region) 128 | tooltip.WriteString(`)`) 129 | tooltip.WriteString(``) 130 | } 131 | if c.tooltip != tooltip.String() { 132 | c.container.SetTooltipMarkup(tooltip.String()) 133 | c.tooltip = tooltip.String() 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func (c *clock) watch() { 140 | ticker := time.NewTicker(time.Second) 141 | c.AddRef(ticker.Stop) 142 | 143 | for range ticker.C { 144 | glib.IdleAdd(&c.updateCallback, 0) 145 | } 146 | } 147 | 148 | func (c *clock) close(container *gtk.Box) { 149 | log.Debug(`Closing module on request`, `module`, style.ClockID) 150 | container.Remove(&c.container.Widget) 151 | c.Unref() 152 | } 153 | 154 | func newClock(cfg *modulev1.Clock, a *api) *clock { 155 | c := &clock{ 156 | refTracker: newRefTracker(), 157 | api: a, 158 | cfg: cfg, 159 | } 160 | 161 | c.updateCallback = func(uintptr) bool { 162 | if err := c.update(); err != nil { 163 | log.Warn(`failed updating clock`, `err`, err) 164 | return false 165 | } 166 | return false 167 | } 168 | 169 | return c 170 | } 171 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the hyprpanel panel plugin binary 2 | package main 3 | 4 | import ( 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/hashicorp/go-hclog" 10 | "github.com/hashicorp/go-plugin" 11 | "github.com/pdf/hyprpanel/internal/panelplugin" 12 | ) 13 | 14 | var log = hclog.New(&hclog.LoggerOptions{ 15 | Level: hclog.Trace, 16 | Output: os.Stderr, 17 | JSONFormat: true, 18 | }) 19 | 20 | func sigHandler(p *panel) { 21 | sigChan := make(chan os.Signal, 1) 22 | signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGUSR1, syscall.SIGINT) 23 | 24 | for s := range sigChan { 25 | switch s { 26 | case syscall.SIGTERM, syscall.SIGINT: 27 | log.Warn(`Quitting`, `sig`, s.String()) 28 | p.app.Quit() 29 | case syscall.SIGUSR1: 30 | log.Warn(`Quitting`, `sig`, s.String()) 31 | // TODO: Implement reload 32 | p.app.Quit() 33 | default: 34 | log.Warn(`Unhandled signal`, `sig`, s.String()) 35 | } 36 | } 37 | } 38 | 39 | func main() { 40 | p, err := newPanel() 41 | if err != nil { 42 | log.Error(`hyprpanel initialization failed`, `err`, err) 43 | os.Exit(1) 44 | } 45 | 46 | go sigHandler(p) 47 | 48 | go func() { 49 | plugin.Serve(&plugin.ServeConfig{ 50 | HandshakeConfig: panelplugin.Handshake, 51 | Plugins: map[string]plugin.Plugin{ 52 | panelplugin.PanelPluginName: &panelplugin.PanelPlugin{Impl: p}, 53 | }, 54 | GRPCServer: plugin.DefaultGRPCServer, 55 | Logger: log, 56 | }) 57 | }() 58 | 59 | os.Exit(p.run()) 60 | } 61 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/menu_xml.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "encoding/xml" 4 | 5 | type menuXMLInterface struct { 6 | XMLName xml.Name `xml:"interface"` 7 | Menu *menuXMLMenu `xml:"menu"` 8 | } 9 | 10 | type menuXMLMenu struct { 11 | XMLName xml.Name `xml:"menu"` 12 | ID string `xml:"id,attr,omitempty"` 13 | Sections []*menuXMLMenuSection `xml:"section"` 14 | } 15 | 16 | type menuXMLMenuSection struct { 17 | XMLName xml.Name `xml:"section"` 18 | ID string `xml:"id,attr,omitempty"` 19 | Items []*menuXMLItem `xml:"item"` 20 | Submenus []*menuXMLMenuSubmenu `xml:"submenu"` 21 | Attributes []*menuXMLAttribute `xml:"attribute"` 22 | } 23 | 24 | type menuXMLMenuSubmenu struct { 25 | XMLName xml.Name `xml:"submenu"` 26 | ID string `xml:"id,attr,omitempty"` 27 | Sections []*menuXMLMenuSection `xml:"section"` 28 | Attributes []*menuXMLAttribute `xml:"attribute"` 29 | } 30 | 31 | type menuXMLItem struct { 32 | XMLName xml.Name `xml:"item"` 33 | Attributes []*menuXMLAttribute `xml:"attribute"` 34 | Links []*mnuXMLLink `xml:"link"` 35 | } 36 | 37 | type menuXMLAttribute struct { 38 | XMLName xml.Name `xml:"attribute"` 39 | Name string `xml:"name,attr"` 40 | Translatable string `xml:"translatable,attr,omitempty"` 41 | Value string `xml:",chardata"` 42 | } 43 | 44 | type mnuXMLLink struct { 45 | XMLName xml.Name `xml:"link"` 46 | Name string `xml:"name,attr"` 47 | Value string `xml:",chardata"` 48 | } 49 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/module.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jwijenbergh/puregotk/v4/gtk" 5 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 6 | ) 7 | 8 | type module interface { 9 | build(container *gtk.Box) error 10 | close(container *gtk.Box) 11 | } 12 | 13 | type moduleReceiver interface { 14 | events() chan<- *eventv1.Event 15 | } 16 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/notifications.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/jwijenbergh/puregotk/v4/glib" 8 | "github.com/jwijenbergh/puregotk/v4/gtk" 9 | gtk4layershell "github.com/pdf/hyprpanel/internal/gtk4-layer-shell" 10 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 11 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 12 | hyprpanelv1 "github.com/pdf/hyprpanel/proto/hyprpanel/v1" 13 | "github.com/pdf/hyprpanel/style" 14 | ) 15 | 16 | type notifications struct { 17 | *refTracker 18 | *api 19 | sync.RWMutex 20 | cfg *modulev1.Notifications 21 | eventCh chan *eventv1.Event 22 | quitCh chan struct{} 23 | items map[uint32]*notificationItem 24 | 25 | container *gtk.CenterBox 26 | overlay *gtk.Window 27 | overlayContainer *gtk.Box 28 | } 29 | 30 | func (n *notifications) build(container *gtk.Box) error { 31 | if err := n.buildOverlay(); err != nil { 32 | return err 33 | } 34 | 35 | if len(n.cfg.Persistent) == 0 { 36 | return nil 37 | } 38 | 39 | n.container = gtk.NewCenterBox() 40 | n.AddRef(n.container.Unref) 41 | n.container.SetName(style.NotificationsID) 42 | n.container.AddCssClass(style.ModuleClass) 43 | icon, err := createIcon(`notification`, int(n.cfg.IconSize), true, []string{`notifications`}) 44 | if err != nil { 45 | return err 46 | } 47 | n.AddRef(icon.Unref) 48 | n.container.SetCenterWidget(&icon.Widget) 49 | 50 | container.Append(&n.container.Widget) 51 | 52 | return nil 53 | } 54 | 55 | func (n *notifications) buildOverlay() error { 56 | n.overlay = gtk.NewWindow() 57 | n.AddRef(n.overlay.Unref) 58 | n.overlay.SetName(style.NotificationsOverlayID) 59 | n.overlay.SetApplication(n.app) 60 | n.overlay.SetResizable(false) 61 | n.overlay.SetDecorated(false) 62 | n.overlay.SetDeletable(false) 63 | 64 | gtk4layershell.InitForWindow(n.overlay) 65 | gtk4layershell.SetNamespace(n.overlay, appName+`.`+style.NotificationsOverlayID) 66 | gtk4layershell.SetLayer(n.overlay, gtk4layershell.LayerShellLayerOverlay) 67 | if n.cfg.Position == modulev1.Position_POSITION_UNSPECIFIED { 68 | n.cfg.Position = modulev1.Position_POSITION_TOP_RIGHT 69 | } 70 | switch n.cfg.Position { 71 | case modulev1.Position_POSITION_TOP_LEFT: 72 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeTop, true) 73 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeLeft, true) 74 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeTop, int(n.cfg.Margin)) 75 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeLeft, int(n.cfg.Margin)) 76 | case modulev1.Position_POSITION_TOP: 77 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeTop, true) 78 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeTop, int(n.cfg.Margin)) 79 | case modulev1.Position_POSITION_TOP_RIGHT: 80 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeTop, true) 81 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeRight, true) 82 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeTop, int(n.cfg.Margin)) 83 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeRight, int(n.cfg.Margin)) 84 | case modulev1.Position_POSITION_RIGHT: 85 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeRight, true) 86 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeRight, int(n.cfg.Margin)) 87 | case modulev1.Position_POSITION_BOTTOM_RIGHT: 88 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeBottom, true) 89 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeRight, true) 90 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeBottom, int(n.cfg.Margin)) 91 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeRight, int(n.cfg.Margin)) 92 | case modulev1.Position_POSITION_BOTTOM: 93 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeBottom, true) 94 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeBottom, int(n.cfg.Margin)) 95 | case modulev1.Position_POSITION_BOTTOM_LEFT: 96 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeBottom, true) 97 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeLeft, true) 98 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeBottom, int(n.cfg.Margin)) 99 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeLeft, int(n.cfg.Margin)) 100 | case modulev1.Position_POSITION_LEFT: 101 | gtk4layershell.SetAnchor(n.overlay, gtk4layershell.LayerShellEdgeLeft, true) 102 | gtk4layershell.SetMargin(n.overlay, gtk4layershell.LayerShellEdgeLeft, int(n.cfg.Margin)) 103 | default: 104 | return fmt.Errorf(`invalid notifications position: %s`, n.cfg.Position.String()) 105 | } 106 | 107 | n.overlayContainer = gtk.NewBox(gtk.OrientationVerticalValue, int(n.cfg.Margin)) 108 | n.AddRef(n.overlayContainer.Unref) 109 | n.overlayContainer.SetSizeRequest(0, 0) 110 | 111 | n.overlay.SetChild(&n.overlayContainer.Widget) 112 | 113 | go n.watch() 114 | 115 | return nil 116 | } 117 | 118 | func (n *notifications) addNotification(item *notificationItem) { 119 | n.Lock() 120 | defer n.Unlock() 121 | n.overlay.SetVisible(true) 122 | if err := item.build(n.overlayContainer); err != nil { 123 | log.Warn(`Failed building notification`, `id`, item.data.Id, `err`, err) 124 | return 125 | } 126 | n.items[item.data.Id] = item 127 | } 128 | 129 | func (n *notifications) deleteNotification(id uint32) { 130 | n.Lock() 131 | defer n.Unlock() 132 | item, ok := n.items[id] 133 | if !ok { 134 | log.Debug(`Received delete request for unknown notification ID`, `id`, id) 135 | } 136 | delete(n.items, id) 137 | defer item.Unref() 138 | 139 | n.overlayContainer.Remove(&item.container.Widget) 140 | 141 | if len(n.items) == 0 { 142 | n.overlay.SetVisible(false) 143 | } 144 | 145 | if err := n.host.NotificationClosed(item.data.Id, hyprpanelv1.NotificationClosedReason_NOTIFICATION_CLOSED_REASON_DISMISSED); err != nil { 146 | log.Debug(`Failed signalling notification closed`, `module`, style.NotificationsID, `err`, err) 147 | } 148 | } 149 | 150 | func (n *notifications) events() chan<- *eventv1.Event { 151 | return n.eventCh 152 | } 153 | 154 | func (n *notifications) watch() { 155 | for { 156 | select { 157 | case <-n.quitCh: 158 | return 159 | default: 160 | select { 161 | case <-n.quitCh: 162 | return 163 | case evt := <-n.eventCh: 164 | switch evt.Kind { 165 | case eventv1.EventKind_EVENT_KIND_DBUS_NOTIFICATION: 166 | data := &eventv1.NotificationValue{} 167 | if !evt.Data.MessageIs(data) { 168 | log.Error(`Invalid event`, `module`, style.NotificationsID, `event`, evt) 169 | continue 170 | } 171 | if err := evt.Data.UnmarshalTo(data); err != nil { 172 | log.Error(`Invalid event`, `module`, style.NotificationsID, `event`, evt) 173 | continue 174 | } 175 | 176 | var cb glib.SourceFunc 177 | cb = func(uintptr) bool { 178 | defer unrefCallback(&cb) 179 | item := newNotificationItem(n.cfg, n.api, data, n.deleteNotification) 180 | n.addNotification(item) 181 | return false 182 | } 183 | 184 | glib.IdleAdd(&cb, 0) 185 | case eventv1.EventKind_EVENT_KIND_DBUS_CLOSENOTIFICATION: 186 | id, err := eventv1.DataUInt32(evt.Data) 187 | if err != nil { 188 | log.Error(`Invalid event`, `module`, style.NotificationsID, `event`, evt) 189 | continue 190 | } 191 | 192 | var cb glib.SourceFunc 193 | cb = func(uintptr) bool { 194 | defer unrefCallback(&cb) 195 | n.RLock() 196 | defer n.RUnlock() 197 | item, ok := n.items[id] 198 | if !ok { 199 | log.Debug(`Received close request for unknown notification`, `id`, id) 200 | return false 201 | } 202 | item.close() 203 | return false 204 | } 205 | 206 | glib.IdleAdd(&cb, 0) 207 | } 208 | } 209 | } 210 | } 211 | } 212 | 213 | func (n *notifications) close(container *gtk.Box) { 214 | log.Debug(`Closing module on request`, `module`, style.NotificationsID) 215 | n.overlay.Close() 216 | if n.container != nil { 217 | container.Remove(&n.container.Widget) 218 | } 219 | n.Unref() 220 | } 221 | 222 | func newNotifications(cfg *modulev1.Notifications, a *api) *notifications { 223 | n := ¬ifications{ 224 | refTracker: newRefTracker(), 225 | api: a, 226 | cfg: cfg, 227 | eventCh: make(chan *eventv1.Event, 10), 228 | quitCh: make(chan struct{}), 229 | items: make(map[uint32]*notificationItem), 230 | } 231 | n.AddRef(func() { 232 | close(n.quitCh) 233 | close(n.eventCh) 234 | }) 235 | 236 | return n 237 | } 238 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/pager_client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jwijenbergh/puregotk/v4/gdk" 7 | "github.com/jwijenbergh/puregotk/v4/gobject" 8 | "github.com/jwijenbergh/puregotk/v4/gtk" 9 | "github.com/pdf/hyprpanel/internal/hypripc" 10 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 11 | hyprpanelv1 "github.com/pdf/hyprpanel/proto/hyprpanel/v1" 12 | "github.com/pdf/hyprpanel/style" 13 | ) 14 | 15 | type pagerClient struct { 16 | *refTracker 17 | *api 18 | cfg *modulev1.Pager 19 | active bool 20 | posX float64 21 | posY float64 22 | width int 23 | height int 24 | title string 25 | client *hypripc.Client 26 | appInfo *hyprpanelv1.AppInfo 27 | 28 | icon *gtk.Image 29 | container *gtk.CenterBox 30 | } 31 | 32 | func (c *pagerClient) updateIcon() { 33 | var err error 34 | if c.appInfo == nil { 35 | c.appInfo, err = c.host.FindApplication(c.client.Class) 36 | if err != nil || c.appInfo == nil { 37 | return 38 | } 39 | } 40 | 41 | if c.icon == nil { 42 | if c.width <= int(c.cfg.IconSize) || c.height <= int(c.cfg.IconSize) { 43 | return 44 | } 45 | if c.icon, err = createIcon(c.appInfo.Icon, int(c.cfg.IconSize), false, nil); err != nil { 46 | return 47 | } 48 | c.container.SetCenterWidget(&c.icon.Widget) 49 | return 50 | } 51 | 52 | if c.width <= int(c.cfg.IconSize) || c.height <= int(c.cfg.IconSize) { 53 | c.container.SetCenterWidget(>k.Widget{}) 54 | icon := c.icon 55 | defer icon.Unref() 56 | c.icon = nil 57 | } 58 | } 59 | 60 | func (c *pagerClient) update(container *gtk.Fixed, posX, posY float64, width, height int, client *hypripc.Client, active bool) { 61 | if c.client != client { 62 | c.client = client 63 | } 64 | c.active = active 65 | if c.active { 66 | if !c.container.HasCssClass(style.ActiveClass) { 67 | c.container.AddCssClass(style.ActiveClass) 68 | } 69 | } else if c.container.HasCssClass(style.ActiveClass) { 70 | c.container.RemoveCssClass(style.ActiveClass) 71 | } 72 | 73 | if c.title != c.client.Title { 74 | c.title = c.client.Title 75 | } 76 | 77 | if c.width != width || c.height != height { 78 | c.width = width 79 | c.height = height 80 | c.updateIcon() 81 | c.container.SetSizeRequest(c.width, c.height) 82 | } 83 | 84 | if c.posX != posX || c.posY != posY { 85 | c.posX = posX 86 | c.posY = posY 87 | container.Move(&c.container.Widget, c.posX, c.posY) 88 | } 89 | } 90 | 91 | func (c *pagerClient) clientTitle() string { 92 | return c.title 93 | } 94 | 95 | func (c *pagerClient) clientSubtitle() string { 96 | mem, err := memKb(int(c.client.Pid)) 97 | if err != nil { 98 | return `` 99 | } 100 | return fmt.Sprintf("Memory: %.1f MB", float64(mem)/1024.0) 101 | } 102 | 103 | func (c *pagerClient) clientAddress() string { 104 | return c.client.Address 105 | } 106 | 107 | func (c *pagerClient) shouldPreview() bool { 108 | return c.client != nil 109 | } 110 | 111 | func (c *pagerClient) build(container *gtk.Fixed) { 112 | c.container = gtk.NewCenterBox() 113 | c.AddRef(c.container.Unref) 114 | c.container.AddCssClass(style.ClientClass) 115 | c.container.SetSizeRequest(c.width, c.height) 116 | c.container.SetMarginStart(1) 117 | c.container.SetMarginEnd(1) 118 | c.container.SetMarginTop(1) 119 | c.container.SetMarginBottom(1) 120 | c.container.SetHasTooltip(true) 121 | 122 | previewHeight := int(c.cfg.PreviewWidth * 9 / 16) 123 | tooltipCb := tooltipPreview(c, int(c.cfg.PreviewWidth), previewHeight) 124 | c.AddRef(func() { unrefCallback(&tooltipCb) }) 125 | c.container.ConnectQueryTooltip(&tooltipCb) 126 | 127 | dragSource := gtk.NewDragSource() 128 | dragPrepCb := func(_ gtk.DragSource, _, _ float64) gdk.ContentProvider { 129 | val := gobject.Value{GType: gobject.TypeStringVal} 130 | val.SetString(c.client.Address) 131 | return *gdk.NewContentProviderForValue(&val) 132 | } 133 | c.AddRef(func() { unrefCallback(&dragPrepCb) }) 134 | dragSource.ConnectPrepare(&dragPrepCb) 135 | dragBeginCb := func(_ gtk.DragSource, _ uintptr) { 136 | preview := gtk.NewWidgetPaintable(&c.container.Widget) 137 | // hotX/hotY don't work here, apparently it's meant to be fixed in GTK, maybe Hyprland bug? 138 | // https://gitlab.gnome.org/GNOME/gtk/-/issues/2341 139 | // https://github.com/hyprwm/Hyprland/issues/9564 140 | dragSource.SetIcon(preview, preview.GetIntrinsicWidth()/2, preview.GetIntrinsicHeight()/2) 141 | preview.Unref() 142 | } 143 | dragSource.ConnectDragBegin(&dragBeginCb) 144 | c.container.AddController(&dragSource.EventController) 145 | 146 | c.updateIcon() 147 | c.update(container, c.posX, c.posY, c.width, c.height, c.client, c.active) 148 | 149 | container.Put(&c.container.Widget, c.posX, c.posY) 150 | } 151 | 152 | func (c *pagerClient) close(container *gtk.Fixed) { 153 | defer c.Unref() 154 | container.Remove(&c.container.Widget) 155 | } 156 | 157 | func newPagerClient(cfg *modulev1.Pager, a *api, posX, posY float64, width, height int, client *hypripc.Client, active bool) *pagerClient { 158 | return &pagerClient{ 159 | refTracker: newRefTracker(), 160 | api: a, 161 | cfg: cfg, 162 | active: active, 163 | posX: posX, 164 | posY: posY, 165 | width: width, 166 | height: height, 167 | client: client, 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/pager_workspace.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "unsafe" 8 | 9 | "github.com/jwijenbergh/puregotk/v4/gdk" 10 | "github.com/jwijenbergh/puregotk/v4/gobject" 11 | "github.com/jwijenbergh/puregotk/v4/gobject/types" 12 | "github.com/jwijenbergh/puregotk/v4/gtk" 13 | "github.com/jwijenbergh/puregotk/v4/pango" 14 | "github.com/pdf/hyprpanel/internal/hypripc" 15 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 16 | "github.com/pdf/hyprpanel/style" 17 | ) 18 | 19 | type pagerWorkspace struct { 20 | *refTracker 21 | *api 22 | cfg *modulev1.Pager 23 | id int 24 | name string 25 | pinned bool 26 | width int 27 | height int 28 | scale float64 29 | live bool 30 | clients map[string]*pagerClient 31 | 32 | container *gtk.Box 33 | inner *gtk.Fixed 34 | label *gtk.Label 35 | } 36 | 37 | func (w *pagerWorkspace) rename(name string) { 38 | if w.name != name { 39 | w.name = name 40 | if w.label != nil { 41 | w.label.SetText(w.name) 42 | } 43 | } 44 | } 45 | 46 | func (w *pagerWorkspace) updateClient(hyprclient *hypripc.Client, active bool) { 47 | width := int(math.Floor(float64(hyprclient.Size[0]) * w.scale * w.currentMonitor.Scale)) 48 | height := int(math.Floor(float64(hyprclient.Size[1]) * w.scale * w.currentMonitor.Scale)) 49 | posX := math.Floor(float64(hyprclient.At[0]) * w.scale * w.currentMonitor.Scale) 50 | posY := math.Floor(float64(hyprclient.At[1]) * w.scale * w.currentMonitor.Scale) 51 | 52 | if widthDelta := w.width - int(posX) - width; widthDelta < 0 { 53 | width += widthDelta 54 | } 55 | if heightDelta := w.height - int(posY) - height; heightDelta < 0 { 56 | height += heightDelta 57 | } 58 | 59 | // margins 60 | width -= 2 61 | height -= 2 62 | 63 | if hyprclient.Class == `` { 64 | if hyprclient.InitialClass != `` { 65 | hyprclient.Class = hyprclient.InitialClass 66 | } else { 67 | hyprclient.Class = hyprclient.InitialTitle 68 | } 69 | } 70 | 71 | if client, ok := w.clients[hyprclient.Address]; ok { 72 | if (hyprclient.Hidden && hyprclient.Hidden != client.client.Hidden) || 73 | (!hyprclient.Mapped && hyprclient.Mapped != client.client.Mapped) || 74 | width < 2 || height < 2 { 75 | w.deleteClient(hyprclient.Address) 76 | return 77 | } 78 | 79 | client.update(w.inner, posX, posY, width, height, hyprclient, active) 80 | 81 | return 82 | } 83 | 84 | if hyprclient.Hidden || !hyprclient.Mapped || width < 2 || height < 2 { 85 | return 86 | } 87 | 88 | client := newPagerClient(w.cfg, w.api, posX, posY, width, height, hyprclient, active) 89 | client.build(w.inner) 90 | w.clients[hyprclient.Address] = client 91 | } 92 | 93 | func (w *pagerWorkspace) deleteClient(addr string) { 94 | client, ok := w.clients[addr] 95 | if !ok { 96 | return 97 | } 98 | 99 | client.close(w.inner) 100 | delete(w.clients, addr) 101 | } 102 | 103 | func (w *pagerWorkspace) setActive(live bool, active bool) { 104 | w.live = live 105 | if live { 106 | if !w.container.HasCssClass(style.LiveClass) { 107 | w.container.AddCssClass(style.LiveClass) 108 | } 109 | } else if w.container.HasCssClass(style.LiveClass) { 110 | w.container.RemoveCssClass(style.LiveClass) 111 | } 112 | if active { 113 | if !w.container.HasCssClass(style.ActiveClass) { 114 | w.container.AddCssClass(style.ActiveClass) 115 | } 116 | } else if w.container.HasCssClass(style.ActiveClass) { 117 | w.container.RemoveCssClass(style.ActiveClass) 118 | } 119 | } 120 | 121 | func (w *pagerWorkspace) build(container *gtk.Box) error { 122 | w.container = gtk.NewBox(gtk.OrientationVerticalValue, 0) 123 | w.AddRef(w.container.Unref) 124 | w.inner = gtk.NewFixed() 125 | w.AddRef(w.inner.Unref) 126 | 127 | if w.orientation == gtk.OrientationHorizontalValue { 128 | w.width = int(math.Floor(w.scale*float64(w.currentMonitor.Width))) - 2 129 | w.height = int(math.Min(math.Floor(w.scale*float64(w.currentMonitor.Height-int(w.panelCfg.Size))), float64(w.panelCfg.Size-2))) - 2 130 | } else { 131 | w.width = int(math.Min(math.Floor(w.scale*float64(w.currentMonitor.Width-int(w.panelCfg.Size))), float64(w.panelCfg.Size-2))) - 2 132 | w.height = int(math.Floor(w.scale*float64(w.currentMonitor.Height))) - 2 133 | } 134 | 135 | w.container.AddCssClass(style.WorkspaceClass) 136 | w.container.SetMarginStart(1) 137 | w.container.SetMarginEnd(1) 138 | w.container.SetMarginTop(1) 139 | w.container.SetMarginBottom(1) 140 | 141 | clickCb := func(ctrl gtk.GestureClick, _ int, _, _ float64) { 142 | switch ctrl.GetCurrentButton() { 143 | case uint(gdk.BUTTON_PRIMARY): 144 | if err := w.hypr.Dispatch(hypripc.DispatchWorkspace, strconv.Itoa(int(w.id))); err != nil { 145 | log.Warn(`Switch workspace failed`, `module`, style.PagerID, `err`, err) 146 | } 147 | } 148 | } 149 | w.AddRef(func() { 150 | unrefCallback(&clickCb) 151 | }) 152 | clickController := gtk.NewGestureClick() 153 | clickController.ConnectReleased(&clickCb) 154 | w.container.AddController(&clickController.EventController) 155 | 156 | dropTarget := gtk.NewDropTarget(gobject.TypeStringVal, gdk.ActionCopyValue) 157 | dropTarget.SetGtypes([]types.GType{gobject.TypeStringVal}, 1) 158 | w.AddRef(dropTarget.Unref) 159 | 160 | dropEnterCb := func(_ gtk.DropTarget, _, _ float64) gdk.DragAction { 161 | return gdk.ActionCopyValue 162 | } 163 | w.AddRef(func() { 164 | unrefCallback(&dropEnterCb) 165 | }) 166 | dropTarget.ConnectEnter(&dropEnterCb) 167 | 168 | dropCb := func(_ gtk.DropTarget, val uintptr, _, _ float64) bool { 169 | if val == 0 { 170 | return false 171 | } 172 | v := *(**gobject.Value)(unsafe.Pointer(&val)) 173 | if !gobject.TypeCheckValueHolds(v, gobject.TypeStringVal) { 174 | return false 175 | } 176 | 177 | addr := v.GetString() 178 | dispatch := hypripc.DispatchMoveToWorkspaceSilent 179 | if w.cfg.FollowWindowOnMove { 180 | dispatch = hypripc.DispatchMoveToWorkspace 181 | } 182 | if err := w.hypr.Dispatch(dispatch, fmt.Sprintf("%d,address:%s", w.id, addr)); err != nil { 183 | log.Debug(`Move client to workspace failed`, `module`, style.PagerID, `workspace`, w.id, `window`, addr, `err`, err) 184 | return false 185 | } 186 | 187 | return true 188 | } 189 | w.AddRef(func() { 190 | unrefCallback(&dropCb) 191 | }) 192 | dropTarget.ConnectDrop(&dropCb) 193 | 194 | w.container.AddController(&dropTarget.EventController) 195 | 196 | w.inner = gtk.NewFixed() 197 | w.container.Append(&w.inner.Widget) 198 | 199 | if w.cfg.EnableWorkspaceNames { 200 | w.label = gtk.NewLabel(w.name) 201 | w.label.SetWrap(false) 202 | w.label.SetSingleLineMode(true) 203 | w.label.SetEllipsize(pango.EllipsizeEndValue) 204 | w.label.SetHalign(gtk.AlignCenterValue) 205 | w.label.SetValign(gtk.AlignCenterValue) 206 | w.label.SetHexpand(true) 207 | w.label.AddCssClass(style.WorkspaceLabelClass) 208 | w.container.Append(&w.label.Widget) 209 | w.AddRef(w.label.Unref) 210 | } 211 | 212 | w.container.SetSizeRequest(w.width, w.height) 213 | w.inner.SetSizeRequest(w.width, w.height) 214 | 215 | container.Append(&w.container.Widget) 216 | 217 | return nil 218 | } 219 | 220 | func (w *pagerWorkspace) close(container *gtk.Box) error { 221 | if w.pinned { 222 | return errPinned 223 | } 224 | defer w.Unref() 225 | for _, client := range w.clients { 226 | client.close(w.inner) 227 | } 228 | container.Remove(&w.container.Widget) 229 | 230 | return nil 231 | } 232 | 233 | func newPagerWorkspace(cfg *modulev1.Pager, a *api, id int, name string, pinned bool, scale float64) *pagerWorkspace { 234 | return &pagerWorkspace{ 235 | refTracker: newRefTracker(), 236 | api: a, 237 | cfg: cfg, 238 | id: id, 239 | name: name, 240 | pinned: pinned, 241 | scale: scale, 242 | clients: make(map[string]*pagerClient), 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/panel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/hashicorp/go-hclog" 10 | "github.com/jwijenbergh/puregotk/v4/gdk" 11 | "github.com/jwijenbergh/puregotk/v4/gio" 12 | "github.com/jwijenbergh/puregotk/v4/gtk" 13 | gtk4layershell "github.com/pdf/hyprpanel/internal/gtk4-layer-shell" 14 | "github.com/pdf/hyprpanel/internal/hypripc" 15 | "github.com/pdf/hyprpanel/internal/panelplugin" 16 | configv1 "github.com/pdf/hyprpanel/proto/hyprpanel/config/v1" 17 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 18 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 19 | "github.com/pdf/hyprpanel/style" 20 | ) 21 | 22 | const appName = `com.c0dedbad.hyprpanel.client` 23 | 24 | var errNotFound = errors.New(`not found`) 25 | 26 | type api struct { 27 | host panelplugin.Host 28 | hypr *hypripc.HyprIPC 29 | orientation gtk.Orientation 30 | currentMonitor *hypripc.Monitor 31 | panelCfg *configv1.Panel 32 | app *gtk.Application 33 | } 34 | 35 | func (a *api) pluginHost() panelplugin.Host { 36 | return a.host 37 | } 38 | 39 | type panel struct { 40 | *refTracker 41 | *api 42 | 43 | id string 44 | stylesheet []byte 45 | 46 | currentGDKMonitor *gdk.Monitor 47 | 48 | win *gtk.Window 49 | container *gtk.Box 50 | 51 | modules []module 52 | eventCh chan *eventv1.Event 53 | receivers map[module]chan<- *eventv1.Event 54 | readyCh chan struct{} 55 | quitCh chan struct{} 56 | } 57 | 58 | func (p *panel) Init(host panelplugin.Host, id string, loglevel configv1.LogLevel, cfg *configv1.Panel, stylesheet []byte) error { 59 | defer close(p.readyCh) 60 | log.SetLevel(hclog.Level(loglevel)) 61 | p.host = host 62 | p.id = id 63 | p.panelCfg = cfg 64 | p.stylesheet = stylesheet 65 | 66 | switch cfg.Edge { 67 | case configv1.Edge_EDGE_TOP, configv1.Edge_EDGE_BOTTOM: 68 | p.orientation = gtk.OrientationHorizontalValue 69 | default: 70 | p.orientation = gtk.OrientationVerticalValue 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func (p *panel) Notify(evt *eventv1.Event) { 77 | select { 78 | case <-p.quitCh: 79 | return 80 | default: 81 | p.eventCh <- evt 82 | } 83 | } 84 | 85 | func (p *panel) Context() context.Context { 86 | return nil 87 | } 88 | 89 | func (p *panel) Close() { 90 | log.Warn(`received close request`) 91 | p.app.Quit() 92 | } 93 | 94 | func (p *panel) initWindow() error { 95 | display := gdk.DisplayGetDefault() 96 | p.AddRef(display.Unref) 97 | 98 | defaultCSSProvider := gtk.NewCssProvider() 99 | p.AddRef(defaultCSSProvider.Unref) 100 | defaultCSSProvider.LoadFromData(string(style.Default), len(style.Default)) 101 | gtk.StyleContextAddProviderForDisplay(display, defaultCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)) 102 | 103 | if len(p.stylesheet) > 0 { 104 | userCSSProvider := gtk.NewCssProvider() 105 | p.AddRef(userCSSProvider.Unref) 106 | userCSSProvider.LoadFromData(string(p.stylesheet), len(p.stylesheet)) 107 | gtk.StyleContextAddProviderForDisplay(display, userCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_USER)) 108 | } 109 | 110 | p.win = gtk.NewWindow() 111 | p.AddRef(p.win.Unref) 112 | if p.panelCfg.Id != `` { 113 | p.win.SetName(p.panelCfg.Id) 114 | } 115 | p.win.SetName(style.PanelID) 116 | p.win.SetApplication(p.app) 117 | p.win.SetCanFocus(false) 118 | p.win.SetDecorated(false) 119 | p.win.SetDeletable(false) 120 | 121 | if p.orientation == gtk.OrientationHorizontalValue { 122 | p.win.SetDefaultSize(-1, int(p.panelCfg.Size)) 123 | } else { 124 | p.win.SetDefaultSize(int(p.panelCfg.Size), -1) 125 | } 126 | gtk4layershell.InitForWindow(p.win) 127 | 128 | hyprMonitors, err := p.hypr.Monitors() 129 | if err != nil { 130 | return err 131 | } 132 | if p.panelCfg.Monitor != `` { 133 | for _, mon := range hyprMonitors { 134 | mon := mon 135 | if mon.Name == p.panelCfg.Monitor { 136 | p.currentMonitor = &mon 137 | break 138 | } 139 | } 140 | } 141 | if p.currentMonitor == nil { 142 | p.currentMonitor = &hyprMonitors[0] 143 | } 144 | p.currentGDKMonitor, err = gdkMonitorFromHypr(p.currentMonitor) 145 | if err != nil { 146 | p.currentGDKMonitor = gdk.MonitorNewFromInternalPtr(gdk.DisplayGetDefault().GetMonitors().GetItem(0)) 147 | } 148 | p.AddRef(p.currentGDKMonitor.Unref) 149 | 150 | gtk4layershell.SetMonitor(p.win, p.currentGDKMonitor) 151 | gtk4layershell.SetNamespace(p.win, appName) 152 | gtk4layershell.AutoExclusiveZoneEnable(p.win) 153 | 154 | switch p.panelCfg.Edge { 155 | case configv1.Edge_EDGE_TOP: 156 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeTop, true) 157 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeLeft, true) 158 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeRight, true) 159 | case configv1.Edge_EDGE_RIGHT: 160 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeRight, true) 161 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeTop, true) 162 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeBottom, true) 163 | case configv1.Edge_EDGE_BOTTOM: 164 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeBottom, true) 165 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeLeft, true) 166 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeRight, true) 167 | case configv1.Edge_EDGE_LEFT: 168 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeLeft, true) 169 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeTop, true) 170 | gtk4layershell.SetAnchor(p.win, gtk4layershell.LayerShellEdgeBottom, true) 171 | default: 172 | return fmt.Errorf(`panel %s missing position configuration`, p.id) 173 | } 174 | gtk4layershell.SetLayer(p.win, gtk4layershell.LayerShellLayerTop) 175 | 176 | destroyCb := func(_ gtk.Widget) { 177 | p.app.Quit() 178 | } 179 | p.win.ConnectDestroy(&destroyCb) 180 | 181 | return nil 182 | } 183 | 184 | func (p *panel) build() error { 185 | var ( 186 | panelOrientation, containerOrientation gtk.Orientation 187 | panelCSSClass string 188 | ) 189 | if p.orientation == gtk.OrientationHorizontalValue { 190 | panelOrientation = gtk.OrientationVerticalValue 191 | panelCSSClass = style.HorizontalClass 192 | containerOrientation = gtk.OrientationHorizontalValue 193 | } else { 194 | panelOrientation = gtk.OrientationHorizontalValue 195 | panelCSSClass = style.VerticalClass 196 | containerOrientation = gtk.OrientationVerticalValue 197 | } 198 | panelMain := gtk.NewBox(panelOrientation, 0) 199 | p.AddRef(panelMain.Unref) 200 | panelMain.AddCssClass(panelCSSClass) 201 | p.win.SetChild(&panelMain.Widget) 202 | 203 | switch p.panelCfg.Edge { 204 | case configv1.Edge_EDGE_TOP: 205 | panelMain.AddCssClass(style.TopClass) 206 | case configv1.Edge_EDGE_RIGHT: 207 | panelMain.AddCssClass(style.RightClass) 208 | case configv1.Edge_EDGE_BOTTOM: 209 | panelMain.AddCssClass(style.BottomClass) 210 | case configv1.Edge_EDGE_LEFT: 211 | panelMain.AddCssClass(style.LeftClass) 212 | } 213 | 214 | p.container = gtk.NewBox(containerOrientation, 0) 215 | p.AddRef(p.container.Unref) 216 | panelMain.Append(&p.container.Widget) 217 | 218 | for _, modCfg := range p.panelCfg.Modules { 219 | modCfg := modCfg 220 | switch modCfg.Kind.(type) { 221 | case *modulev1.Module_Pager: 222 | cfg := modCfg.GetPager() 223 | mod := newPager(cfg, p.api) 224 | p.modules = append(p.modules, mod) 225 | case *modulev1.Module_Taskbar: 226 | cfg := modCfg.GetTaskbar() 227 | mod := newTaskbar(cfg, p.api) 228 | p.modules = append(p.modules, mod) 229 | case *modulev1.Module_Systray: 230 | cfg := modCfg.GetSystray() 231 | mod := newSystray(cfg, p.api) 232 | p.modules = append(p.modules, mod) 233 | case *modulev1.Module_Notifications: 234 | cfg := modCfg.GetNotifications() 235 | mod := newNotifications(cfg, p.api) 236 | p.modules = append(p.modules, mod) 237 | case *modulev1.Module_Hud: 238 | cfg := modCfg.GetHud() 239 | mod := newHud(cfg, p.api) 240 | p.modules = append(p.modules, mod) 241 | case *modulev1.Module_Audio: 242 | cfg := modCfg.GetAudio() 243 | mod := newAudio(cfg, p.api) 244 | p.modules = append(p.modules, mod) 245 | case *modulev1.Module_Power: 246 | cfg := modCfg.GetPower() 247 | mod := newPower(cfg, p.api) 248 | p.modules = append(p.modules, mod) 249 | case *modulev1.Module_Clock: 250 | cfg := modCfg.GetClock() 251 | mod := newClock(cfg, p.api) 252 | p.modules = append(p.modules, mod) 253 | case *modulev1.Module_Session: 254 | cfg := modCfg.GetSession() 255 | mod := newSession(cfg, p.api) 256 | p.modules = append(p.modules, mod) 257 | case *modulev1.Module_Spacer: 258 | cfg := modCfg.GetSpacer() 259 | mod := newSpacer(cfg, p.api) 260 | p.modules = append(p.modules, mod) 261 | default: 262 | log.Warn(`Unhandled module config`, `module`, modCfg) 263 | } 264 | } 265 | 266 | for _, mod := range p.modules { 267 | mod := mod 268 | if rec, ok := mod.(moduleReceiver); ok { 269 | p.receivers[mod] = rec.events() 270 | } 271 | if err := mod.build(p.container); err != nil { 272 | return err 273 | } 274 | p.AddRef(func() { 275 | delete(p.receivers, mod) 276 | mod.close(p.container) 277 | }) 278 | } 279 | 280 | p.AddRef(func() { 281 | close(p.eventCh) 282 | }) 283 | go p.watch() 284 | 285 | return nil 286 | } 287 | 288 | func (p *panel) watch() { 289 | for evt := range p.eventCh { 290 | log.Trace(`received panel event`, `panelID`, p.id, `evt`, evt.Kind.String()) 291 | for _, rec := range p.receivers { 292 | rec <- evt 293 | } 294 | } 295 | } 296 | 297 | func (p *panel) run() int { 298 | <-p.readyCh 299 | 300 | defer p.Unref() 301 | return p.app.Run(len(os.Args), os.Args) 302 | } 303 | 304 | func newPanel() (*panel, error) { 305 | hypr, err := hypripc.New(log) 306 | if err != nil { 307 | return nil, err 308 | } 309 | 310 | p := &panel{ 311 | refTracker: newRefTracker(), 312 | api: &api{ 313 | hypr: hypr, 314 | app: gtk.NewApplication(appName, gio.GApplicationFlagsNoneValue), 315 | }, 316 | modules: make([]module, 0), 317 | eventCh: make(chan *eventv1.Event, 10), 318 | receivers: make(map[module]chan<- *eventv1.Event), 319 | readyCh: make(chan struct{}), 320 | quitCh: make(chan struct{}), 321 | } 322 | p.AddRef(p.app.Unref) 323 | p.AddRef(func() { 324 | close(p.quitCh) 325 | }) 326 | p.app.SetFlags(gio.GApplicationNonUniqueValue) 327 | 328 | var activate func(gio.Application) 329 | activate = func(_ gio.Application) { 330 | defer unrefCallback(&activate) 331 | if err := p.initWindow(); err != nil { 332 | log.Error(`failed initializing window`, `err`, err) 333 | p.app.Quit() 334 | } 335 | 336 | if err := p.build(); err != nil { 337 | log.Error(`Failed initializing window`, `err`, err) 338 | p.app.Quit() 339 | } 340 | 341 | p.win.Show() 342 | } 343 | 344 | p.app.ConnectActivate(&activate) 345 | 346 | return p, nil 347 | } 348 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/power.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/jwijenbergh/puregotk/v4/glib" 9 | "github.com/jwijenbergh/puregotk/v4/gtk" 10 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 11 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 12 | "github.com/pdf/hyprpanel/style" 13 | ) 14 | 15 | type powerChangeCache map[string]*eventv1.PowerChangeValue 16 | 17 | type powerChangeSort []*eventv1.PowerChangeValue 18 | 19 | func (p powerChangeSort) Len() int { 20 | return len(p) 21 | } 22 | 23 | func (p powerChangeSort) Swap(i, j int) { 24 | p[i], p[j] = p[j], p[i] 25 | } 26 | 27 | func (p powerChangeSort) Less(i, j int) bool { 28 | return p[i].Id < p[j].Id 29 | } 30 | 31 | func (p powerChangeCache) toSlice() powerChangeSort { 32 | res := make(powerChangeSort, len(p)) 33 | i := 0 34 | for _, v := range p { 35 | res[i] = v 36 | i++ 37 | } 38 | 39 | return res 40 | } 41 | 42 | type power struct { 43 | *refTracker 44 | *api 45 | cfg *modulev1.Power 46 | cache powerChangeCache 47 | tooltip string 48 | eventCh chan *eventv1.Event 49 | quitCh chan struct{} 50 | 51 | container *gtk.CenterBox 52 | icon *gtk.Image 53 | } 54 | 55 | func writeTooltip(evt *eventv1.PowerChangeValue, tooltip *strings.Builder) { 56 | _, err := fmt.Fprintf(tooltip, "%d%% ", evt.Percentage) 57 | if err != nil { 58 | return 59 | } 60 | if evt.Vendor != `` { 61 | tooltip.WriteString(evt.Vendor) 62 | tooltip.WriteString(` `) 63 | } 64 | if evt.Model != `` { 65 | tooltip.WriteString(evt.Model) 66 | } else { 67 | tooltip.WriteString(eventv1.PowerDefaultID) 68 | } 69 | if tooltip.Len() == 0 { 70 | tooltip.WriteString(`Unknown`) 71 | } 72 | switch evt.State { 73 | case eventv1.PowerState_POWER_STATE_CHARGING: 74 | tooltip.WriteString(` (Charging`) 75 | timeToFull := evt.TimeToFull.AsDuration() 76 | if timeToFull > 0 { 77 | tooltip.WriteString(" - ") 78 | tooltip.WriteString(timeToFull.String()) 79 | tooltip.WriteString(` until charged`) 80 | } 81 | tooltip.WriteString(`)`) 82 | case eventv1.PowerState_POWER_STATE_DISCHARGING: 83 | tooltip.WriteString(` (Discharging`) 84 | timeToEmpty := evt.TimeToEmpty.AsDuration() 85 | if timeToEmpty > 0 { 86 | tooltip.WriteString(" - ") 87 | tooltip.WriteString(timeToEmpty.String()) 88 | tooltip.WriteString(` until empty`) 89 | } 90 | tooltip.WriteString(`)`) 91 | case eventv1.PowerState_POWER_STATE_EMPTY: 92 | tooltip.WriteString(` (Empty!)`) 93 | case eventv1.PowerState_POWER_STATE_FULLY_CHARGED: 94 | tooltip.WriteString(` (Fully Charged)`) 95 | case eventv1.PowerState_POWER_STATE_PENDING_CHARGE: 96 | tooltip.WriteString(` (Pending Charge)`) 97 | case eventv1.PowerState_POWER_STATE_PENDING_DISCHARGE: 98 | tooltip.WriteString(` (Pending Discharge)`) 99 | default: 100 | tooltip.WriteString(` (Unknown)`) 101 | } 102 | } 103 | 104 | func (p *power) update(evt *eventv1.PowerChangeValue) error { 105 | var err error 106 | 107 | if evt.Id == eventv1.PowerDefaultID { 108 | prev, hasPrev := p.cache[evt.Id] 109 | p.cache[evt.Id] = evt 110 | if !hasPrev || prev.Icon != evt.Icon { 111 | if p.icon != nil { 112 | icon := p.icon 113 | defer icon.Unref() 114 | p.icon = nil 115 | } 116 | p.icon, err = createIcon(evt.Icon, int(p.cfg.IconSize), p.cfg.IconSymbolic, nil) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | p.container.SetCenterWidget(&p.icon.Widget) 122 | } 123 | return nil 124 | } 125 | 126 | tooltip := &strings.Builder{} 127 | p.cache[evt.Id] = evt 128 | s := p.cache.toSlice() 129 | sort.Sort(s) 130 | 131 | for i, v := range s { 132 | if v.Id == eventv1.PowerDefaultID || v.State == eventv1.PowerState_POWER_STATE_UNSPECIFIED { 133 | continue 134 | } 135 | 136 | if i > 0 && tooltip.Len() > 0 { 137 | tooltip.WriteString("\n") 138 | } 139 | writeTooltip(v, tooltip) 140 | } 141 | 142 | if p.tooltip != tooltip.String() { 143 | p.tooltip = tooltip.String() 144 | p.container.SetTooltipMarkup(p.tooltip) 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func (p *power) build(container *gtk.Box) error { 151 | p.container = gtk.NewCenterBox() 152 | p.container.SetName(style.PowerID) 153 | p.container.AddCssClass(style.ModuleClass) 154 | 155 | scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool { 156 | if dy < 0 { 157 | if err := p.host.BrightnessAdjust(``, eventv1.Direction_DIRECTION_UP); err != nil { 158 | log.Warn(`Brightness adjustment failed`, `module`, style.PowerID, `err`, err) 159 | } 160 | } else { 161 | if err := p.host.BrightnessAdjust(``, eventv1.Direction_DIRECTION_DOWN); err != nil { 162 | log.Warn(`Brightness adjustment failed`, `module`, style.PowerID, `err`, err) 163 | } 164 | } 165 | 166 | return true 167 | } 168 | p.AddRef(func() { 169 | unrefCallback(&scrollCb) 170 | }) 171 | 172 | scrollController := gtk.NewEventControllerScroll(gtk.EventControllerScrollVerticalValue | gtk.EventControllerScrollDiscreteValue) 173 | scrollController.ConnectScroll(&scrollCb) 174 | p.container.AddController(&scrollController.EventController) 175 | 176 | container.Append(&p.container.Widget) 177 | 178 | go p.watch() 179 | 180 | return nil 181 | } 182 | 183 | func (p *power) events() chan<- *eventv1.Event { 184 | return p.eventCh 185 | } 186 | 187 | func (p *power) watch() { 188 | for { 189 | select { 190 | case <-p.quitCh: 191 | return 192 | default: 193 | select { 194 | case <-p.quitCh: 195 | return 196 | case evt := <-p.eventCh: 197 | switch evt.Kind { 198 | case eventv1.EventKind_EVENT_KIND_DBUS_POWER_CHANGE: 199 | data := &eventv1.PowerChangeValue{} 200 | if !evt.Data.MessageIs(data) { 201 | log.Warn(`Invalid event`, `module`, style.PowerID, `evt`, evt) 202 | continue 203 | } 204 | if err := evt.Data.UnmarshalTo(data); err != nil { 205 | log.Warn(`Invalid event`, `module`, style.PowerID, `err`, err, `evt`, evt) 206 | continue 207 | } 208 | 209 | var cb glib.SourceFunc 210 | cb = func(uintptr) bool { 211 | defer unrefCallback(&cb) 212 | if err := p.update(data); err != nil { 213 | log.Warn(`Failed updating`, `module`, style.PowerID, `err`, err) 214 | } 215 | return false 216 | } 217 | 218 | glib.IdleAdd(&cb, 0) 219 | } 220 | } 221 | } 222 | } 223 | } 224 | 225 | func (p *power) close(container *gtk.Box) { 226 | defer p.Unref() 227 | log.Debug(`Closing module on request`, `module`, style.PowerID) 228 | container.Remove(&p.container.Widget) 229 | if p.icon != nil { 230 | p.icon.Unref() 231 | } 232 | } 233 | 234 | func newPower(cfg *modulev1.Power, a *api) *power { 235 | p := &power{ 236 | refTracker: newRefTracker(), 237 | api: a, 238 | cfg: cfg, 239 | cache: make(powerChangeCache), 240 | eventCh: make(chan *eventv1.Event), 241 | quitCh: make(chan struct{}), 242 | } 243 | 244 | p.AddRef(func() { 245 | close(p.quitCh) 246 | close(p.eventCh) 247 | }) 248 | 249 | return p 250 | } 251 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/session.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/jwijenbergh/puregotk/v4/gdk" 7 | "github.com/jwijenbergh/puregotk/v4/gtk" 8 | "github.com/mattn/go-shellwords" 9 | gtk4layershell "github.com/pdf/hyprpanel/internal/gtk4-layer-shell" 10 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 11 | hyprpanelv1 "github.com/pdf/hyprpanel/proto/hyprpanel/v1" 12 | "github.com/pdf/hyprpanel/style" 13 | ) 14 | 15 | type session struct { 16 | *refTracker 17 | *api 18 | cfg *modulev1.Session 19 | 20 | container *gtk.CenterBox 21 | overlay *gtk.Window 22 | } 23 | 24 | func (s *session) build(container *gtk.Box) error { 25 | s.container = gtk.NewCenterBox() 26 | s.container.SetName(style.SessionID) 27 | s.container.AddCssClass(style.ModuleClass) 28 | icon, err := createIcon(`system-shutdown`, int(s.cfg.IconSize), s.cfg.IconSymbolic, nil) 29 | if err != nil { 30 | return err 31 | } 32 | s.container.SetCenterWidget(&icon.Widget) 33 | 34 | s.overlay = gtk.NewWindow() 35 | s.overlay.SetName(style.SessionOverlayID) 36 | s.overlay.Hide() 37 | gtk4layershell.InitForWindow(s.overlay) 38 | gtk4layershell.SetNamespace(s.overlay, appName+`.`+style.SessionOverlayID) 39 | gtk4layershell.SetLayer(s.overlay, gtk4layershell.LayerShellLayerOverlay) 40 | gtk4layershell.SetAnchor(s.overlay, gtk4layershell.LayerShellEdgeTop, true) 41 | gtk4layershell.SetAnchor(s.overlay, gtk4layershell.LayerShellEdgeLeft, true) 42 | gtk4layershell.SetAnchor(s.overlay, gtk4layershell.LayerShellEdgeRight, true) 43 | gtk4layershell.SetAnchor(s.overlay, gtk4layershell.LayerShellEdgeBottom, true) 44 | 45 | overlayContainer := gtk.NewCenterBox() 46 | overlayInner := gtk.NewBox(gtk.OrientationHorizontalValue, int(s.cfg.OverlayIconSize)) 47 | 48 | buttonSize := int(math.Floor(float64(s.cfg.OverlayIconSize) * 1.5)) 49 | 50 | if s.cfg.CommandLogout != `` { 51 | p := shellwords.NewParser() 52 | p.ParseBacktick = true 53 | p.ParseEnv = true 54 | exec, err := p.Parse(s.cfg.CommandLogout) 55 | if err != nil { 56 | return err 57 | } 58 | logoutIcon, err := createIcon(`system-log-out`, int(s.cfg.OverlayIconSize), s.cfg.OverlayIconSymbolic, nil) 59 | if err != nil { 60 | return err 61 | } 62 | logoutIcon.SetValign(gtk.AlignCenterValue) 63 | logoutIcon.SetHalign(gtk.AlignCenterValue) 64 | logout := gtk.NewBox(gtk.OrientationVerticalValue, 0) 65 | logout.SetValign(gtk.AlignCenterValue) 66 | logoutButton := gtk.NewButton() 67 | logoutButton.SetValign(gtk.AlignCenterValue) 68 | logoutButton.SetHalign(gtk.AlignCenterValue) 69 | logoutButton.SetSizeRequest(buttonSize, buttonSize) 70 | logoutButton.SetChild(&logoutIcon.Widget) 71 | logoutLabel := gtk.NewLabel(`Logout`) 72 | logout.Append(&logoutButton.Widget) 73 | logout.Append(&logoutLabel.Widget) 74 | logoutCb := func(_ gtk.Button) { 75 | s.overlay.Hide() 76 | if err := s.host.Exec(&hyprpanelv1.AppInfo_Action{Name: `logount`, Exec: exec}); err != nil { 77 | log.Error(`Failed executing logout`, `module`, style.SessionID, `err`, err) 78 | } 79 | } 80 | logoutButton.ConnectClicked(&logoutCb) 81 | s.AddRef(func() { 82 | unrefCallback(&logoutCb) 83 | }) 84 | overlayInner.Append(&logout.Widget) 85 | } 86 | 87 | if s.cfg.CommandReboot != `` { 88 | p := shellwords.NewParser() 89 | p.ParseBacktick = true 90 | p.ParseEnv = true 91 | exec, err := p.Parse(s.cfg.CommandReboot) 92 | if err != nil { 93 | return err 94 | } 95 | rebootIcon, err := createIcon(`system-reboot`, int(s.cfg.OverlayIconSize), s.cfg.OverlayIconSymbolic, nil) 96 | if err != nil { 97 | return err 98 | } 99 | rebootIcon.SetValign(gtk.AlignCenterValue) 100 | rebootIcon.SetHalign(gtk.AlignCenterValue) 101 | reboot := gtk.NewBox(gtk.OrientationVerticalValue, 0) 102 | reboot.SetValign(gtk.AlignCenterValue) 103 | rebootButton := gtk.NewButton() 104 | rebootButton.SetValign(gtk.AlignCenterValue) 105 | rebootButton.SetHalign(gtk.AlignCenterValue) 106 | rebootButton.SetSizeRequest(buttonSize, buttonSize) 107 | rebootButton.SetChild(&rebootIcon.Widget) 108 | rebootLabel := gtk.NewLabel(`Reboot`) 109 | reboot.Append(&rebootButton.Widget) 110 | reboot.Append(&rebootLabel.Widget) 111 | rebootCb := func(_ gtk.Button) { 112 | s.overlay.Hide() 113 | if err := s.host.Exec(&hyprpanelv1.AppInfo_Action{Name: `reboot`, Exec: exec}); err != nil { 114 | log.Error(`Failed executing reboot`, `module`, style.SessionID, `err`, err) 115 | } 116 | } 117 | rebootButton.ConnectClicked(&rebootCb) 118 | s.AddRef(func() { 119 | unrefCallback(&rebootCb) 120 | }) 121 | overlayInner.Append(&reboot.Widget) 122 | } 123 | 124 | if s.cfg.CommandSuspend != `` { 125 | p := shellwords.NewParser() 126 | p.ParseBacktick = true 127 | p.ParseEnv = true 128 | exec, err := p.Parse(s.cfg.CommandSuspend) 129 | if err != nil { 130 | return err 131 | } 132 | suspendIcon, err := createIcon(`system-suspend`, int(s.cfg.OverlayIconSize), s.cfg.OverlayIconSymbolic, nil) 133 | if err != nil { 134 | return err 135 | } 136 | suspendIcon.SetValign(gtk.AlignCenterValue) 137 | suspendIcon.SetHalign(gtk.AlignCenterValue) 138 | suspend := gtk.NewBox(gtk.OrientationVerticalValue, 0) 139 | suspend.SetValign(gtk.AlignCenterValue) 140 | suspendButton := gtk.NewButton() 141 | suspendButton.SetValign(gtk.AlignCenterValue) 142 | suspendButton.SetHalign(gtk.AlignCenterValue) 143 | suspendButton.SetSizeRequest(buttonSize, buttonSize) 144 | suspendButton.SetChild(&suspendIcon.Widget) 145 | suspendLabel := gtk.NewLabel(`Suspend`) 146 | suspend.Append(&suspendButton.Widget) 147 | suspend.Append(&suspendLabel.Widget) 148 | suspendCb := func(_ gtk.Button) { 149 | s.overlay.Hide() 150 | if err := s.host.Exec(&hyprpanelv1.AppInfo_Action{Name: `suspend`, Exec: exec}); err != nil { 151 | log.Error(`Failed executing suspend`, `module`, style.SessionID, `err`, err) 152 | } 153 | } 154 | suspendButton.ConnectClicked(&suspendCb) 155 | s.AddRef(func() { 156 | unrefCallback(&suspendCb) 157 | }) 158 | overlayInner.Append(&suspend.Widget) 159 | } 160 | 161 | if s.cfg.CommandShutdown != `` { 162 | p := shellwords.NewParser() 163 | p.ParseBacktick = true 164 | p.ParseEnv = true 165 | exec, err := p.Parse(s.cfg.CommandShutdown) 166 | if err != nil { 167 | return err 168 | } 169 | shutdownIcon, err := createIcon(`system-shutdown`, int(s.cfg.OverlayIconSize), s.cfg.OverlayIconSymbolic, nil) 170 | if err != nil { 171 | return err 172 | } 173 | shutdownIcon.SetValign(gtk.AlignCenterValue) 174 | shutdownIcon.SetHalign(gtk.AlignCenterValue) 175 | shutdown := gtk.NewBox(gtk.OrientationVerticalValue, 0) 176 | shutdown.SetValign(gtk.AlignCenterValue) 177 | shutdownButton := gtk.NewButton() 178 | shutdownButton.SetValign(gtk.AlignCenterValue) 179 | shutdownButton.SetHalign(gtk.AlignCenterValue) 180 | shutdownButton.SetSizeRequest(buttonSize, buttonSize) 181 | shutdownButton.SetChild(&shutdownIcon.Widget) 182 | shutdownLabel := gtk.NewLabel(`Shutdown`) 183 | shutdown.Append(&shutdownButton.Widget) 184 | shutdown.Append(&shutdownLabel.Widget) 185 | shutdownCb := func(_ gtk.Button) { 186 | s.overlay.Hide() 187 | if err := s.host.Exec(&hyprpanelv1.AppInfo_Action{Name: `shutdown`, Exec: exec}); err != nil { 188 | log.Error(`Failed executing suspend`, `module`, style.SessionID, `err`, err) 189 | } 190 | } 191 | shutdownButton.ConnectClicked(&shutdownCb) 192 | s.AddRef(func() { 193 | unrefCallback(&shutdownCb) 194 | }) 195 | overlayInner.Append(&shutdown.Widget) 196 | } 197 | 198 | overlayContainer.SetCenterWidget(&overlayInner.Widget) 199 | 200 | s.overlay.SetChild(&overlayContainer.Widget) 201 | 202 | overlayClickController := gtk.NewGestureClick() 203 | overlayClickCb := func(ctrl gtk.GestureClick, nPress int, x, y float64) { 204 | s.overlay.Hide() 205 | } 206 | overlayClickController.ConnectReleased(&overlayClickCb) 207 | s.overlay.AddController(&overlayClickController.EventController) 208 | s.AddRef(func() { 209 | unrefCallback(&overlayClickCb) 210 | }) 211 | 212 | overlayKeyController := gtk.NewEventControllerKey() 213 | overlayKeyCb := func(_ gtk.EventControllerKey, keyVal uint, keyCode uint, mods gdk.ModifierType) { 214 | if keyVal == uint(gdk.KEY_Escape) { 215 | s.overlay.Hide() 216 | } 217 | } 218 | overlayKeyController.ConnectKeyReleased(&overlayKeyCb) 219 | s.overlay.AddController(&overlayKeyController.EventController) 220 | s.AddRef(func() { 221 | unrefCallback(&overlayKeyCb) 222 | }) 223 | 224 | buttonClickController := gtk.NewGestureClick() 225 | buttonClickCb := func(ctrl gtk.GestureClick, nPress int, x, y float64) { 226 | if ctrl.GetCurrentButton() == uint(gdk.BUTTON_PRIMARY) { 227 | s.overlay.Show() 228 | } 229 | } 230 | buttonClickController.ConnectReleased(&buttonClickCb) 231 | s.container.AddController(&buttonClickController.EventController) 232 | s.AddRef(func() { 233 | unrefCallback(&buttonClickCb) 234 | }) 235 | 236 | container.Append(&s.container.Widget) 237 | 238 | return nil 239 | } 240 | 241 | func (s *session) close(container *gtk.Box) { 242 | log.Debug(`Closing module on request`, `module`, style.SessionID) 243 | container.Remove(&s.container.Widget) 244 | s.Unref() 245 | } 246 | 247 | func newSession(cfg *modulev1.Session, a *api) *session { 248 | return &session{ 249 | refTracker: newRefTracker(), 250 | api: a, 251 | cfg: cfg, 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/spacer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jwijenbergh/puregotk/v4/gtk" 5 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 6 | "github.com/pdf/hyprpanel/style" 7 | ) 8 | 9 | type spacer struct { 10 | *refTracker 11 | *api 12 | cfg *modulev1.Spacer 13 | 14 | container *gtk.Box 15 | } 16 | 17 | func (s *spacer) build(container *gtk.Box) error { 18 | s.container = gtk.NewBox(gtk.OrientationHorizontalValue, 0) 19 | s.AddRef(s.container.Unref) 20 | s.container.SetName(style.SpacerID) 21 | if s.orientation == gtk.OrientationHorizontalValue { 22 | s.container.SetSizeRequest(int(s.cfg.Size), int(s.panelCfg.Size)) 23 | s.container.SetHexpand(s.cfg.Expand) 24 | } else { 25 | s.container.SetSizeRequest(int(s.panelCfg.Size), int(s.cfg.Size)) 26 | s.container.SetVexpand(s.cfg.Expand) 27 | } 28 | container.Append(&s.container.Widget) 29 | return nil 30 | } 31 | 32 | func (s *spacer) close(container *gtk.Box) { 33 | log.Debug(`Closing module on request`, `module`, style.SpacerID) 34 | container.Remove(&s.container.Widget) 35 | s.Unref() 36 | } 37 | 38 | func newSpacer(cfg *modulev1.Spacer, a *api) *spacer { 39 | return &spacer{ 40 | refTracker: newRefTracker(), 41 | api: a, 42 | cfg: cfg, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/systray_inhibitor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/jwijenbergh/puregotk/v4/glib" 8 | "github.com/jwijenbergh/puregotk/v4/gtk" 9 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 10 | ) 11 | 12 | type systrayInhibitor struct { 13 | *refTracker 14 | *api 15 | cfg *modulev1.Systray 16 | mu sync.RWMutex 17 | timer *time.Timer 18 | hidden chan struct{} 19 | inhib bool 20 | revealer *gtk.Revealer 21 | revealBtn *gtk.Button 22 | enterCb func(ctrl gtk.EventControllerMotion, x, y float64) 23 | leaveCb func(ctrl gtk.EventControllerMotion) 24 | } 25 | 26 | func (s *systrayInhibitor) newController() *gtk.EventControllerMotion { 27 | ctrl := gtk.NewEventControllerMotion() 28 | ctrl.ConnectEnter(&s.enterCb) 29 | ctrl.ConnectLeave(&s.leaveCb) 30 | 31 | return ctrl 32 | } 33 | 34 | /* 35 | func (s *systrayInhibitor) inhibited() bool { 36 | s.mu.RLock() 37 | defer s.mu.RUnlock() 38 | return s.inhib 39 | } 40 | */ 41 | 42 | func (s *systrayInhibitor) wait() <-chan time.Time { 43 | return s.timer.C 44 | } 45 | 46 | func (s *systrayInhibitor) inhibit() { 47 | if !s.timer.Stop() { 48 | select { 49 | case <-s.timer.C: 50 | default: 51 | } 52 | } 53 | s.mu.Lock() 54 | s.inhib = true 55 | s.mu.Unlock() 56 | } 57 | 58 | func (s *systrayInhibitor) uninhibit() { 59 | if !s.timer.Stop() { 60 | select { 61 | case <-s.timer.C: 62 | default: 63 | } 64 | } 65 | s.timer.Reset(s.cfg.AutoHideDelay.AsDuration()) 66 | s.mu.Lock() 67 | s.inhib = false 68 | s.mu.Unlock() 69 | } 70 | 71 | func (s *systrayInhibitor) updateRevealBtn() { 72 | if s.orientation == gtk.OrientationHorizontalValue { 73 | if s.revealer.GetChildRevealed() && s.revealBtn.GetLabel() != systrayRevealLabelRight { 74 | s.revealBtn.SetLabel(systrayRevealLabelRight) 75 | } else if s.revealBtn.GetLabel() != systrayRevealLabelLeft { 76 | s.revealBtn.SetLabel(systrayRevealLabelLeft) 77 | } 78 | } else { 79 | if s.revealer.GetChildRevealed() && s.revealBtn.GetLabel() != systrayRevealLabelDown { 80 | s.revealBtn.SetLabel(systrayRevealLabelDown) 81 | } else if s.revealBtn.GetLabel() != systrayRevealLabelUp { 82 | s.revealBtn.SetLabel(systrayRevealLabelUp) 83 | } 84 | } 85 | } 86 | 87 | func (s *systrayInhibitor) build(container *gtk.Box, hiddenContainer *gtk.Widget) error { 88 | s.revealer = gtk.NewRevealer() 89 | s.AddRef(s.revealer.Unref) 90 | s.revealer.SetRevealChild(false) 91 | 92 | s.revealBtn = gtk.NewButton() 93 | s.AddRef(s.revealBtn.Unref) 94 | s.updateRevealBtn() 95 | 96 | revealBtnCb := func(gtk.Button) { 97 | s.inhibit() 98 | s.revealer.SetRevealChild(!s.revealer.GetRevealChild()) 99 | } 100 | s.AddRef(func() { 101 | unrefCallback(&revealBtnCb) 102 | }) 103 | s.revealBtn.ConnectClicked(&revealBtnCb) 104 | 105 | revealCb := func() { 106 | s.updateRevealBtn() 107 | if s.cfg.AutoHideDelay.AsDuration() == 0 { 108 | return 109 | } 110 | 111 | if s.revealer.GetRevealChild() { 112 | go func() { 113 | select { 114 | case <-s.hidden: 115 | case <-s.wait(): 116 | var cb glib.SourceFunc 117 | cb = func(uintptr) bool { 118 | defer unrefCallback(&cb) 119 | s.revealer.SetRevealChild(false) 120 | return false 121 | } 122 | glib.IdleAdd(&cb, 0) 123 | } 124 | }() 125 | } else { 126 | s.inhibit() 127 | select { 128 | case s.hidden <- struct{}{}: 129 | default: 130 | } 131 | } 132 | } 133 | s.AddRef(func() { 134 | unrefCallback(&revealCb) 135 | }) 136 | s.revealer.ConnectSignal(`notify::child-revealed`, &revealCb) 137 | 138 | s.revealer.SetChild(hiddenContainer) 139 | 140 | container.Append(&s.revealBtn.Widget) 141 | container.Append(&s.revealer.Widget) 142 | 143 | return nil 144 | } 145 | 146 | func newSystrayInhibitor(cfg *modulev1.Systray, a *api) *systrayInhibitor { 147 | s := &systrayInhibitor{ 148 | refTracker: newRefTracker(), 149 | api: a, 150 | cfg: cfg, 151 | timer: time.NewTimer(0), 152 | hidden: make(chan struct{}), 153 | } 154 | s.uninhibit() 155 | 156 | s.enterCb = func(ctrl gtk.EventControllerMotion, x, y float64) { 157 | s.inhibit() 158 | } 159 | s.leaveCb = func(ctrl gtk.EventControllerMotion) { 160 | s.uninhibit() 161 | } 162 | 163 | s.AddRef(func() { 164 | unrefCallback(&s.enterCb) 165 | unrefCallback(&s.leaveCb) 166 | }) 167 | 168 | return s 169 | } 170 | -------------------------------------------------------------------------------- /cmd/hyprpanel-client/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "math" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/jwijenbergh/puregotk/v4/gdk" 16 | "github.com/jwijenbergh/puregotk/v4/gdkpixbuf" 17 | "github.com/jwijenbergh/puregotk/v4/glib" 18 | "github.com/jwijenbergh/puregotk/v4/gtk" 19 | "github.com/jwijenbergh/puregotk/v4/pango" 20 | "github.com/pdf/hyprpanel/internal/hypripc" 21 | "github.com/pdf/hyprpanel/internal/panelplugin" 22 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 23 | hyprpanelv1 "github.com/pdf/hyprpanel/proto/hyprpanel/v1" 24 | "github.com/pdf/hyprpanel/style" 25 | ) 26 | 27 | const ( 28 | tooltipDebounceTime = 500 * time.Millisecond 29 | tooltipMaxChars = 40 30 | ) 31 | 32 | type refTracker struct { 33 | mu sync.Mutex 34 | refs []func() 35 | } 36 | 37 | func (r *refTracker) AddRef(f func()) { 38 | r.mu.Lock() 39 | defer r.mu.Unlock() 40 | r.refs = append(r.refs, f) 41 | } 42 | 43 | func (r *refTracker) Unref() { 44 | r.mu.Lock() 45 | defer r.mu.Unlock() 46 | for _, ref := range r.refs { 47 | ref() 48 | } 49 | } 50 | 51 | func newRefTracker() *refTracker { 52 | return &refTracker{ 53 | refs: make([]func(), 0), 54 | } 55 | } 56 | 57 | func gdkMonitorFromHypr(monitor *hypripc.Monitor) (*gdk.Monitor, error) { 58 | disp := gdk.DisplayGetDefault() 59 | gdkMonitors := disp.GetMonitors() 60 | for i := uint(0); i < gdkMonitors.GetNItems(); i++ { 61 | p := gdkMonitors.GetItem(i) 62 | gmon := gdk.MonitorNewFromInternalPtr(p) 63 | gmon.GetConnector() 64 | if monitor.Name == gmon.GetConnector() { 65 | return gmon, nil 66 | } 67 | } 68 | 69 | return nil, errors.New(`monitor match not found`) 70 | } 71 | 72 | func pixbufFromSNIData(buf *eventv1.StatusNotifierValue_Pixmap, size int) (*gdkpixbuf.Pixbuf, error) { 73 | if len(buf.Data) == 0 || 74 | len(buf.Data) != 4*int(buf.Width)*int(buf.Height) { 75 | return nil, errInvalidPixbufArray 76 | } 77 | 78 | // Convert ARGB to RGBA 79 | // TODO: Deal with endianness 80 | for i := 0; i < 4*int(buf.Width)*int(buf.Height); i += 4 { 81 | alpha := buf.Data[i] 82 | buf.Data[i] = buf.Data[i+1] 83 | buf.Data[i+1] = buf.Data[i+2] 84 | buf.Data[i+2] = buf.Data[i+3] 85 | buf.Data[i+3] = alpha 86 | } 87 | 88 | pixbuf := gdkpixbuf.NewPixbufFromBytes(glib.NewBytes(buf.Data, uint(len(buf.Data))), gdkpixbuf.GdkColorspaceRgbValue, true, 8, int(buf.Width), int(buf.Height), int(buf.Width)*4) 89 | scaled, err := pixbufScale(pixbuf, size) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | return scaled, nil 95 | } 96 | 97 | func pixbufFromNotificationData(buf *eventv1.NotificationValue_Pixmap, size int) (*gdkpixbuf.Pixbuf, error) { 98 | if len(buf.Data) == 0 || int32(len(buf.Data)) != buf.Channels*buf.Width*buf.Height { 99 | return nil, errInvalidPixbufArray 100 | } 101 | 102 | pixbuf := gdkpixbuf.NewPixbufFromBytes(glib.NewBytes(buf.Data, uint(len(buf.Data))), gdkpixbuf.GdkColorspaceRgbValue, buf.HasAlpha, int(buf.BitsPerSample), int(buf.Width), int(buf.Height), int(buf.RowStride)) 103 | if pixbuf == nil { 104 | return nil, errInvalidPixbufArray 105 | } 106 | scaled, err := pixbufScale(pixbuf, size) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | return scaled, nil 112 | } 113 | 114 | func pixbufFromNRGBA(buf *hyprpanelv1.ImageNRGBA) (*gdkpixbuf.Pixbuf, error) { 115 | if len(buf.Pixels) == 0 { 116 | return nil, errInvalidPixbufArray 117 | } 118 | 119 | pixbuf := gdkpixbuf.NewPixbufFromBytes(glib.NewBytes(buf.Pixels, uint(len(buf.Pixels))), gdkpixbuf.GdkColorspaceRgbValue, true, 8, int(buf.Width), int(buf.Height), int(buf.Stride)) 120 | if pixbuf == nil { 121 | return nil, errInvalidPixbufArray 122 | } 123 | 124 | return pixbuf, nil 125 | } 126 | 127 | func pixbufScale(pixbuf *gdkpixbuf.Pixbuf, size int) (*gdkpixbuf.Pixbuf, error) { 128 | width := pixbuf.GetWidth() 129 | height := pixbuf.GetHeight() 130 | if (width == size && height <= size) || (height == size && width <= size) { 131 | return pixbuf, nil 132 | } 133 | if (width > size || height > size) || 134 | (width < size && height < size) { 135 | targetWidth, targetHeight := size, size 136 | if width > height { 137 | scale := float64(height) / float64(width) 138 | targetWidth = size 139 | targetHeight = int(math.Floor(scale * float64(size))) 140 | } else if height > width { 141 | scale := float64(width) / float64(height) 142 | targetHeight = size 143 | targetWidth = int(math.Floor(scale * float64(size))) 144 | } 145 | result := pixbuf.ScaleSimple(targetWidth, targetHeight, gdkpixbuf.GdkInterpBilinearValue) 146 | if result == nil { 147 | return nil, errors.New(`failed scaling pixbuf`) 148 | } 149 | 150 | return result, nil 151 | } 152 | 153 | return pixbuf, nil 154 | } 155 | 156 | func createIcon(icon string, size int, symbolic bool, fallbacks []string, searchPaths ...string) (*gtk.Image, error) { 157 | if strings.HasPrefix(icon, `/`) { 158 | pixbuf, err := gdkpixbuf.NewPixbufFromFileAtSize(icon, size, size) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | image := gtk.NewImageFromPixbuf(pixbuf) 164 | if image == nil { 165 | return nil, fmt.Errorf("could not convert icon pixbuf to image: %s", icon) 166 | } 167 | image.SetPixelSize(size) 168 | 169 | return image, nil 170 | } 171 | 172 | theme := gtk.IconThemeGetForDisplay(gdk.DisplayGetDefault()) 173 | if theme == nil { 174 | return nil, errors.New(`could not find default icon theme`) 175 | } 176 | for _, path := range searchPaths { 177 | if path != `` { 178 | theme.AddSearchPath(path) 179 | } 180 | } 181 | flags := gtk.IconLookupPreloadValue 182 | if symbolic { 183 | flags |= gtk.IconLookupForceSymbolicValue 184 | } 185 | iconInfo := theme.LookupIcon(icon, fallbacks, size, 1, gtk.TextDirLtrValue, flags) 186 | if iconInfo == nil { 187 | return nil, fmt.Errorf("icon not found in theme (%s): %s", theme.GetThemeName(), icon) 188 | } 189 | imageWidget := gtk.NewImageFromPaintable(iconInfo) 190 | if imageWidget == nil { 191 | return nil, fmt.Errorf("could not convert icon to image: %s", icon) 192 | } 193 | var image gtk.Image 194 | imageWidget.Cast(&image) 195 | image.SetPixelSize(size) 196 | return &image, nil 197 | } 198 | 199 | func unrefCallback(fnPtr any) { 200 | if err := glib.UnrefCallback(fnPtr); err != nil { 201 | log.Warn(`UnrefCallback failed`, `err`, err) 202 | } 203 | } 204 | 205 | type tooltipPreviewer interface { 206 | clientAddress() string 207 | clientTitle() string 208 | clientSubtitle() string 209 | shouldPreview() bool 210 | pluginHost() panelplugin.Host 211 | } 212 | 213 | func tooltipPreview(target tooltipPreviewer, width, height int) func(widget gtk.Widget, x int, y int, keyboardMod bool, tooltipPtr uintptr) bool { 214 | var lastTooltipTime time.Time 215 | var lastTooltip *gtk.Widget 216 | 217 | return func(widget gtk.Widget, x int, y int, keyboardMod bool, tooltipPtr uintptr) bool { 218 | tooltip := gtk.TooltipNewFromInternalPtr(tooltipPtr) 219 | 220 | // Debounce tooltip rendering to avoid excessive updates 221 | if !lastTooltipTime.IsZero() && time.Since(lastTooltipTime) < tooltipDebounceTime && lastTooltip != nil { 222 | tooltip.SetCustom(lastTooltip) 223 | return true 224 | } 225 | lastTooltipTime = time.Now() 226 | time.AfterFunc(tooltipDebounceTime, func() { 227 | if lastTooltip == nil { 228 | return 229 | } 230 | lastTooltip.Unref() 231 | lastTooltip = nil 232 | }) 233 | 234 | container := gtk.NewBox(gtk.OrientationVerticalValue, 0) 235 | tooltip.SetCustom(&container.Widget) 236 | lastTooltip = &container.Widget 237 | 238 | title := gtk.NewLabel(target.clientTitle()) 239 | title.SetEllipsize(pango.EllipsizeMiddleValue) 240 | title.SetMaxWidthChars(tooltipMaxChars) 241 | container.Append(&title.Widget) 242 | title.Unref() 243 | 244 | if !target.shouldPreview() { 245 | return true 246 | } 247 | 248 | addr, err := strconv.ParseUint(target.clientAddress(), 0, 64) 249 | if err != nil { 250 | log.Warn(`failed to parse client address`, `err`, err) 251 | return true 252 | } 253 | img, err := target.pluginHost().CaptureFrame(addr, int32(width), int32(height)) 254 | if err != nil { 255 | log.Warn(`failed to capture frame`, `err`, err) 256 | return true 257 | } 258 | 259 | pixbuf, err := pixbufFromNRGBA(img) 260 | if err != nil { 261 | log.Warn(`failed to create pixbuf from ImageNRGBA`, `err`, err) 262 | return true 263 | } 264 | image := gtk.NewPictureForPixbuf(pixbuf) 265 | image.AddCssClass(style.TooltipImageClass) 266 | image.SetSizeRequest(width, height) 267 | container.Append(&image.Widget) 268 | image.Unref() 269 | 270 | subtitleText := target.clientSubtitle() 271 | if len(subtitleText) > 0 { 272 | subtitleContainer := gtk.NewBox(gtk.OrientationVerticalValue, 0) 273 | subtitleContainer.AddCssClass(style.TooltipSubtitleClass) 274 | subtitle := gtk.NewLabel(target.clientSubtitle()) 275 | subtitleContainer.Append(&subtitle.Widget) 276 | subtitle.Unref() 277 | container.Append(&subtitleContainer.Widget) 278 | subtitleContainer.Unref() 279 | } 280 | 281 | return true 282 | } 283 | } 284 | 285 | func memKb(pid int) (int, error) { 286 | f, err := os.Open(filepath.Join(`/proc`, strconv.Itoa(pid), `status`)) 287 | if err != nil { 288 | return 0, err 289 | } 290 | defer func() { 291 | if err := f.Close(); err != nil { 292 | log.Warn(`failed to close file`, `err`, err) 293 | } 294 | }() 295 | s := bufio.NewScanner(f) 296 | for s.Scan() { 297 | if strings.HasPrefix(s.Text(), `VmRSS:`) { 298 | parts := strings.Fields(s.Text()) 299 | if len(parts) < 3 { 300 | return 0, fmt.Errorf(`invalid VmRSS format`) 301 | } 302 | mem, err := strconv.Atoi(parts[1]) 303 | if err != nil { 304 | return 0, fmt.Errorf(`invalid VmRSS value`) 305 | } 306 | return mem, nil 307 | } 308 | } 309 | if err := s.Err(); err != nil { 310 | return 0, err 311 | } 312 | 313 | return 0, fmt.Errorf(`VmRSS not found`) 314 | } 315 | -------------------------------------------------------------------------------- /cmd/hyprpanel/main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the hyprpanel host binary 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "path/filepath" 10 | "runtime/debug" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/fsnotify/fsnotify" 15 | "github.com/hashicorp/go-hclog" 16 | "github.com/hashicorp/go-plugin" 17 | "github.com/pdf/hyprpanel/config" 18 | "github.com/pdf/hyprpanel/style" 19 | "github.com/peterbourgon/ff/v4" 20 | "github.com/peterbourgon/ff/v4/ffhelp" 21 | "google.golang.org/protobuf/encoding/protojson" 22 | ) 23 | 24 | const ( 25 | name = `hyprpanel` 26 | crashTimeout = 2 * time.Second 27 | crashRetry = 200 * time.Millisecond 28 | crashCount = 3 29 | ) 30 | 31 | func sigHandler(log hclog.Logger, h *host) { 32 | sigChan := make(chan os.Signal, 1) 33 | signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGUSR1, syscall.SIGINT) 34 | 35 | for s := range sigChan { 36 | switch s { 37 | case syscall.SIGTERM, syscall.SIGINT: 38 | log.Warn(`Quitting`, `sig`, s.String()) 39 | h.Close() 40 | default: 41 | log.Warn(`Unhandled signal`, `sig`, s.String()) 42 | } 43 | } 44 | } 45 | 46 | func main() { 47 | fs := ff.NewFlagSet(name) 48 | var configPath string 49 | xdgConfigPath := os.Getenv(`XDG_CONFIG_HOME`) 50 | if xdgConfigPath == `` { 51 | configPath = filepath.Join(os.Getenv(`HOME`), `.config`, `hyprpanel`) 52 | } else { 53 | configPath = filepath.Join(xdgConfigPath, `hyprpanel`) 54 | } 55 | configFileDefault := filepath.Join(configPath, `config.json`) 56 | configFile := fs.String('c', `config`, configFileDefault, `Path to configuration file`) 57 | styleFileDefault := filepath.Join(configPath, `style.css`) 58 | styleFile := fs.String('s', `style`, styleFileDefault, `Path to stylesheet`) 59 | version := fs.BoolLong(`version`, `Display the application version`) 60 | 61 | log := hclog.New(&hclog.LoggerOptions{ 62 | Name: `host`, 63 | Output: os.Stdout, 64 | }) 65 | 66 | if err := ff.Parse(fs, os.Args[1:], ff.WithEnvVarPrefix(`HYPRPANEL`)); err != nil { 67 | fmt.Printf("%s\n", ffhelp.Flags(fs)) 68 | if errors.Is(err, ff.ErrHelp) { 69 | os.Exit(0) 70 | } 71 | 72 | fmt.Printf("err=%v\n", err) 73 | os.Exit(1) 74 | } 75 | 76 | if *version { 77 | info, ok := debug.ReadBuildInfo() 78 | if !ok { 79 | fmt.Printf("%s unknown version", name) 80 | os.Exit(1) 81 | } 82 | fmt.Printf("%s version %s built with %s\n", name, info.Main.Version, info.GoVersion) 83 | os.Exit(0) 84 | } 85 | 86 | cfg, err := config.Load(*configFile) 87 | if err != nil { 88 | if !errors.Is(err, os.ErrNotExist) { 89 | log.Error(`Failed loading configuration file`, `file`, *configFile, `err`, err) 90 | os.Exit(1) 91 | } 92 | log.Warn(`Failed loading configuration file, creating with defaults`, `file`, *configFile) 93 | cfg, err = config.Default() 94 | if err != nil { 95 | log.Error(`Failed loading default configuration file`, `err`, err) 96 | os.Exit(1) 97 | } 98 | if err := os.MkdirAll(configPath, 0o755); err != nil && err != os.ErrExist { 99 | log.Error(`Failed creating configuration directory`, `path`, configPath, `err`, err) 100 | os.Exit(1) 101 | } 102 | 103 | marshal := protojson.MarshalOptions{ 104 | Multiline: true, 105 | Indent: "\t", 106 | EmitUnpopulated: true, 107 | UseProtoNames: true, 108 | } 109 | b, err := marshal.Marshal(cfg) 110 | if err != nil { 111 | log.Error(`Failed encoding default configuration file`, `err`, err) 112 | os.Exit(1) 113 | } 114 | 115 | if err := os.WriteFile(*configFile, b, 0o644); err != nil { 116 | log.Error(`Failed writing default configuration file`, `file`, *configFile, `err`, err) 117 | os.Exit(1) 118 | } 119 | } 120 | 121 | log.SetLevel(hclog.Level(cfg.LogLevel)) 122 | 123 | stylesheet, err := style.Load(*styleFile) 124 | if err != nil { 125 | if !errors.Is(err, os.ErrNotExist) { 126 | log.Error(`Failed loading stylesheet`, `file`, *styleFile, `err`, err) 127 | os.Exit(1) 128 | } 129 | log.Warn(`Failed loading stylesheet, continuing with defaults`, `file`, *styleFile) 130 | } 131 | 132 | h, err := newHost(cfg, stylesheet, log) 133 | if err != nil { 134 | log.Error(`Failed initializing hyprpanel`, `err`, err) 135 | os.Exit(1) 136 | } 137 | go sigHandler(log, h) 138 | 139 | watcher, err := fsnotify.NewWatcher() 140 | if err != nil { 141 | log.Error(`Failed initializaing filesystem watcher`, `err`, err) 142 | os.Exit(1) 143 | } 144 | defer func() { 145 | if err := watcher.Close(); err != nil { 146 | log.Error(`Failed closing filesystem watcher`, `err`, err) 147 | } 148 | }() 149 | 150 | go func() { 151 | for { 152 | select { 153 | case evt := <-watcher.Events: 154 | if !evt.Has(fsnotify.Write) && !evt.Has(fsnotify.Create) && !evt.Has(fsnotify.Remove) { 155 | continue 156 | } 157 | switch evt.Name { 158 | case *configFile: 159 | cfg, err := config.Load(*configFile) 160 | if os.IsNotExist(err) { 161 | cfg, err = config.Default() 162 | } 163 | if err != nil { 164 | log.Error(`Failed reloading config`, `err`, err) 165 | continue 166 | } 167 | log.SetLevel(hclog.Level(cfg.LogLevel)) 168 | h.updateConfig(cfg) 169 | case *styleFile: 170 | stylesheet, err := style.Load(*styleFile) 171 | if os.IsNotExist(err) { 172 | stylesheet = style.Default 173 | } else if err != nil { 174 | log.Error(`Failed reloading stylesheet`, `err`, err) 175 | continue 176 | } 177 | h.updateStyle(stylesheet) 178 | } 179 | case err := <-watcher.Errors: 180 | if err != nil { 181 | log.Error(`Filesystem watcher failed`, `err`, err) 182 | os.Exit(1) 183 | } 184 | } 185 | } 186 | }() 187 | 188 | configDir := filepath.Dir(*configFile) 189 | styleDir := filepath.Dir(*styleFile) 190 | if err := watcher.Add(configDir); err != nil { 191 | log.Error(`Failed adding filesystem watch path`, `path`, configDir, `err`, err) 192 | os.Exit(1) 193 | } 194 | if configDir != styleDir { 195 | if err := watcher.Add(styleDir); err != nil { 196 | log.Error(`Failed adding filesystem watch path`, `path`, styleDir, `err`, err) 197 | os.Exit(1) 198 | } 199 | } 200 | defer plugin.CleanupClients() 201 | 202 | count := 0 203 | timer := time.NewTimer(crashTimeout) 204 | for { 205 | err := h.run() 206 | if err != nil && err != errReload { 207 | count += 1 208 | log.Error(`Clients failed`, `err`, err) 209 | select { 210 | case <-timer.C: 211 | if count >= crashCount { 212 | log.Error(`Restarting too quickly, terminating`) 213 | os.Exit(1) 214 | } 215 | count = 0 216 | timer.Reset(crashTimeout) 217 | default: 218 | } 219 | time.Sleep(crashRetry) 220 | continue 221 | } else if err != nil && err == errReload { 222 | continue 223 | } 224 | 225 | os.Exit(0) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /cmd/hyprpanel/util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | ) 13 | 14 | func findClient() (string, error) { 15 | exe, err := os.Executable() 16 | if err != nil { 17 | return ``, err 18 | } 19 | exe, err = filepath.EvalSymlinks(exe) 20 | if err != nil { 21 | return ``, err 22 | } 23 | exe, err = filepath.Abs(exe) 24 | if err != nil { 25 | return ``, err 26 | } 27 | 28 | curDir := filepath.Dir(exe) 29 | clientPath := filepath.Join(curDir, clientName) 30 | if _, err := os.Stat(clientPath); err != nil { 31 | clientPath, err = exec.LookPath(clientName) 32 | if err != nil { 33 | return ``, err 34 | } 35 | } 36 | 37 | return clientPath, nil 38 | } 39 | 40 | func findLayerShell() (string, error) { 41 | var searchPaths []string 42 | switch runtime.GOARCH { 43 | case `amd64`: 44 | searchPaths = []string{`/usr/lib/x86_64-linux-gnu/`, `/usr/lib64/`} 45 | case `arm64`: 46 | searchPaths = []string{"/usr/lib/aarch64-linux-gnu/", "/usr/lib64/"} 47 | default: 48 | return ``, fmt.Errorf("unsupported architecture: %s", runtime.GOARCH) 49 | } 50 | 51 | if path, err := findLayerShellBySearchPath(searchPaths); err == nil { 52 | return path, nil 53 | } 54 | return findLayerShellByPkgconfig() 55 | } 56 | 57 | func findLayerShellBySearchPath(paths []string) (string, error) { 58 | for _, dir := range paths { 59 | path := filepath.Join(dir, layerShellLib) 60 | if _, err := os.Stat(path); err == nil { 61 | return path, nil 62 | } 63 | } 64 | 65 | return ``, errors.New(`could not find gtk4-layer-shell library`) 66 | } 67 | 68 | func findLayerShellByPkgconfig() (string, error) { 69 | cmd := exec.Command(`pkg-config`, `--libs-only-L`, layerShellPkg) 70 | var stdOut, stdErr bytes.Buffer 71 | cmd.Stdout, cmd.Stderr = &stdOut, &stdErr 72 | if err := cmd.Run(); err != nil { 73 | return ``, fmt.Errorf("pkg-config failed finding '%s' with error %s: %s", layerShellPkg, err, stdOut.String()) 74 | } 75 | 76 | outs := strings.Split(stdOut.String(), "-L") 77 | for _, v := range outs { 78 | c := strings.TrimSpace(v) 79 | if c == "" { 80 | continue 81 | } 82 | g, err := findLayerShellBySearchPath([]string{c}) 83 | if err != nil { 84 | return ``, err 85 | } 86 | if g != "" { 87 | return g, nil 88 | } 89 | } 90 | 91 | return ``, errors.New(`pkg-config search failed`) 92 | } 93 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Package config handles config loading. 2 | package config 3 | 4 | import ( 5 | _ "embed" 6 | "io" 7 | "os" 8 | 9 | configv1 "github.com/pdf/hyprpanel/proto/hyprpanel/config/v1" 10 | "google.golang.org/protobuf/encoding/protojson" 11 | ) 12 | 13 | //go:embed default.json 14 | var defaultConfig []byte 15 | 16 | // Default returns the default configuration values. 17 | func Default() (*configv1.Config, error) { 18 | c := &configv1.Config{} 19 | if err := protojson.Unmarshal(defaultConfig, c); err != nil { 20 | return nil, err 21 | } 22 | 23 | return c, nil 24 | } 25 | 26 | // Load and parse a configuration file from disk. 27 | func Load(filePath string) (*configv1.Config, error) { 28 | f, err := os.Open(filePath) 29 | if err != nil { 30 | return nil, err 31 | } 32 | defer func() { 33 | if err := f.Close(); err != nil { 34 | panic(err) 35 | } 36 | }() 37 | 38 | b, err := io.ReadAll(f) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | c := &configv1.Config{} 44 | 45 | if err := protojson.Unmarshal(b, c); err != nil { 46 | return nil, err 47 | } 48 | 49 | return c, nil 50 | } 51 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "log_level": "LOG_LEVEL_INFO", 3 | "dbus": { 4 | "enabled": true, 5 | "connect_timeout": "20s", 6 | "connect_interval": "0.200s", 7 | "notifications": { 8 | "enabled": true 9 | }, 10 | "systray": { 11 | "enabled": true 12 | }, 13 | "shortcuts": { 14 | "enabled": true 15 | }, 16 | "brightness": { 17 | "enabled": true, 18 | "adjust_step_percent": 5, 19 | "min_brightness": 1, 20 | "enable_logind": true, 21 | "hud_notifications": true 22 | }, 23 | "power": { 24 | "enabled": true, 25 | "low_percent": 10, 26 | "critical_percent": 5, 27 | "low_command": "", 28 | "critical_command": "", 29 | "hud_notifications": true 30 | } 31 | }, 32 | "audio": { 33 | "enabled": true, 34 | "volume_step_percent": 5, 35 | "volume_exceed_maximum": false, 36 | "hud_notifications": true 37 | }, 38 | "icon_overrides": [], 39 | "launch_wrapper": ["sh", "-c"], 40 | "panels": [ 41 | { 42 | "id": "panel0", 43 | "edge": "EDGE_RIGHT", 44 | "size": 64, 45 | "monitor": "", 46 | "modules": [ 47 | { 48 | "pager": { 49 | "icon_size": 12, 50 | "active_monitor_only": false, 51 | "scroll_wrap_workspaces": true, 52 | "scroll_include_inactive": true, 53 | "enable_workspace_names": false, 54 | "pinned": [ 55 | 1, 56 | 2, 57 | 3, 58 | 4, 59 | 5, 60 | 6 61 | ], 62 | "ignore_windows": [], 63 | "preview_width": 256, 64 | "follow_window_on_move": false 65 | } 66 | }, 67 | { 68 | "spacer": { 69 | "size": 16, 70 | "expand": false 71 | } 72 | }, 73 | { 74 | "taskbar": { 75 | "icon_size": 48, 76 | "active_workspace_only": true, 77 | "active_monitor_only": true, 78 | "group_tasks": true, 79 | "hide_indicators": false, 80 | "expand": true, 81 | "max_size": 0, 82 | "pinned": [], 83 | "preview_width": 256 84 | } 85 | }, 86 | { 87 | "spacer": { 88 | "size": 16, 89 | "expand": false 90 | } 91 | }, 92 | { 93 | "systray": { 94 | "icon_size": 22, 95 | "menu_icon_size": 22, 96 | "auto_hide_statuses": [ 97 | "STATUS_UNSPECIFIED", 98 | "STATUS_PASSIVE", 99 | "STATUS_ACTIVE" 100 | ], 101 | "auto_hide_delay": "4s", 102 | "pinned": [ 103 | "nm-applet", 104 | "chrome_status_icon_1" 105 | ], 106 | "modules": [ 107 | { 108 | "power": { 109 | "icon_size": 22, 110 | "icon_symbolic": true 111 | } 112 | } 113 | ] 114 | } 115 | }, 116 | { 117 | "notifications": { 118 | "icon_size": 24, 119 | "notification_icon_size": 48, 120 | "default_timeout": "7s", 121 | "position": "POSITION_TOP_RIGHT", 122 | "margin": 24, 123 | "persistent": [] 124 | } 125 | }, 126 | { 127 | "hud": { 128 | "notification_icon_size": 64, 129 | "timeout": "2s", 130 | "position": "POSITION_BOTTOM", 131 | "margin": 256 132 | } 133 | }, 134 | { 135 | "spacer": { 136 | "size": 16, 137 | "expand": false 138 | } 139 | }, 140 | { 141 | "audio": { 142 | "icon_size": 32, 143 | "icon_symbolic": true, 144 | "command_mixer": "pavucontrol", 145 | "enable_source": true 146 | } 147 | }, 148 | { 149 | "spacer": { 150 | "size": 16, 151 | "expand": false 152 | } 153 | }, 154 | { 155 | "clock": { 156 | "time_format": "15:04", 157 | "date_format": "2006-01-02", 158 | "tooltip_time_format": "15:04", 159 | "tooltip_date_format": "Mon, 02 Jan 2006 MST", 160 | "additional_regions": [ 161 | "America/Los_Angeles", 162 | "America/Chicago", 163 | "America/New_York", 164 | "Europe/London" 165 | ] 166 | } 167 | }, 168 | { 169 | "spacer": { 170 | "size": 16, 171 | "expand": false 172 | } 173 | }, 174 | { 175 | "session": { 176 | "icon_size": 48, 177 | "icon_symbolic": true, 178 | "overlay_icon_size": 96, 179 | "overlay_icon_symbolic": true, 180 | "command_logout": "loginctl terminate-session $XDG_SESSION_ID", 181 | "command_reboot": "systemctl reboot", 182 | "command_suspend": "systemctl suspend", 183 | "command_shutdown": "systemctl poweroff" 184 | } 185 | }, 186 | { 187 | "spacer": { 188 | "size": 16, 189 | "expand": false 190 | } 191 | } 192 | ] 193 | } 194 | ] 195 | } 196 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pdf/hyprpanel 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/disintegration/imaging v1.6.2 9 | github.com/fsnotify/fsnotify v1.9.0 10 | github.com/godbus/dbus/v5 v5.1.0 11 | github.com/google/uuid v1.6.0 12 | github.com/hashicorp/go-hclog v1.6.3 13 | github.com/hashicorp/go-plugin v1.6.3 14 | github.com/iancoleman/strcase v0.3.0 15 | github.com/jfreymuth/pulse v0.1.1 16 | github.com/jwijenbergh/purego v0.0.0-20241210143217-aeaa0bfe09e0 17 | github.com/jwijenbergh/puregotk v0.0.0-20250407124134-bc1a52f44fd4 18 | github.com/mattn/go-shellwords v1.0.12 19 | github.com/pdf/go-wayland v0.0.2 20 | github.com/peterbourgon/ff/v4 v4.0.0-alpha.4 21 | github.com/rkoesters/xdg v0.0.1 22 | golang.org/x/sync v0.13.0 23 | golang.org/x/sys v0.32.0 24 | google.golang.org/grpc v1.71.1 25 | google.golang.org/protobuf v1.36.6 26 | ) 27 | 28 | require ( 29 | github.com/fatih/color v1.18.0 // indirect 30 | github.com/golang/protobuf v1.5.4 // indirect 31 | github.com/google/go-cmp v0.6.0 // indirect 32 | github.com/hashicorp/yamux v0.1.2 // indirect 33 | github.com/mattn/go-colorable v0.1.14 // indirect 34 | github.com/mattn/go-isatty v0.0.20 // indirect 35 | github.com/oklog/run v1.1.0 // indirect 36 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 37 | github.com/stretchr/testify v1.8.4 // indirect 38 | golang.org/x/image v0.26.0 // indirect 39 | golang.org/x/mod v0.21.0 // indirect 40 | golang.org/x/net v0.39.0 // indirect 41 | golang.org/x/text v0.24.0 // indirect 42 | golang.org/x/tools v0.26.0 // indirect 43 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect 44 | mvdan.cc/gofumpt v0.7.0 // indirect 45 | ) 46 | 47 | tool github.com/pdf/go-wayland/cmd/go-wayland-scanner 48 | -------------------------------------------------------------------------------- /internal/applications/applications.go: -------------------------------------------------------------------------------- 1 | // Package applications provides an API for querying Desktop entries. 2 | package applications 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/fsnotify/fsnotify" 15 | "github.com/hashicorp/go-hclog" 16 | "github.com/mattn/go-shellwords" 17 | configv1 "github.com/pdf/hyprpanel/proto/hyprpanel/config/v1" 18 | hyprpanelv1 "github.com/pdf/hyprpanel/proto/hyprpanel/v1" 19 | "github.com/rkoesters/xdg/desktop" 20 | ) 21 | 22 | const ( 23 | defaultName = `Unknown` 24 | defaultIcon = `wayland` 25 | ) 26 | 27 | // ErrNotFound is returned when an application is not found. 28 | var ErrNotFound = errors.New(`application not found`) 29 | 30 | // AppCache holds an auto-updated list of Desktop application data. 31 | type AppCache struct { 32 | log hclog.Logger 33 | mu sync.RWMutex 34 | watcher *fsnotify.Watcher 35 | quitCh chan struct{} 36 | targets []string 37 | cache map[string]*hyprpanelv1.AppInfo 38 | cacheLower map[string]*hyprpanelv1.AppInfo 39 | iconOverrides map[string]string 40 | } 41 | 42 | // Find an application by class. 43 | func (a *AppCache) Find(class string) *hyprpanelv1.AppInfo { 44 | a.mu.RLock() 45 | defer a.mu.RUnlock() 46 | if app, ok := a.cache[class]; ok { 47 | return app 48 | } else if app, ok := a.cacheLower[strings.ToLower(class)]; ok { 49 | return app 50 | } else if idx := strings.Index(class, `-`); idx > 0 { 51 | class = class[:idx] 52 | if app, ok := a.cache[class]; ok { 53 | return app 54 | } 55 | if app, ok := a.cacheLower[strings.ToLower(class)]; ok { 56 | return app 57 | } 58 | } 59 | 60 | return &hyprpanelv1.AppInfo{ 61 | Name: defaultName, 62 | Icon: defaultIcon, 63 | } 64 | } 65 | 66 | // Refresh the cache. 67 | func (a *AppCache) Refresh() error { 68 | a.mu.Lock() 69 | defer a.mu.Unlock() 70 | 71 | for _, target := range a.targets { 72 | stat, err := os.Stat(target) 73 | if err != nil || !stat.IsDir() { 74 | continue 75 | } 76 | 77 | err = filepath.WalkDir(target, a.cacheWalk) 78 | if err != nil { 79 | return err 80 | } 81 | } 82 | 83 | return nil 84 | } 85 | 86 | // Close this instance. 87 | func (a *AppCache) Close() error { 88 | close(a.quitCh) 89 | return a.watcher.Close() 90 | } 91 | 92 | func (a *AppCache) cacheWalk(path string, d fs.DirEntry, err error) error { 93 | if err != nil { 94 | return err 95 | } 96 | 97 | if d.IsDir() { 98 | return nil 99 | } 100 | 101 | if !strings.HasSuffix(path, `.desktop`) { 102 | return nil 103 | } 104 | 105 | p, err := filepath.Abs(path) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | app, err := newAppInfo(p, a.log) 111 | if err != nil { 112 | a.log.Error(`Failed parsing desktop file`, `file`, p, `err`, err) 113 | return nil 114 | } 115 | 116 | name := strings.TrimSuffix(d.Name(), `.desktop`) 117 | // Match app_id with missing prefix 118 | if idx := strings.LastIndex(name, `.`); idx > 0 { 119 | dotPrefixed := name[idx+1:] 120 | a.cache[dotPrefixed] = app 121 | a.cacheLower[strings.ToLower(dotPrefixed)] = app 122 | if ico, ok := a.iconOverrides[strings.ToLower(dotPrefixed)]; ok { 123 | app.Icon = ico 124 | } 125 | } 126 | // Match app_id for mismatched .desktop and WmClass 127 | if app.StartupWmClass != `` { 128 | a.cache[app.StartupWmClass] = app 129 | a.cacheLower[strings.ToLower(app.StartupWmClass)] = app 130 | if ico, ok := a.iconOverrides[strings.ToLower(app.StartupWmClass)]; ok { 131 | app.Icon = ico 132 | } 133 | } 134 | // Last ditch, by Name for apps with missing app_id 135 | if app.Name != `` { 136 | a.cache[app.Name] = app 137 | a.cacheLower[strings.ToLower(app.Name)] = app 138 | if ico, ok := a.iconOverrides[strings.ToLower(app.Name)]; ok { 139 | app.Icon = ico 140 | } 141 | } 142 | // Standard match app_id by .desktop name 143 | a.cache[name] = app 144 | a.cacheLower[strings.ToLower(name)] = app 145 | if ico, ok := a.iconOverrides[strings.ToLower(name)]; ok { 146 | app.Icon = ico 147 | } 148 | 149 | return nil 150 | } 151 | 152 | func (a *AppCache) watch() { 153 | for _, target := range a.targets { 154 | if err := a.watcher.Add(target); err != nil { 155 | a.log.Warn(`Failed adding application watcher`, `target`, target, `err`, err) 156 | } 157 | } 158 | debounce := time.NewTimer(200 * time.Millisecond) 159 | if !debounce.Stop() { 160 | select { 161 | case <-debounce.C: 162 | default: 163 | } 164 | } 165 | defer debounce.Stop() 166 | 167 | for { 168 | select { 169 | case <-a.quitCh: 170 | return 171 | default: 172 | select { 173 | case <-a.quitCh: 174 | return 175 | case <-a.watcher.Events: 176 | if !debounce.Stop() { 177 | select { 178 | case <-debounce.C: 179 | default: 180 | } 181 | } 182 | debounce.Reset(200 * time.Millisecond) 183 | case <-debounce.C: 184 | if err := a.Refresh(); err != nil { 185 | a.log.Warn(`Failed refreshing AppCache`, `err`, err) 186 | } 187 | } 188 | } 189 | } 190 | } 191 | 192 | func newAppInfo(file string, log hclog.Logger) (*hyprpanelv1.AppInfo, error) { 193 | r, err := os.Open(file) 194 | if err != nil { 195 | return nil, err 196 | } 197 | defer func() { 198 | if err := r.Close(); err != nil { 199 | log.Warn(`failed to close file`, `file`, file, `err`, err) 200 | } 201 | }() 202 | 203 | a := &hyprpanelv1.AppInfo{ 204 | DesktopFile: file, 205 | } 206 | 207 | entry, err := desktop.New(r) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | exec, err := parseExec(entry.Exec, entry, file) 213 | if err != nil { 214 | log.Warn(`Failed parsing Exec field`, `file`, file, `err`, err) 215 | } 216 | a.Name = entry.Name 217 | a.Icon = entry.Icon 218 | a.TryExec = entry.TryExec 219 | a.Exec = exec 220 | a.RawExec = entry.Exec 221 | a.Path = entry.Path 222 | a.StartupWmClass = entry.StartupWMClass 223 | a.Terminal = entry.Terminal 224 | if len(entry.Actions) > 0 { 225 | a.Actions = make([]*hyprpanelv1.AppInfo_Action, len(entry.Actions)) 226 | for i, action := range entry.Actions { 227 | exec, err := parseExec(action.Exec, entry, file) 228 | if err != nil { 229 | log.Warn(`Failed parsing Action Exec field`, `file`, file, `action`, action.Name, `err`, err) 230 | } 231 | a.Actions[i] = &hyprpanelv1.AppInfo_Action{ 232 | Name: action.Name, 233 | Icon: action.Icon, 234 | Exec: exec, 235 | RawExec: action.Exec, 236 | } 237 | } 238 | } 239 | 240 | if a.Name == `` { 241 | a.Name = defaultName 242 | } 243 | if a.Icon == `` { 244 | a.Icon = defaultIcon 245 | } 246 | 247 | return a, nil 248 | } 249 | 250 | // New instantiate a new AppCache. 251 | func New(log hclog.Logger, iconOverrides []*configv1.IconOverride) (*AppCache, error) { 252 | overrides := make(map[string]string, len(iconOverrides)) 253 | for _, v := range iconOverrides { 254 | overrides[strings.ToLower(v.WindowClass)] = v.Icon 255 | } 256 | a := &AppCache{ 257 | log: log, 258 | cache: make(map[string]*hyprpanelv1.AppInfo), 259 | cacheLower: make(map[string]*hyprpanelv1.AppInfo), 260 | quitCh: make(chan struct{}), 261 | iconOverrides: overrides, 262 | } 263 | 264 | var err error 265 | a.watcher, err = fsnotify.NewWatcher() 266 | if err != nil { 267 | return nil, err 268 | } 269 | 270 | dedup := make(map[string]struct{}) 271 | a.targets = make([]string, 0) 272 | xdgDataDirs := os.Getenv(`XDG_DATA_DIRS`) 273 | xdgDataHome := os.Getenv(`XDG_DATA_HOME`) 274 | home := os.Getenv(`HOME`) 275 | if xdgDataDirs != `` { 276 | for _, dir := range strings.Split(xdgDataDirs, `:`) { 277 | target := filepath.Join(dir, `applications`) 278 | dedup[target] = struct{}{} 279 | a.targets = append(a.targets, target) 280 | } 281 | } else { 282 | usr := `/usr/share/applications` 283 | usrLocal := `/usr/local/share/applications` 284 | dedup[usr] = struct{}{} 285 | a.targets = append(a.targets, usr) 286 | dedup[usrLocal] = struct{}{} 287 | a.targets = append(a.targets, usrLocal) 288 | } 289 | varFlatpak := `/var/lib/flatpak/exports/share/applications` 290 | if _, found := dedup[varFlatpak]; !found { 291 | dedup[varFlatpak] = struct{}{} 292 | a.targets = append(a.targets, varFlatpak) 293 | } 294 | var homeApps, homeFlatpak string 295 | if xdgDataHome != `` { 296 | homeApps = filepath.Join(xdgDataHome, `applications`) 297 | homeFlatpak = filepath.Join(xdgDataHome, `flatpak`, `exports`, `share`, `applications`) 298 | } else if home != `` { 299 | homeApps = filepath.Join(home, `.local`, `share`, `applications`) 300 | homeFlatpak = filepath.Join(home, `.local`, `share`, `flatpak`, `exports`, `share`, `applications`) 301 | } 302 | if _, found := dedup[homeApps]; !found { 303 | dedup[homeApps] = struct{}{} 304 | a.targets = append(a.targets, homeApps) 305 | } 306 | if _, found := dedup[homeFlatpak]; !found { 307 | dedup[homeFlatpak] = struct{}{} 308 | a.targets = append(a.targets, homeFlatpak) 309 | } 310 | 311 | go func() { 312 | if err := a.Refresh(); err != nil { 313 | log.Error(`Failed walking application directories`, `err`, err) 314 | } 315 | }() 316 | go a.watch() 317 | 318 | return a, nil 319 | } 320 | 321 | func parseExec(exec string, entry *desktop.Entry, desktopFile string) ([]string, error) { 322 | if len(exec) == 0 { 323 | return nil, fmt.Errorf(`empty Exec field`) 324 | } 325 | p := shellwords.NewParser() 326 | p.ParseEnv = true 327 | p.ParseBacktick = true 328 | words, err := p.Parse(exec) 329 | if err != nil { 330 | return nil, err 331 | } 332 | 333 | command := make([]string, 0, len(words)) 334 | for _, word := range words { 335 | switch word { 336 | case ``: 337 | continue 338 | case `%f`, `%F`, `%u`, `%U`, `%d`, `%D`, `%n`, `%N`, `%v`, `%m`: 339 | continue 340 | case `%i`: 341 | if len(entry.Icon) == 0 { 342 | continue 343 | } 344 | command = append(command, `--icon`, entry.Icon) 345 | case `%c`: 346 | if len(entry.Name) == 0 { 347 | continue 348 | } 349 | command = append(command, entry.Name) 350 | case `%k`: 351 | if len(desktopFile) == 0 { 352 | continue 353 | } 354 | command = append(command, desktopFile) 355 | default: 356 | command = append(command, word) 357 | } 358 | } 359 | 360 | return command, nil 361 | } 362 | -------------------------------------------------------------------------------- /internal/dbus/brightness.go: -------------------------------------------------------------------------------- 1 | package dbus 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "math" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/godbus/dbus/v5" 14 | "github.com/hashicorp/go-hclog" 15 | configv1 "github.com/pdf/hyprpanel/proto/hyprpanel/config/v1" 16 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 17 | "google.golang.org/protobuf/types/known/anypb" 18 | ) 19 | 20 | const ( 21 | brightnessHudID = `brightness` 22 | 23 | brightnessSysfsDevice = `device` 24 | 25 | brightnessBase = `/sys/class/backlight` 26 | brightnessNode = `brightness` 27 | brightnessMaxNode = `max_brightness` 28 | ) 29 | 30 | type brightness struct { 31 | sync.RWMutex 32 | conn *dbus.Conn 33 | log hclog.Logger 34 | cfg *configv1.Config_DBUS_Brightness 35 | 36 | cacheBrightness map[string]*eventv1.BrightnessChangeValue 37 | 38 | eventCh chan *eventv1.Event 39 | signals chan *dbus.Signal 40 | readyCh chan struct{} 41 | quitCh chan struct{} 42 | } 43 | 44 | func (b *brightness) Adjust(devName string, direction eventv1.Direction) error { 45 | if devName != `` { 46 | return b.adjust(filepath.Join(brightnessBase, devName), direction) 47 | } 48 | 49 | targets, err := os.ReadDir(brightnessBase) 50 | if err != nil { 51 | return err 52 | } 53 | for _, target := range targets { 54 | path := filepath.Join(brightnessBase, target.Name()) 55 | if target.Type()&fs.ModeSymlink == fs.ModeSymlink { 56 | path, err = os.Readlink(filepath.Join(brightnessBase, target.Name())) 57 | if err != nil { 58 | return err 59 | } 60 | path = filepath.Join(brightnessBase, path) 61 | } else if !target.IsDir() { 62 | continue 63 | } 64 | 65 | if err := b.adjust(path, direction); err != nil { 66 | return err 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (b *brightness) adjust(path string, direction eventv1.Direction) error { 74 | maxB, err := os.ReadFile(filepath.Join(path, brightnessMaxNode)) 75 | if err != nil { 76 | return err 77 | } 78 | curB, err := os.ReadFile(filepath.Join(path, brightnessNode)) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | max, err := strconv.Atoi(strings.TrimSuffix(string(maxB), "\n")) 84 | if err != nil { 85 | return err 86 | } 87 | cur, err := strconv.Atoi(strings.TrimSuffix(string(curB), "\n")) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | if direction == eventv1.Direction_DIRECTION_UP { 93 | if cur >= max { 94 | return nil 95 | } 96 | cur += max / 100 * int(b.cfg.AdjustStepPercent) 97 | if cur > max { 98 | cur = max 99 | } 100 | } else { 101 | if cur <= int(b.cfg.MinBrightness) { 102 | return nil 103 | } 104 | cur -= max / 100 * int(b.cfg.AdjustStepPercent) 105 | if cur < int(b.cfg.MinBrightness) { 106 | cur = int(b.cfg.MinBrightness) 107 | } 108 | } 109 | 110 | if !b.cfg.EnableLogind { 111 | return os.WriteFile(filepath.Join(path, brightnessNode), []byte(strconv.Itoa(cur)+"\n"), 0664) 112 | } 113 | 114 | obj := b.conn.Object(fdoLogindName, fdoLogindSessionPath) 115 | return obj.Call(fdoLogindSessionMethodSetBrightness, 0, `backlight`, filepath.Base(path), uint32(cur)).Err 116 | } 117 | 118 | func (b *brightness) pollBrightness(path string) error { 119 | maxB, err := os.ReadFile(filepath.Join(path, brightnessMaxNode)) 120 | if err != nil { 121 | return err 122 | } 123 | curB, err := os.ReadFile(filepath.Join(path, brightnessNode)) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | max, err := strconv.Atoi(strings.TrimSuffix(string(maxB), "\n")) 129 | if err != nil { 130 | return err 131 | } 132 | cur, err := strconv.Atoi(strings.TrimSuffix(string(curB), "\n")) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | dev, err := os.Readlink(filepath.Join(path, brightnessSysfsDevice)) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | brightnessValue := &eventv1.BrightnessChangeValue{ 143 | Id: filepath.Base(path), 144 | Name: filepath.Base(dev), 145 | Brightness: int32(cur), 146 | BrightnessMax: int32(max), 147 | } 148 | 149 | if v, ok := b.cacheBrightness[brightnessValue.Id]; ok { 150 | if v.Brightness == brightnessValue.Brightness && v.BrightnessMax == brightnessValue.Brightness { 151 | return nil 152 | } 153 | } 154 | 155 | b.cacheBrightness[brightnessValue.Id] = brightnessValue 156 | 157 | brightnessData, err := anypb.New(brightnessValue) 158 | if err != nil { 159 | return fmt.Errorf(`failed encodiung event data for brightness dev (%s): %w`, dev, err) 160 | } 161 | 162 | b.eventCh <- &eventv1.Event{ 163 | Kind: eventv1.EventKind_EVENT_KIND_DBUS_BRIGHTNESS_CHANGE, 164 | Data: brightnessData, 165 | } 166 | 167 | if !b.cfg.HudNotifications { 168 | return nil 169 | } 170 | 171 | select { 172 | case <-b.readyCh: 173 | default: 174 | return nil 175 | } 176 | 177 | percent := math.Round(float64(brightnessValue.Brightness)/float64(brightnessValue.BrightnessMax)*100) / 100 178 | icon := `display-brightness-off` 179 | switch { 180 | case percent >= 1: 181 | icon = `display-brightness-high` 182 | case percent >= 0.5: 183 | icon = `display-brightness-medium` 184 | case cur > int(b.cfg.MinBrightness): 185 | icon = `display-brightness-low` 186 | } 187 | 188 | hudValue := &eventv1.HudNotificationValue{ 189 | Id: brightnessHudID, 190 | Icon: icon, 191 | IconSymbolic: true, 192 | Title: brightnessValue.Id, 193 | Body: brightnessValue.Name, 194 | Percent: percent, 195 | } 196 | 197 | hudData, err := anypb.New(hudValue) 198 | if err != nil { 199 | return err 200 | } 201 | 202 | b.eventCh <- &eventv1.Event{ 203 | Kind: eventv1.EventKind_EVENT_KIND_HUD_NOTIFY, 204 | Data: hudData, 205 | } 206 | 207 | return nil 208 | } 209 | 210 | func (b *brightness) init() error { 211 | targets, err := os.ReadDir(brightnessBase) 212 | if err != nil { 213 | return err 214 | } 215 | 216 | for _, target := range targets { 217 | if target.Type()&fs.ModeSymlink != fs.ModeSymlink { 218 | continue 219 | } 220 | path, err := os.Readlink(filepath.Join(brightnessBase, target.Name())) 221 | if err != nil { 222 | return err 223 | } 224 | path = filepath.Join(brightnessBase, path) 225 | if err := b.pollBrightness(path); err != nil { 226 | b.log.Error(`Failed retrieving brightness`, `err`, err) 227 | } 228 | 229 | objectPath, err := systemdUnitToObjectPath(path + `.device`) 230 | if err != nil { 231 | b.log.Warn(`Failed encoding brightness unit name`, `path`, path, `err`, err) 232 | } 233 | 234 | busObj := b.conn.Object(fdoSystemdName, fdoSystemdUnitPath+objectPath) 235 | if busObj.AddMatchSignal(fdoPropertiesName, fdoPropertiesMemberPropertiesChanged).Err != nil { 236 | return err 237 | } 238 | } 239 | 240 | close(b.readyCh) 241 | 242 | go b.watch() 243 | 244 | return nil 245 | } 246 | 247 | func (b *brightness) watch() { 248 | for { 249 | select { 250 | case <-b.quitCh: 251 | return 252 | default: 253 | select { 254 | case <-b.quitCh: 255 | return 256 | case sig, ok := <-b.signals: 257 | if !ok { 258 | return 259 | } 260 | switch sig.Name { 261 | case fdoPropertiesSignalPropertiesChanged: 262 | if len(sig.Body) != 3 { 263 | b.log.Warn(`Failed parsing DBUS PropertiesChanged body`, `body`, sig.Body) 264 | continue 265 | } 266 | 267 | kind, ok := sig.Body[0].(string) 268 | if !ok { 269 | b.log.Warn(`Failed asserting DBUS PropertiesChanged body kind`, `kind`, sig.Body[0]) 270 | continue 271 | } 272 | if kind != fdoSystemdDeviceName { 273 | continue 274 | } 275 | 276 | properties, ok := sig.Body[1].(map[string]dbus.Variant) 277 | if !ok { 278 | b.log.Warn(`Failed asserting DBUS PropertiesChanged body properties`, `properties`, sig.Body[1]) 279 | continue 280 | } 281 | if len(properties) == 0 { 282 | continue 283 | } 284 | 285 | pathVar, ok := properties[`SysFSPath`] 286 | if !ok { 287 | continue 288 | } 289 | var path string 290 | if err := pathVar.Store(&path); err != nil { 291 | b.log.Warn(`Failed parsing SysFSPath`, `pathVar`, pathVar, `err`, err) 292 | continue 293 | } 294 | if path == `` { 295 | continue 296 | } 297 | if err := b.pollBrightness(path); err != nil { 298 | b.log.Warn(`Failed polling brightness`, `path`, path, `err`, err) 299 | continue 300 | } 301 | } 302 | } 303 | } 304 | } 305 | } 306 | 307 | func newBrightness(conn *dbus.Conn, logger hclog.Logger, eventCh chan *eventv1.Event, cfg *configv1.Config_DBUS_Brightness) (*brightness, error) { 308 | s := &brightness{ 309 | conn: conn, 310 | log: logger, 311 | cfg: cfg, 312 | cacheBrightness: make(map[string]*eventv1.BrightnessChangeValue), 313 | eventCh: eventCh, 314 | signals: make(chan *dbus.Signal), 315 | readyCh: make(chan struct{}), 316 | quitCh: make(chan struct{}), 317 | } 318 | 319 | s.conn.Signal(s.signals) 320 | 321 | if err := s.init(); err != nil { 322 | return nil, err 323 | } 324 | 325 | return s, nil 326 | } 327 | -------------------------------------------------------------------------------- /internal/dbus/dbus.go: -------------------------------------------------------------------------------- 1 | // Package dbus provides access to DBUS APIs. 2 | package dbus 3 | 4 | import ( 5 | "context" 6 | "embed" 7 | "errors" 8 | "time" 9 | 10 | "github.com/godbus/dbus/v5" 11 | "github.com/hashicorp/go-hclog" 12 | configv1 "github.com/pdf/hyprpanel/proto/hyprpanel/config/v1" 13 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 14 | hyprpanelv1 "github.com/pdf/hyprpanel/proto/hyprpanel/v1" 15 | ) 16 | 17 | //go:embed interfaces 18 | var ifaces embed.FS 19 | 20 | var ( 21 | errDbusNotSupported = dbus.NewError(`org.freedesktop.DBus.Error.NotSupported`, nil) 22 | errUnsupported = errors.New(`unsupported`) 23 | ) 24 | 25 | // Systray DBUS API, may return nil if Systray is disabled. 26 | type Systray interface { 27 | Activate(busName string, x, y int32) error 28 | SecondaryActivate(busName string, x, y int32) error 29 | Scroll(busName string, delta int32, orientation hyprpanelv1.SystrayScrollOrientation) error 30 | MenuContextActivate(busName string, x, y int32) error 31 | MenuAboutToShow(busName string, menuItemID string) error 32 | MenuEvent(busName string, id int32, eventID hyprpanelv1.SystrayMenuEvent, data any, timestamp time.Time) error 33 | } 34 | 35 | // Notification DBUS API, may return nil if Notifications are disabled. 36 | type Notification interface { 37 | Closed(id uint32, reason hyprpanelv1.NotificationClosedReason) error 38 | Action(id uint32, actionKey string) error 39 | } 40 | 41 | // Brightness DBUS API, may return nil if Brightness is disabled. 42 | type Brightness interface { 43 | Adjust(devName string, direction eventv1.Direction) error 44 | } 45 | 46 | // Client for DBUS. 47 | type Client struct { 48 | cfg *configv1.Config_DBUS 49 | log hclog.Logger 50 | sessionConn *dbus.Conn 51 | systemConn *dbus.Conn 52 | eventCh chan *eventv1.Event 53 | quitCh chan struct{} 54 | snw *statusNotifierWatcher 55 | globalShortcuts *globalShortcuts 56 | notifications *notifications 57 | brightness *brightness 58 | power *power 59 | } 60 | 61 | // Systray API. 62 | func (c *Client) Systray() Systray { 63 | return c.snw 64 | } 65 | 66 | // Notification API. 67 | func (c *Client) Notification() Notification { 68 | return c.notifications 69 | } 70 | 71 | // Brightness API. 72 | func (c *Client) Brightness() Brightness { 73 | return c.brightness 74 | } 75 | 76 | // Events channel will deliver events from DBUS. 77 | func (c *Client) Events() <-chan *eventv1.Event { 78 | return c.eventCh 79 | } 80 | 81 | // Close the client. 82 | func (c *Client) Close() error { 83 | close(c.quitCh) 84 | if c.snw != nil { 85 | if err := c.snw.close(); err != nil { 86 | c.log.Warn(`Failed closing SNW session`, `err`, err) 87 | } 88 | } 89 | if c.notifications != nil { 90 | if err := c.notifications.close(); err != nil { 91 | c.log.Warn(`Failed closing Notifications session`, `err`, err) 92 | } 93 | } 94 | if c.globalShortcuts != nil { 95 | if err := c.globalShortcuts.close(); err != nil { 96 | c.log.Warn(`Failed closing GlobalShortcuts session`, `err`, err) 97 | } 98 | } 99 | return c.sessionConn.Close() 100 | } 101 | 102 | func (c *Client) init() error { 103 | return nil 104 | } 105 | 106 | // New instantiates a new DBUS client. 107 | func New(cfg *configv1.Config_DBUS, logger hclog.Logger) (*Client, <-chan *eventv1.Event, error) { 108 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(cfg.ConnectTimeout.AsDuration())) 109 | defer cancel() 110 | sessionConn, err := connectDbusSession(ctx, cfg.ConnectInterval.AsDuration()) 111 | if err != nil { 112 | return nil, nil, err 113 | } 114 | systemConn, err := connectDbusSystem(ctx, cfg.ConnectInterval.AsDuration()) 115 | if err != nil { 116 | return nil, nil, err 117 | } 118 | 119 | c := &Client{ 120 | cfg: cfg, 121 | log: logger, 122 | sessionConn: sessionConn, 123 | systemConn: systemConn, 124 | eventCh: make(chan *eventv1.Event, 10), 125 | quitCh: make(chan struct{}), 126 | } 127 | 128 | if cfg.Notifications.Enabled { 129 | if c.notifications, err = newNotifications(sessionConn, logger, c.eventCh); err != nil { 130 | return nil, nil, err 131 | } 132 | } 133 | 134 | if cfg.Systray.Enabled { 135 | if c.snw, err = newStatusNotifierWatcher(sessionConn, logger, c.eventCh); err != nil { 136 | return nil, nil, err 137 | } 138 | } 139 | 140 | if cfg.Shortcuts.Enabled { 141 | if c.globalShortcuts, err = newGlobalShortcuts(sessionConn, logger, c.eventCh); err != nil { 142 | return nil, nil, err 143 | } 144 | } 145 | 146 | if cfg.Brightness.Enabled { 147 | if c.brightness, err = newBrightness(systemConn, logger, c.eventCh, cfg.Brightness); err != nil { 148 | return nil, nil, err 149 | } 150 | } 151 | 152 | if cfg.Power.Enabled { 153 | if c.power, err = newPower(systemConn, logger, c.eventCh, cfg.Power); err != nil { 154 | return nil, nil, err 155 | } 156 | } 157 | 158 | if err := c.init(); err != nil { 159 | return nil, nil, err 160 | } 161 | 162 | return c, c.eventCh, nil 163 | } 164 | 165 | func connectDbusSession(ctx context.Context, connectInterval time.Duration) (*dbus.Conn, error) { 166 | conn, err := dbus.SessionBus() 167 | if err == nil { 168 | return conn, nil 169 | } 170 | 171 | ticker := time.NewTicker(connectInterval) 172 | for { 173 | select { 174 | case <-ctx.Done(): 175 | return nil, ctx.Err() 176 | default: 177 | select { 178 | case <-ctx.Done(): 179 | return nil, ctx.Err() 180 | case <-ticker.C: 181 | conn, err = dbus.SessionBus() 182 | if err == nil { 183 | return conn, nil 184 | } 185 | } 186 | } 187 | } 188 | } 189 | 190 | func connectDbusSystem(ctx context.Context, connectInterval time.Duration) (*dbus.Conn, error) { 191 | conn, err := dbus.SystemBus() 192 | if err == nil { 193 | return conn, nil 194 | } 195 | 196 | ticker := time.NewTicker(connectInterval) 197 | for { 198 | select { 199 | case <-ctx.Done(): 200 | return nil, ctx.Err() 201 | default: 202 | select { 203 | case <-ctx.Done(): 204 | return nil, ctx.Err() 205 | case <-ticker.C: 206 | conn, err = dbus.SystemBus() 207 | if err == nil { 208 | return conn, nil 209 | } 210 | } 211 | } 212 | } 213 | } 214 | 215 | func isValidObjectPathChar(c rune) bool { 216 | return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || 217 | (c >= 'a' && c <= 'z') || c == '_' || c == '/' 218 | } 219 | -------------------------------------------------------------------------------- /internal/dbus/fdo.go: -------------------------------------------------------------------------------- 1 | package dbus 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/godbus/dbus/v5" 8 | ) 9 | 10 | const ( 11 | fdoName = `org.freedesktop.DBus` 12 | fdoPath = dbus.ObjectPath(`/org/freedesktop/DBus`) 13 | fdoSignalNameOwnerChanged = fdoName + `.NameOwnerChanged` 14 | fdoIntrospectableName = fdoName + `.Introspectable` 15 | 16 | fdoPropertiesName = fdoName + `.Properties` 17 | fdoPropertiesMethodGetAll = fdoPropertiesName + `.GetAll` 18 | fdoPropertiesMemberPropertiesChanged = `PropertiesChanged` 19 | fdoPropertiesSignalPropertiesChanged = fdoPropertiesName + `.` + fdoPropertiesMemberPropertiesChanged 20 | 21 | fdoLogindName = `org.freedesktop.login1` 22 | fdoLogindSessionName = fdoLogindName + `.Session` 23 | fdoLogindSessionPath = `/org/freedesktop/login1/session/auto` 24 | fdoLogindSessionMethodSetBrightness = fdoLogindSessionName + `.SetBrightness` 25 | 26 | fdoSystemdName = `org.freedesktop.systemd1` 27 | fdoSystemdUnitPath = `/org/freedesktop/systemd1/unit` 28 | fdoSystemdDeviceName = `org.freedesktop.systemd1.Device` 29 | 30 | fdoUPowerName = `org.freedesktop.UPower` 31 | fdoUPowerPath = `/org/freedesktop/UPower` 32 | fdoUPowerMethodGetDisplayDevice = fdoUPowerName + `.GetDisplayDevice` 33 | 34 | fdoUPowerDeviceName = fdoUPowerName + `.Device` 35 | fdoUPowerDevicePropertyVendor = `Vendor` 36 | fdoUPowerDevicePropertyModel = `Model` 37 | fdoUPowerDevicePropertyType = `Type` 38 | fdoUPowerDevicePropertyPowerSupply = `PowerSupply` 39 | fdoUPowerDevicePropertyOnline = `Online` 40 | fdoUPowerDevicePropertyTimeToEmpty = `TimeToEmpty` 41 | fdoUPowerDevicePropertyTimeToFull = `TimeToFull` 42 | fdoUPowerDevicePropertyPercentage = `Percentage` 43 | fdoUPowerDevicePropertyIsPresent = `IsPresent` 44 | fdoUPowerDevicePropertyState = `State` 45 | fdoUPowerDevicePropertyIconName = `IconName` 46 | fdoUPowerDevicePropertyEnergy = `Energy` 47 | fdoUPowerDevicePropertyEnergyEmpty = `EnergyEmpty` 48 | fdoUPowerDevicePropertyEnergyFull = `EnergyFull` 49 | ) 50 | 51 | func systemdUnitToObjectPath(unitName string) (dbus.ObjectPath, error) { 52 | unitName = strings.ReplaceAll(unitName, `-`, `\x2d`) 53 | 54 | var result strings.Builder 55 | for _, r := range unitName { 56 | if !isValidObjectPathChar(r) { 57 | if _, err := result.WriteString(fmt.Sprintf("%d", r)); err != nil { 58 | return ``, err 59 | } 60 | continue 61 | } 62 | 63 | if _, err := result.WriteRune(r); err != nil { 64 | return ``, err 65 | } 66 | } 67 | 68 | return dbus.ObjectPath(result.String()), nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/dbus/interfaces/org.freedesktop.Notifications.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /internal/dbus/interfaces/org.freedesktop.portal.GlobalShortcuts.xml: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 22 | 44 | 45 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | -------------------------------------------------------------------------------- /internal/dbus/interfaces/org.kde.StatusNotifierItem.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /internal/dbus/interfaces/org.kde.StatusNotifierWatcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /internal/dbus/menu.go: -------------------------------------------------------------------------------- 1 | package dbus 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/godbus/dbus/v5" 7 | "github.com/hashicorp/go-hclog" 8 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 9 | ) 10 | 11 | type menuProp string 12 | 13 | const ( 14 | sniMenuName = `com.canonical.dbusmenu` 15 | sniMenuPath = dbus.ObjectPath(`/MenuBar`) 16 | 17 | sniMenuMethodAboutToShow = sniMenuName + `.AboutToShow` 18 | sniMenuMethodGetLayout = sniMenuName + `.GetLayout` 19 | sniMenuMethodEvent = sniMenuName + `.Event` 20 | 21 | sniMenuMemberItemPropertiesUpdated = `ItemsPropertiesUpdated` 22 | sniMenuMemberLayoutUpdated = `LayoutUpdated` 23 | 24 | sniMenuSignalItemsPropertiesUpdated = sniMenuName + `.` + sniMenuMemberItemPropertiesUpdated 25 | sniMenuSignalLayoutUpdated = sniMenuName + `.` + sniMenuMemberLayoutUpdated 26 | 27 | menuType menuProp = `type` 28 | menuLabel menuProp = `label` 29 | menuEnabled menuProp = `enabled` 30 | menuVisible menuProp = `visible` 31 | menuIconName menuProp = `icon-name` 32 | menuIconData menuProp = `icon-data` 33 | menuShortcut menuProp = `shortcut` 34 | menuToggleType menuProp = `toggle-type` 35 | menuToggleState menuProp = `toggle-state` 36 | menuChildrenDisplay menuProp = `children-display` 37 | menuAccessibleDesc menuProp = `accessible-desc` 38 | ) 39 | 40 | type sniMenu struct { 41 | ID int32 42 | Properties map[menuProp]dbus.Variant 43 | Children []*sniMenu 44 | busName string 45 | log hclog.Logger 46 | } 47 | 48 | func (m *sniMenu) Parse() (*eventv1.StatusNotifierValue_Menu, error) { 49 | menu := &eventv1.StatusNotifierValue_Menu{ 50 | Id: m.ID, 51 | Properties: &eventv1.StatusNotifierValue_Menu_Properties{}, 52 | Children: make([]*eventv1.StatusNotifierValue_Menu, len(m.Children)), 53 | } 54 | 55 | for k, v := range m.Properties { 56 | switch k { 57 | case menuType: 58 | var val string 59 | if err := v.Store(&val); err != nil { 60 | return nil, err 61 | } 62 | 63 | switch val { 64 | case `standard`: 65 | case `separator`: 66 | menu.Properties.IsSeparator = true 67 | default: 68 | m.log.Warn(`Unhandled menu type`, `busName`, m.busName, `menuID`, m.ID, `menuType`, val) 69 | } 70 | case menuLabel: 71 | var val string 72 | if err := v.Store(&val); err != nil { 73 | return nil, err 74 | } 75 | // TDOD: Handle menu accelerators 76 | menu.Properties.Label = val 77 | case menuEnabled: 78 | var val bool 79 | if err := v.Store(&val); err != nil { 80 | return nil, err 81 | } 82 | menu.Properties.IsDisabled = !val 83 | case menuVisible: 84 | var val bool 85 | if err := v.Store(&val); err != nil { 86 | return nil, err 87 | } 88 | menu.Properties.IsHidden = !val 89 | case menuIconName: 90 | var val string 91 | if err := v.Store(&val); err != nil { 92 | return nil, err 93 | } 94 | menu.Properties.IconName = val 95 | case menuIconData: 96 | var val []byte 97 | if err := v.Store(&val); err != nil { 98 | return nil, err 99 | } 100 | menu.Properties.IconData = val 101 | case menuToggleType: 102 | var val string 103 | if err := v.Store(&val); err != nil { 104 | return nil, err 105 | } 106 | switch val { 107 | case `checkmark`: 108 | menu.Properties.IsCheckbox = true 109 | case `radio`: 110 | menu.Properties.IsRadio = true 111 | case ``: 112 | continue 113 | default: 114 | m.log.Warn(`Unknown menu toggle type, defaulting to checkbox`, `busName`, m.busName, `menuID`, m.ID, `toggleType`, val) 115 | menu.Properties.IsCheckbox = true 116 | } 117 | case menuToggleState: 118 | var val int32 119 | if err := v.Store(&val); err != nil { 120 | return nil, err 121 | } 122 | menu.Properties.ToggleState = val 123 | case menuShortcut: 124 | // TODO: Handle shortcuts, maybe 125 | case menuAccessibleDesc: 126 | var val string 127 | if err := v.Store(&val); err != nil { 128 | return nil, err 129 | } 130 | case menuChildrenDisplay: 131 | var val string 132 | if err := v.Store(&val); err != nil { 133 | return nil, err 134 | } 135 | if val != `submenu` { 136 | return nil, fmt.Errorf("unknown menu children-display: '%s'", val) 137 | } 138 | menu.Properties.IsParent = true 139 | default: 140 | return nil, fmt.Errorf("unhandled menu prop: '%s'", k) 141 | } 142 | } 143 | 144 | for i, sub := range m.Children { 145 | sub.busName = m.busName 146 | sub.log = m.log 147 | subMenu, err := sub.Parse() 148 | if err != nil { 149 | return nil, err 150 | } 151 | menu.Children[i] = subMenu 152 | } 153 | 154 | return menu, nil 155 | } 156 | 157 | func newSNIMenu(logger hclog.Logger, busName string) *sniMenu { 158 | return &sniMenu{ 159 | log: logger, 160 | busName: busName, 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /internal/dbus/portal.go: -------------------------------------------------------------------------------- 1 | package dbus 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/godbus/dbus/v5" 12 | "github.com/hashicorp/go-hclog" 13 | ) 14 | 15 | const ( 16 | portalName = `org.freedesktop.portal.Desktop` 17 | portalPath = dbus.ObjectPath(`/org/freedesktop/portal/desktop`) 18 | 19 | portalRequestName = `org.freedesktop.portal.Request` 20 | portalRequestMemberResponse = `Response` 21 | portalRequestSignalResponse = portalRequestName + `.` + portalRequestMemberResponse 22 | 23 | portalSessionName = `org.freedesktop.portal.Session` 24 | portalSessionMethodClose = portalSessionName + `.Close` 25 | 26 | portalTokenPrefix = `hyprpanel_` 27 | ) 28 | 29 | type portalClient struct { 30 | sync.RWMutex 31 | conn *dbus.Conn 32 | log hclog.Logger 33 | requests map[dbus.ObjectPath]chan *dbus.Signal 34 | busObj dbus.BusObject 35 | signals chan *dbus.Signal 36 | quitCh chan struct{} 37 | } 38 | 39 | func (c *portalClient) request(ctx context.Context, method string, token string, args ...any) (*dbus.Signal, error) { 40 | requestPath := c.requestPath(token) 41 | responseCh := make(chan *dbus.Signal, 1) 42 | 43 | c.Lock() 44 | c.requests[requestPath] = responseCh 45 | c.Unlock() 46 | 47 | defer func() { 48 | c.Lock() 49 | delete(c.requests, requestPath) 50 | c.Unlock() 51 | }() 52 | 53 | if call := c.busObj.Call(method, 0, args...); call.Err != nil { 54 | return nil, call.Err 55 | } 56 | 57 | select { 58 | case sig := <-responseCh: 59 | return sig, nil 60 | case <-ctx.Done(): 61 | return nil, ctx.Err() 62 | } 63 | } 64 | 65 | func (c *portalClient) requestPath(token string) dbus.ObjectPath { 66 | busName := c.conn.Names()[0] 67 | busName = strings.ReplaceAll(busName, `:`, ``) 68 | busName = strings.ReplaceAll(busName, `.`, `_`) 69 | return dbus.ObjectPath(fmt.Sprintf("/org/freedesktop/portal/desktop/request/%s/%s", busName, token)) 70 | } 71 | 72 | func (c *portalClient) token() string { 73 | return portalTokenPrefix + strconv.Itoa(rand.Int()) 74 | } 75 | 76 | func (c *portalClient) watch() { 77 | for { 78 | select { 79 | case <-c.quitCh: 80 | return 81 | default: 82 | select { 83 | case <-c.quitCh: 84 | return 85 | case sig, ok := <-c.signals: 86 | if !ok { 87 | return 88 | } 89 | switch sig.Name { 90 | case portalRequestSignalResponse: 91 | c.RLock() 92 | if ch, ok := c.requests[sig.Path]; ok { 93 | ch <- sig 94 | } 95 | c.RUnlock() 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | func newPortalClient(conn *dbus.Conn, logger hclog.Logger) (*portalClient, error) { 103 | c := &portalClient{ 104 | conn: conn, 105 | log: logger, 106 | busObj: conn.Object(portalName, portalPath), 107 | requests: make(map[dbus.ObjectPath]chan *dbus.Signal), 108 | signals: make(chan *dbus.Signal), 109 | quitCh: make(chan struct{}), 110 | } 111 | 112 | if err := c.conn.AddMatchSignal( 113 | dbus.WithMatchInterface(portalRequestName), 114 | dbus.WithMatchMember(portalRequestMemberResponse), 115 | ); err != nil { 116 | return nil, err 117 | } 118 | 119 | c.conn.Signal(c.signals) 120 | 121 | go c.watch() 122 | 123 | return c, nil 124 | } 125 | -------------------------------------------------------------------------------- /internal/dbus/sni.go: -------------------------------------------------------------------------------- 1 | package dbus 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/godbus/dbus/v5" 7 | "github.com/hashicorp/go-hclog" 8 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 9 | modulev1 "github.com/pdf/hyprpanel/proto/hyprpanel/module/v1" 10 | ) 11 | 12 | const ( 13 | sniName = `org.kde.StatusNotifierItem` 14 | sniPath = dbus.ObjectPath(`/StatusNotifierItem`) 15 | 16 | sniPropertyID = sniName + `.Id` 17 | sniPropertyTooltip = sniName + `.Tooltip` 18 | sniPropertyTitle = sniName + `.Title` 19 | sniPropertyIconThemePath = sniName + `.IconThemePath` 20 | sniPropertyStatus = sniName + `.Status` 21 | sniPropertyWindowID = sniName + `.WindowId` 22 | sniPropertyIconName = sniName + `.IconName` 23 | sniPropertyIconPixmap = sniName + `.IconPixmap` 24 | sniPropertyAttentionIconName = sniName + `.AttentionIconName` 25 | sniPropertyAttentionIconPixmap = sniName + `.AttentionIconPixmap` 26 | sniPropertyMenu = sniName + `.Menu` 27 | 28 | sniMethodContextMenu = sniName + `.ContextMenu` 29 | sniMethodActivate = sniName + `.Activate` 30 | sniMethodSecondaryActivate = sniName + `.SecondaryActivate` 31 | sniMethodScroll = sniName + `.Scroll` 32 | 33 | sniMemberNewTitle = `NewTitle` 34 | sniMemberNewIcon = `NewIcon` 35 | sniMemberNewAttentionIcon = `NewAttentionIcon` 36 | sniMemberNewOverlayIcon = `NewOverlayIcon` 37 | sniMemberNewToolTip = `NewToolTip` 38 | sniMemberNewStatus = `NewStatus` 39 | 40 | sniSignalNewTitle = sniName + `.` + sniMemberNewTitle 41 | sniSignalNewIcon = sniName + `.` + sniMemberNewIcon 42 | sniSignalNewAttentionIcon = sniName + `.` + sniMemberNewAttentionIcon 43 | sniSignalNewOverlayIcon = sniName + `.` + sniMemberNewOverlayIcon 44 | sniSignalNewToolTip = sniName + `.` + sniMemberNewToolTip 45 | sniSignalNewStatus = sniName + `.` + sniMemberNewStatus 46 | ) 47 | 48 | type statusNotifierItem struct { 49 | conn *dbus.Conn 50 | log hclog.Logger 51 | busName string 52 | objectPath dbus.ObjectPath 53 | busObj dbus.BusObject 54 | menuObj dbus.BusObject 55 | target *eventv1.StatusNotifierValue 56 | } 57 | 58 | func (i *statusNotifierItem) updateID() error { 59 | idProp, err := i.busObj.GetProperty(sniPropertyID) 60 | if err != nil { 61 | return err 62 | } 63 | if err := idProp.Store(&i.target.Id); err != nil { 64 | return err 65 | } 66 | if i.target.Id == `` { 67 | return errors.New(`id not found`) 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (i *statusNotifierItem) updateTitle() error { 74 | titleProp, err := i.busObj.GetProperty(sniPropertyTitle) 75 | if err != nil { 76 | return err 77 | } 78 | if err := titleProp.Store(&i.target.Title); err != nil { 79 | return err 80 | } 81 | if i.target.Title == `` { 82 | return errors.New(`title not found`) 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func (i *statusNotifierItem) updateStatus() error { 89 | statusProp, err := i.busObj.GetProperty(sniPropertyStatus) 90 | if err != nil { 91 | return err 92 | } 93 | var status string 94 | if err := statusProp.Store(&status); err != nil { 95 | return err 96 | } 97 | 98 | switch status { 99 | case `Passive`: 100 | i.target.Status = modulev1.Systray_STATUS_PASSIVE 101 | case `Active`: 102 | i.target.Status = modulev1.Systray_STATUS_ACTIVE 103 | case `NeedsAttention`: 104 | i.target.Status = modulev1.Systray_STATUS_NEEDS_ATTENTION 105 | default: 106 | i.target.Status = modulev1.Systray_STATUS_UNSPECIFIED 107 | } 108 | 109 | if i.target.Status == modulev1.Systray_STATUS_UNSPECIFIED { 110 | return errors.New(`status not found`) 111 | } 112 | 113 | return nil 114 | } 115 | 116 | func (i *statusNotifierItem) updateTooltip() error { 117 | tooltipProp, err := i.busObj.GetProperty(sniPropertyTooltip) 118 | if err != nil { 119 | i.target.Tooltip = &eventv1.StatusNotifierValue_Tooltip{ 120 | Title: i.target.Title, 121 | } 122 | 123 | return nil 124 | } 125 | if err := tooltipProp.Store(i.target.Tooltip); err != nil { 126 | return err 127 | } 128 | 129 | return nil 130 | } 131 | 132 | func (i *statusNotifierItem) updateIcon() error { 133 | if i.target.Icon == nil { 134 | i.target.Icon = &eventv1.StatusNotifierValue_Icon{} 135 | } 136 | 137 | var themePath string 138 | themePathProp, err := i.busObj.GetProperty(sniPropertyIconThemePath) 139 | if err != nil { 140 | i.log.Debug(`Missing IconThemePath from StatusNotifierItem, continuing`, `busName`, i.busName, `err`, err) 141 | } else if err := themePathProp.Store(&themePath); err != nil { 142 | return err 143 | } 144 | i.target.Icon.IconThemePath = themePath 145 | 146 | var status string 147 | statusProp, err := i.busObj.GetProperty(sniPropertyStatus) 148 | if err != nil { 149 | i.log.Debug(`Missing Status from StatusNotifierItem, continuing`, `busName`, i.busName, `err`, err) 150 | } else if err := statusProp.Store(&status); err != nil { 151 | return err 152 | } 153 | 154 | var name string 155 | if status == `NeedsAttention` { 156 | iconProp, err := i.busObj.GetProperty(sniPropertyAttentionIconName) 157 | if err != nil { 158 | i.log.Debug(`Missing AttentionIconName from StatusNotifierItem, continuing`, `busName`, i.busName, `err`, err) 159 | } else if err := iconProp.Store(&name); err != nil { 160 | return err 161 | } 162 | } else { 163 | iconProp, err := i.busObj.GetProperty(sniPropertyIconName) 164 | if err != nil { 165 | i.log.Debug(`Missing IconName from StatusNotifierItem, continuing`, `busName`, i.busName, `err`, err) 166 | } else if err := iconProp.Store(&name); err != nil { 167 | return err 168 | } 169 | } 170 | i.target.Icon.IconName = name 171 | 172 | pixbufArr := make([]*eventv1.StatusNotifierValue_Pixmap, 0) 173 | if status == `NeedsAttention` { 174 | pixbufArrProp, err := i.busObj.GetProperty(sniPropertyAttentionIconPixmap) 175 | if err == nil { 176 | if err := pixbufArrProp.Store(&pixbufArr); err != nil { 177 | return err 178 | } 179 | } 180 | } else { 181 | pixbufArrProp, err := i.busObj.GetProperty(sniPropertyIconPixmap) 182 | if err == nil { 183 | if err := pixbufArrProp.Store(&pixbufArr); err != nil { 184 | return err 185 | } 186 | } 187 | } 188 | 189 | if len(pixbufArr) == 0 && i.target.Icon.IconName == `` { 190 | return errors.New(`icon not found`) 191 | } 192 | i.target.Icon.IconPixmap = nil 193 | for _, buf := range pixbufArr { 194 | if i.target.Icon.IconPixmap == nil { 195 | i.target.Icon.IconPixmap = &eventv1.StatusNotifierValue_Pixmap{ 196 | Width: int32(buf.Width), 197 | Height: int32(buf.Height), 198 | Data: buf.Data, 199 | } 200 | continue 201 | } 202 | if buf.Width > i.target.Icon.IconPixmap.Height || buf.Height > i.target.Icon.IconPixmap.Height { 203 | i.target.Icon.IconPixmap = &eventv1.StatusNotifierValue_Pixmap{ 204 | Width: int32(buf.Width), 205 | Height: int32(buf.Height), 206 | Data: buf.Data, 207 | } 208 | } 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func (i *statusNotifierItem) getMenuPath() error { 215 | menuPathProp, err := i.busObj.GetProperty(sniPropertyMenu) 216 | if err != nil { 217 | return err 218 | } 219 | 220 | var menuPath string 221 | if err := menuPathProp.Store(&menuPath); err != nil { 222 | return err 223 | } 224 | if menuPath == `` { 225 | return errors.New(`menu empty`) 226 | } 227 | 228 | i.menuObj = i.conn.Object(i.busName, dbus.ObjectPath(menuPath)) 229 | if i.menuObj == nil { 230 | return errors.New(`menu path provided but invalid`) 231 | } 232 | 233 | return nil 234 | } 235 | 236 | func (i *statusNotifierItem) updateMenu() error { 237 | if i.menuObj == nil { 238 | return errUnsupported 239 | } 240 | 241 | dmenu := newSNIMenu(i.log, i.target.BusName) 242 | layoutCall := i.menuObj.Call(sniMenuMethodGetLayout, 0, 0, -1, []string{}) 243 | if err := layoutCall.Store(&i.target.MenuRevision, dmenu); err != nil { 244 | return err 245 | } 246 | 247 | var err error 248 | i.target.Menu, err = dmenu.Parse() 249 | if err != nil { 250 | return err 251 | } 252 | 253 | return nil 254 | } 255 | 256 | func newStatusNotifierItem(conn *dbus.Conn, logger hclog.Logger, busName string, objectPath dbus.ObjectPath, busObj dbus.BusObject) (*statusNotifierItem, error) { 257 | i := &statusNotifierItem{ 258 | conn: conn, 259 | log: logger, 260 | busName: busName, 261 | objectPath: objectPath, 262 | busObj: busObj, 263 | target: &eventv1.StatusNotifierValue{ 264 | BusName: busName, 265 | ObjectPath: string(objectPath), 266 | }, 267 | } 268 | 269 | if err := i.updateID(); err != nil { 270 | return nil, err 271 | } 272 | 273 | if err := i.updateStatus(); err != nil { 274 | i.log.Debug(`SNI item update status failed`, `busName`, i.busName, `err`, err) 275 | } 276 | 277 | if err := i.updateIcon(); err != nil { 278 | return nil, err 279 | } 280 | 281 | if err := i.updateTitle(); err != nil { 282 | i.log.Debug(`SNI item update title failed`, `busName`, busName, `err`, err) 283 | } 284 | 285 | if err := i.updateTooltip(); err != nil { 286 | i.log.Debug(`SNI item update tooltip failed`, `busName`, busName, `err`, err) 287 | } 288 | 289 | if err := i.getMenuPath(); err != nil { 290 | i.log.Debug(`SNI item get menu path failed`, `busName`, busName, `err`, err) 291 | } 292 | 293 | if err := i.updateMenu(); err != nil { 294 | i.log.Debug(`SNI item update menu failed`, `busName`, busName, `err`, err) 295 | } 296 | 297 | return i, nil 298 | } 299 | -------------------------------------------------------------------------------- /internal/gtk4-layer-shell/gtk4-layer-shell.go: -------------------------------------------------------------------------------- 1 | // Package gtk4layershell provides purego bindings to the gtk4-layer-shell library. 2 | package gtk4layershell 3 | 4 | import ( 5 | "github.com/jwijenbergh/purego" 6 | "github.com/jwijenbergh/puregotk/v4/gdk" 7 | "github.com/jwijenbergh/puregotk/v4/gtk" 8 | 9 | "fmt" 10 | ) 11 | 12 | // Edge enum for screen edges. 13 | type Edge int 14 | 15 | const ( 16 | // LayerShellEdgeLeft enum value 17 | LayerShellEdgeLeft Edge = iota 18 | // LayerShellEdgeRight enum value 19 | LayerShellEdgeRight 20 | // LayerShellEdgeTop enum value 21 | LayerShellEdgeTop 22 | // LayerShellEdgeBottom enum value 23 | LayerShellEdgeBottom 24 | // LayerShellEdgeEntryNumber should not be used except to get the number of entries 25 | LayerShellEdgeEntryNumber 26 | ) 27 | 28 | func (e Edge) String() string { 29 | switch e { 30 | case LayerShellEdgeLeft: 31 | return `Left` 32 | case LayerShellEdgeRight: 33 | return `Right` 34 | case LayerShellEdgeTop: 35 | return `Top` 36 | case LayerShellEdgeBottom: 37 | return `Bottom` 38 | case LayerShellEdgeEntryNumber: 39 | return `EntryNumber` 40 | default: 41 | return fmt.Sprintf("Edge(%d)", e) 42 | } 43 | } 44 | 45 | // Layer enum 46 | type Layer int 47 | 48 | const ( 49 | // LayerShellLayerBackground enum value 50 | LayerShellLayerBackground Layer = iota 51 | // LayerShellLayerBottom enum value 52 | LayerShellLayerBottom 53 | // LayerShellLayerTop enum value 54 | LayerShellLayerTop 55 | // LayerShellLayerOverlay enum value 56 | LayerShellLayerOverlay 57 | // LayerShellLayerEntryNumber should not be used except to get the number of entries 58 | LayerShellLayerEntryNumber 59 | ) 60 | 61 | func (l Layer) String() string { 62 | switch l { 63 | case LayerShellLayerBackground: 64 | return `Background` 65 | case LayerShellLayerBottom: 66 | return `Bottom` 67 | case LayerShellLayerTop: 68 | return `Top` 69 | case LayerShellLayerOverlay: 70 | return `Overlay` 71 | case LayerShellLayerEntryNumber: 72 | return `EntryNumber` 73 | default: 74 | return fmt.Sprintf("Layer(%d)", l) 75 | } 76 | } 77 | 78 | // InitForWindow wraps gtk_layer_init_for_window 79 | func InitForWindow(window *gtk.Window) { 80 | xInitForWindow(window.GoPointer()) 81 | } 82 | 83 | var xInitForWindow func(uintptr) 84 | 85 | // SetMonitor wraps gtk_layer_set_monitor 86 | func SetMonitor(window *gtk.Window, monitor *gdk.Monitor) { 87 | xSetMonitor(window.GoPointer(), monitor.GoPointer()) 88 | } 89 | 90 | var xSetMonitor func(uintptr, uintptr) 91 | 92 | // AutoExclusiveZoneEnable wraps gtk_layer_auto_exclusive_zone_enable 93 | func AutoExclusiveZoneEnable(window *gtk.Window) { 94 | xAutoExclusiveZoneEnable(window.GoPointer()) 95 | } 96 | 97 | var xAutoExclusiveZoneEnable func(uintptr) 98 | 99 | // SetAnchor wraps gtk_layer_set_anchor 100 | func SetAnchor(window *gtk.Window, edge Edge, anchorToEdge bool) { 101 | xSetAnchor(window.GoPointer(), edge, anchorToEdge) 102 | } 103 | 104 | var xSetAnchor func(uintptr, Edge, bool) 105 | 106 | // SetMargin wraps gtk_layer_set_margin 107 | func SetMargin(window *gtk.Window, edge Edge, margin int) { 108 | xSetMargin(window.GoPointer(), edge, margin) 109 | } 110 | 111 | var xSetMargin func(uintptr, Edge, int) 112 | 113 | // SetLayer wraps gtk_layer_set_layer 114 | func SetLayer(window *gtk.Window, layer Layer) { 115 | xSetLayer(window.GoPointer(), layer) 116 | } 117 | 118 | var xSetLayer func(uintptr, Layer) 119 | 120 | // SetNamespace wraps gtk_layer_set_namespace 121 | func SetNamespace(window *gtk.Window, namespace string) { 122 | xSetNamespace(window.GoPointer(), namespace) 123 | } 124 | 125 | var xSetNamespace func(uintptr, string) 126 | 127 | func puregoSafeRegister(fptr any, handle uintptr, name string) error { 128 | sym, err := purego.Dlsym(handle, name) 129 | if err != nil { 130 | return err 131 | } 132 | purego.RegisterFunc(fptr, sym) 133 | 134 | return nil 135 | } 136 | 137 | func init() { 138 | lib, err := purego.Dlopen(`libgtk4-layer-shell.so.0`, purego.RTLD_NOW|purego.RTLD_GLOBAL) 139 | if err != nil { 140 | panic(err) 141 | } 142 | 143 | if err := puregoSafeRegister(&xInitForWindow, lib, `gtk_layer_init_for_window`); err != nil { 144 | panic(err) 145 | } 146 | if err := puregoSafeRegister(&xSetMonitor, lib, `gtk_layer_set_monitor`); err != nil { 147 | panic(err) 148 | } 149 | if err := puregoSafeRegister(&xAutoExclusiveZoneEnable, lib, `gtk_layer_auto_exclusive_zone_enable`); err != nil { 150 | panic(err) 151 | } 152 | if err := puregoSafeRegister(&xSetAnchor, lib, `gtk_layer_set_anchor`); err != nil { 153 | panic(err) 154 | } 155 | if err := puregoSafeRegister(&xSetMargin, lib, `gtk_layer_set_margin`); err != nil { 156 | panic(err) 157 | } 158 | if err := puregoSafeRegister(&xSetLayer, lib, `gtk_layer_set_layer`); err != nil { 159 | panic(err) 160 | } 161 | if err := puregoSafeRegister(&xSetNamespace, lib, `gtk_layer_set_namespace`); err != nil { 162 | panic(err) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /internal/hypripc/client.go: -------------------------------------------------------------------------------- 1 | package hypripc 2 | 3 | // Client window container. 4 | type Client struct { 5 | Address string `json:"address"` 6 | Mapped bool `json:"mapped"` 7 | Hidden bool `json:"hidden"` 8 | At []int `json:"at"` 9 | Size []int `json:"size"` 10 | Workspace struct { 11 | ID int `json:"id"` 12 | Name string `json:"name"` 13 | } `json:"workspace"` 14 | Floating bool `json:"floating"` 15 | Monitor int `json:"monitor"` 16 | Class string `json:"class"` 17 | Title string `json:"title"` 18 | InitialClass string `json:"initialClass"` 19 | InitialTitle string `json:"initialTitle"` 20 | Pid int64 `json:"pid"` 21 | Xwayland bool `json:"xwayland"` 22 | Pinned bool `json:"pinned"` 23 | Fullscreen int `json:"fullscreen"` 24 | FullscreenMode int `json:"fullscreenMode"` 25 | FakeFullscreen bool `json:"fakeFullscreen"` 26 | Grouped []interface{} `json:"grouped"` 27 | Swallowing string `json:"swallowing"` 28 | FocusHistoryID int `json:"focusHistoryID"` 29 | } 30 | -------------------------------------------------------------------------------- /internal/hypripc/monitor.go: -------------------------------------------------------------------------------- 1 | package hypripc 2 | 3 | // Monitor container. 4 | type Monitor struct { 5 | ID int `json:"id"` 6 | Name string `json:"name"` 7 | Description string `json:"description"` 8 | Make string `json:"make"` 9 | Model string `json:"model"` 10 | Serial string `json:"serial"` 11 | Width int `json:"width"` 12 | Height int `json:"height"` 13 | RefreshRate float64 `json:"refreshRate"` 14 | X int `json:"x"` 15 | Y int `json:"y"` 16 | ActiveWorkspace struct { 17 | ID int `json:"id"` 18 | Name string `json:"name"` 19 | } `json:"activeWorkspace"` 20 | SpecialWorkspace struct { 21 | ID int `json:"id"` 22 | Name string `json:"name"` 23 | } `json:"specialWorkspace"` 24 | Reserved []int `json:"reserved"` 25 | Scale float64 `json:"scale"` 26 | Transform int `json:"transform"` 27 | Focused bool `json:"focused"` 28 | DpmsStatus bool `json:"dpmsStatus"` 29 | Vrr bool `json:"vrr"` 30 | ActivelyTearing bool `json:"activelyTearing"` 31 | } 32 | -------------------------------------------------------------------------------- /internal/hypripc/workspace.go: -------------------------------------------------------------------------------- 1 | package hypripc 2 | 3 | // Workspace container. 4 | type Workspace struct { 5 | ID int `json:"id"` 6 | Name string `json:"name"` 7 | Monitor string `json:"monitor"` 8 | MonitorID int `json:"monitorID"` 9 | Windows int `json:"windows"` 10 | Hasfullscreen bool `json:"hasfullscreen"` 11 | Lastwindow string `json:"lastwindow"` 12 | Lastwindowtitle string `json:"lastwindowtitle"` 13 | } 14 | -------------------------------------------------------------------------------- /internal/panelplugin/interface.go: -------------------------------------------------------------------------------- 1 | package panelplugin 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/hashicorp/go-plugin" 8 | configv1 "github.com/pdf/hyprpanel/proto/hyprpanel/config/v1" 9 | eventv1 "github.com/pdf/hyprpanel/proto/hyprpanel/event/v1" 10 | hyprpanelv1 "github.com/pdf/hyprpanel/proto/hyprpanel/v1" 11 | "google.golang.org/grpc" 12 | ) 13 | 14 | // Compile-time check 15 | var _ plugin.GRPCPlugin = &PanelPlugin{} 16 | 17 | const ( 18 | // PanelPluginName constant. 19 | PanelPluginName = `panel` 20 | ) 21 | 22 | // Handshake default parameters. 23 | var Handshake = plugin.HandshakeConfig{ 24 | ProtocolVersion: 1, 25 | MagicCookieKey: `hyprpanel`, 26 | MagicCookieValue: `panel`, 27 | } 28 | 29 | // PluginMap default parameters. 30 | var PluginMap = map[string]plugin.Plugin{ 31 | PanelPluginName: &PanelPlugin{}, 32 | } 33 | 34 | // Host interface. 35 | type Host interface { 36 | Exec(action *hyprpanelv1.AppInfo_Action) error 37 | FindApplication(query string) (*hyprpanelv1.AppInfo, error) 38 | SystrayActivate(busName string, x, y int32) error 39 | SystraySecondaryActivate(busName string, x, y int32) error 40 | SystrayScroll(busName string, delta int32, orientation hyprpanelv1.SystrayScrollOrientation) error 41 | SystrayMenuContextActivate(busName string, x, y int32) error 42 | SystrayMenuAboutToShow(busName string, menuItemID string) error 43 | SystrayMenuEvent(busName string, id int32, eventID hyprpanelv1.SystrayMenuEvent, data any, timestamp time.Time) error 44 | NotificationClosed(id uint32, reason hyprpanelv1.NotificationClosedReason) error 45 | NotificationAction(id uint32, actionKey string) error 46 | AudioSinkVolumeAdjust(id string, direction eventv1.Direction) error 47 | AudioSinkMuteToggle(id string) error 48 | AudioSourceVolumeAdjust(id string, direction eventv1.Direction) error 49 | AudioSourceMuteToggle(id string) error 50 | BrightnessAdjust(devName string, direction eventv1.Direction) error 51 | CaptureFrame(address uint64, width, height int32) (*hyprpanelv1.ImageNRGBA, error) 52 | } 53 | 54 | // Panel interface. 55 | type Panel interface { 56 | Init(host Host, id string, loglevel configv1.LogLevel, config *configv1.Panel, stylesheet []byte) error 57 | Notify(evt *eventv1.Event) 58 | Context() context.Context 59 | Close() 60 | } 61 | 62 | // PanelPlugin definition. 63 | type PanelPlugin struct { 64 | plugin.NetRPCUnsupportedPlugin 65 | 66 | Impl Panel 67 | } 68 | 69 | // GRPCServer satisfies the plugin interface. 70 | func (p *PanelPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { 71 | hyprpanelv1.RegisterPanelServiceServer(s, &PanelGRPCServer{ 72 | Impl: p.Impl, 73 | broker: broker, 74 | }) 75 | return nil 76 | } 77 | 78 | // GRPCClient satsifise the plugin interface. 79 | func (p *PanelPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { 80 | return &PanelGRPCClient{ 81 | client: hyprpanelv1.NewPanelServiceClient(c), 82 | broker: broker, 83 | ctx: ctx, 84 | }, nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/panelplugin/panelplugin.go: -------------------------------------------------------------------------------- 1 | // Package panelplugin defines the panel plugin contract. 2 | package panelplugin 3 | -------------------------------------------------------------------------------- /proto/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | 3 | managed: 4 | enabled: true 5 | override: 6 | - file_option: go_package_prefix 7 | value: github.com/pdf/hyprpanel/proto 8 | plugins: 9 | - remote: buf.build/protocolbuffers/go:v1.32.0 10 | out: . 11 | opt: 12 | - paths=source_relative 13 | - remote: buf.build/grpc/go:v1.3.0 14 | out: . 15 | opt: 16 | - paths=source_relative 17 | - local: 18 | [ 19 | "go", 20 | "run", 21 | "github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@v1.5.1", 22 | ] 23 | out: ./doc 24 | opt: markdown,doc.md,source_relative 25 | -------------------------------------------------------------------------------- /proto/buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | -------------------------------------------------------------------------------- /proto/hyprpanel/config/v1/config.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package hyprpanel.config.v1; 4 | 5 | import "google/protobuf/duration.proto"; 6 | import "hyprpanel/module/v1/module.proto"; 7 | 8 | enum Edge { 9 | EDGE_UNSPECIFIED = 0; 10 | EDGE_TOP = 1; 11 | EDGE_RIGHT = 2; 12 | EDGE_BOTTOM = 3; 13 | EDGE_LEFT = 4; 14 | } 15 | 16 | enum LogLevel { 17 | LOG_LEVEL_UNSPECIFIED = 0; 18 | LOG_LEVEL_TRACE = 1; 19 | LOG_LEVEL_DEBUG = 2; 20 | LOG_LEVEL_INFO = 3; 21 | LOG_LEVEL_WARN = 4; 22 | LOG_LEVEL_ERROR = 5; 23 | LOG_LEVEL_OFF = 6; 24 | } 25 | 26 | message Panel { 27 | string id = 1; // unique identifier for this panel. 28 | Edge edge = 2; // screen edge to place this panel. 29 | uint32 size = 3; // either width or height in pixels, depending on orientation for screen edge. 30 | string monitor = 4; // monitor to display this panel on. 31 | repeated hyprpanel.module.v1.Module modules = 5; // list of modules for this panel. 32 | } 33 | 34 | message IconOverride { 35 | string window_class = 1; // window class of the application to match. 36 | string icon = 2; // icon name to use for this application. 37 | } 38 | 39 | message Config { 40 | message DBUS { 41 | message Notifications { 42 | bool enabled = 1; // toggles the notification host functionality, required for "notifications" module. 43 | } 44 | 45 | message Systray { 46 | bool enabled = 3; // toggles the StatusNotifierItem host, required for "systray" module. Must be the only SNI implementation running in the session. 47 | } 48 | 49 | message Shortcuts { 50 | bool enabled = 4; // enables GlobalShortcuts support. 51 | } 52 | 53 | message Brightness { 54 | bool enabled = 1; // enables brightness control functionality. 55 | uint32 adjust_step_percent = 2; // percentage that brightness should change on each adjustment. 56 | uint32 min_brightness = 3; // minimum brightness value. 57 | bool enable_logind = 4; // set brightness via systemd-logind DBUS interface instead of direct sysfs. Requires logind session, and DBUS.enabled = true. 58 | bool hud_notifications = 5; // display HUD notifications on change (requires at least one HUD module). 59 | } 60 | 61 | message Power { 62 | bool enabled = 1; // enables power functionality. 63 | uint32 low_percent = 2; // percentage below which we should consider low power. 64 | uint32 critical_percent = 3; // percentage below which we should consider critical power. 65 | string low_command = 4; // command to execute on low power. 66 | string critical_command = 5; // command to execute on critical power. 67 | bool hud_notifications = 6; // display HUD notifications on power state change or low power. 68 | } 69 | 70 | bool enabled = 1; // if false, no DBUS functionality is available. 71 | google.protobuf.Duration connect_timeout = 2; // specifies the maximum time we will attempt to connect to the bus before failing (format: "20s"). 72 | google.protobuf.Duration connect_interval = 3; // specifies the interval that we will attempt to connect to the session bus on startup (format: "0.200s"). 73 | 74 | Notifications notifications = 4; // notifications configuration. 75 | Systray systray = 5; // systray configuration. 76 | Shortcuts shortcuts = 6; // shortcuts configuration. 77 | Brightness brightness = 7; // brightness configuration. 78 | Power power = 8; // power configuration. 79 | } 80 | 81 | message Audio { 82 | bool enabled = 1; // if false, no Audio functionality is available. 83 | uint32 volume_step_percent = 2; // percentage that volume should change on each adjustment. 84 | bool volume_exceed_maximum = 3; // allow increasing volume above 100%. 85 | bool hud_notifications = 4; // display HUD notifications on volume change (requires at least one HUD module). 86 | } 87 | 88 | LogLevel log_level = 1; // specifies the maximum log level for output. 89 | bool log_subprocesses_to_journal = 2 [deprecated = true]; // Deprecated: set launch_wrapper to ["systemd-cat"] to emulate this behaviour. 90 | DBUS dbus = 3; // dbus configuration section. 91 | Audio audio = 4; // audio configuration section. 92 | repeated Panel panels = 6; // list of panels to display. 93 | repeated IconOverride icon_overrides = 7; // list of icon overrides. 94 | repeated string launch_wrapper = 8; // command to wrap application launches with (e.g. ["uwsm", "app", "--"]). 95 | } 96 | -------------------------------------------------------------------------------- /proto/hyprpanel/event/v1/const.go: -------------------------------------------------------------------------------- 1 | package eventv1 2 | 3 | const ( 4 | // AudioDefaultSink constant. 5 | AudioDefaultSink = `@DEFAULT_SINK@` 6 | // AudioDefaultSource constant. 7 | AudioDefaultSource = `@DEFAULT_SOURCE@` 8 | // PowerDefaultID constant. 9 | PowerDefaultID = `Battery` 10 | ) 11 | -------------------------------------------------------------------------------- /proto/hyprpanel/event/v1/event.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package hyprpanel.event.v1; 4 | 5 | import "google/protobuf/any.proto"; 6 | import "google/protobuf/duration.proto"; 7 | import "hyprpanel/module/v1/module.proto"; 8 | 9 | enum Direction { 10 | DIRECTION_UNSPECIFIED = 0; 11 | DIRECTION_UP = 1; 12 | DIRECTION_DOWN = 2; 13 | } 14 | 15 | enum PowerType { 16 | POWER_TYPE_UNSPECIFIED = 0; 17 | POWER_TYPE_LINE_POWER = 1; 18 | POWER_TYPE_BATTERY = 2; 19 | POWER_TYPE_UPS = 3; 20 | POWER_TYPE_MONITOR = 4; 21 | POWER_TYPE_MOUSE = 5; 22 | POWER_TYPE_KEYBOARD = 6; 23 | POWER_TYPE_PDA = 7; 24 | POWER_TYPE_PHONE = 8; 25 | } 26 | 27 | enum PowerState { 28 | POWER_STATE_UNSPECIFIED = 0; 29 | POWER_STATE_CHARGING = 1; 30 | POWER_STATE_DISCHARGING = 2; 31 | POWER_STATE_EMPTY = 3; 32 | POWER_STATE_FULLY_CHARGED = 4; 33 | POWER_STATE_PENDING_CHARGE = 5; 34 | POWER_STATE_PENDING_DISCHARGE = 6; 35 | } 36 | 37 | enum EventKind { 38 | EVENT_KIND_UNSPECIFIED = 0; 39 | EVENT_KIND_HYPR_WORKSPACE = 1; 40 | EVENT_KIND_HYPR_FOCUSEDMON = 2; 41 | EVENT_KIND_HYPR_ACTIVEWINDOW = 4; 42 | EVENT_KIND_HYPR_ACTIVEWINDOWV2 = 5; 43 | EVENT_KIND_HYPR_FULLSCREEN = 6; 44 | EVENT_KIND_HYPR_MONITORREMOVED = 7; 45 | EVENT_KIND_HYPR_MONITORADDED = 8; 46 | EVENT_KIND_HYPR_CREATEWORKSPACE = 9; 47 | EVENT_KIND_HYPR_DESTROYWORKSPACE = 10; 48 | EVENT_KIND_HYPR_MOVEWORKSPACE = 11; 49 | EVENT_KIND_HYPR_RENAMEWORKSPACE = 12; 50 | EVENT_KIND_HYPR_ACTIVESPECIAL = 13; 51 | EVENT_KIND_HYPR_ACTIVELAYOUT = 14; 52 | EVENT_KIND_HYPR_OPENWINDOW = 15; 53 | EVENT_KIND_HYPR_CLOSEWINDOW = 16; 54 | EVENT_KIND_HYPR_MOVEWINDOW = 17; 55 | EVENT_KIND_HYPR_OPENLAYER = 18; 56 | EVENT_KIND_HYPR_CLOSELAYER = 19; 57 | EVENT_KIND_HYPR_SUBMAP = 20; 58 | EVENT_KIND_HYPR_CHANGEFLOATINGMODE = 21; 59 | EVENT_KIND_HYPR_URGENT = 22; 60 | EVENT_KIND_HYPR_MINIMIZE = 23; 61 | EVENT_KIND_HYPR_SCREENCAST = 24; 62 | EVENT_KIND_HYPR_WINDOWTITLE = 25; 63 | EVENT_KIND_HYPR_IGNOREGROUPLOCK = 26; 64 | EVENT_KIND_HYPR_EVENTLOCKGROUPS = 27; 65 | EVENT_KIND_DBUS_REGISTERSTATUSNOTIFIER = 28; 66 | EVENT_KIND_DBUS_UNREGISTERSTATUSNOTIFIER = 29; 67 | EVENT_KIND_DBUS_UPDATETITLE = 30; 68 | EVENT_KIND_DBUS_UPDATETOOLTIP = 31; 69 | EVENT_KIND_DBUS_UPDATEICON = 32; 70 | EVENT_KIND_DBUS_UPDATEMENU = 33; 71 | EVENT_KIND_DBUS_UPDATESTATUS = 34; 72 | EVENT_KIND_DBUS_NOTIFICATION = 35; 73 | EVENT_KIND_DBUS_CLOSENOTIFICATION = 36; 74 | EVENT_KIND_DBUS_BRIGHTNESS_CHANGE = 37; 75 | EVENT_KIND_DBUS_BRIGHTNESS_ADJUST = 38; 76 | EVENT_KIND_AUDIO_SINK_NEW = 39; 77 | EVENT_KIND_AUDIO_SINK_CHANGE = 40; 78 | EVENT_KIND_AUDIO_SINK_REMOVE = 41; 79 | EVENT_KIND_AUDIO_SOURCE_NEW = 42; 80 | EVENT_KIND_AUDIO_SOURCE_CHANGE = 43; 81 | EVENT_KIND_AUDIO_SOURCE_REMOVE = 44; 82 | EVENT_KIND_AUDIO_CARD_NEW = 45; 83 | EVENT_KIND_AUDIO_CARD_CHANGE = 46; 84 | EVENT_KIND_AUDIO_CARD_REMOVE = 47; 85 | EVENT_KIND_AUDIO_SINK_VOLUME_ADJUST = 48; 86 | EVENT_KIND_AUDIO_SINK_MUTE_TOGGLE = 49; 87 | EVENT_KIND_AUDIO_SOURCE_VOLUME_ADJUST = 50; 88 | EVENT_KIND_AUDIO_SOURCE_MUTE_TOGGLE = 51; 89 | EVENT_KIND_HUD_NOTIFY = 52; 90 | EVENT_KIND_DBUS_POWER_CHANGE = 53; 91 | EVENT_KIND_HYPR_MOVEWORKSPACEV2 = 54; 92 | EVENT_KIND_HYPR_MOVEWINDOWV2 = 55; 93 | EVENT_KIND_HYPR_CREATEWORKSPACEV2 = 56; 94 | EVENT_KIND_HYPR_DESTROYWORKSPACEV2 = 57; 95 | EVENT_KIND_HYPR_WORKSPACEV2 = 58; 96 | EVENT_KIND_EXEC = 59; 97 | } 98 | 99 | message HyprWorkspaceV2Value { 100 | int32 id = 1; 101 | string name = 2; 102 | } 103 | 104 | message HyprDestroyWorkspaceV2Value { 105 | int32 id = 1; 106 | string name = 2; 107 | } 108 | 109 | message HyprCreateWorkspaceV2Value { 110 | int32 id = 1; 111 | string name = 2; 112 | } 113 | 114 | message HyprMoveWindowValue { 115 | string address = 1; 116 | string workspace_name = 2; 117 | } 118 | 119 | message HyprMoveWindowV2Value { 120 | string address = 1; 121 | int32 workspace_id = 2; 122 | string workspace_name = 3; 123 | } 124 | 125 | message HyprMoveWorkspaceValue { 126 | string name = 1; 127 | string monitor = 2; 128 | } 129 | 130 | message HyprMoveWorkspaceV2Value { 131 | int32 id = 1; 132 | string name = 2; 133 | string monitor = 3; 134 | } 135 | 136 | message HyprRenameWorkspaceValue { 137 | int32 id = 1; 138 | string name = 2; 139 | } 140 | 141 | message HyprActiveWindowValue { 142 | string class = 1; 143 | string title = 2; 144 | } 145 | 146 | message HyprOpenWindowValue { 147 | string address = 1; 148 | string workspace_name = 2; 149 | string class = 3; 150 | string title = 4; 151 | } 152 | 153 | message StatusNotifierValue { 154 | message Pixmap { 155 | int32 width = 1; 156 | int32 height = 2; 157 | bytes data = 3; 158 | } 159 | 160 | message Tooltip { 161 | string icon_name = 1; 162 | Pixmap icon_pixmap = 2; 163 | string title = 3; 164 | string body = 4; 165 | } 166 | message Icon { 167 | string icon_name = 1; 168 | string icon_theme_path = 2; 169 | Pixmap icon_pixmap = 3; 170 | } 171 | 172 | message Menu { 173 | message Properties { 174 | string label = 1; 175 | string icon_name = 2; 176 | bytes icon_data = 3; 177 | int32 toggle_state = 4; 178 | bool is_separator = 5; 179 | bool is_parent = 6; 180 | bool is_hidden = 7; 181 | bool is_disabled = 8; 182 | bool is_radio = 9; 183 | bool is_checkbox = 10; 184 | } 185 | 186 | int32 id = 1; 187 | Properties properties = 2; 188 | repeated Menu children = 3; 189 | } 190 | 191 | string bus_name = 1; 192 | string object_path = 2; 193 | string id = 3; 194 | string title = 4; 195 | hyprpanel.module.v1.Systray.Status status = 5; 196 | Tooltip tooltip = 6; 197 | Icon icon = 7; 198 | Menu menu = 8; 199 | int32 menu_revision = 9; 200 | } 201 | 202 | message UpdateTitleValue { 203 | string bus_name = 1; 204 | string title = 2; 205 | } 206 | 207 | message UpdateTooltipValue { 208 | string bus_name = 1; 209 | StatusNotifierValue.Tooltip tooltip = 2; 210 | } 211 | 212 | message UpdateIconValue { 213 | string bus_name = 1; 214 | StatusNotifierValue.Icon icon = 2; 215 | } 216 | 217 | message UpdateStatusValue { 218 | string bus_name = 1; 219 | hyprpanel.module.v1.Systray.Status status = 2; 220 | } 221 | 222 | message UpdateMenuValue { 223 | string bus_name = 1; 224 | StatusNotifierValue.Menu menu = 2; 225 | } 226 | 227 | message NotificationValue { 228 | message Hint { 229 | string key = 1; 230 | google.protobuf.Any value = 2; 231 | } 232 | 233 | message Action { 234 | string key = 1; 235 | string value = 2; 236 | } 237 | 238 | message Pixmap { 239 | int32 width = 1; // Width of image in pixels 240 | int32 height = 2; // Height of image in pixels 241 | int32 row_stride = 3; // Distance in bytes between row starts 242 | bool has_alpha = 4; // Whether the image has an alpha channel 243 | int32 bits_per_sample = 5; // Must always be 8 244 | int32 channels = 6; // If has_alpha is TRUE, must be 4, otherwise 3 245 | bytes data = 7; // The image data, in RGB byte order 246 | } 247 | 248 | uint32 id = 1; 249 | string app_name = 2; 250 | uint32 replaces_id = 3; 251 | string app_icon = 4; 252 | string summary = 5; 253 | string body = 6; 254 | repeated Action actions = 7; 255 | repeated Hint hints = 8; 256 | google.protobuf.Duration timeout = 9; 257 | } 258 | 259 | message HudNotificationValue { 260 | string id = 1; 261 | string icon = 2; 262 | bool icon_symbolic = 3; 263 | string title = 4; 264 | string body = 5; 265 | double percent = 6; 266 | double percent_max = 7; 267 | } 268 | 269 | message AudioSinkChangeValue { 270 | string id = 1; 271 | string name = 2; 272 | int32 volume = 3; 273 | double percent = 4; 274 | double percent_max = 5; 275 | bool mute = 6; 276 | bool default = 7; 277 | } 278 | 279 | message AudioSourceChangeValue { 280 | string id = 1; 281 | string name = 2; 282 | int32 volume = 3; 283 | double percent = 4; 284 | double percent_max = 5; 285 | bool mute = 6; 286 | bool default = 7; 287 | } 288 | 289 | message AudioSinkVolumeAdjust { 290 | string id = 1; 291 | Direction direction = 2; 292 | } 293 | 294 | message AudioSinkMuteToggle { 295 | string id = 1; 296 | } 297 | 298 | message AudioSourceVolumeAdjust { 299 | string id = 1; 300 | Direction direction = 2; 301 | } 302 | 303 | message AudioSourceMuteToggle { 304 | string id = 1; 305 | } 306 | 307 | message BrightnessChangeValue { 308 | string id = 1; 309 | string name = 2; 310 | int32 brightness = 3; 311 | int32 brightness_max = 4; 312 | } 313 | 314 | message BrightnessAdjustValue { 315 | string dev_name = 1; 316 | Direction direction = 2; 317 | } 318 | 319 | message PowerChangeValue { 320 | string id = 1; 321 | string vendor = 2; 322 | string model = 3; 323 | PowerType type = 4; 324 | bool power_supply = 5; 325 | bool online = 6; 326 | google.protobuf.Duration time_to_empty = 7; 327 | google.protobuf.Duration time_to_full = 8; 328 | uint32 percentage = 9; 329 | PowerState state = 10; 330 | string icon = 11; 331 | double energy = 12; 332 | double energy_empty = 13; 333 | double energy_full = 14; 334 | } 335 | 336 | message Event { 337 | EventKind kind = 1; 338 | google.protobuf.Any data = 2; 339 | } 340 | -------------------------------------------------------------------------------- /proto/hyprpanel/event/v1/eventv1.go: -------------------------------------------------------------------------------- 1 | // Package eventv1 provides the protobuf API for events. 2 | package eventv1 3 | -------------------------------------------------------------------------------- /proto/hyprpanel/event/v1/utils.go: -------------------------------------------------------------------------------- 1 | package eventv1 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | 7 | anypb "google.golang.org/protobuf/types/known/anypb" 8 | "google.golang.org/protobuf/types/known/wrapperspb" 9 | ) 10 | 11 | // ErrTypeMismatch is returned when operating on incompatible protobuf message type. 12 | var ErrTypeMismatch = errors.New(`requested type did not match data type`) 13 | 14 | // NewString convenience function for instantiating an event with a string data attribute. 15 | func NewString(kind EventKind, value string) (*Event, error) { 16 | data, err := anypb.New(wrapperspb.String(value)) 17 | if err != nil { 18 | return nil, err 19 | } 20 | return &Event{ 21 | Kind: kind, 22 | Data: data, 23 | }, nil 24 | } 25 | 26 | // NewInt32 convenience function for instantiating an event with an int32 data attribute. 27 | func NewInt32(kind EventKind, value string) (*Event, error) { 28 | v, err := strconv.Atoi(value) 29 | if err != nil { 30 | return nil, err 31 | } 32 | data, err := anypb.New(wrapperspb.Int32(int32(v))) 33 | if err != nil { 34 | return nil, err 35 | } 36 | return &Event{ 37 | Kind: kind, 38 | Data: data, 39 | }, nil 40 | } 41 | 42 | // NewUInt32 convenience function for instantiating an event with a uint32 data attribute. 43 | func NewUInt32(kind EventKind, value string) (*Event, error) { 44 | v, err := strconv.ParseUint(value, 10, 32) 45 | if err != nil { 46 | return nil, err 47 | } 48 | data, err := anypb.New(wrapperspb.UInt32(uint32(v))) 49 | if err != nil { 50 | return nil, err 51 | } 52 | return &Event{ 53 | Kind: kind, 54 | Data: data, 55 | }, nil 56 | } 57 | 58 | // DataString convenience function for extracting a value from a string anypb field. 59 | func DataString(a *anypb.Any) (string, error) { 60 | v := &wrapperspb.StringValue{} 61 | if !a.MessageIs(v) { 62 | return ``, ErrTypeMismatch 63 | } 64 | if err := a.UnmarshalTo(v); err != nil { 65 | return ``, err 66 | } 67 | return v.Value, nil 68 | 69 | } 70 | 71 | // DataInt32 convenience function for extracting a value from an int32 anypb field. 72 | func DataInt32(a *anypb.Any) (int32, error) { 73 | v := &wrapperspb.Int32Value{} 74 | if !a.MessageIs(v) { 75 | return 0, ErrTypeMismatch 76 | } 77 | if err := a.UnmarshalTo(v); err != nil { 78 | return 0, err 79 | } 80 | return v.Value, nil 81 | } 82 | 83 | // DataInt64 convenience function for extracting a value from an int64 anypb field. 84 | func DataInt64(a *anypb.Any) (int64, error) { 85 | v := &wrapperspb.Int64Value{} 86 | if !a.MessageIs(v) { 87 | return 0, ErrTypeMismatch 88 | } 89 | if err := a.UnmarshalTo(v); err != nil { 90 | return 0, err 91 | } 92 | return v.Value, nil 93 | } 94 | 95 | // DataUInt32 convenience function for extracting a value from a uint32 anypb field. 96 | func DataUInt32(a *anypb.Any) (uint32, error) { 97 | v := &wrapperspb.UInt32Value{} 98 | if !a.MessageIs(v) { 99 | return 0, ErrTypeMismatch 100 | } 101 | if err := a.UnmarshalTo(v); err != nil { 102 | return 0, err 103 | } 104 | return v.Value, nil 105 | } 106 | -------------------------------------------------------------------------------- /proto/hyprpanel/module/v1/module.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package hyprpanel.module.v1; 4 | 5 | import "google/protobuf/duration.proto"; 6 | 7 | enum Position { 8 | POSITION_UNSPECIFIED = 0; 9 | POSITION_TOP_LEFT = 1; 10 | POSITION_TOP = 2; 11 | POSITION_TOP_RIGHT = 3; 12 | POSITION_RIGHT = 4; 13 | POSITION_BOTTOM_RIGHT = 5; 14 | POSITION_BOTTOM = 6; 15 | POSITION_BOTTOM_LEFT = 7; 16 | POSITION_LEFT = 8; 17 | POSITION_CENTER = 9; 18 | } 19 | 20 | message Pager { 21 | uint32 icon_size = 1; // size in pixels for pager window preview application icons. 22 | bool active_monitor_only = 2; // show only workspaces from the monitor the panel is running on. 23 | bool scroll_wrap_workspaces = 3; // when switching workspaces via mouse scroll, wrap to start/end on over-scroll. 24 | bool scroll_include_inactive = 4; // when scrolling workspaces, include inactive workspaces 25 | bool enable_workspace_names = 5; // display workspace name labels. 26 | repeated int32 pinned = 6; // list of workspace IDs that will always be included in the pager, regardless of activation state. 27 | repeated string ignore_windows = 7; // list of window classes that will be excluded from preview on the pager. 28 | uint32 preview_width = 8; // width in pixels for task preview windows. 29 | bool follow_window_on_move = 9; // when moving a window, switch to the workspace the window is being moved to. 30 | } 31 | 32 | message Taskbar { 33 | uint32 icon_size = 1; // size in pixels for task icons. 34 | bool active_workspace_only = 2; // show only tasks from the current workspace. 35 | bool active_monitor_only = 3; // show only tasks from the monitor the panel is running on. 36 | bool group_tasks = 4; // group tasks for the same application into a single icon. Scroll wheel cycles tasks. 37 | bool hide_indicators = 5; // if you're not using pinned tasks, you may wish to hide the running task indicators. 38 | bool expand = 6; // expand this module to fill available space in the panel. 39 | uint32 max_size = 7; // maximum size in pixels for this module. Zero means no limit. 40 | repeated string pinned = 8; // list of window classes that should always be displayed on the taskbar. Allows the taskbar to act as a launcher. 41 | uint32 preview_width = 9; // width in pixels for task preview windows. 42 | } 43 | 44 | message Systray { 45 | enum Status { 46 | STATUS_UNSPECIFIED = 0; 47 | STATUS_PASSIVE = 1; 48 | STATUS_ACTIVE = 2; 49 | STATUS_NEEDS_ATTENTION = 3; 50 | } 51 | 52 | uint32 icon_size = 1; // size in pixels for icons in the systray. 53 | uint32 menu_icon_size = 2; // size in pixels for menu icons. Currently unused because GNOME developers hate user/developer choice. 54 | repeated Status auto_hide_statuses = 3; // list of statuses that should be auto-hidden. 55 | google.protobuf.Duration auto_hide_delay = 4; // delay before new (or status-changed) icons are auto-hidden (format "4s", zero to disable). 56 | repeated string pinned = 6; // list of SNI IDs that should never be hidden. There's no convention for ID values - if you want to collect IDs, start hyprpanel with LOG_LEVEL_DEBUG and look for SNI registration events. 57 | repeated SystrayModule modules = 7; // list of modules to dislpay in systray. Currently supported modules: ["audio", "power"] 58 | } 59 | 60 | message Notifications { 61 | uint32 icon_size = 1; // size in pixels for the panel notification icon. Currently unused as notification history is unimplemented. 62 | uint32 notification_icon_size = 2; //size in pixels for icons in notifications. 63 | google.protobuf.Duration default_timeout = 3; // delay before notifications are hidden, if the notification does not specify a timemout (format: "7s"). 64 | Position position = 4; // screen position to display notifications. 65 | uint32 margin = 5; // space in pixels between notifications. 66 | repeated string persistent = 6; // list of application names to retain notification history for. Currently unused as notification history is unimplemented. 67 | } 68 | 69 | message Hud { 70 | uint32 notification_icon_size = 1; //size in pixels for icons in notifications. 71 | google.protobuf.Duration timeout = 2; // delay before notifications are hidden (format: "7s"). 72 | Position position = 3; // screen position to display notifications. 73 | uint32 margin = 4; // space in pixels between notifications. 74 | } 75 | 76 | message Clock { 77 | string time_format = 1; // Go time layout string for panel time display formatting, see https://pkg.go.dev/time#pkg-constants for details. 78 | string date_format = 2; // Go time layout string for panel date display formatting, see https://pkg.go.dev/time#pkg-constants for details. 79 | string tooltip_time_format = 3; // Go time layout string for tooltip time display formatting, see https://pkg.go.dev/time#pkg-constants for details. 80 | string tooltip_date_format = 4; // Go time layout string for tooltip time display formatting, see https://pkg.go.dev/time#pkg-constants for details. 81 | repeated string additional_regions = 5; // list of addtional regions to display in the tooltip. 82 | } 83 | 84 | message Audio { 85 | uint32 icon_size = 1; // size in pixels for panel icon. 86 | bool icon_symbolic = 2; // display symbolic or coloured icon in panel. 87 | string command_mixer = 3; // command to execute on mixer button. 88 | bool enable_source = 4; // display source (mic) icon in panel. 89 | } 90 | 91 | message Power { 92 | uint32 icon_size = 1; // size in pixels for panel icon. 93 | bool icon_symbolic = 2; // display symbolic or coloured icon in panel. 94 | } 95 | 96 | message Session { 97 | uint32 icon_size = 1; // size in pixels for panel icon. 98 | bool icon_symbolic = 2; // display symbolic or coloured icon in panel. 99 | uint32 overlay_icon_size = 3; // size in pixels for overlay popup icons. 100 | bool overlay_icon_symbolic = 4; // display symbolic or coloured icons in overlay popup. 101 | string command_logout = 5; // command that will be executed for logout action, empty disabled the button. 102 | string command_reboot = 6; // command that will be executed for reboot action, empty disabled the button. 103 | string command_suspend = 7; // command that will be executed for suspend action, empty disabled the button. 104 | string command_shutdown = 8; // command that will be executed for shutdown action, empty disabled the button. 105 | } 106 | 107 | message Spacer { 108 | uint32 size = 1; // size in pixels for this spacer. 109 | bool expand = 2; // expand to fill available space. 110 | } 111 | 112 | message SystrayModule { 113 | oneof kind { 114 | Audio audio = 1; 115 | Power power = 2; 116 | } 117 | } 118 | 119 | message Module { 120 | oneof kind { 121 | Pager pager = 1; 122 | Taskbar taskbar = 2; 123 | Systray systray = 3; 124 | Notifications notifications = 4; 125 | Hud hud = 5; 126 | Audio audio = 6; 127 | Power power = 7; 128 | Clock clock = 8; 129 | Session session = 9; 130 | Spacer spacer = 10; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /proto/hyprpanel/v1/hyprpanel.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package hyprpanel.v1; 4 | 5 | import "google/protobuf/any.proto"; 6 | import "hyprpanel/config/v1/config.proto"; 7 | import "hyprpanel/event/v1/event.proto"; 8 | 9 | enum SystrayScrollOrientation { 10 | SYSTRAY_SCROLL_ORIENTATION_UNSPECIFIED = 0; 11 | SYSTRAY_SCROLL_ORIENTATION_VERTICAL = 1; 12 | SYSTRAY_SCROLL_ORIENTATION_HORIZONTAL = 2; 13 | } 14 | 15 | enum SystrayMenuEvent { 16 | SYSTRAY_MENU_EVENT_UNSPECIFIED = 0; 17 | SYSTRAY_MENU_EVENT_CLICKED = 1; 18 | SYSTRAY_MENU_EVENT_HOVERED = 2; 19 | } 20 | 21 | enum NotificationClosedReason { 22 | NOTIFICATION_CLOSED_REASON_UNSPECIFIED = 0; 23 | NOTIFICATION_CLOSED_REASON_EXPIRED = 1; 24 | NOTIFICATION_CLOSED_REASON_DISMISSED = 2; 25 | NOTIFICATION_CLOSED_REASON_SIGNAL = 3; 26 | } 27 | 28 | message ImageNRGBA { 29 | bytes pixels = 1; 30 | uint32 stride = 2; 31 | uint32 width = 3; 32 | uint32 height = 4; 33 | } 34 | 35 | message AppInfo { 36 | message Action { 37 | string name = 1; 38 | string icon = 2; 39 | repeated string exec = 3; 40 | string raw_exec = 4; 41 | } 42 | 43 | string desktop_file = 1; 44 | string name = 2; 45 | string icon = 3; 46 | string try_exec = 4; 47 | repeated string exec = 5; 48 | string raw_exec = 6; 49 | string path = 7; 50 | string startup_wm_class = 8; 51 | bool terminal = 9; 52 | repeated Action actions = 10; 53 | } 54 | 55 | message PanelServiceInitRequest { 56 | uint32 host = 1; 57 | string id = 2; 58 | hyprpanel.config.v1.LogLevel log_level = 3; 59 | hyprpanel.config.v1.Panel config = 4; 60 | bytes stylesheet = 5; 61 | } 62 | message PanelServiceInitResponse {} 63 | 64 | message PanelServiceNotifyRequest { 65 | hyprpanel.event.v1.Event event = 1; 66 | } 67 | message PanelServiceNotifyResponse {} 68 | 69 | message PanelServiceNotificationCloseRequest { 70 | uint32 id = 1; 71 | } 72 | message PanelServiceNotificationCloseResponse {} 73 | 74 | message PanelServiceCloseRequest {} 75 | message PanelServiceCloseResponse {} 76 | 77 | service PanelService { 78 | rpc Init(PanelServiceInitRequest) returns (PanelServiceInitResponse); 79 | rpc Notify(PanelServiceNotifyRequest) returns (PanelServiceNotifyResponse); 80 | rpc Close(PanelServiceCloseRequest) returns (PanelServiceCloseResponse); 81 | } 82 | 83 | message HostServiceExecRequest { 84 | AppInfo.Action action = 1; 85 | } 86 | message HostServiceExecResponse {} 87 | 88 | message HostServiceFindApplicationRequest { 89 | string query = 1; 90 | } 91 | message HostServiceFindApplicationResponse { 92 | AppInfo app_info = 1; 93 | } 94 | 95 | message HostServiceSystrayActivateRequest { 96 | string bus_name = 1; 97 | int32 x = 2; 98 | int32 y = 3; 99 | } 100 | message HostServiceSystrayActivateResponse {} 101 | 102 | message HostServiceSystraySecondaryActivateRequest { 103 | string bus_name = 1; 104 | int32 x = 2; 105 | int32 y = 3; 106 | } 107 | message HostServiceSystraySecondaryActivateResponse {} 108 | 109 | message HostServiceSystrayScrollRequest { 110 | string bus_name = 1; 111 | int32 delta = 2; 112 | SystrayScrollOrientation orientation = 3; 113 | } 114 | message HostServiceSystrayScrollResponse {} 115 | 116 | message HostServiceSystrayMenuContextActivateRequest { 117 | string bus_name = 1; 118 | int32 x = 2; 119 | int32 y = 3; 120 | } 121 | message HostServiceSystrayMenuContextActivateResponse {} 122 | 123 | message HostServiceSystrayMenuAboutToShowRequest { 124 | string bus_name = 1; 125 | string menu_item_id = 2; 126 | } 127 | message HostServiceSystrayMenuAboutToShowResponse {} 128 | 129 | message HostServiceSystrayMenuEventRequest { 130 | string bus_name = 1; 131 | int32 id = 2; 132 | SystrayMenuEvent event_id = 3; 133 | google.protobuf.Any data = 4; 134 | uint32 timestamp = 5; 135 | } 136 | message HostServiceSystrayMenuEventResponse {} 137 | 138 | message HostServiceNotificationClosedRequest { 139 | uint32 id = 1; 140 | NotificationClosedReason reason = 2; 141 | } 142 | message HostServiceNotificationClosedResponse {} 143 | 144 | message HostServiceNotificationActionRequest { 145 | uint32 id = 1; 146 | string action_key = 2; 147 | } 148 | message HostServiceNotificationActionResponse {} 149 | 150 | message HostServiceAudioSinkVolumeAdjustRequest { 151 | string id = 1; 152 | hyprpanel.event.v1.Direction direction = 2; 153 | } 154 | message HostServiceAudioSinkVolumeAdjustResponse {} 155 | 156 | message HostServiceAudioSinkMuteToggleRequest { 157 | string id = 1; 158 | } 159 | message HostServiceAudioSinkMuteToggleResponse {} 160 | 161 | message HostServiceAudioSourceVolumeAdjustRequest { 162 | string id = 1; 163 | hyprpanel.event.v1.Direction direction = 2; 164 | } 165 | message HostServiceAudioSourceVolumeAdjustResponse {} 166 | 167 | message HostServiceAudioSourceMuteToggleRequest { 168 | string id = 1; 169 | } 170 | message HostServiceAudioSourceMuteToggleResponse {} 171 | 172 | message HostServiceBrightnessAdjustRequest { 173 | string dev_name = 1; 174 | hyprpanel.event.v1.Direction direction = 2; 175 | } 176 | message HostServiceBrightnessAdjustResponse {} 177 | 178 | message HostServiceCaptureFrameRequest { 179 | uint64 address = 1; 180 | int32 width = 2; 181 | int32 height = 3; 182 | } 183 | 184 | message HostServiceCaptureFrameResponse { 185 | ImageNRGBA image = 1; 186 | } 187 | 188 | service HostService { 189 | rpc Exec(HostServiceExecRequest) returns (HostServiceExecResponse); 190 | rpc FindApplication(HostServiceFindApplicationRequest) returns (HostServiceFindApplicationResponse); 191 | rpc SystrayActivate(HostServiceSystrayActivateRequest) returns (HostServiceSystrayActivateResponse); 192 | rpc SystraySecondaryActivate(HostServiceSystraySecondaryActivateRequest) returns (HostServiceSystraySecondaryActivateResponse); 193 | rpc SystrayScroll(HostServiceSystrayScrollRequest) returns (HostServiceSystrayScrollResponse); 194 | rpc SystrayMenuContextActivate(HostServiceSystrayMenuContextActivateRequest) returns (HostServiceSystrayMenuContextActivateResponse); 195 | rpc SystrayMenuAboutToShow(HostServiceSystrayMenuAboutToShowRequest) returns (HostServiceSystrayMenuAboutToShowResponse); 196 | rpc SystrayMenuEvent(HostServiceSystrayMenuEventRequest) returns (HostServiceSystrayMenuEventResponse); 197 | rpc NotificationClosed(HostServiceNotificationClosedRequest) returns (HostServiceNotificationClosedResponse); 198 | rpc NotificationAction(HostServiceNotificationActionRequest) returns (HostServiceNotificationActionResponse); 199 | rpc AudioSinkVolumeAdjust(HostServiceAudioSinkVolumeAdjustRequest) returns (HostServiceAudioSinkVolumeAdjustResponse); 200 | rpc AudioSinkMuteToggle(HostServiceAudioSinkMuteToggleRequest) returns (HostServiceAudioSinkMuteToggleResponse); 201 | rpc AudioSourceVolumeAdjust(HostServiceAudioSourceVolumeAdjustRequest) returns (HostServiceAudioSourceVolumeAdjustResponse); 202 | rpc AudioSourceMuteToggle(HostServiceAudioSourceMuteToggleRequest) returns (HostServiceAudioSourceMuteToggleResponse); 203 | rpc BrightnessAdjust(HostServiceBrightnessAdjustRequest) returns (HostServiceBrightnessAdjustResponse); 204 | rpc CaptureFrame(HostServiceCaptureFrameRequest) returns (HostServiceCaptureFrameResponse); 205 | } 206 | -------------------------------------------------------------------------------- /proto/proto.go: -------------------------------------------------------------------------------- 1 | //go:generate buf generate 2 | 3 | // Package proto is for codegen only 4 | package proto 5 | -------------------------------------------------------------------------------- /style/default.css: -------------------------------------------------------------------------------- 1 | /* 2 | * GTK doesn't support CSS vars, so use their syntax 3 | */ 4 | @define-color Highlight rgba(107, 82, 166, 1.0); 5 | @define-color Indicator rgba(240, 240, 240, 0.5); 6 | @define-color PanelBackground rgba(16, 16, 16, 0.8); 7 | @define-color ModuleBackground rgba(0, 0, 0, 0.0); 8 | @define-color NotificationBackground rgba(32, 32, 32, 1.0); 9 | @define-color Border rgba(64, 64, 64, 0.4); 10 | 11 | flowbox, 12 | flowboxchild { 13 | padding: 0; 14 | } 15 | 16 | #panel { 17 | background-color: @PanelBackground; 18 | } 19 | 20 | #panel.top { 21 | border-bottom: @Border 1px solid; 22 | } 23 | 24 | #panel.right { 25 | border-left: @Border 1px solid; 26 | } 27 | 28 | #panel.bottom { 29 | border-top: @Border 1px solid; 30 | } 31 | 32 | #panel.left { 33 | border-right: @Border 1px solid; 34 | } 35 | 36 | .module { 37 | background-color: @ModuleBackground; 38 | } 39 | 40 | .top .module { 41 | border-radius: 0 0 8px 8px; 42 | } 43 | 44 | .right .module { 45 | border-radius: 8px 0 0 8px; 46 | } 47 | 48 | .bottom .module { 49 | border-radius: 8px 8px 0 0; 50 | } 51 | 52 | .left .module { 53 | border-radius: 0 8px 8px 0; 54 | } 55 | 56 | #pager .workspace { 57 | background-color: alpha(@ModuleBackground, 0.3); 58 | border: @Border 1px solid; 59 | border-radius: 4px; 60 | transition: background-color 300ms ease-in-out, border 300ms ease-in-out; 61 | } 62 | 63 | #pager .workspace.live { 64 | background-color: alpha(@Highlight, 0.2); 65 | transition: background-color 300ms ease-in-out; 66 | } 67 | 68 | #pager .workspace.active { 69 | border: alpha(@Highlight, 0.8) 1px solid; 70 | transition: border 300ms ease-in-out; 71 | } 72 | 73 | #pager .workspace .workspaceLabel { 74 | background-color: alpha(@ModuleBackground, 0.8); 75 | font-size: 10px; 76 | } 77 | 78 | #pager .workspace .client { 79 | border: alpha(@Highlight, 0.4) 1px solid; 80 | background-color: alpha(@Highlight, 0.3); 81 | border-radius: 4px; 82 | transition: background-color 300ms ease-in-out, border 300ms ease-in-out; 83 | } 84 | 85 | #pager .workspace .client.active { 86 | border: alpha(@Highlight, 0.9) 1px solid; 87 | background-color: alpha(@Highlight, 0.7); 88 | transition: background-color 300ms ease-in-out, border 300ms ease-in-out; 89 | } 90 | 91 | #taskbar .client { 92 | border: rgba(0, 0, 0, 0) 1px solid; 93 | border-radius: 4px; 94 | transition: background-color 150ms ease-in-out, border 150ms ease-in-out; 95 | } 96 | 97 | #taskbar .client.hover { 98 | border: alpha(@Highlight, 0.3) 1px solid; 99 | background-color: alpha(@Highlight, 0.3); 100 | transition: background-color 150ms ease-in-out, border 150ms ease-in-out; 101 | } 102 | 103 | #taskbar .client.active { 104 | border: alpha(@Highlight, 0.6) 1px solid; 105 | background-color: alpha(@Highlight, 0.6); 106 | transition: background-color 150ms ease-in-out, border 150ms ease-in-out; 107 | } 108 | 109 | #taskbar .indicator { 110 | border: lighter(alpha(@Indicator, 0.9)) 1px solid; 111 | border-radius: 2px; 112 | background-color: @Indicator; 113 | } 114 | 115 | /* Text sizing in GTK sucks, let the user deal with it */ 116 | #clock #clockTime { 117 | font-size: 1.2rem; 118 | font-weight: 500; 119 | } 120 | 121 | #clock #clockDate { 122 | font-size: 0.6rem; 123 | } 124 | 125 | #sessionOverlay { 126 | background-color: alpha(@PanelBackground, 0.7); 127 | font-size: 2rem; 128 | } 129 | 130 | #sessionOverlay button { 131 | border-radius: 20px; 132 | } 133 | 134 | #spacer { 135 | background-color: rgba(0, 0, 0, 0); 136 | } 137 | 138 | #audio .overlay { 139 | border: alpha(@Highlight, 0.90) 2px solid; 140 | border-radius: 2px; 141 | background-color: alpha(@PanelBackground, 0.95); 142 | transition: border 300ms ease-in-out; 143 | } 144 | 145 | #audio .disabled { 146 | border: alpha(@Border, 0.90) 2px solid; 147 | transition: border 300ms ease-in-out; 148 | } 149 | 150 | #notificationsOverlay { 151 | background-color: rgba(0, 0, 0, 0); 152 | } 153 | 154 | .notification { 155 | border: @Border 1px solid; 156 | border-width: 2px; 157 | background-color: alpha(@NotificationBackground, 0.7); 158 | border-radius: 16px; 159 | transition: background-color 300ms ease-in-out; 160 | } 161 | 162 | .notification.hover { 163 | background-color: @NotificationBackground; 164 | transition: background-color 300ms ease-in-out; 165 | } 166 | 167 | .notification .notificationIcon { 168 | margin: 8px 0px 8px 8px; 169 | } 170 | 171 | .notification .notificationSummary { 172 | margin: 8px 12px 0px 12px; 173 | } 174 | 175 | .notification .notificationBody { 176 | margin: 8px 12px 12px 12px; 177 | } 178 | 179 | .notification button { 180 | border-radius: 0; 181 | border: 0; 182 | } 183 | 184 | .notification .notificationActions button:first-child { 185 | border-bottom-left-radius: 16px; 186 | } 187 | 188 | .notification .notificationActions button:last-child { 189 | border-bottom-right-radius: 16px; 190 | } 191 | 192 | #hudOverlay { 193 | background-color: rgba(0, 0, 0, 0); 194 | } 195 | 196 | .hudNotification { 197 | border: @Border 1px solid; 198 | border-width: 2px; 199 | background-color: alpha(@NotificationBackground, 0.7); 200 | border-radius: 16px; 201 | transition: background-color 300ms ease-in-out; 202 | } 203 | 204 | .hudNotification .hudIcon { 205 | margin: 16px; 206 | } 207 | 208 | .hudNotification .hudTitle { 209 | margin: 0px 12px 0px 12px; 210 | } 211 | 212 | .hudNotification .hudBody { 213 | margin: 8px 12px 16px 12px; 214 | } 215 | 216 | .hudNotification .hudPercent { 217 | margin: 0px 12px 16px 12px; 218 | font-size: 2rem; 219 | } 220 | 221 | .tooltipImage { 222 | margin: 8px; 223 | padding: 0; 224 | } 225 | 226 | .tooltipSubtitle { 227 | background-color: alpha(@NotificationBackground, 0.7); 228 | border-radius: 4px; 229 | } -------------------------------------------------------------------------------- /style/style.go: -------------------------------------------------------------------------------- 1 | // Package style handles application styles. 2 | package style 3 | 4 | import ( 5 | _ "embed" 6 | "os" 7 | ) 8 | 9 | const ( 10 | // PanelID element identifier. 11 | PanelID = `panel` 12 | // PagerID element identifier. 13 | PagerID = `pager` 14 | // TaskbarID element identifier. 15 | TaskbarID = `taskbar` 16 | // SystrayID element identifier. 17 | SystrayID = `systray` 18 | // AudioID element identifier. 19 | AudioID = `audio` 20 | // PowerID element identifier. 21 | PowerID = `power` 22 | // ClockID element identifier. 23 | ClockID = `clock` 24 | // ClockTimeID element identifier. 25 | ClockTimeID = `clockTime` 26 | // ClockDateID element identifier. 27 | ClockDateID = `clockDate` 28 | // ClockCalendarID element identifier. 29 | ClockCalendarID = `clockCalendar` 30 | // SessionID element identifier. 31 | SessionID = `session` 32 | // SessionOverlayID element identifier. 33 | SessionOverlayID = `sessionOverlay` 34 | // SpacerID element identifier. 35 | SpacerID = `spacer` 36 | // NotificationsID element identifier. 37 | NotificationsID = `notifications` 38 | // NotificationsOverlayID element identifier. 39 | NotificationsOverlayID = `notificationsOverlay` 40 | // HudID element identifier. 41 | HudID = `hud` 42 | // HudOverlayID element identifier. 43 | HudOverlayID = `hudOverlay` 44 | 45 | // ModuleClass class name. 46 | ModuleClass = `module` 47 | // WorkspaceClass class name. 48 | WorkspaceClass = `workspace` 49 | // WorkspaceLabelClass class name. 50 | WorkspaceLabelClass = `workspaceLabel` 51 | // ClientClass class name. 52 | ClientClass = `client` 53 | // LiveClass class name. 54 | LiveClass = `live` 55 | // ActiveClass class name. 56 | ActiveClass = `active` 57 | // HoverClass class name. 58 | HoverClass = `hover` 59 | // OverlayClass class name. 60 | OverlayClass = `overlay` 61 | // OverlayClass class name. 62 | DisabledClass = `disabled` 63 | // IndicatorClass class name. 64 | IndicatorClass = `indicator` 65 | // NotificationItemClass class name. 66 | NotificationItemClass = `notification` 67 | // NotificationItemSummaryClass class name. 68 | NotificationItemSummaryClass = `notificationSummary` 69 | // NotificationItemBodyClass class name. 70 | NotificationItemBodyClass = `notificationBody` 71 | // NotificationItemActionsClass class name. 72 | NotificationItemActionsClass = `notificationActions` 73 | // NotificationItemIconClass class name. 74 | NotificationItemIconClass = `notificationIcon` 75 | // HudNotificationClass class name. 76 | HudNotificationClass = `hudNotification` 77 | // HudIconClass class name. 78 | HudIconClass = `hudIcon` 79 | // HudTitleClass class name. 80 | HudTitleClass = `hudTitle` 81 | // HudBodyClass class name. 82 | HudBodyClass = `hudBody` 83 | // HudPercentClass class name. 84 | HudPercentClass = `hudPercent` 85 | // HudGaugeClass class name. 86 | HudGaugeClass = `hudGauge` 87 | 88 | // TooltipImageClass class name. 89 | TooltipImageClass = `tooltipImage` 90 | // TooltipSubtitleClass class name. 91 | TooltipSubtitleClass = `tooltipSubtitle` 92 | 93 | // TopClass class name. 94 | TopClass = `top` 95 | // RightClass class name. 96 | RightClass = `right` 97 | // BottomClass class name. 98 | BottomClass = `bottom` 99 | // LeftClass class name. 100 | LeftClass = `left` 101 | 102 | // HorizontalClass class name. 103 | HorizontalClass = `horizontal` 104 | // VerticalClass class name. 105 | VerticalClass = `vertical` 106 | ) 107 | 108 | // Default stylesheet as bytes. 109 | // 110 | //go:embed default.css 111 | var Default []byte 112 | 113 | // Load a stylesheet from disk. 114 | func Load(path string) ([]byte, error) { 115 | b, err := os.ReadFile(path) 116 | if err != nil { 117 | return nil, err 118 | } 119 | return b, nil 120 | } 121 | -------------------------------------------------------------------------------- /wl/app.go: -------------------------------------------------------------------------------- 1 | package wl 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | 8 | "github.com/hashicorp/go-hclog" 9 | "github.com/pdf/go-wayland/client" 10 | "golang.org/x/sys/unix" 11 | ) 12 | 13 | type App struct { 14 | display *client.Display 15 | registry *client.Registry 16 | shm *client.Shm 17 | tl *HyprlandToplevelExportManagerV1 18 | log hclog.Logger 19 | } 20 | 21 | type shmPool struct { 22 | *client.ShmPool 23 | fd int 24 | data []byte 25 | } 26 | 27 | func (p *shmPool) Data() []byte { 28 | return p.data 29 | } 30 | 31 | func (p *shmPool) Close() error { 32 | if err := unix.Munmap(p.data); err != nil { 33 | return err 34 | } 35 | if err := p.Destroy(); err != nil { 36 | return err 37 | } 38 | if err := unix.Close(p.fd); err != nil { 39 | return err 40 | } 41 | return nil 42 | } 43 | 44 | func (a *App) handleDisplayError(evt client.DisplayErrorEvent) { 45 | panic(evt) 46 | } 47 | 48 | func (a *App) handleShmFormat(evt client.ShmFormatEvent) { 49 | a.log.Trace(`reported available SHM format`, `format`, client.ShmFormat(evt.Format)) 50 | } 51 | 52 | func (a *App) handleRegistryGlobal(evt client.RegistryGlobalEvent) { 53 | a.log.Trace(`global object`, `name`, evt.Name, `interface`, evt.Interface, `version`, evt.Version) 54 | 55 | switch evt.Interface { 56 | case `wl_shm`: 57 | shm := client.NewShm(a.display.Context()) 58 | if err := a.registry.Bind(evt.Name, evt.Interface, evt.Version, shm); err != nil { 59 | a.log.Error(`failed binding SHM`, `err`, err) 60 | return 61 | } 62 | shm.SetFormatHandler(a.handleShmFormat) 63 | a.shm = shm 64 | case `hyprland_toplevel_export_manager_v1`: 65 | tl := NewHyprlandToplevelExportManagerV1(a.display.Context()) 66 | if err := a.registry.Bind(evt.Name, evt.Interface, evt.Version, tl); err != nil { 67 | a.log.Error(`failed binding toplevel export manager`, `err`, err) 68 | return 69 | } 70 | a.tl = tl 71 | } 72 | } 73 | 74 | func (a *App) createShmPool(size int32) (*shmPool, error) { 75 | fd, err := unix.MemfdSecret(0) 76 | if err != nil { 77 | return nil, fmt.Errorf(`failed creating memfd: %w`, err) 78 | } 79 | if err := unix.Ftruncate(fd, int64(size)); err != nil { 80 | return nil, fmt.Errorf(`failed truncating memfd: %w`, err) 81 | } 82 | 83 | data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) 84 | if err != nil { 85 | return nil, fmt.Errorf(`failed mmapping memfd: %w`, err) 86 | } 87 | 88 | pool, err := a.shm.CreatePool(fd, int32(size)) 89 | if err != nil { 90 | return nil, fmt.Errorf(`failed creating SHM pool: %w`, err) 91 | } 92 | 93 | return &shmPool{ 94 | ShmPool: pool, 95 | fd: fd, 96 | data: data, 97 | }, nil 98 | } 99 | 100 | func (a *App) roundTrip() error { 101 | cb, err := a.display.Sync() 102 | if err != nil { 103 | return err 104 | } 105 | defer func() { 106 | if err := cb.Destroy(); err != nil { 107 | a.log.Error(`failed destroying callback`, `err`, err) 108 | } 109 | }() 110 | 111 | done := make(chan struct{}) 112 | cb.SetDoneHandler(func(_ client.CallbackDoneEvent) { 113 | close(done) 114 | }) 115 | 116 | for { 117 | select { 118 | case <-done: 119 | return nil 120 | default: 121 | if err := a.display.Context().Dispatch(); err != nil { 122 | a.log.Trace(`dispatch error`, `err`, err) 123 | } 124 | } 125 | } 126 | } 127 | 128 | func (a *App) CaptureFrame(handle uint64) (*image.NRGBA, error) { 129 | if a.tl == nil { 130 | return nil, fmt.Errorf(`toplevel export manager not available`) 131 | } 132 | 133 | frame, err := a.tl.CaptureToplevel(0, uint32(handle)) 134 | if err != nil { 135 | return nil, err 136 | } 137 | defer func() { 138 | if err := frame.Destroy(); err != nil { 139 | a.log.Error(`failed destroying frame`, `err`, err) 140 | } 141 | }() 142 | 143 | formats := make([]HyprlandToplevelExportFrameV1BufferEvent, 0) 144 | done := make(chan struct{}) 145 | ready := make(chan struct{}) 146 | failed := make(chan error, 1) 147 | frame.SetBufferHandler(func(evt HyprlandToplevelExportFrameV1BufferEvent) { 148 | formats = append(formats, evt) 149 | }) 150 | frame.SetBufferDoneHandler(func(evt HyprlandToplevelExportFrameV1BufferDoneEvent) { 151 | close(done) 152 | }) 153 | frame.SetReadyHandler(func(evt HyprlandToplevelExportFrameV1ReadyEvent) { 154 | close(ready) 155 | }) 156 | frame.SetFailedHandler(func(evt HyprlandToplevelExportFrameV1FailedEvent) { 157 | failed <- fmt.Errorf(`frame failed`) 158 | }) 159 | 160 | if err := a.roundTrip(); err != nil { 161 | return nil, err 162 | } 163 | 164 | select { 165 | case <-done: 166 | case err := <-failed: 167 | return nil, err 168 | } 169 | 170 | if len(formats) == 0 { 171 | return nil, fmt.Errorf(`no buffer formats`) 172 | } 173 | 174 | var selected *HyprlandToplevelExportFrameV1BufferEvent 175 | OUTER: 176 | for _, format := range formats { 177 | switch client.ShmFormat(format.Format) { 178 | case client.ShmFormatArgb8888: 179 | selected = &format 180 | break OUTER 181 | case client.ShmFormatXrgb8888: 182 | selected = &format 183 | break OUTER 184 | } 185 | } 186 | 187 | if selected == nil { 188 | return nil, fmt.Errorf(`no suitable buffer format`) 189 | } 190 | 191 | pool, err := a.createShmPool(int32(selected.Height * selected.Stride)) 192 | if err != nil { 193 | return nil, err 194 | } 195 | defer func() { 196 | if err := pool.Close(); err != nil { 197 | a.log.Error(`failed closing SHM pool`, `err`, err) 198 | } 199 | }() 200 | 201 | buf, err := pool.CreateBuffer(0, int32(selected.Width), int32(selected.Height), int32(selected.Stride), selected.Format) 202 | if err != nil { 203 | return nil, err 204 | } 205 | defer func() { 206 | if err := buf.Destroy(); err != nil { 207 | a.log.Error(`failed destroying buffer`, `err`, err) 208 | } 209 | }() 210 | 211 | if err := frame.Copy(buf, 1); err != nil { 212 | return nil, err 213 | } 214 | 215 | if err := a.roundTrip(); err != nil { 216 | return nil, err 217 | } 218 | 219 | select { 220 | case <-ready: 221 | case err := <-failed: 222 | return nil, err 223 | } 224 | 225 | data := pool.Data() 226 | img := image.NewNRGBA(image.Rect(0, 0, int(selected.Width), int(selected.Height))) 227 | if len(img.Pix) < int(selected.Height)*int(selected.Stride) { 228 | return nil, fmt.Errorf(`image buffer too small`) 229 | } 230 | for y := range int(selected.Height) { 231 | for x := range int(selected.Width) { 232 | pix := data[y*int(selected.Stride)+(x*4) : y*int(selected.Stride)+(x*4)+4] 233 | col := color.NRGBA{} 234 | switch client.ShmFormat(selected.Format) { 235 | case client.ShmFormatArgb8888: 236 | col.A = pix[3] 237 | col.R = pix[2] 238 | col.G = pix[1] 239 | col.B = pix[0] 240 | case client.ShmFormatXrgb8888: 241 | col.A = 0xff 242 | col.R = pix[2] 243 | col.G = pix[1] 244 | col.B = pix[0] 245 | } 246 | img.SetNRGBA(x, y, col) 247 | } 248 | } 249 | 250 | return img, nil 251 | } 252 | 253 | func (a *App) Close() error { 254 | if a.tl != nil { 255 | if err := a.tl.Destroy(); err != nil { 256 | return err 257 | } 258 | } 259 | if a.shm != nil { 260 | if err := a.shm.Release(); err != nil { 261 | return nil 262 | } 263 | } 264 | if a.registry != nil { 265 | if err := a.registry.Destroy(); err != nil { 266 | return err 267 | } 268 | } 269 | if a.display != nil { 270 | if err := a.display.Destroy(); err != nil { 271 | return err 272 | } 273 | } 274 | return nil 275 | } 276 | 277 | func NewApp(log hclog.Logger) (*App, error) { 278 | display, err := client.Connect(``) 279 | if err != nil { 280 | return nil, err 281 | } 282 | 283 | registry, err := display.GetRegistry() 284 | if err != nil { 285 | return nil, err 286 | } 287 | 288 | app := &App{ 289 | display: display, 290 | registry: registry, 291 | log: log.Named(`wl`), 292 | } 293 | display.SetErrorHandler(app.handleDisplayError) 294 | registry.SetGlobalHandler(app.handleRegistryGlobal) 295 | 296 | // init registry 297 | if err := app.roundTrip(); err != nil { 298 | return nil, err 299 | } 300 | 301 | // get events 302 | if err := app.roundTrip(); err != nil { 303 | return nil, err 304 | } 305 | 306 | return app, nil 307 | } 308 | --------------------------------------------------------------------------------