├── .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 |
--------------------------------------------------------------------------------