├── .gitignore ├── configs ├── pinned.json ├── themes │ └── lotos │ │ ├── point │ │ ├── 0.svg │ │ ├── 1.svg │ │ ├── 3.svg │ │ └── 2.svg │ │ ├── lotos.jsonc │ │ └── style.css └── config.jsonc ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── other.md │ ├── feature_request.md │ └── bug_report.md ├── docs ├── tasks │ ├── ipc │ │ ├── structs.go │ │ ├── ipc-req.md │ │ ├── ipc-main.md │ │ ├── dockIPC-example.md │ │ └── dockIPC-task.md │ ├── cli │ │ ├── structs.go │ │ ├── req.go │ │ ├── out.md │ │ └── hypr-dock-ctl-example.md │ ├── test.md │ └── ipc-main.md └── customize │ ├── themes.md │ └── themes_RU.md ├── internal ├── pkg │ ├── utils │ │ ├── common.utils.go │ │ ├── os.utils.go │ │ └── gtk.utils.go │ ├── flags │ │ └── flags.go │ ├── signals │ │ └── signals.go │ ├── validate │ │ └── validate.go │ ├── timer │ │ └── timer.go │ ├── desktop │ │ ├── parse.go │ │ └── desktop.go │ ├── popup │ │ └── popup.go │ ├── indicator │ │ └── indicator.go │ └── cfg │ │ └── cfg.go ├── itemsctl │ └── itemsctl.go ├── hysc │ ├── masks.go │ ├── utils.go │ └── hysc.go ├── layerInfo │ └── layerInfo.go ├── hypr │ ├── hyprOpt │ │ └── hypr.option.go │ └── hyprEvents │ │ └── events.go ├── placeholders │ └── placeholders.go ├── detectZone │ └── detectZone.go ├── btnctl │ ├── utils.go │ └── btnctl.go ├── settings │ └── settings.go ├── state │ └── state.go ├── layering │ └── layering.go ├── item │ ├── popup.go │ └── item.go ├── app │ └── app.go ├── pvwidget │ └── pvwidget.go └── pvctl │ └── pvctl.go ├── go.mod ├── Makefile ├── pkg ├── ipc │ ├── ipc.go │ ├── getters.go │ ├── utils.go │ ├── structs.go │ └── listeners.go └── wl │ ├── app.go │ └── hyprland_toplevel_export.go ├── main └── main.go ├── go.sum ├── README_RU.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | configs/pinned.json 3 | project_structure.md -------------------------------------------------------------------------------- /configs/pinned.json: -------------------------------------------------------------------------------- 1 | { 2 | "Pinned": [ 3 | "firefox", 4 | "vlc", 5 | "one.ablaze.floorp" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | custom: [https://www.donationalerts.com/r/sujerux] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other 3 | about: Any other issue type 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | -------------------------------------------------------------------------------- /configs/themes/lotos/point/0.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/tasks/ipc/structs.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | type Action struct { 4 | Handler func(data ...string) error 5 | NeedsData bool 6 | Usage string 7 | Description string 8 | } 9 | 10 | type Command struct { 11 | Description string 12 | Actions map[string]Action 13 | Default *Action 14 | } 15 | 16 | // var commands map[string]Command 17 | -------------------------------------------------------------------------------- /docs/tasks/cli/structs.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | type Action struct { 4 | Handler func(data ...string) error 5 | NeedsData bool 6 | Usage string 7 | Description string 8 | } 9 | 10 | type Command struct { 11 | Description string 12 | Actions map[string]Action 13 | Default *Action 14 | } 15 | 16 | // var commands map[string]Command 17 | -------------------------------------------------------------------------------- /configs/themes/lotos/point/1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /configs/themes/lotos/point/3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /configs/themes/lotos/point/2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /configs/themes/lotos/lotos.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | // Blur window ("true", "false") (default "on") 3 | "Blur": "true", 4 | 5 | // Distance between elements (px) (default 9) 6 | "Spacing": 5, 7 | 8 | // Preview settings 9 | "PreviewStyle": { 10 | // Size (px) (default 120) 11 | "Size": 120, 12 | 13 | // Image/Stream border-radius (px) (default 0) 14 | "BorderRadius": 0, 15 | 16 | // Popup padding (px) (default 10) 17 | "Padding": 10 18 | } 19 | } -------------------------------------------------------------------------------- /internal/pkg/utils/common.utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func AddToSlice(slice *[]string, value string) { 4 | *slice = append(*slice, value) 5 | } 6 | 7 | func RemoveFromSliceByValue(slice *[]string, value string) { 8 | index := -1 9 | for i, v := range *slice { 10 | if v == value { 11 | index = i 12 | break 13 | } 14 | } 15 | 16 | if index != -1 { 17 | *slice = append((*slice)[:index], (*slice)[index+1:]...) 18 | } 19 | } 20 | 21 | func RemoveFromSlice(slice []map[string]string, s int) []map[string]string { 22 | return append(slice[:s], slice[s+1:]...) 23 | } 24 | -------------------------------------------------------------------------------- /internal/pkg/flags/flags.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | ) 7 | 8 | type Flags struct { 9 | DevMode bool 10 | Config string 11 | Theme string 12 | } 13 | 14 | func Get(defaultTheme string) Flags { 15 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 16 | 17 | dev := flag.Bool("dev", false, "enable developer mode") 18 | config := flag.String("config", "~/.config/hypr-dock", "config file") 19 | theme := flag.String("theme", defaultTheme, "theme dir") 20 | flag.Parse() 21 | 22 | return Flags{ 23 | DevMode: *dev, 24 | Config: *config, 25 | Theme: *theme, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/tasks/test.md: -------------------------------------------------------------------------------- 1 | #app.gtx 2 | ```tsx 3 | package main 4 | 5 | import "user" 6 | 7 | var users = []string{"Alice", "Bob", "Charlie"} 8 | 9 | func Render() gtk.Widget { 10 | return 11 | {for _, name := range users { 12 | 13 | }} 14 | 15 | } 16 | ``` 17 | 18 | #user.gtx 19 | ```tsx 20 | package user 21 | 22 | type Props struct { 23 | Name string 24 | } 25 | 26 | func Render(props Props) gtk.Widget { 27 | return 28 | 30 | } 31 | ``` -------------------------------------------------------------------------------- /internal/pkg/signals/signals.go: -------------------------------------------------------------------------------- 1 | package signals 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/gotk3/gotk3/gtk" 10 | ) 11 | 12 | func Handler() { 13 | signalChanel := make(chan os.Signal, 1) 14 | signal.Notify(signalChanel, syscall.SIGTERM, syscall.SIGUSR1) 15 | 16 | go func() { 17 | for { 18 | signalU := <-signalChanel 19 | switch signalU { 20 | case syscall.SIGTERM: 21 | log.Println("Exit... (SIGTERM)") 22 | gtk.MainQuit() 23 | case syscall.SIGUSR1: 24 | log.Println("Exit... (SIGUSR1)") 25 | gtk.MainQuit() 26 | default: 27 | log.Println("Unknow signal") 28 | } 29 | } 30 | }() 31 | } 32 | -------------------------------------------------------------------------------- /docs/tasks/cli/req.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import "strings" 4 | 5 | func ParseRequest(request string) (command string, action string, data string, json bool) { 6 | request = strings.TrimSpace(request) 7 | if request == "" { 8 | return "", "", "", false 9 | } 10 | 11 | if strings.HasPrefix(request, "j/") { 12 | json = true 13 | request = request[2:] 14 | request = strings.TrimSpace(request) 15 | } 16 | 17 | parts := strings.SplitN(request, " ", 3) 18 | 19 | if len(parts) > 0 { 20 | command = parts[0] 21 | } 22 | if len(parts) > 1 { 23 | action = parts[1] 24 | } 25 | if len(parts) > 2 { 26 | data = parts[2] 27 | } 28 | 29 | return command, action, data, json 30 | } 31 | -------------------------------------------------------------------------------- /internal/itemsctl/itemsctl.go: -------------------------------------------------------------------------------- 1 | package itemsctl 2 | 3 | import "hypr-dock/internal/item" 4 | 5 | type List struct { 6 | list map[string]*item.Item 7 | } 8 | 9 | func New() *List { 10 | return &List{ 11 | list: make(map[string]*item.Item), 12 | } 13 | } 14 | 15 | func (l *List) GetMap() map[string]*item.Item { 16 | return l.list 17 | } 18 | 19 | func (l *List) Get(className string) *item.Item { 20 | return l.list[className] 21 | } 22 | 23 | func (l *List) Add(className string, item *item.Item) { 24 | l.list[className] = item 25 | } 26 | 27 | func (l *List) Remove(className string) { 28 | delete(l.list, className) 29 | } 30 | 31 | func (l *List) Len() int { 32 | return len(l.list) 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module hypr-dock 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/allan-simon/go-singleinstance v0.0.0-20210120080615-d0997106ab37 9 | github.com/dlasky/gotk3-layershell v0.0.0-20240515133811-5c5115f0d774 10 | github.com/goccy/go-json v0.10.3 11 | github.com/gotk3/gotk3 v0.6.3 12 | github.com/hashicorp/go-hclog v1.6.3 13 | github.com/pdf/go-wayland v0.0.2 14 | github.com/pkg/errors v0.9.1 15 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a 16 | golang.org/x/sys v0.33.0 17 | ) 18 | 19 | require ( 20 | github.com/fatih/color v1.13.0 // indirect 21 | github.com/mattn/go-colorable v0.1.12 // indirect 22 | github.com/mattn/go-isatty v0.0.14 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LOCAL_CONFIG_DIR = $(HOME)/.config/hypr-dock 2 | 3 | PROJECT_BIN_DIR = bin 4 | PROJECT_CONFIG_DIR = configs 5 | 6 | EXECUTABLE = hypr-dock 7 | 8 | install: 9 | sudo cp $(PROJECT_BIN_DIR)/$(EXECUTABLE) /usr/bin/ 10 | 11 | mkdir -p $(LOCAL_CONFIG_DIR) 12 | cp -r $(PROJECT_CONFIG_DIR)/* $(LOCAL_CONFIG_DIR)/ 13 | 14 | @echo -e "\033[32mInstallation completed." 15 | 16 | uninstall: 17 | sudo rm -f /usr/bin/$(EXECUTABLE) 18 | 19 | rm -rf $(LOCAL_CONFIG_DIR) 20 | 21 | @echo -e "\033[32mInstallation removed." 22 | 23 | update: 24 | sudo rm -f /usr/bin/$(EXECUTABLE) 25 | sudo cp $(PROJECT_BIN_DIR)/$(EXECUTABLE) /usr/bin/ 26 | 27 | @echo -e "\033[32mUpdating comleted." 28 | 29 | get: 30 | go mod tidy 31 | 32 | build: 33 | go build -v -o bin/hypr-dock ./main/. 34 | 35 | exec: 36 | ./bin/hypr-dock -dev 37 | -------------------------------------------------------------------------------- /configs/themes/lotos/style.css: -------------------------------------------------------------------------------- 1 | window { 2 | background-color: transparent; 3 | } 4 | 5 | #app { 6 | background-color: rgba(42, 41, 49, 0.473); 7 | border-radius: 12px; 8 | padding: 5px; 9 | } 10 | 11 | button { 12 | background-color: rgba(0, 0, 0, 0); 13 | padding: 4px; 14 | border-radius: 12px; 15 | border: none; 16 | } 17 | 18 | #menu-item { 19 | padding: 3px; 20 | padding-left: 0; 21 | } 22 | 23 | menu { 24 | border: none; 25 | box-shadow: none; 26 | outline: none; 27 | background-image: none; 28 | } 29 | 30 | menuitem { 31 | padding: 3px 6px; 32 | } 33 | 34 | #pv-item { 35 | background-color: rgba(42, 41, 49, 0.473); 36 | transition: all .2s ease-out; 37 | border-radius: 5px; 38 | border: 1px solid rgba(255, 255, 255, 0.062); 39 | } 40 | 41 | #pv-item.hover { 42 | background-color: rgba(67, 66, 75, 0.301); 43 | 44 | 45 | } 46 | -------------------------------------------------------------------------------- /docs/customize/themes.md: -------------------------------------------------------------------------------- 1 | ## Open Window Indicators 2 | #### Place indicator images in `~/.config/hypr-dock/themes/[theme_name]/point`. 3 | **Default set:** `0.svg` `1.svg` `2.svg` `3.svg` 4 | 5 | ![Preview](https://github.com/user-attachments/assets/9f9cb607-c0c7-48ef-9379-266f2b253246) 6 | ![Preview](https://github.com/user-attachments/assets/46b0e75f-2212-4e54-a2b3-02edf3965142) 7 | 8 | ### Customization 9 | - Delete the `point` folder/files to **disable indicators entirely** 10 | - Supported formats: `.svg`, `.png`, `.jpg`, `.webp` 11 | - **Minimum requirement:** Two files (`0.*` and any other) 12 | - Naming must match window counts (e.g. `5.png` for 5 windows) 13 | - Add unlimited files in any order 14 | 15 | ### Logic Example 16 | #### For set: `0.svg` `3.png` `10.jpg` 17 | - **0-2 windows:** Uses `0.svg` 18 | - **3-9 windows:** Uses `3.png` 19 | - **10+ windows:** Uses `10.jpg` -------------------------------------------------------------------------------- /docs/customize/themes_RU.md: -------------------------------------------------------------------------------- 1 | ## Индикаторы открытых окон 2 | #### Изображения для индикации находятся в `~/.config/hypr-dock/themes/[имя_темы]/point` 3 | **Стандартный набор:** `0.svg` `1.svg` `2.svg` `3.svg` 4 | 5 | ![Пример](https://github.com/user-attachments/assets/9f9cb607-c0c7-48ef-9379-266f2b253246) 6 | ![Пример](https://github.com/user-attachments/assets/46b0e75f-2212-4e54-a2b3-02edf3965142) 7 | 8 | ### Кастомизация 9 | - Удалите папку `point` или файлы в ней, чтобы **полностью отключить индикаторы** 10 | - Поддерживаемые форматы: `.svg`, `.png`, `.jpg`, `.webp` 11 | - **Минимальные требования:** Два файла (`0.*` и любой другой) 12 | - Имена должны соответствовать количеству окон (например `5.png` для 5 окон) 13 | - Можно добавлять неограниченное количество файлов в любом порядке 14 | 15 | ### Пример работы 16 | #### Для набора: `0.svg` `3.png` `10.jpg` 17 | - **0-2 окна:** Используется `0.svg` 18 | - **3-9 окон:** Используется `3.png` 19 | - **10+ окон:** Используется `10.jpg` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Hypr-dock 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Feature request 11 | 12 | ### Description 13 | 14 | 15 | ### Use case 16 | 17 | 18 | ### Proposed solution 19 | 20 | 21 | ### Alternatives considered 22 | 23 | 24 | ### Additional context 25 | Add any other context or screenshots about the feature request here. This could include: 26 | - Links to relevant documentation or discussions 27 | - Screenshots or mockups 28 | - Any other relevant information 29 | 30 | -------------------------------------------------------------------------------- /internal/hysc/masks.go: -------------------------------------------------------------------------------- 1 | package hysc 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/gotk3/gotk3/gdk" 7 | ) 8 | 9 | func ApplyVector(p *gdk.Pixbuf, pos Cord, size Size, mask func(Cord) float64) { 10 | if p.GetNChannels() != 4 { 11 | return 12 | } 13 | 14 | pixels := p.GetPixels() 15 | stride := p.GetRowstride() 16 | width, height := p.GetWidth(), p.GetHeight() 17 | 18 | x0 := max(0, pos.X) 19 | y0 := max(0, pos.Y) 20 | x1 := min(pos.X+size.W, width) 21 | y1 := min(pos.Y+size.H, height) 22 | 23 | for y := y0; y < y1; y++ { 24 | rowOffset := y * stride 25 | relY := y - pos.Y 26 | 27 | for x := x0; x < x1; x++ { 28 | alpha := mask(Cord{x - pos.X, relY}) 29 | offset := rowOffset + x*4 + 3 30 | pixels[offset] = uint8(alpha * 255) 31 | } 32 | } 33 | } 34 | 35 | func radiusmask(pixel Cord, center Cord, R float64) float64 { 36 | dx := float64(pixel.X - center.X) 37 | dy := float64(pixel.Y - center.Y) 38 | distance := math.Sqrt(dx*dx + dy*dy) 39 | 40 | if distance <= R-1.0 { 41 | return 1.0 42 | } 43 | if distance >= R+1.0 { 44 | return 0.0 45 | } 46 | 47 | t := (distance - (R - 1.0)) / 2.0 48 | return 1.0 - t*t 49 | } 50 | -------------------------------------------------------------------------------- /internal/pkg/utils/os.utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | ) 10 | 11 | func Launch(command string) { 12 | cmd := exec.Command("sh", "-c", command) 13 | log.Printf("Launching command: %s\n", command) 14 | 15 | if err := cmd.Start(); err != nil { 16 | log.Printf("Unable to launch command: %s, error: %v\n", command, err) 17 | } 18 | } 19 | 20 | func LoadTextFile(path string) ([]string, error) { 21 | bytes, err := os.ReadFile(path) 22 | if err != nil { 23 | return nil, err 24 | } 25 | lines := strings.Split(string(bytes), "\n") 26 | var output []string 27 | for _, line := range lines { 28 | line = strings.TrimSpace(line) 29 | if line != "" { 30 | output = append(output, line) 31 | } 32 | 33 | } 34 | return output, nil 35 | } 36 | 37 | func TempDir() string { 38 | if os.Getenv("TMPDIR") != "" { 39 | return os.Getenv("TMPDIR") 40 | } else if os.Getenv("TEMP") != "" { 41 | return os.Getenv("TEMP") 42 | } else if os.Getenv("TMP") != "" { 43 | return os.Getenv("TMP") 44 | } 45 | return "/tmp" 46 | } 47 | 48 | func FileExists(path string) bool { 49 | _, err := os.Stat(path) 50 | return !errors.Is(err, os.ErrNotExist) 51 | } 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue with Hypr-dock 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Start hypr-dock with command `hypr-dock` 17 | 2. Click something 18 | 3. Something is broken 19 | 20 | **Expected behavior** 21 | 22 | 23 | **Setup information:** 24 | - Operating System: [e.g. Arch Linux, Ubuntu 22.10] 25 | - Output of `hyprctl version`: 26 | 27 | ``` 28 | 29 | ``` 30 | - Hypr-dock version or commit: [e.g. 0.1.0] 31 | 32 | **Configuration** 33 | 34 | 35 |
Main config 36 | 37 | ```jsonc 38 | 39 | ``` 40 | 41 |
42 | 43 | 44 |
Themes 45 | 46 | ```jsonc 47 | 48 | ``` 49 | 50 |
51 | 52 | **Additional context** 53 | 54 | 55 | **Screenshots** 56 | 57 | 58 | -------------------------------------------------------------------------------- /internal/hysc/utils.go: -------------------------------------------------------------------------------- 1 | package hysc 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/gotk3/gotk3/gdk" 10 | ) 11 | 12 | func getHandle(address string) (uint64, error) { 13 | prefix := "0x" 14 | 15 | address = strings.TrimPrefix(address, prefix) 16 | 17 | handle, err := strconv.ParseUint(address, 16, 64) 18 | if err != nil { 19 | return 0, fmt.Errorf("failed to parse address: %v", err) 20 | } 21 | 22 | return handle, nil 23 | } 24 | 25 | func nRGBAtoPixbuf(img *image.NRGBA) (*gdk.Pixbuf, error) { 26 | width := img.Bounds().Dx() 27 | height := img.Bounds().Dy() 28 | 29 | pixbuf, err := gdk.PixbufNew(gdk.COLORSPACE_RGB, true, 8, width, height) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | pixels := pixbuf.GetPixels() 35 | 36 | for y := 0; y < height; y++ { 37 | for x := 0; x < width; x++ { 38 | srcOffset := y*img.Stride + x*4 39 | dstOffset := (y*width + x) * 4 40 | 41 | copy( 42 | pixels[dstOffset:dstOffset+4], 43 | img.Pix[srcOffset:srcOffset+4], 44 | ) 45 | } 46 | } 47 | 48 | return pixbuf, nil 49 | } 50 | 51 | func min(a, b int) int { 52 | if a < b { 53 | return a 54 | } 55 | return b 56 | } 57 | 58 | func max(a, b int) int { 59 | if a > b { 60 | return a 61 | } 62 | return b 63 | } 64 | -------------------------------------------------------------------------------- /pkg/ipc/ipc.go: -------------------------------------------------------------------------------- 1 | package ipc 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "time" 7 | ) 8 | 9 | func Hyprctl(cmd string) (response []byte, err error) { 10 | conn, err := net.Dial("unix", getUnixSockAdress()) 11 | if err != nil { 12 | return nil, err 13 | } 14 | 15 | message := []byte(cmd) 16 | _, err = conn.Write(message) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | response = make([]byte, 102400) 22 | n, err := conn.Read(response) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | defer conn.Close() 28 | 29 | return response[:n], nil 30 | } 31 | 32 | func InitHyprEvents() { 33 | for { 34 | unixConnect, err := net.DialUnix("unix", nil, getUnixSock2Adress()) 35 | if err != nil { 36 | log.Printf("Failed to connect to Unix socket: %v. Retrying in 5 seconds...", err) 37 | time.Sleep(5 * time.Second) 38 | continue 39 | } 40 | defer unixConnect.Close() 41 | 42 | for { 43 | buffer := make([]byte, 10240) 44 | unixNumber, err := unixConnect.Read(buffer) 45 | if err != nil { 46 | log.Printf("Error reading from Unix socket: %v. Reconnecting...", err) 47 | break 48 | } 49 | 50 | events := splitEvent(string(buffer[:unixNumber])) 51 | for _, event := range events { 52 | go DispatchEvent(event) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/pkg/validate/validate.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "log" 5 | "slices" 6 | ) 7 | 8 | func Preview(value string, runtime bool) bool { 9 | validList := []string{"live", "static", "none"} 10 | return Allowed("Preview", value, validList, runtime, true) 11 | } 12 | 13 | func Layer(value string, runtime bool) bool { 14 | validList := []string{"auto", "exclusive-top", "exclusive-bottom", "background", "bottom", "top", "overlay"} 15 | return Allowed("Layer", value, validList, runtime, true) 16 | } 17 | 18 | func Position(value string, runtime bool) bool { 19 | validList := []string{"left", "right", "top", "bottom"} 20 | return Allowed("Position", value, validList, runtime, true) 21 | } 22 | 23 | func Blur(value string, runtime bool) bool { 24 | validList := []string{"true", "false"} 25 | return Allowed("Blur", value, validList, runtime, false) 26 | } 27 | 28 | func SystemGapUsed(value string, runtime bool) bool { 29 | validList := []string{"true", "false"} 30 | return Allowed("SystemGapUsed", value, validList, runtime, true) 31 | } 32 | 33 | func Allowed[T comparable](key string, value T, validList []T, runtime bool, logs bool) bool { 34 | if slices.Contains(validList, value) { 35 | return true 36 | } 37 | 38 | if !logs { 39 | return false 40 | } 41 | 42 | if runtime { 43 | log.Printf("%s \"%v\" is incorrect or empty", key, value) 44 | return false 45 | } 46 | 47 | log.Printf("%s \"%v\" is incorrect or empty. Default value will be used", key, value) 48 | return false 49 | } 50 | -------------------------------------------------------------------------------- /pkg/ipc/getters.go: -------------------------------------------------------------------------------- 1 | package ipc 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/goccy/go-json" 8 | ) 9 | 10 | func GetMonitors() ([]Monitor, error) { 11 | var monitors []Monitor 12 | response, err := Hyprctl("j/monitors") 13 | if err != nil { 14 | return nil, err 15 | } 16 | err = json.Unmarshal([]byte(response), &monitors) 17 | return monitors, err 18 | } 19 | 20 | func GetClients() ([]Client, error) { 21 | var clients []Client 22 | response, err := Hyprctl("j/clients") 23 | if err != nil { 24 | return nil, err 25 | } 26 | err = json.Unmarshal([]byte(response), &clients) 27 | return clients, err 28 | } 29 | 30 | func GetActiveWindow() (*Client, error) { 31 | var activeWindow Client 32 | response, err := Hyprctl("j/activewindow") 33 | if err != nil { 34 | log.Printf("Failed to get active window: %v", err) 35 | return nil, err 36 | } 37 | err = json.Unmarshal([]byte(response), &activeWindow) 38 | if err != nil { 39 | log.Printf("Failed to unmarshal active window: %v", err) 40 | return nil, err 41 | } 42 | return &activeWindow, nil 43 | } 44 | 45 | func GetOption(option string, v interface{}) error { 46 | cmd := fmt.Sprintf("j/getoption %s", option) 47 | response, err := Hyprctl(cmd) 48 | if err != nil { 49 | log.Printf("Failed to execute Hyprctl command for option '%s': %v", option, err) 50 | return err 51 | } 52 | err = json.Unmarshal(response, v) 53 | if err != nil { 54 | log.Printf("Failed to unmarshal JSON response for option '%s': %v", option, err) 55 | return err 56 | } 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/layerInfo/layerInfo.go: -------------------------------------------------------------------------------- 1 | package layerinfo 2 | 3 | import ( 4 | "fmt" 5 | "hypr-dock/pkg/ipc" 6 | 7 | "github.com/goccy/go-json" 8 | ) 9 | 10 | type Layer struct { 11 | Address string `json:"address"` 12 | X int `json:"x"` 13 | Y int `json:"y"` 14 | W int `json:"w"` 15 | H int `json:"h"` 16 | Namespace string `json:"namespace"` 17 | Pid int `json:"pid"` 18 | 19 | Monitor string 20 | Layer string 21 | } 22 | 23 | type Monitor struct { 24 | Levels map[string][]Layer `json:"levels"` 25 | } 26 | 27 | func GetDock() (*Layer, error) { 28 | return Get("hypr-dock") 29 | } 30 | 31 | func Get(namespace string) (*Layer, error) { 32 | jsonData, err := ipc.Hyprctl("j/layers") 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | var monitors map[string]Monitor 38 | 39 | err = json.Unmarshal(jsonData, &monitors) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | for monitorName, monitor := range monitors { 45 | for layerName, layers := range monitor.Levels { 46 | for _, layer := range layers { 47 | if layer.Namespace == namespace { 48 | layer.Monitor = monitorName 49 | layer.Layer = layerName 50 | return &layer, nil 51 | } 52 | } 53 | } 54 | } 55 | 56 | return nil, fmt.Errorf("%s layer not found", namespace) 57 | } 58 | 59 | func GetMonitor() *ipc.Monitor { 60 | dock, _ := GetDock() 61 | monitors, _ := ipc.GetMonitors() 62 | for _, monitor := range monitors { 63 | if monitor.Name == dock.Monitor { 64 | return &monitor 65 | } 66 | } 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /pkg/ipc/utils.go: -------------------------------------------------------------------------------- 1 | package ipc 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "net" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | func SearchClientByAddress(address string) (Client, error) { 13 | clients, err := GetClients() 14 | if err != nil { 15 | log.Println(err) 16 | return Client{}, err 17 | } 18 | 19 | for _, ipcClient := range clients { 20 | if ipcClient.Address == address { 21 | return ipcClient, nil 22 | } 23 | } 24 | 25 | err = errors.New("Client non found by address: " + address) 26 | return Client{}, err 27 | } 28 | 29 | func getHyprPathes() (XDGRuntimeDirHypr string, HIS string) { 30 | XDGRuntimeDirHypr = filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), "hypr") 31 | HIS = os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") 32 | return XDGRuntimeDirHypr, HIS 33 | } 34 | 35 | func getUnixSockAdress() (unixSockAdress string) { 36 | XDGRuntimeDirHypr, HIS := getHyprPathes() 37 | 38 | return filepath.Join(XDGRuntimeDirHypr, HIS, ".socket.sock") 39 | } 40 | 41 | func getUnixSock2Adress() (unixSock2Adress *net.UnixAddr) { 42 | XDGRuntimeDirHypr, HIS := getHyprPathes() 43 | 44 | return &net.UnixAddr{ 45 | Name: filepath.Join(XDGRuntimeDirHypr, HIS, ".socket2.sock"), 46 | Net: "unix", 47 | } 48 | } 49 | 50 | func splitEvent(multiLineEvent string) []string { 51 | events := strings.Split(multiLineEvent, "\n") 52 | 53 | var filteredEvents []string 54 | for _, event := range events { 55 | event = strings.TrimSpace(event) 56 | if event != "" { 57 | filteredEvents = append(filteredEvents, event) 58 | } 59 | } 60 | 61 | return filteredEvents 62 | } 63 | -------------------------------------------------------------------------------- /main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strconv" 8 | "syscall" 9 | 10 | "github.com/allan-simon/go-singleinstance" 11 | "github.com/gotk3/gotk3/gtk" 12 | 13 | "hypr-dock/internal/app" 14 | "hypr-dock/internal/hypr/hyprEvents" 15 | "hypr-dock/internal/layering" 16 | "hypr-dock/internal/pkg/signals" 17 | "hypr-dock/internal/pkg/utils" 18 | "hypr-dock/internal/settings" 19 | "hypr-dock/internal/state" 20 | ) 21 | 22 | func main() { 23 | signals.Handler() 24 | 25 | lockFilePath := fmt.Sprintf("%s/hypr-dock-%s.lock", utils.TempDir(), os.Getenv("USER")) 26 | lockFile, err := singleinstance.CreateLockFile(lockFilePath) 27 | if err != nil { 28 | file, err := utils.LoadTextFile(lockFilePath) 29 | if err == nil { 30 | pidStr := file[0] 31 | pidInt, _ := strconv.Atoi(pidStr) 32 | syscall.Kill(pidInt, syscall.SIGUSR1) 33 | } 34 | os.Exit(0) 35 | } 36 | defer lockFile.Close() 37 | 38 | // window build 39 | settings, err := settings.Init() 40 | if err != nil { 41 | log.Println("Settings init error: ", err) 42 | } 43 | 44 | gtk.Init(nil) 45 | 46 | appState := state.New(settings) 47 | 48 | window, err := gtk.WindowNew(gtk.WINDOW_TOPLEVEL) 49 | if err != nil { 50 | log.Fatal("Unable to create window:", err) 51 | } 52 | appState.SetWindow(window) 53 | 54 | window.SetTitle("hypr-dock") 55 | 56 | layering.SetWindowProperty(appState) 57 | 58 | err = utils.AddCssProvider(settings.CurrentThemeStylePath) 59 | if err != nil { 60 | log.Println("CSS file not found, the default GTK theme is running!\n", err) 61 | } 62 | 63 | app := app.BuildApp(appState) 64 | 65 | window.Add(app) 66 | window.Connect("destroy", func() { gtk.MainQuit() }) 67 | window.ShowAll() 68 | 69 | // post 70 | hyprEvents.Init(appState) 71 | 72 | // end 73 | gtk.Main() 74 | } 75 | -------------------------------------------------------------------------------- /internal/hypr/hyprOpt/hypr.option.go: -------------------------------------------------------------------------------- 1 | package hyprOpt 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "hypr-dock/pkg/ipc" 7 | "log" 8 | "math" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | type gapsOut struct { 14 | Option string `json:"option"` 15 | Custom string `json:"custom"` 16 | Set bool `json:"set"` 17 | } 18 | 19 | func GetGap() ([]int, error) { 20 | option := "general:gaps_out" 21 | 22 | gapsVal := gapsOut{} 23 | err := ipc.GetOption(option, &gapsVal) 24 | if err != nil { 25 | err = fmt.Errorf("failed to get option \"%s\": %v", option, err) 26 | log.Println(err) 27 | return nil, err 28 | } 29 | 30 | if !gapsVal.Set { 31 | errorText := fmt.Sprintf("value \"%s\" is unset", option) 32 | log.Println(errorText) 33 | return nil, errors.New(errorText) 34 | } 35 | 36 | if gapsVal.Custom == "" { 37 | errorText := fmt.Sprintf("value \"%s\" is empty", option) 38 | log.Println(errorText) 39 | return nil, errors.New(errorText) 40 | } 41 | 42 | outValues := []int{} 43 | gapsVal.Custom = strings.TrimSpace(gapsVal.Custom) 44 | values := strings.Split(gapsVal.Custom, " ") 45 | for _, value := range values { 46 | intValue, err := strconv.ParseFloat(value, 64) 47 | if err != nil { 48 | err = fmt.Errorf("failed to convert \"%s\" to int: %v", value, err) 49 | log.Println(err) 50 | return nil, err 51 | } 52 | 53 | outValues = append(outValues, int(math.Round(math.Max(intValue, 0)))) 54 | } 55 | 56 | return outValues, nil 57 | } 58 | 59 | func GapChangeEvent(handler func(gap int)) { 60 | var preGap int 61 | ipc.AddEventListener("configreloaded", func(e string) { 62 | gaps, err := GetGap() 63 | if err != nil { 64 | log.Println("Reading gap error", err) 65 | return 66 | } 67 | 68 | if gaps[0] == preGap { 69 | return 70 | } 71 | 72 | preGap = gaps[0] 73 | handler(gaps[0]) 74 | }, true) 75 | } 76 | -------------------------------------------------------------------------------- /configs/config.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "CurrentTheme": "lotos", 3 | 4 | // Icon size (px) (default 23) 5 | "IconSize": 23, 6 | 7 | // Window overlay layer height (auto, exclusive-top, exclusive-bottom, background, bottom, top, overlay) (default "auto") 8 | "Layer": "exclusive-bottom", 9 | 10 | // Window position on screen (top, bottom, left, right) (default "bottom") 11 | "Position": "bottom", 12 | 13 | // Delay before hiding the dock (ms) (default 400) 14 | "AutoHideDeley": 400, // *Only for "Layer": "auto"* 15 | 16 | // Use system gap (true, false) (default "true") 17 | "SystemGapUsed": "true", 18 | 19 | // Indent from the edge of the screen (px) (default 8) 20 | "Margin": 8, 21 | 22 | // Distance of the context menu from the window (px) (default 0) 23 | "ContextPos": 5, 24 | 25 | 26 | // Window thumbnail mode selection (none, live, static) (default "none") 27 | "Preview": "none", 28 | /* 29 | "none" - disabled (text menus) 30 | "static" - last window frame (stable) 31 | "live" - window streaming (unstable) !EXPEREMENTAL! 32 | 33 | !WARNING! 34 | BY SETTING "Preview" TO "live" OR "static", YOU AGREE TO THE CAPTURE 35 | OF WINDOW CONTENTS. 36 | THE "HYPR-DOCK" PROGRAM DOES NOT COLLECT, STORE, OR TRANSMIT ANY DATA. 37 | WINDOW CAPTURE OCCURS ONLY FOR THE DURATION OF THE THUMBNAIL DISPLAY! 38 | 39 | Source code: https://github.com/lotos-linux/hypr-dock 40 | */ 41 | 42 | "PreviewAdvanced": { 43 | // Live preview fps (0 - ∞) (default 30) 44 | "FPS": 30, 45 | 46 | // Live preview bufferSize (1 - 20) (default 5) 47 | "BufferSize": 5, 48 | 49 | // Popup show/hide/move delays (ms) 50 | "ShowDelay": 600, // (default 600) 51 | "HideDelay": 300, // (default 300) 52 | "MoveDelay": 200 // (default 200) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /docs/tasks/cli/out.md: -------------------------------------------------------------------------------- 1 | # **Спецификация текстового формата ответов** 2 | 3 | ## **1. Форматы ответов** 4 | 5 | ### **1.1 Успешный ответ с данными** 6 | ``` 7 | <ключ>: <значение> 8 | <вложенный_ключ>: 9 | <подключ>: <значение> 10 | ``` 11 | 12 | **Пример:** 13 | ``` 14 | user: 15 | id: 42 16 | name: Ivan Petrov 17 | permissions: 18 | - read 19 | - write 20 | ``` 21 | 22 | ### **1.2 Успешный ответ без данных** 23 | ``` 24 | ok: <сообщение> 25 | ``` 26 | 27 | **Пример:** 28 | ``` 29 | ok: settings updated successfully 30 | ``` 31 | 32 | ### **1.3 Ответ с ошибкой** 33 | ``` 34 | error: <текст ошибки> [<код>] 35 | ``` 36 | 37 | **Пример:** 38 | ``` 39 | error: file not found [1] 40 | ``` 41 | 42 | ## **2. Таблица стандартных форматов** 43 | 44 | | Тип ответа | Формат вывода | Примеры | 45 | |---------------------|-----------------------------------|----------------------------------| 46 | | Успех с данными | Ключ-значение с табами | `id: 42`
`name: Ivan` | 47 | | Успех без данных | `ok: <сообщение>` | `ok: operation completed` | 48 | | Ошибка | `error: <текст> [<код>]` | `error: invalid input [2]` | 49 | | Вложенные данные | С отступом 4 пробела | `settings:`
` theme: dark` | 50 | | Списки значений | С дефисом и отступом | `- read`
`- write` | 51 | 52 | ## **3. Особенности форматирования** 53 | 54 | 1. **Отступы**: 55 | - Каждый уровень вложенности: 4 пробела 56 | - Выравнивание значений после двоеточия 57 | 58 | 2. **Списки**: 59 | - Каждый элемент с новой строки 60 | - Префикс в виде дефиса и пробела 61 | 62 | 3. **Ошибки**: 63 | - Всегда начинаются с `error: ` 64 | - Код ошибки в квадратных скобках 65 | 66 | ## **4. Примеры вывода** 67 | 68 | ### **Комплексные данные** 69 | ``` 70 | server: 71 | id: srv-01 72 | status: active 73 | ips: 74 | - 192.168.1.1 75 | - 10.0.0.1 76 | config: 77 | max_connections: 100 78 | timeout: 30 79 | ``` 80 | 81 | ### **Простое подтверждение** 82 | ``` 83 | ok: cache cleared 84 | ``` 85 | 86 | ### **Ошибка с кодом** 87 | ``` 88 | error: connection timeout [5] 89 | ``` -------------------------------------------------------------------------------- /internal/pkg/timer/timer.go: -------------------------------------------------------------------------------- 1 | package timer 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type Timer struct { 9 | timer *time.Timer 10 | active bool 11 | startTime time.Time 12 | mu sync.Mutex 13 | handler func() // текущий handler (только для активного таймера) 14 | duration time.Duration // текущая длительность (только для активного таймера) 15 | } 16 | 17 | func New() *Timer { 18 | return &Timer{} 19 | } 20 | 21 | func (t *Timer) Run(ms int, handler func()) { 22 | t.mu.Lock() 23 | defer t.mu.Unlock() 24 | 25 | t.stopUnsafe() // останавливаем предыдущий таймер, если был 26 | 27 | t.active = true 28 | t.startTime = time.Now() 29 | t.handler = handler 30 | t.duration = time.Duration(ms) * time.Millisecond 31 | 32 | t.timer = time.AfterFunc(t.duration, func() { 33 | t.mu.Lock() 34 | defer t.mu.Unlock() 35 | 36 | if t.active { 37 | t.active = false 38 | t.handler() 39 | } 40 | }) 41 | } 42 | 43 | func (t *Timer) Stop() { 44 | t.mu.Lock() 45 | defer t.mu.Unlock() 46 | t.stopUnsafe() 47 | } 48 | 49 | func (t *Timer) stopUnsafe() { 50 | if !t.active { 51 | return 52 | } 53 | 54 | t.timer.Stop() 55 | t.active = false 56 | // Очищаем временные данные 57 | t.handler = nil 58 | t.duration = 0 59 | } 60 | 61 | func (t *Timer) ExecIf(check func(elapsedMs int) bool) bool { 62 | t.mu.Lock() 63 | defer t.mu.Unlock() 64 | 65 | if !t.active { 66 | return false 67 | } 68 | 69 | elapsed := time.Since(t.startTime) 70 | if check(int(elapsed.Milliseconds())) { 71 | t.triggerUnsafe() 72 | return true 73 | } 74 | return false 75 | } 76 | 77 | func (t *Timer) ExecNow() bool { 78 | t.mu.Lock() 79 | defer t.mu.Unlock() 80 | 81 | if !t.active { 82 | return false 83 | } 84 | 85 | t.triggerUnsafe() 86 | return true 87 | } 88 | 89 | func (t *Timer) IsRunning() bool { 90 | t.mu.Lock() 91 | defer t.mu.Unlock() 92 | return t.active 93 | } 94 | 95 | func (t *Timer) triggerUnsafe() { 96 | if !t.active || t.handler == nil { 97 | return 98 | } 99 | 100 | t.timer.Stop() 101 | t.active = false 102 | handler := t.handler 103 | // Очищаем временные данные перед вызовом handler 104 | t.handler = nil 105 | t.duration = 0 106 | 107 | handler() 108 | } 109 | -------------------------------------------------------------------------------- /docs/tasks/ipc/ipc-req.md: -------------------------------------------------------------------------------- 1 | # **Стандарт ответов через IPC-сокет** 2 | 3 | ## **1. Форматы ответов** 4 | 5 | ### **1.1 Успешный ответ** 6 | #### Текстовый режим (без `j/`): 7 | ``` 8 | <ключ>: <значение> 9 | <вложенный_ключ>: 10 | <поле>: <значение> 11 | ``` 12 | Пример: 13 | ``` 14 | workspace: main 15 | active_window: 16 | id: 42 17 | title: Terminal 18 | ``` 19 | 20 | #### JSON-режим (с `j/`): 21 | ```json 22 | { 23 | "<ключ>": "<значение>", 24 | "<вложенный_ключ>": { 25 | "<поле>": "<значение>" 26 | } 27 | } 28 | ``` 29 | 30 | ### **1.2 Ответ с ошибкой** 31 | #### Текстовый режим: 32 | ``` 33 | error: <описание_ошибки> [<код>] 34 | ``` 35 | Где `<код>` - числовой код для os.Exit() 36 | 37 | Пример: 38 | ``` 39 | error: Invalid command [1] 40 | ``` 41 | 42 | #### JSON-режим: 43 | ```json 44 | { 45 | "error": "<описание_ошибки>", 46 | "code": <числовой_код> 47 | } 48 | ``` 49 | Где `code` - числовой код для os.Exit() 50 | 51 | Пример: 52 | ```json 53 | { 54 | "error": "Invalid command", 55 | "code": 1 56 | } 57 | ``` 58 | 59 | ## **2. Коды завершения** 60 | 61 | Коды ошибок соответствуют стандартным значениям для os.Exit(): 62 | - `0` - Успешное выполнение 63 | - `1` - Общая ошибка 64 | - `2` - Ошибка в аргументах 65 | - `3` - Ошибка доступа 66 | - `4` - Ресурс не найден 67 | 68 | ## **3. Примеры взаимодействия** 69 | 70 | ### Успешный запрос (текст): 71 | ``` 72 | get workspace 73 | → 74 | workspace: main 75 | ``` 76 | 77 | ### Ошибка (текст): 78 | ``` 79 | get invalid 80 | → 81 | error: Not found [4] 82 | ``` 83 | 84 | ### Успешный запрос (JSON): 85 | ``` 86 | j/ get workspace 87 | → 88 | {"workspace":"main"} 89 | ``` 90 | 91 | ### Ошибка (JSON): 92 | ``` 93 | j/ get invalid 94 | → 95 | {"error":"Not found","code":4} 96 | ``` 97 | 98 | ## **4. Особенности** 99 | 100 | 1. При успешном выполнении: 101 | - В текстовом режиме возвращаются только данные 102 | - В JSON-режиме возвращается только объект с данными 103 | 104 | 2. При ошибке: 105 | - Всегда содержит описание ошибки 106 | - Всегда содержит числовой код для os.Exit() 107 | - Формат един для всех типов ошибок 108 | 109 | 3. Совместимость: 110 | - Прямой доступ к сокету возвращает данные в чистом виде 111 | - CLI-утилита использует коды для os.Exit() -------------------------------------------------------------------------------- /internal/hypr/hyprEvents/events.go: -------------------------------------------------------------------------------- 1 | package hyprEvents 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/gotk3/gotk3/glib" 9 | 10 | "hypr-dock/internal/app" 11 | "hypr-dock/internal/state" 12 | "hypr-dock/pkg/ipc" 13 | ) 14 | 15 | func Init(appState *state.State) { 16 | ipc.AddEventListener("windowtitlev2", func(event string) { 17 | windowTitleHandler(event, appState) 18 | }, true) 19 | 20 | ipc.AddEventListener("openwindow", func(event string) { 21 | openwindowHandler(event, appState) 22 | }, true) 23 | 24 | ipc.AddEventListener("closewindow", func(event string) { 25 | closewindowHandler(event, appState) 26 | }, true) 27 | 28 | ipc.AddEventListener("activespecial", func(event string) { 29 | activatespecialHandler(event, appState) 30 | }, true) 31 | 32 | go ipc.InitHyprEvents() 33 | } 34 | 35 | func windowTitleHandler(event string, appState *state.State) { 36 | data := eventHandler(event, 2) 37 | address := "0x" + strings.TrimSpace(data[0]) 38 | go app.ChangeWindowTitle(address, data[1], appState) 39 | } 40 | 41 | func activatespecialHandler(event string, appState *state.State) { 42 | data := eventHandler(event, 2) 43 | log.Printf("Received activespecial event: %v", data) 44 | 45 | if data[0] == "special:special" { 46 | log.Println("Special workspace activated") 47 | appState.SetSpecial(true) 48 | } else { 49 | log.Println("Special workspace deactivated") 50 | appState.SetSpecial(false) 51 | } 52 | } 53 | 54 | func openwindowHandler(event string, appState *state.State) { 55 | data := eventHandler(event, 4) 56 | address := "0x" + strings.TrimSpace(data[0]) 57 | windowClient, err := ipc.SearchClientByAddress(address) 58 | if err != nil { 59 | fmt.Println(err) 60 | } else { 61 | glib.IdleAdd(func() { 62 | app.InitNewItemInIPC(windowClient, appState) 63 | }) 64 | } 65 | } 66 | 67 | func closewindowHandler(event string, appState *state.State) { 68 | data := eventHandler(event, 1) 69 | address := "0x" + strings.TrimSpace(data[0]) 70 | glib.IdleAdd(func() { 71 | app.RemoveApp(address, appState) 72 | }) 73 | } 74 | 75 | func eventHandler(event string, n int) []string { 76 | parts := strings.SplitN(event, ">>", 2) 77 | dataClast := strings.TrimSpace(parts[1]) 78 | dataParts := strings.SplitN(dataClast, ",", n) 79 | 80 | for i := range dataParts { 81 | dataParts[i] = strings.TrimSpace(dataParts[i]) 82 | } 83 | 84 | return dataParts 85 | } 86 | -------------------------------------------------------------------------------- /internal/placeholders/placeholders.go: -------------------------------------------------------------------------------- 1 | package placeholders 2 | 3 | import ( 4 | "fmt" 5 | "hypr-dock/internal/pkg/utils" 6 | "log" 7 | "strings" 8 | ) 9 | 10 | var desktopPlaceholders = map[string]bool{ 11 | "%f": true, 12 | "%F": true, 13 | "%u": true, 14 | "%U": true, 15 | "%d": true, 16 | "%D": true, 17 | "%n": true, 18 | "%N": true, 19 | "%i": true, 20 | "%c": true, 21 | "%k": true, 22 | "%v": true, 23 | "%m": true, 24 | } 25 | 26 | func Run(command string) error { 27 | cleanCommand, err := Clean(command) 28 | if err != nil { 29 | log.Println(err) 30 | return err 31 | } 32 | 33 | fmt.Println(command, " ", cleanCommand) 34 | utils.Launch(cleanCommand) 35 | return nil 36 | } 37 | 38 | func Clean(execLine string) (string, error) { 39 | args, err := splitCommandLine(execLine) 40 | if err != nil { 41 | return "", fmt.Errorf("failed to parse command line: %w", err) 42 | } 43 | 44 | var filteredArgs []string 45 | for _, arg := range args { 46 | if !containsPlaceholder(arg) { 47 | filteredArgs = append(filteredArgs, arg) 48 | } 49 | } 50 | 51 | return strings.Join(filteredArgs, " "), nil 52 | } 53 | 54 | func containsPlaceholder(s string) bool { 55 | for i := 0; i < len(s)-1; i++ { 56 | if s[i] == '%' { 57 | placeholder := s[i : i+2] 58 | if desktopPlaceholders[placeholder] { 59 | return true 60 | } 61 | } 62 | } 63 | return false 64 | } 65 | 66 | func splitCommandLine(line string) ([]string, error) { 67 | var args []string 68 | var currentArg strings.Builder 69 | inQuotes := false 70 | escapeNext := false 71 | 72 | for i, r := range line { 73 | if escapeNext { 74 | currentArg.WriteRune(r) 75 | escapeNext = false 76 | continue 77 | } 78 | 79 | switch r { 80 | case '\\': 81 | if i < len(line)-1 { 82 | escapeNext = true 83 | } 84 | case '"': 85 | inQuotes = !inQuotes 86 | case ' ', '\t', '\n', '\r': 87 | if inQuotes { 88 | currentArg.WriteRune(r) 89 | } else { 90 | if currentArg.Len() > 0 { 91 | args = append(args, currentArg.String()) 92 | currentArg.Reset() 93 | } 94 | } 95 | default: 96 | currentArg.WriteRune(r) 97 | } 98 | } 99 | 100 | if currentArg.Len() > 0 { 101 | args = append(args, currentArg.String()) 102 | } 103 | 104 | if inQuotes { 105 | return nil, fmt.Errorf("unclosed quotes in command line") 106 | } 107 | 108 | return args, nil 109 | } 110 | -------------------------------------------------------------------------------- /pkg/ipc/structs.go: -------------------------------------------------------------------------------- 1 | package ipc 2 | 3 | type Workspace struct { 4 | Id int `json:"id"` 5 | Name string `json:"name"` 6 | Monitor string `json:"monitor"` 7 | Windows int `json:"windows"` 8 | Hasfullscreen bool `json:"hasfullscreen"` 9 | Lastwindow string `json:"lastwindow"` 10 | Lastwindowtitle string `json:"lastwindowtitle"` 11 | } 12 | 13 | type Monitor struct { 14 | Id int `json:"id"` 15 | Name string `json:"name"` 16 | Description string `json:"description"` 17 | Make string `json:"make"` 18 | Model string `json:"model"` 19 | Serial string `json:"serial"` 20 | Width int `json:"width"` 21 | Height int `json:"height"` 22 | RefreshRate float64 `json:"refreshRate"` 23 | X int `json:"x"` 24 | Y int `json:"y"` 25 | 26 | ActiveWorkspace struct { 27 | Id int `json:"id"` 28 | Name string `json:"name"` 29 | } `json:"activeWorkspace"` 30 | 31 | Reserved []int `json:"reserved"` 32 | Scale float64 `json:"scale"` 33 | Transform int `json:"transform"` 34 | Focused bool `json:"focused"` 35 | DpmsStatus bool `json:"dpmsStatus"` 36 | Vrr bool `json:"vrr"` 37 | } 38 | 39 | type Client struct { 40 | Address string `json:"address"` 41 | Mapped bool `json:"mapped"` 42 | Hidden bool `json:"hidden"` 43 | At []int `json:"at"` 44 | Size []int `json:"size"` 45 | 46 | Workspace struct { 47 | Id int `json:"id"` 48 | Name string `json:"name"` 49 | } `json:"workspace"` 50 | 51 | Floating bool `json:"floating"` 52 | Pseudo bool `json:"pseudo"` 53 | Monitor int `json:"monitor"` 54 | Class string `json:"class"` 55 | Title string `json:"title"` 56 | InitialClass string `json:"initialClass"` 57 | InitialTitle string `json:"initialTitle"` 58 | Pid int `json:"pid"` 59 | Xwayland bool `json:"xwayland"` 60 | Pinned bool `json:"pinned"` 61 | Fullscreen int `json:"fullscreen"` 62 | FullscreenClient int `json:"fullscreenClient"` 63 | Grouped []interface{} `json:"grouped"` 64 | Tags []interface{} `json:"tags"` 65 | Swallowing string `json:"swallowing"` 66 | FocusHistoryID int `json:"focusHistoryID"` 67 | InhibitingIdle bool `json:"inhibitingIdle"` 68 | } 69 | -------------------------------------------------------------------------------- /pkg/ipc/listeners.go: -------------------------------------------------------------------------------- 1 | package ipc 2 | 3 | import ( 4 | "strings" 5 | "sync" 6 | ) 7 | 8 | type EventListener struct { 9 | Event string 10 | Handler func(string) 11 | ID int 12 | running bool 13 | } 14 | 15 | type eventManager struct { 16 | eventListeners []*EventListener 17 | listenerCounter int 18 | mu sync.Mutex 19 | } 20 | 21 | var ( 22 | eventManagerInstance *eventManager 23 | once sync.Once 24 | ) 25 | 26 | func getEventManager() *eventManager { 27 | once.Do(func() { 28 | eventManagerInstance = &eventManager{ 29 | eventListeners: make([]*EventListener, 0), 30 | listenerCounter: 0, 31 | } 32 | }) 33 | return eventManagerInstance 34 | } 35 | 36 | func AddEventListener(event string, handler func(string), running bool) *EventListener { 37 | em := getEventManager() 38 | em.mu.Lock() 39 | defer em.mu.Unlock() 40 | 41 | listener := &EventListener{ 42 | Event: event, 43 | Handler: handler, 44 | ID: em.listenerCounter, 45 | running: running, 46 | } 47 | 48 | em.eventListeners = append(em.eventListeners, listener) 49 | em.listenerCounter++ 50 | return listener 51 | } 52 | 53 | func DispatchEvent(event string) { 54 | em := getEventManager() 55 | em.mu.Lock() 56 | defer em.mu.Unlock() 57 | 58 | for _, listener := range em.eventListeners { 59 | if strings.Contains(event, listener.Event) && listener.running { 60 | listener.Handler(event) 61 | } 62 | } 63 | } 64 | 65 | func (el *EventListener) Run() { 66 | em := getEventManager() 67 | em.mu.Lock() 68 | defer em.mu.Unlock() 69 | 70 | for i := range em.eventListeners { 71 | if em.eventListeners[i].ID == el.ID { 72 | em.eventListeners[i].running = true 73 | break 74 | } 75 | } 76 | } 77 | 78 | func (el *EventListener) Pause() { 79 | em := getEventManager() 80 | em.mu.Lock() 81 | defer em.mu.Unlock() 82 | 83 | for i := range em.eventListeners { 84 | if em.eventListeners[i].ID == el.ID { 85 | em.eventListeners[i].running = false 86 | break 87 | } 88 | } 89 | } 90 | 91 | func (el *EventListener) IsRunning() bool { 92 | em := getEventManager() 93 | em.mu.Lock() 94 | defer em.mu.Unlock() 95 | 96 | for i := range em.eventListeners { 97 | if em.eventListeners[i].ID == el.ID { 98 | return em.eventListeners[i].running 99 | } 100 | } 101 | return false 102 | } 103 | 104 | func (el *EventListener) Remove() { 105 | em := getEventManager() 106 | em.mu.Lock() 107 | defer em.mu.Unlock() 108 | 109 | for i := range em.eventListeners { 110 | if em.eventListeners[i].ID == el.ID { 111 | em.eventListeners = append(em.eventListeners[:i], em.eventListeners[i+1:]...) 112 | break 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /internal/detectZone/detectZone.go: -------------------------------------------------------------------------------- 1 | package detectzone 2 | 3 | import ( 4 | "hypr-dock/internal/state" 5 | "log" 6 | 7 | "github.com/dlasky/gotk3-layershell/layershell" 8 | "github.com/gotk3/gotk3/gdk" 9 | "github.com/gotk3/gotk3/gtk" 10 | ) 11 | 12 | func Init(appState *state.State) { 13 | window := appState.GetWindow() 14 | 15 | detectArea, err := gtk.WindowNew(gtk.WINDOW_TOPLEVEL) 16 | if err != nil { 17 | log.Fatal("InitDetectArea(), gtk.WindowNew() | ", err) 18 | } 19 | detectArea.SetName("detect") 20 | detectArea.SetSizeRequest(-1, 1) 21 | 22 | layershell.InitForWindow(detectArea) 23 | layershell.SetNamespace(detectArea, "dock-detect") 24 | layershell.SetLayer(detectArea, layershell.LAYER_SHELL_LAYER_TOP) 25 | selectEdges(detectArea, appState) 26 | 27 | detectArea.Connect("enter-notify-event", func(detectWindow *gtk.Window, e *gdk.Event) { 28 | timer := appState.GetDockHideTimer() 29 | timer.Stop() 30 | 31 | go func() { 32 | layershell.SetLayer(window, layershell.LAYER_SHELL_LAYER_TOP) 33 | }() 34 | }) 35 | 36 | detectArea.Connect("leave-notify-event", func(detectWindow *gtk.Window, e *gdk.Event) { 37 | timer := appState.GetDockHideTimer() 38 | 39 | timer.Run(appState.Settings.AutoHideDeley, func() { 40 | layershell.SetLayer(window, layershell.LAYER_SHELL_LAYER_BOTTOM) 41 | }) 42 | }) 43 | 44 | detectArea.ShowAll() 45 | appState.SetDetectArea(detectArea) 46 | } 47 | 48 | func selectEdges(window *gtk.Window, appState *state.State) { 49 | settings := appState.GetSettings() 50 | 51 | switch settings.Position { 52 | case "left": 53 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_BOTTOM, true) 54 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_LEFT, true) 55 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_TOP, true) 56 | layershell.SetMargin(window, layershell.LAYER_SHELL_EDGE_LEFT, 0) 57 | case "top": 58 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_RIGHT, true) 59 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_LEFT, true) 60 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_TOP, true) 61 | layershell.SetMargin(window, layershell.LAYER_SHELL_EDGE_TOP, 0) 62 | case "right": 63 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_BOTTOM, true) 64 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_RIGHT, true) 65 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_TOP, true) 66 | layershell.SetMargin(window, layershell.LAYER_SHELL_EDGE_RIGHT, 0) 67 | case "bottom": 68 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_BOTTOM, true) 69 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_LEFT, true) 70 | layershell.SetAnchor(window, layershell.LAYER_SHELL_EDGE_RIGHT, true) 71 | layershell.SetMargin(window, layershell.LAYER_SHELL_EDGE_BOTTOM, 0) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/pkg/desktop/parse.go: -------------------------------------------------------------------------------- 1 | package desktop 2 | 3 | import ( 4 | "errors" 5 | "hypr-dock/internal/pkg/utils" 6 | "strings" 7 | ) 8 | 9 | type Desktop2 map[string]map[string]string 10 | 11 | func NewV2(className string) (*Desktop2, error) { 12 | return Parse(SearchDesktopFile(className)) 13 | } 14 | 15 | func Parse(path string) (*Desktop2, error) { 16 | lines, err := utils.LoadTextFile(path) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | result := make(Desktop2) 22 | currentSection := "desktop-entry" 23 | 24 | for _, line := range lines { 25 | line = strings.TrimSpace(line) 26 | if line == "" || strings.HasPrefix(line, "#") { 27 | continue 28 | } 29 | 30 | if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { 31 | currentSection = strings.ToLower(line[1 : len(line)-1]) 32 | continue 33 | } 34 | 35 | parts := strings.SplitN(line, "=", 2) 36 | if len(parts) != 2 { 37 | continue 38 | } 39 | 40 | key := strings.ToLower(strings.TrimSpace(parts[0])) 41 | value := strings.TrimSpace(parts[1]) 42 | 43 | if result[currentSection] == nil { 44 | result[currentSection] = make(map[string]string) 45 | } 46 | 47 | result[currentSection][key] = value 48 | } 49 | 50 | return &result, nil 51 | } 52 | 53 | type Action struct { 54 | Name string 55 | Exec string 56 | Icon string 57 | } 58 | 59 | func GetAppActions(className string) ([]*Action, error) { 60 | appDataPtr, err := Parse(SearchDesktopFile(className)) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | appData := *appDataPtr 66 | general, exist := appData["desktop entry"] 67 | if !exist { 68 | return nil, errors.New("'desktop entry' section not found") 69 | } 70 | 71 | actionsStr, exist := general["actions"] 72 | if !exist { 73 | return nil, errors.New("'actions' field not found in desktop entry") 74 | } 75 | 76 | actionsList := strings.Split(actionsStr, ";") 77 | var actionsRes []*Action 78 | 79 | for _, action := range actionsList { 80 | if action == "" { 81 | continue 82 | } 83 | 84 | key := "desktop action " + action 85 | actionGroup, exist := appData[key] 86 | if !exist { 87 | continue 88 | } 89 | 90 | name, exist := actionGroup["name"] 91 | if !exist { 92 | name = action 93 | } 94 | 95 | exec, exist := actionGroup["exec"] 96 | if !exist { 97 | continue 98 | } 99 | 100 | icon, exist := actionGroup["icon"] 101 | if !exist { 102 | icon = "" 103 | } 104 | 105 | actionsRes = append(actionsRes, &Action{ 106 | Name: name, 107 | Exec: exec, 108 | Icon: icon, 109 | }) 110 | } 111 | 112 | if len(actionsRes) == 0 { 113 | return nil, errors.New("no valid actions found") 114 | } 115 | 116 | return actionsRes, nil 117 | } 118 | -------------------------------------------------------------------------------- /internal/pkg/popup/popup.go: -------------------------------------------------------------------------------- 1 | package popup 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/dlasky/gotk3-layershell/layershell" 7 | "github.com/gotk3/gotk3/gtk" 8 | ) 9 | 10 | type Popup struct { 11 | x, y int 12 | acitve bool 13 | win *gtk.Window 14 | content gtk.IWidget 15 | xstart, ystart string 16 | 17 | winCallBack func(*gtk.Window) error 18 | } 19 | 20 | func New() *Popup { 21 | return &Popup{ 22 | x: 0, 23 | y: 0, 24 | acitve: false, 25 | win: nil, 26 | content: nil, 27 | winCallBack: nil, 28 | } 29 | } 30 | 31 | func (p *Popup) Open(x, y int, xstart, ystart string) error { 32 | p.Close() 33 | 34 | win, err := gtk.WindowNew(gtk.WINDOW_TOPLEVEL) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | p.win = win 40 | if p.winCallBack != nil { 41 | p.winCallBack(win) 42 | } 43 | initLayerShell(win) 44 | 45 | p.x = x 46 | p.y = y 47 | p.xstart = xstart 48 | p.ystart = ystart 49 | 50 | if p.content != nil { 51 | p.win.Add(p.content) 52 | } 53 | p.setCord() 54 | p.win.ShowAll() 55 | p.acitve = true 56 | 57 | return nil 58 | } 59 | 60 | func (p *Popup) Close() { 61 | p.acitve = false 62 | if p.win != nil { 63 | p.win.Destroy() 64 | p.win = nil 65 | } 66 | } 67 | 68 | func (p *Popup) Set(content gtk.IWidget) error { 69 | if content == nil { 70 | return errors.New("content is nil") 71 | } 72 | 73 | p.content = content 74 | if !p.acitve { 75 | return nil 76 | } 77 | 78 | child, err := p.win.GetChild() 79 | if err == nil && child != nil { 80 | child.ToWidget().Destroy() 81 | } 82 | 83 | p.win.Add(content) 84 | p.win.ShowAll() 85 | 86 | return nil 87 | } 88 | 89 | func (p *Popup) Move(x, y int) { 90 | p.x = x 91 | p.y = y 92 | 93 | if p.acitve { 94 | p.setCord() 95 | } 96 | } 97 | 98 | func (p *Popup) Shift(dx, dy int) { 99 | p.x = p.x + dx 100 | p.y = p.y + dy 101 | 102 | if p.acitve { 103 | p.setCord() 104 | } 105 | } 106 | 107 | func (p *Popup) SetWinCallBack(callback func(*gtk.Window) error) { 108 | p.winCallBack = callback 109 | } 110 | 111 | func (p *Popup) setCord() { 112 | if p.win == nil { 113 | return 114 | } 115 | 116 | xstarts := map[string]layershell.LayerShellEdgeFlags{ 117 | "left": layershell.LAYER_SHELL_EDGE_LEFT, 118 | "right": layershell.LAYER_SHELL_EDGE_RIGHT, 119 | } 120 | 121 | ystarts := map[string]layershell.LayerShellEdgeFlags{ 122 | "top": layershell.LAYER_SHELL_EDGE_TOP, 123 | "bottom": layershell.LAYER_SHELL_EDGE_BOTTOM, 124 | } 125 | 126 | layershell.SetAnchor(p.win, xstarts[p.xstart], true) 127 | layershell.SetAnchor(p.win, ystarts[p.ystart], true) 128 | layershell.SetMargin(p.win, xstarts[p.xstart], p.x) 129 | layershell.SetMargin(p.win, ystarts[p.ystart], p.y) 130 | } 131 | 132 | func initLayerShell(win *gtk.Window) { 133 | layershell.InitForWindow(win) 134 | layershell.SetNamespace(win, "dock-popup") 135 | layershell.SetLayer(win, layershell.LAYER_SHELL_LAYER_TOP) 136 | layershell.SetExclusiveZone(win, -1) 137 | } 138 | -------------------------------------------------------------------------------- /internal/pkg/utils/gtk.utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | "math" 6 | "strings" 7 | 8 | "github.com/gotk3/gotk3/gdk" 9 | "github.com/gotk3/gotk3/gtk" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | func CreateImageWidthScale(source string, size int, scaleFactor float64) (*gtk.Image, error) { 14 | scaleSize := int(math.Round(float64(size) * math.Max(scaleFactor, 0))) 15 | 16 | return CreateImage(source, scaleSize) 17 | } 18 | 19 | func CreateImage(source string, size int) (*gtk.Image, error) { 20 | // Create image in file 21 | if strings.Contains(source, "/") { 22 | pixbuf, err := gdk.PixbufNewFromFileAtSize(source, size, size) 23 | if err != nil { 24 | log.Println(err) 25 | return CreateImage("image-missing", size) 26 | } 27 | 28 | return CreateImageFromPixbuf(pixbuf), nil 29 | } 30 | 31 | // Create image in icon name 32 | iconTheme, err := gtk.IconThemeGetDefault() 33 | if err != nil { 34 | log.Println("Unable to icon theme:", err) 35 | return CreateImage("image-missing", size) 36 | } 37 | 38 | pixbuf, err := iconTheme.LoadIcon(source, size, gtk.ICON_LOOKUP_FORCE_SIZE) 39 | if err != nil { 40 | log.Println(source, err) 41 | return CreateImage("image-missing", size) 42 | } 43 | 44 | return CreateImageFromPixbuf(pixbuf), nil 45 | } 46 | 47 | func CreateImageFromPixbuf(pixbuf *gdk.Pixbuf) *gtk.Image { 48 | image, err := gtk.ImageNewFromPixbuf(pixbuf) 49 | if err != nil { 50 | log.Println("Error creating image from pixbuf:", err) 51 | return nil 52 | } 53 | return image 54 | } 55 | 56 | func AddStyle(widget gtk.IWidget, style string) (*gtk.CssProvider, error) { 57 | provider, err := gtk.CssProviderNew() 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | err = provider.LoadFromData(style) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | context, err := widget.ToWidget().GetStyleContext() 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | context.AddProvider(provider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) 73 | 74 | return provider, nil 75 | } 76 | 77 | func AddCssProvider(cssFile string) error { 78 | cssProvider, err := gtk.CssProviderNew() 79 | if err != nil { 80 | log.Printf("Failed to create CSS provider: %v", err) 81 | return errors.Wrap(err, "failed to create CSS provider") 82 | } 83 | 84 | if err := cssProvider.LoadFromPath(cssFile); err != nil { 85 | log.Printf("Failed to load CSS from %q: %v", cssFile, err) 86 | return errors.Wrapf(err, "failed to load CSS from %q", cssFile) 87 | } 88 | 89 | screen, err := gdk.ScreenGetDefault() 90 | if err != nil { 91 | log.Printf("Failed to get default screen: %v", err) 92 | return errors.Wrap(err, "failed to get default screen") 93 | } 94 | 95 | gtk.AddProviderForScreen( 96 | screen, cssProvider, 97 | gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, 98 | ) 99 | 100 | return nil 101 | } 102 | 103 | func RemoveStyleProvider(widget *gtk.Box, provider *gtk.CssProvider) { 104 | if provider == nil { 105 | log.Println("provider is nil") 106 | return 107 | } 108 | 109 | styleContext, err := widget.GetStyleContext() 110 | if err != nil { 111 | log.Println(err) 112 | return 113 | } 114 | 115 | styleContext.RemoveProvider(provider) 116 | } 117 | -------------------------------------------------------------------------------- /internal/pkg/desktop/desktop.go: -------------------------------------------------------------------------------- 1 | package desktop 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "slices" 7 | "strings" 8 | ) 9 | 10 | type Desktop struct { 11 | Name string 12 | Icon string 13 | Exec string 14 | SingleWindow bool 15 | } 16 | 17 | // var desktopDirs = GetAppDirs() 18 | 19 | func New(className string) *Desktop { 20 | errData := &Desktop{ 21 | Name: "Untitle", 22 | Icon: "", 23 | Exec: "", 24 | SingleWindow: false, 25 | } 26 | 27 | allData, err := Parse(SearchDesktopFile(className)) 28 | if err != nil { 29 | return errData 30 | } 31 | 32 | appData := *allData 33 | general, exist := appData["desktop entry"] 34 | if !exist { 35 | return errData 36 | } 37 | 38 | name, exist := general["name"] 39 | if !exist { 40 | name = errData.Name 41 | } 42 | 43 | icon, exist := general["icon"] 44 | if !exist { 45 | icon = "" 46 | } 47 | 48 | exec, exist := general["exec"] 49 | if !exist { 50 | exec = "" 51 | } 52 | 53 | singleWindowStr, exist := general["singlemainwindow"] 54 | if !exist { 55 | singleWindowStr = "false" 56 | } 57 | 58 | singleWindow := singleWindowStr == "true" 59 | 60 | return &Desktop{ 61 | Name: name, 62 | Icon: icon, 63 | Exec: exec, 64 | SingleWindow: singleWindow, 65 | } 66 | } 67 | 68 | func SearchDesktopFile(className string) string { 69 | for _, appDir := range GetAppDirs() { 70 | desktopFile := className + ".desktop" 71 | _, err := os.Stat(filepath.Join(appDir, desktopFile)) 72 | if err == nil { 73 | return filepath.Join(appDir, desktopFile) 74 | } 75 | 76 | // If file non found 77 | files, _ := os.ReadDir(appDir) 78 | for _, file := range files { 79 | fileName := file.Name() 80 | 81 | // "krita" > "org.kde.krita.desktop" / "lutris" > "net.lutris.Lutris.desktop" 82 | if strings.Count(fileName, ".") > 1 && strings.Contains(fileName, className) { 83 | return filepath.Join(appDir, fileName) 84 | } 85 | // "VirtualBox Manager" > "virtualbox.desktop" 86 | if fileName == strings.Split(strings.ToLower(className), " ")[0]+".desktop" { 87 | return filepath.Join(appDir, fileName) 88 | } 89 | } 90 | } 91 | 92 | return "" 93 | } 94 | 95 | func GetAppDirs() []string { 96 | var dirs []string 97 | xdgDataDirs := "" 98 | 99 | home := os.Getenv("HOME") 100 | xdgDataHome := os.Getenv("XDG_DATA_HOME") 101 | if os.Getenv("XDG_DATA_DIRS") != "" { 102 | xdgDataDirs = os.Getenv("XDG_DATA_DIRS") 103 | } else { 104 | xdgDataDirs = "/usr/local/share/:/usr/share/" 105 | } 106 | if xdgDataHome != "" { 107 | dirs = append(dirs, filepath.Join(xdgDataHome, "applications")) 108 | } else if home != "" { 109 | dirs = append(dirs, filepath.Join(home, ".local/share/applications")) 110 | } 111 | for _, d := range strings.Split(xdgDataDirs, ":") { 112 | dirs = append(dirs, filepath.Join(d, "applications")) 113 | } 114 | flatpakDirs := []string{filepath.Join(home, ".local/share/flatpak/exports/share/applications"), 115 | "/var/lib/flatpak/exports/share/applications"} 116 | 117 | for _, d := range flatpakDirs { 118 | if !slices.Contains(dirs, d) { 119 | dirs = append(dirs, d) 120 | } 121 | } 122 | return dirs 123 | } 124 | -------------------------------------------------------------------------------- /internal/pkg/indicator/indicator.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "hypr-dock/internal/pkg/utils" 7 | "hypr-dock/internal/settings" 8 | "os" 9 | "path/filepath" 10 | "slices" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | 15 | "github.com/gotk3/gotk3/gtk" 16 | ) 17 | 18 | // IndicatorFile represents an indicator image file 19 | type IndicatorFile struct { 20 | Number int // Numeric prefix of the file (e.g. 0 from "0.svg") 21 | Extension string // File extension including dot (e.g. ".svg") 22 | FullName string // Complete filename (e.g. "0.svg") 23 | } 24 | 25 | // New creates a new indicator image based on instances count 26 | func New(instances int, settings settings.Settings) (*gtk.Image, error) { 27 | available, err := GetAvailable(settings) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to get indicators: %w", err) 30 | } 31 | if len(available) < 2 { 32 | return nil, errors.New("at least 2 indicator files required (0.* and *.*)") 33 | } 34 | if available[0].Number != 0 { 35 | return nil, errors.New("at least 2 indicator files required (0.* and *.*)") 36 | } 37 | 38 | selected := selectIndicatorFile(instances, available) 39 | path := filepath.Join(settings.CurrentThemeDir, "point", selected.FullName) 40 | return utils.CreateImageWidthScale(path, settings.IconSize, 0.56) 41 | } 42 | 43 | // selectIndicatorFile chooses the appropriate indicator file based on instances count 44 | func selectIndicatorFile(instances int, files []IndicatorFile) IndicatorFile { 45 | selected := files[0] 46 | for _, file := range files { 47 | if file.Number > instances { 48 | break 49 | } 50 | selected = file 51 | } 52 | return selected 53 | } 54 | 55 | // GetAvailable returns all valid indicator files sorted by their numeric value 56 | func GetAvailable(settings settings.Settings) ([]IndicatorFile, error) { 57 | dirPath := filepath.Join(settings.CurrentThemeDir, "point") 58 | 59 | if _, err := os.Stat(dirPath); os.IsNotExist(err) { 60 | return nil, fmt.Errorf("indicator directory not found: %w", err) 61 | } 62 | 63 | entries, err := os.ReadDir(dirPath) 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to read indicator directory: %w", err) 66 | } 67 | 68 | var files []IndicatorFile 69 | for _, entry := range entries { 70 | if entry.IsDir() { 71 | continue 72 | } 73 | 74 | file, ok := parseIndicatorFile(entry.Name()) 75 | if ok { 76 | files = append(files, file) 77 | } 78 | } 79 | 80 | sort.Slice(files, func(i, j int) bool { 81 | return files[i].Number < files[j].Number 82 | }) 83 | 84 | return files, nil 85 | } 86 | 87 | // parseIndicatorFile attempts to parse a filename into an IndicatorFile 88 | func parseIndicatorFile(name string) (IndicatorFile, bool) { 89 | ext := strings.ToLower(filepath.Ext(name)) 90 | if !isSupportedExtension(ext) { 91 | return IndicatorFile{}, false 92 | } 93 | 94 | baseName := name[:len(name)-len(ext)] 95 | num, err := strconv.Atoi(baseName) 96 | if err != nil { 97 | return IndicatorFile{}, false 98 | } 99 | 100 | return IndicatorFile{ 101 | Number: num, 102 | Extension: ext, 103 | FullName: name, 104 | }, true 105 | } 106 | 107 | // isSupportedExtension checks if the extension is valid for indicator files 108 | func isSupportedExtension(ext string) bool { 109 | supported := []string{".svg", ".jpg", ".png", ".webp"} 110 | return slices.Contains(supported, ext) 111 | } 112 | -------------------------------------------------------------------------------- /internal/btnctl/utils.go: -------------------------------------------------------------------------------- 1 | package btnctl 2 | 3 | import ( 4 | "hypr-dock/internal/item" 5 | "hypr-dock/internal/layering" 6 | "hypr-dock/internal/state" 7 | "hypr-dock/pkg/ipc" 8 | "log" 9 | 10 | "github.com/gotk3/gotk3/gdk" 11 | "github.com/gotk3/gotk3/gtk" 12 | ) 13 | 14 | func connectContextMenu(item *item.Item, appState *state.State) { 15 | settings := appState.GetSettings() 16 | 17 | item.Button.Connect("button-release-event", func(button *gtk.Button, e *gdk.Event) { 18 | event := gdk.EventButtonNewFromEvent(e) 19 | if event.Button() == 3 { 20 | menu, err := item.ContextMenu(settings) 21 | if err != nil { 22 | log.Println(err) 23 | return 24 | } 25 | 26 | win, zone, err := getActivateZone(item.Button, settings.ContextPos, settings.Position) 27 | if err != nil { 28 | log.Println(err) 29 | return 30 | } 31 | 32 | firstg, secondg := getGravity(settings.Position) 33 | menu.PopupAtRect(win, zone, firstg, secondg, nil) 34 | ipc.DispatchEvent("hd>>open-context") 35 | menu.Connect("deactivate", func() { 36 | ipc.DispatchEvent("hd>>close-context") 37 | dispather(appState, item.Button) 38 | }) 39 | 40 | return 41 | } 42 | }) 43 | } 44 | 45 | func leftClick(btn *gtk.Button, handler func(e *gdk.Event)) { 46 | btn.Connect("button-release-event", func(button *gtk.Button, e *gdk.Event) { 47 | event := gdk.EventButtonNewFromEvent(e) 48 | if event.Button() != 3 { 49 | handler(e) 50 | } 51 | }) 52 | } 53 | 54 | func dispather(appState *state.State, btn *gtk.Button) { 55 | window := appState.GetWindow() 56 | btn.SetStateFlags(gtk.STATE_FLAG_NORMAL, true) 57 | if appState.GetSettings().Layer == "auto" { 58 | layering.DispathLeaveEvent(window, nil, appState) 59 | } 60 | } 61 | 62 | func getActivateZone(v *gtk.Button, margin int, pos string) (*gdk.Window, *gdk.Rectangle, error) { 63 | var rect *gdk.Rectangle 64 | 65 | win, err := v.GetWindow() 66 | if err != nil { 67 | return nil, nil, err 68 | } 69 | 70 | switch pos { 71 | case "bottom": 72 | rect = gdk.RectangleNew( 73 | v.GetAllocation().GetX(), 74 | 0-margin, 75 | v.GetAllocatedWidth(), 76 | v.GetAllocatedHeight(), 77 | ) 78 | case "left": 79 | rect = gdk.RectangleNew( 80 | 0-(v.GetAllocatedWidth()/2)-v.GetAllocation().GetX()+win.WindowGetWidth()+margin, 81 | v.GetAllocation().GetY(), 82 | v.GetAllocatedWidth(), 83 | v.GetAllocatedHeight(), 84 | ) 85 | case "top": 86 | rect = gdk.RectangleNew( 87 | v.GetAllocation().GetX(), 88 | 0-(v.GetAllocatedHeight()/2)-v.GetAllocation().GetY()+win.WindowGetHeight()+margin, 89 | v.GetAllocatedWidth(), 90 | v.GetAllocatedHeight(), 91 | ) 92 | case "right": 93 | rect = gdk.RectangleNew( 94 | 0-margin, 95 | v.GetAllocation().GetY(), 96 | v.GetAllocatedWidth(), 97 | v.GetAllocatedHeight(), 98 | ) 99 | } 100 | 101 | return win, rect, err 102 | } 103 | 104 | func getGravity(pos string) (gdk.Gravity, gdk.Gravity) { 105 | var first, second gdk.Gravity 106 | 107 | switch pos { 108 | case "bottom": 109 | first = gdk.GDK_GRAVITY_NORTH 110 | second = gdk.GDK_GRAVITY_SOUTH 111 | case "left": 112 | first = gdk.GDK_GRAVITY_EAST 113 | second = gdk.GDK_GRAVITY_WEST 114 | case "top": 115 | second = gdk.GDK_GRAVITY_NORTH 116 | first = gdk.GDK_GRAVITY_SOUTH 117 | case "right": 118 | second = gdk.GDK_GRAVITY_EAST 119 | first = gdk.GDK_GRAVITY_WEST 120 | } 121 | 122 | return first, second 123 | } 124 | -------------------------------------------------------------------------------- /internal/btnctl/btnctl.go: -------------------------------------------------------------------------------- 1 | package btnctl 2 | 3 | import ( 4 | "hypr-dock/internal/item" 5 | "hypr-dock/internal/placeholders" 6 | "hypr-dock/internal/state" 7 | "hypr-dock/pkg/ipc" 8 | "log" 9 | 10 | "github.com/gotk3/gotk3/gdk" 11 | "github.com/gotk3/gotk3/glib" 12 | ) 13 | 14 | func Dispatch(item *item.Item, appState *state.State) { 15 | connectContextMenu(item, appState) 16 | 17 | if appState.GetSettings().Preview == "none" { 18 | defaultControl(item, appState) 19 | return 20 | } 21 | 22 | previewControl(item, appState) 23 | } 24 | 25 | func previewControl(item *item.Item, appState *state.State) { 26 | settings := appState.GetSettings() 27 | pv := appState.GetPV() 28 | showTimer := pv.GetShowTimer() 29 | hideTimer := pv.GetHideTimer() 30 | moveTimer := pv.GetMoveTimer() 31 | 32 | show := func() { 33 | glib.IdleAdd(func() { 34 | pv.Show(item, settings) 35 | }) 36 | pv.SetActive(true) 37 | } 38 | 39 | hide := func() { 40 | glib.IdleAdd(func() { 41 | pv.Hide() 42 | }) 43 | pv.SetActive(false) 44 | } 45 | 46 | move := func() { 47 | glib.IdleAdd(func() { 48 | pv.Change(item, settings) 49 | }) 50 | } 51 | 52 | ipc.AddEventListener("hd>>open-context", func(e string) { 53 | showTimer.Stop() 54 | if pv.GetActive() { 55 | hideTimer.Run(0, hide) 56 | } 57 | }, true) 58 | 59 | leftClick(item.Button, func(e *gdk.Event) { 60 | if item.Instances == 0 { 61 | placeholders.Run(item.DesktopData.Exec) 62 | } 63 | if item.Instances == 1 { 64 | ipc.Hyprctl("dispatch focuswindow address:" + item.Windows[0]["Address"]) 65 | ipc.DispatchEvent("hd>>focus-window") 66 | } 67 | if item.Instances > 1 { 68 | if !pv.GetActive() { 69 | showTimer.Run(0, show) 70 | pv.SetCurrentClass(item.ClassName) 71 | } 72 | } 73 | }) 74 | 75 | item.Button.Connect("enter-notify-event", func() { 76 | if item.Instances == 0 { 77 | return 78 | } 79 | 80 | hideTimer.Stop() 81 | 82 | if pv.GetActive() && pv.HasClassChanged(item.ClassName) { 83 | // fmt.Println("if true") 84 | moveTimer.Stop() 85 | moveTimer.Run(settings.PreviewAdvanced.MoveDelay, move) 86 | pv.SetCurrentClass(item.ClassName) 87 | return 88 | } 89 | 90 | if !pv.GetActive() { 91 | showTimer.Run(settings.PreviewAdvanced.ShowDelay, show) 92 | pv.SetCurrentClass(item.ClassName) 93 | } 94 | }) 95 | 96 | item.Button.Connect("leave-notify-event", func() { 97 | if item.Instances == 0 { 98 | return 99 | } 100 | 101 | showTimer.Stop() 102 | if pv.GetActive() { 103 | hideTimer.Run(settings.PreviewAdvanced.HideDelay, hide) 104 | } 105 | }) 106 | } 107 | 108 | func defaultControl(item *item.Item, appState *state.State) { 109 | settings := appState.GetSettings() 110 | 111 | leftClick(item.Button, func(e *gdk.Event) { 112 | if item.Instances == 0 { 113 | placeholders.Run(item.DesktopData.Exec) 114 | } 115 | if item.Instances == 1 { 116 | ipc.Hyprctl("dispatch focuswindow address:" + item.Windows[0]["Address"]) 117 | } 118 | if item.Instances > 1 { 119 | menu, err := item.WindowsMenu() 120 | if err != nil { 121 | log.Println(err) 122 | return 123 | } 124 | 125 | win, zone, err := getActivateZone(item.Button, settings.ContextPos, settings.Position) 126 | if err != nil { 127 | log.Println(err) 128 | return 129 | } 130 | 131 | firstg, secondg := getGravity(settings.Position) 132 | menu.PopupAtRect(win, zone, firstg, secondg, nil) 133 | menu.Connect("deactivate", func() { 134 | dispather(appState, item.Button) 135 | }) 136 | } 137 | }) 138 | } 139 | -------------------------------------------------------------------------------- /internal/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "hypr-dock/internal/pkg/cfg" 5 | "hypr-dock/internal/pkg/flags" 6 | "hypr-dock/internal/pkg/utils" 7 | "hypr-dock/pkg/ipc" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | type Settings struct { 15 | cfg.Config 16 | ConfigDir string 17 | ConfigPath string 18 | PinnedPath string 19 | ThemesDir string 20 | CurrentThemeDir string 21 | CurrentThemeConfigPath string 22 | CurrentThemeStylePath string 23 | PinnedApps []string 24 | } 25 | 26 | func Init() (Settings, error) { 27 | const DefaultTheme = "lotos" 28 | 29 | var settings Settings 30 | 31 | flags := flags.Get(DefaultTheme) 32 | 33 | if flags.DevMode { 34 | settings.ConfigDir = setConfigDir("dev") 35 | } else { 36 | settings.ConfigDir = setConfigDir("normal") 37 | } 38 | 39 | settings.PinnedPath = filepath.Join(settings.ConfigDir, "pinned.json") 40 | settings.PinnedApps = cfg.ReadItemList(settings.PinnedPath) 41 | defaultConfigPath := filepath.Join(settings.ConfigDir, "config.jsonc") 42 | 43 | if flags.Config == "~/.config/hypr-dock" { 44 | settings.ConfigPath = defaultConfigPath 45 | } else { 46 | settings.ConfigPath = expandPath(flags.Config) 47 | } 48 | 49 | settings.Config = cfg.ReadConfig(settings.ConfigPath, settings.ThemesDir) 50 | 51 | settings.ThemesDir = filepath.Join(settings.ConfigDir, "themes") 52 | settings.CurrentThemeDir = filepath.Join(settings.ThemesDir, settings.CurrentTheme) 53 | 54 | if !utils.FileExists(settings.CurrentThemeDir) { 55 | log.Println("Current theme not found (", settings.CurrentTheme, "). Loading default theme") 56 | 57 | if settings.CurrentTheme == DefaultTheme { 58 | log.Println("Default theme not found") 59 | } 60 | 61 | settings.CurrentTheme = DefaultTheme 62 | } 63 | 64 | settings.CurrentThemeStylePath = filepath.Join(settings.CurrentThemeDir, "style.css") 65 | settings.CurrentThemeConfigPath = filepath.Join(settings.CurrentThemeDir, settings.CurrentTheme+".jsonc") 66 | 67 | themeConfig := cfg.ReadTheme(settings.CurrentThemeConfigPath, settings.Config) 68 | if themeConfig != nil { 69 | settings.Blur = themeConfig.Blur 70 | settings.Spacing = themeConfig.Spacing 71 | settings.PreviewStyle = themeConfig.PreviewStyle 72 | } 73 | 74 | if settings.Blur == "true" { 75 | enableBlur() 76 | ipc.AddEventListener("configreloaded", func(event string) { 77 | go enableBlur() 78 | }, true) 79 | } 80 | 81 | return settings, nil 82 | } 83 | 84 | func setConfigDir(mode string) string { 85 | homeDir, err := os.UserHomeDir() 86 | if err != nil { 87 | log.Fatal("Home dir: " + err.Error()) 88 | } 89 | 90 | exePath, err := os.Executable() 91 | if err != nil { 92 | log.Fatal("Exe dir: " + err.Error()) 93 | } 94 | 95 | exeDir := filepath.Dir(exePath) 96 | 97 | runModes := map[string]func() string{ 98 | "normal": func() string { 99 | return filepath.Join(homeDir, ".config/hypr-dock") 100 | }, 101 | "dev": func() string { 102 | return filepath.Join(filepath.Dir(exeDir), "configs") 103 | }, 104 | } 105 | return runModes[mode]() 106 | } 107 | 108 | func expandPath(path string) string { 109 | if strings.HasPrefix(path, "~/") { 110 | home, _ := os.UserHomeDir() 111 | return filepath.Join(home, path[2:]) 112 | } 113 | return path 114 | } 115 | 116 | func enableBlur() { 117 | ipc.Hyprctl("keyword layerrule blur,hypr-dock") 118 | ipc.Hyprctl("keyword layerrule ignorealpha 0.1,hypr-dock") 119 | 120 | ipc.Hyprctl("keyword layerrule blur,dock-popup") 121 | ipc.Hyprctl("keyword layerrule ignorealpha 0.1,dock-popup") 122 | } 123 | -------------------------------------------------------------------------------- /docs/tasks/ipc-main.md: -------------------------------------------------------------------------------- 1 | ### **Техническое задание: Реализация `hypr-dock-ctl` для управления hypr-dock** 2 | 3 | #### **1. Цель** 4 | Создать **отдельный CLI-бинарник** `hypr-dock-ctl` для управления доком через IPC, обеспечив: 5 | - **Смену режима слоя** (`auto` ↔ `exclusive`) без перезапуска дока. 6 | - **Единообразие с `hyprctl`** (синтаксис, флаги, вывод). 7 | - **Масштабируемость** для будущих команд (темы, настройки, статус). 8 | 9 | --- 10 | 11 | ### **2. Требования** 12 | 13 | #### **2.1. Базовый функционал (v1.0)** 14 | - **Команда `toggle`** 15 | ```bash 16 | hypr-dock-ctl toggle layer # Переключает между auto и exclusive 17 | ``` 18 | - Отправляет команду через **Unix-socket** (`/tmp/hypr-dock.sock`). 19 | - Если док **не запущен** — игнорирует команду (или выводит ошибку). 20 | 21 | - **Команда `get`** 22 | ```bash 23 | hypr-dock-ctl get layer # Выводит текущий режим (auto/exclusive) 24 | ``` 25 | - Возвращает текст или JSON (если `--json`). 26 | 27 | - **Интеграция с Hyprland** 28 | ```bash 29 | bind = SuperShift, D, exec, hypr-dock-ctl toggle layer 30 | ``` 31 | 32 | #### **2.2. Дополнительные требования** 33 | - **Единый стиль с `hyprctl`**: 34 | - Поддержка флагов `--json`, `--help`. 35 | - Чёткие сообщения об ошибках (например, `Dock is not running`). 36 | - **Только один инстанс дока**: 37 | - При запуске `hypr-dock` проверяется, что сокет свободен. 38 | 39 | --- 40 | 41 | ### **3. Концепция реализации** 42 | 43 | #### **3.1. IPC-протокол** 44 | - **Формат команд**: Текстовые строки (для простоты). 45 | ```text 46 | TOGGLE_LAYER 47 | GET_LAYER 48 | ``` 49 | - **Ответы**: 50 | - `OK` — успех. 51 | - `ERROR: Not running` — док не активен. 52 | - `auto`/`exclusive` — для `get`. 53 | 54 | #### **3.2. Схема взаимодействия** 55 | ```mermaid 56 | sequenceDiagram 57 | participant Hyprland 58 | participant hypr-dock-ctl 59 | participant hypr-dock (сервер) 60 | 61 | Hyprland ->> hypr-dock-ctl: toggle layer 62 | hypr-dock-ctl ->> hypr-dock: TOGGLE_LAYER (сокет) 63 | hypr-dock -->> hypr-dock-ctl: OK 64 | hypr-dock-ctl -->> Hyprland: Успех (код 0) 65 | ``` 66 | 67 | #### **3.3. Обработка ошибок** 68 | | Ситуация | Действие | 69 | |---------------------------|-----------------------------------| 70 | | Док не запущен | `ERROR: Dock is not running` | 71 | | Неверная команда | `ERROR: Unknown command` | 72 | | Сокет занят/недоступен | `ERROR: Cannot connect to dock` | 73 | 74 | --- 75 | 76 | ### **4. Возможные расширения (будущие версии)** 77 | 78 | #### **4.1. Управление темами** 79 | ```bash 80 | hypr-dock-ctl set theme dark 81 | hypr-dock-ctl get theme 82 | ``` 83 | 84 | #### **4.2. Динамические настройки** 85 | ```bash 86 | hypr-dock-ctl set margin 10 87 | hypr-dock-ctl set spacing 5 88 | ``` 89 | 90 | #### **4.3. Статус и отладка** 91 | ```bash 92 | hypr-dock-ctl status # Вывод всех параметров (JSON) 93 | hypr-dock-ctl reload # Перезагрузка конфига 94 | ``` 95 | 96 | #### **4.4. Интеграция с `hyprctl`** 97 | В будущем — добавление подкоманд в `hyprctl`: 98 | ```bash 99 | hyprctl dock toggle layer 100 | hyprctl dock get theme 101 | ``` 102 | 103 | --- 104 | ### **5. Этапы реализации** 105 | 1. **Реализация IPC-сервера** в `hypr-dock` (обработка команд). 106 | 2. **Создание `hypr-dock-ctl`** с командами `toggle` и `get`. 107 | 3. **Тестирование**: 108 | - Запуск/остановка дока. 109 | - Конкуренция за сокет. 110 | 4. **Документация**: 111 | - `man hypr-dock-ctl`. 112 | - Примеры для `hyprland.conf`. -------------------------------------------------------------------------------- /docs/tasks/ipc/ipc-main.md: -------------------------------------------------------------------------------- 1 | ### **Техническое задание: Реализация `hypr-dock-ctl` для управления hypr-dock** 2 | 3 | #### **1. Цель** 4 | Создать **отдельный CLI-бинарник** `hypr-dock-ctl` для управления доком через IPC, обеспечив: 5 | - **Смену режима слоя** (`auto` ↔ `exclusive`) без перезапуска дока. 6 | - **Единообразие с `hyprctl`** (синтаксис, флаги, вывод). 7 | - **Масштабируемость** для будущих команд (темы, настройки, статус). 8 | 9 | --- 10 | 11 | ### **2. Требования** 12 | 13 | #### **2.1. Базовый функционал (v1.0)** 14 | - **Команда `toggle`** 15 | ```bash 16 | hypr-dock-ctl toggle layer # Переключает между auto и exclusive 17 | ``` 18 | - Отправляет команду через **Unix-socket** (`/tmp/hypr-dock.sock`). 19 | - Если док **не запущен** — игнорирует команду (или выводит ошибку). 20 | 21 | - **Команда `get`** 22 | ```bash 23 | hypr-dock-ctl get layer # Выводит текущий режим (auto/exclusive) 24 | ``` 25 | - Возвращает текст или JSON (если `--json`). 26 | 27 | - **Интеграция с Hyprland** 28 | ```bash 29 | bind = SuperShift, D, exec, hypr-dock-ctl toggle layer 30 | ``` 31 | 32 | #### **2.2. Дополнительные требования** 33 | - **Единый стиль с `hyprctl`**: 34 | - Поддержка флагов `--json`, `--help`. 35 | - Чёткие сообщения об ошибках (например, `Dock is not running`). 36 | - **Только один инстанс дока**: 37 | - При запуске `hypr-dock` проверяется, что сокет свободен. 38 | 39 | --- 40 | 41 | ### **3. Концепция реализации** 42 | 43 | #### **3.1. IPC-протокол** 44 | - **Формат команд**: Текстовые строки (для простоты). 45 | ```text 46 | TOGGLE_LAYER 47 | GET_LAYER 48 | ``` 49 | - **Ответы**: 50 | - `OK` — успех. 51 | - `ERROR: Not running` — док не активен. 52 | - `auto`/`exclusive` — для `get`. 53 | 54 | #### **3.2. Схема взаимодействия** 55 | ```mermaid 56 | sequenceDiagram 57 | participant Hyprland 58 | participant hypr-dock-ctl 59 | participant hypr-dock (сервер) 60 | 61 | Hyprland ->> hypr-dock-ctl: toggle layer 62 | hypr-dock-ctl ->> hypr-dock: TOGGLE_LAYER (сокет) 63 | hypr-dock -->> hypr-dock-ctl: OK 64 | hypr-dock-ctl -->> Hyprland: Успех (код 0) 65 | ``` 66 | 67 | #### **3.3. Обработка ошибок** 68 | | Ситуация | Действие | 69 | |---------------------------|-----------------------------------| 70 | | Док не запущен | `ERROR: Dock is not running` | 71 | | Неверная команда | `ERROR: Unknown command` | 72 | | Сокет занят/недоступен | `ERROR: Cannot connect to dock` | 73 | 74 | --- 75 | 76 | ### **4. Возможные расширения (будущие версии)** 77 | 78 | #### **4.1. Управление темами** 79 | ```bash 80 | hypr-dock-ctl set theme dark 81 | hypr-dock-ctl get theme 82 | ``` 83 | 84 | #### **4.2. Динамические настройки** 85 | ```bash 86 | hypr-dock-ctl set margin 10 87 | hypr-dock-ctl set spacing 5 88 | ``` 89 | 90 | #### **4.3. Статус и отладка** 91 | ```bash 92 | hypr-dock-ctl status # Вывод всех параметров (JSON) 93 | hypr-dock-ctl reload # Перезагрузка конфига 94 | ``` 95 | 96 | #### **4.4. Интеграция с `hyprctl`** 97 | В будущем — добавление подкоманд в `hyprctl`: 98 | ```bash 99 | hyprctl dock toggle layer 100 | hyprctl dock get theme 101 | ``` 102 | 103 | --- 104 | ### **5. Этапы реализации** 105 | 1. **Реализация IPC-сервера** в `hypr-dock` (обработка команд). 106 | 2. **Создание `hypr-dock-ctl`** с командами `toggle` и `get`. 107 | 3. **Тестирование**: 108 | - Запуск/остановка дока. 109 | - Конкуренция за сокет. 110 | 4. **Документация**: 111 | - `man hypr-dock-ctl`. 112 | - Примеры для `hyprland.conf`. 113 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/allan-simon/go-singleinstance v0.0.0-20210120080615-d0997106ab37 h1:28uU3TtuvQ6KRndxg9TrC868jBWmSKgh0GTXkACCXmA= 2 | github.com/allan-simon/go-singleinstance v0.0.0-20210120080615-d0997106ab37/go.mod h1:6AXRstqK+32jeFmw89QGL2748+dj34Av4xc/I9oo9BY= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dlasky/gotk3-layershell v0.0.0-20240515133811-5c5115f0d774 h1:o87OVL4olQBlVwN3+NSVQpS6gj9FWUYtxOfHXWZigUE= 7 | github.com/dlasky/gotk3-layershell v0.0.0-20240515133811-5c5115f0d774/go.mod h1:JHLx2Wz4mAPVwn4PFhC69ydwyHP4A3wQvlg7HKVVc1U= 8 | github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= 9 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 10 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 11 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 12 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 13 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 14 | github.com/gotk3/gotk3 v0.6.1/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= 15 | github.com/gotk3/gotk3 v0.6.3 h1:+Ke4WkM1TQUNOlM2TZH6szqknqo+zNbX3BZWVXjSHYw= 16 | github.com/gotk3/gotk3 v0.6.3/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= 17 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 18 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 19 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 20 | github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= 21 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 22 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 23 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 24 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 25 | github.com/pdf/go-wayland v0.0.2 h1:/RzSdcUN3kSgd9Dyd8j4xFDwtJpYSprXSPxJ9AWDdlI= 26 | github.com/pdf/go-wayland v0.0.2/go.mod h1:aJPz7VZFFutb5bx9UwD6Pzw4gYziTBf2tRZsgaKx5xA= 27 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 28 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 32 | github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= 33 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 34 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a h1:a6TNDN9CgG+cYjaeN8l2mc4kSz2iMiCDQxPEyltUV/I= 35 | github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= 36 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 42 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 44 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 45 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 46 | -------------------------------------------------------------------------------- /docs/tasks/ipc/dockIPC-example.md: -------------------------------------------------------------------------------- 1 | # **Модуль dockIPC** 2 | 3 | Реализация модуля для работы с IPC через Unix domain sockets: 4 | 5 | ```go 6 | package dockIPC 7 | 8 | import ( 9 | "errors" 10 | "net" 11 | "os" 12 | "syscall" 13 | ) 14 | 15 | // StartServer - запускает IPC сервер 16 | // fileName: путь к сокету (например "/tmp/hypr-dock.sock") 17 | // handler: функция обработки входящих команд 18 | func StartServer(fileName string, handler func(string) ([]byte, error)) error { 19 | // Удаляем старый сокет если существует 20 | if err := os.RemoveAll(fileName); err != nil { 21 | return err 22 | } 23 | 24 | // Создаем Unix domain socket 25 | listener, err := net.Listen("unix", fileName) 26 | if err != nil { 27 | return err 28 | } 29 | defer listener.Close() 30 | 31 | // Устанавлием права на сокет 32 | if err := os.Chmod(fileName, 0666); err != nil { 33 | return err 34 | } 35 | 36 | // Обработка входящих соединений 37 | for { 38 | conn, err := listener.Accept() 39 | if err != nil { 40 | if errors.Is(err, net.ErrClosed) { 41 | return nil 42 | } 43 | continue 44 | } 45 | 46 | go handleConnection(conn, handler) 47 | } 48 | } 49 | 50 | // handleConnection обрабатывает одно соединение 51 | func handleConnection(conn net.Conn, handler func(string) ([]byte, error)) { 52 | defer conn.Close() 53 | 54 | buf := make([]byte, 4096) 55 | n, err := conn.Read(buf) 56 | if err != nil { 57 | return 58 | } 59 | 60 | command := string(buf[:n]) 61 | response, err := handler(command) 62 | if err != nil { 63 | // Форматируем ошибку в стандартный формат 64 | response = []byte("error: " + err.Error() + "\n") 65 | } 66 | 67 | conn.Write(response) 68 | } 69 | 70 | // Send отправляет команду в IPC сокет 71 | // command: строка команды (например "j/layer get") 72 | func Send(fileName string, command string) ([]byte, error) { 73 | conn, err := net.Dial("unix", fileName) 74 | if err != nil { 75 | return nil, err 76 | } 77 | defer conn.Close() 78 | 79 | _, err = conn.Write([]byte(command + "\n")) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | buf := make([]byte, 4096) 85 | n, err := conn.Read(buf) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | return buf[:n], nil 91 | } 92 | 93 | // StopServer останавливает сервер (вспомогательная функция) 94 | func StopServer(fileName string) error { 95 | return syscall.Unlink(fileName) 96 | } 97 | ``` 98 | 99 | ## **Использование модуля** 100 | 101 | ### **1. Серверная часть** 102 | ```go 103 | package main 104 | 105 | import ( 106 | "fmt" 107 | "dockIPC" 108 | ) 109 | 110 | func main() { 111 | // Обработчик команд 112 | handler := func(command string) ([]byte, error) { 113 | switch command { 114 | case "j/layer get": 115 | return []byte(`{"layers":["bottom","top"]}`), nil 116 | case "layer get": 117 | return []byte("bottom\ntop"), nil 118 | default: 119 | return nil, fmt.Errorf("unknown command") 120 | } 121 | } 122 | 123 | // Запуск сервера 124 | err := dockIPC.StartServer("/tmp/hypr-dock.sock", handler) 125 | if err != nil { 126 | panic(err) 127 | } 128 | } 129 | ``` 130 | 131 | ### **2. Клиентская часть** 132 | ```go 133 | package main 134 | 135 | import ( 136 | "fmt" 137 | "dockIPC" 138 | ) 139 | 140 | func main() { 141 | // Отправка команды 142 | response, err := dockIPC.Send("/tmp/hypr-dock.sock", "j/layer get") 143 | if err != nil { 144 | fmt.Println("Error:", err) 145 | return 146 | } 147 | 148 | fmt.Println("Response:", string(response)) 149 | } 150 | ``` 151 | 152 | ## **Особенности реализации** 153 | 154 | 1. **Безопасность**: 155 | - Удаление старого сокета перед созданием 156 | - Установка прав 0666 на сокет 157 | - Корректная обработка закрытия соединений 158 | 159 | 2. **Производительность**: 160 | - Каждое соединение обрабатывается в отдельной goroutine 161 | - Буферизированное чтение (4096 байт) 162 | 163 | 3. **Гибкость**: 164 | - Обработчик команд может возвращать любые бинарные данные 165 | - Поддержка текстовых и JSON-команд 166 | 167 | 4. **Вспомогательные функции**: 168 | - `StopServer()` для корректного завершения 169 | - Автоматическое форматирование ошибок 170 | 171 | Модуль готов к интеграции в проект `hypr-dock-ctl` и может быть расширен при необходимости. -------------------------------------------------------------------------------- /internal/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "hypr-dock/internal/itemsctl" 5 | "hypr-dock/internal/pkg/timer" 6 | "hypr-dock/internal/pvctl" 7 | "hypr-dock/internal/settings" 8 | "sync" 9 | 10 | "github.com/dlasky/gotk3-layershell/layershell" 11 | "github.com/gotk3/gotk3/glib" 12 | "github.com/gotk3/gotk3/gtk" 13 | ) 14 | 15 | type State struct { 16 | Settings settings.Settings 17 | Window *gtk.Window 18 | SignalHandlers map[string]glib.SignalHandle 19 | DetectArea *gtk.Window 20 | ItemsBox *gtk.Box 21 | Orientation gtk.Orientation 22 | Edge layershell.LayerShellEdgeFlags 23 | DockHideTimer *timer.Timer 24 | List *itemsctl.List 25 | Special bool 26 | PV *pvctl.PV 27 | ContextOpen bool 28 | mu sync.Mutex 29 | } 30 | 31 | func New(settings settings.Settings) *State { 32 | return &State{ 33 | Settings: settings, 34 | DockHideTimer: timer.New(), 35 | List: itemsctl.New(), 36 | PV: pvctl.New(settings), 37 | } 38 | } 39 | 40 | func (s *State) GetList() *itemsctl.List { 41 | s.mu.Lock() 42 | defer s.mu.Unlock() 43 | 44 | return s.List 45 | } 46 | 47 | func (s *State) GetDockHideTimer() *timer.Timer { 48 | s.mu.Lock() 49 | defer s.mu.Unlock() 50 | 51 | return s.DockHideTimer 52 | } 53 | 54 | func (s *State) AddSignalHandler(name string, id glib.SignalHandle) { 55 | s.mu.Lock() 56 | defer s.mu.Unlock() 57 | 58 | if s.SignalHandlers == nil { 59 | s.SignalHandlers = make(map[string]glib.SignalHandle) 60 | } 61 | s.SignalHandlers[name] = id 62 | } 63 | 64 | func (s *State) RemoveSignalHandler(name string, window *gtk.Window) { 65 | s.mu.Lock() 66 | defer s.mu.Unlock() 67 | 68 | if id, exists := s.SignalHandlers[name]; exists { 69 | window.HandlerDisconnect(id) 70 | delete(s.SignalHandlers, name) 71 | } 72 | } 73 | 74 | func (s *State) SetEdge(edge layershell.LayerShellEdgeFlags) { 75 | s.mu.Lock() 76 | defer s.mu.Unlock() 77 | s.Edge = edge 78 | } 79 | 80 | func (s *State) GetEdge() layershell.LayerShellEdgeFlags { 81 | s.mu.Lock() 82 | defer s.mu.Unlock() 83 | return s.Edge 84 | } 85 | 86 | func (s *State) SetSettings(settings settings.Settings) { 87 | s.mu.Lock() 88 | defer s.mu.Unlock() 89 | s.Settings = settings 90 | } 91 | 92 | func (s *State) GetSettings() settings.Settings { 93 | s.mu.Lock() 94 | defer s.mu.Unlock() 95 | return s.Settings 96 | } 97 | 98 | func (s *State) GetPinned() *[]string { 99 | s.mu.Lock() 100 | defer s.mu.Unlock() 101 | return &s.Settings.PinnedApps 102 | } 103 | 104 | func (s *State) Update(fn func(*State)) { 105 | s.mu.Lock() 106 | defer s.mu.Unlock() 107 | fn(s) 108 | } 109 | 110 | func (s *State) SetWindow(window *gtk.Window) { 111 | s.mu.Lock() 112 | defer s.mu.Unlock() 113 | s.Window = window 114 | } 115 | 116 | func (s *State) SetDetectArea(window *gtk.Window) { 117 | s.mu.Lock() 118 | defer s.mu.Unlock() 119 | s.DetectArea = window 120 | } 121 | 122 | func (s *State) SetItemsBox(box *gtk.Box) { 123 | s.mu.Lock() 124 | defer s.mu.Unlock() 125 | s.ItemsBox = box 126 | } 127 | 128 | func (s *State) SetOrientation(orientation gtk.Orientation) { 129 | s.mu.Lock() 130 | defer s.mu.Unlock() 131 | s.Orientation = orientation 132 | } 133 | 134 | func (s *State) SetSpecial(is bool) { 135 | s.mu.Lock() 136 | defer s.mu.Unlock() 137 | s.Special = is 138 | } 139 | 140 | func (s *State) GetWindow() *gtk.Window { 141 | s.mu.Lock() 142 | defer s.mu.Unlock() 143 | return s.Window 144 | } 145 | 146 | func (s *State) GetDetectArea() *gtk.Window { 147 | s.mu.Lock() 148 | defer s.mu.Unlock() 149 | return s.DetectArea 150 | } 151 | 152 | func (s *State) GetItemsBox() *gtk.Box { 153 | s.mu.Lock() 154 | defer s.mu.Unlock() 155 | return s.ItemsBox 156 | } 157 | 158 | func (s *State) GetOrientation() gtk.Orientation { 159 | s.mu.Lock() 160 | defer s.mu.Unlock() 161 | return s.Orientation 162 | } 163 | 164 | func (s *State) GetSpecial() bool { 165 | s.mu.Lock() 166 | defer s.mu.Unlock() 167 | return s.Special 168 | } 169 | 170 | func (s *State) GetPV() *pvctl.PV { 171 | s.mu.Lock() 172 | defer s.mu.Unlock() 173 | 174 | return s.PV 175 | } 176 | 177 | func (s *State) GetContextOpen() bool { 178 | s.mu.Lock() 179 | defer s.mu.Unlock() 180 | 181 | return s.ContextOpen 182 | } 183 | 184 | func (s *State) SetContextOpen(flag bool) { 185 | s.mu.Lock() 186 | defer s.mu.Unlock() 187 | 188 | s.ContextOpen = flag 189 | } 190 | -------------------------------------------------------------------------------- /internal/layering/layering.go: -------------------------------------------------------------------------------- 1 | package layering 2 | 3 | import ( 4 | detectzone "hypr-dock/internal/detectZone" 5 | "hypr-dock/internal/state" 6 | "hypr-dock/pkg/ipc" 7 | "strings" 8 | 9 | "github.com/dlasky/gotk3-layershell/layershell" 10 | "github.com/gotk3/gotk3/gdk" 11 | "github.com/gotk3/gotk3/gtk" 12 | ) 13 | 14 | func SetWindowProperty(appState *state.State) { 15 | window := appState.GetWindow() 16 | settings := appState.GetSettings() 17 | 18 | layershell.InitForWindow(window) 19 | layershell.SetNamespace(window, "hypr-dock") 20 | 21 | ChangePosition(settings.Position, appState) 22 | ChangeLayer(settings.Layer, appState) 23 | } 24 | 25 | func ChangeLayer(layer string, appState *state.State) { 26 | window := appState.GetWindow() 27 | if window == nil { 28 | return 29 | } 30 | 31 | layers := map[string]layershell.LayerShellLayerFlags{ 32 | "background": layershell.LAYER_SHELL_LAYER_BACKGROUND, 33 | "bottom": layershell.LAYER_SHELL_LAYER_BOTTOM, 34 | "top": layershell.LAYER_SHELL_LAYER_TOP, 35 | "overlay": layershell.LAYER_SHELL_LAYER_OVERLAY, 36 | } 37 | 38 | if layer == "auto" { 39 | layershell.SetLayer(window, layers["bottom"]) 40 | layershell.SetExclusiveZone(window, 0) 41 | AutoLayer(appState) 42 | detectzone.Init(appState) 43 | return 44 | } 45 | 46 | DisableAutoLayer(appState) 47 | if strings.Contains(layer, "exclusive") { 48 | exLayer := strings.Split(layer, "-")[1] 49 | layershell.SetLayer(window, layers[exLayer]) 50 | layershell.AutoExclusiveZoneEnable(window) 51 | return 52 | } 53 | 54 | layershell.SetLayer(window, layers[layer]) 55 | } 56 | 57 | func ChangePosition(position string, appState *state.State) { 58 | window := appState.GetWindow() 59 | if window == nil { 60 | return 61 | } 62 | 63 | oreintations := map[string]gtk.Orientation{ 64 | "bottom": gtk.ORIENTATION_HORIZONTAL, 65 | "top": gtk.ORIENTATION_HORIZONTAL, 66 | "left": gtk.ORIENTATION_VERTICAL, 67 | "right": gtk.ORIENTATION_VERTICAL, 68 | } 69 | 70 | edges := map[string]layershell.LayerShellEdgeFlags{ 71 | "bottom": layershell.LAYER_SHELL_EDGE_BOTTOM, 72 | "top": layershell.LAYER_SHELL_EDGE_TOP, 73 | "left": layershell.LAYER_SHELL_EDGE_LEFT, 74 | "right": layershell.LAYER_SHELL_EDGE_RIGHT, 75 | } 76 | 77 | layershell.SetAnchor(window, edges[position], true) 78 | layershell.SetMargin(window, edges[position], 0) 79 | 80 | appState.SetOrientation(oreintations[position]) 81 | appState.SetEdge(edges[position]) 82 | } 83 | 84 | func AutoLayer(appState *state.State) { 85 | DisableAutoLayer(appState) 86 | window := appState.GetWindow() 87 | 88 | ipc.AddEventListener("hd>>pv-pointer-enter", func(e string) { 89 | appState.GetDockHideTimer().Stop() 90 | }, true) 91 | 92 | ipc.AddEventListener("hd>>pv-pointer-leave", func(e string) { 93 | DispathLeaveEvent(window, nil, appState) 94 | }, true) 95 | 96 | ipc.AddEventListener("hd>>focus-window", func(e string) { 97 | DispathLeaveEvent(window, nil, appState) 98 | }, true) 99 | 100 | enterSig := window.Connect("enter-notify-event", func(window *gtk.Window, e *gdk.Event) { 101 | event := gdk.EventCrossingNewFromEvent(e) 102 | isInWindow := event.Detail() == 3 || event.Detail() == 4 103 | 104 | if !isInWindow || appState.GetSpecial() { 105 | return 106 | } 107 | 108 | timer := appState.GetDockHideTimer() 109 | 110 | timer.Stop() 111 | layershell.SetLayer(window, layershell.LAYER_SHELL_LAYER_TOP) 112 | }) 113 | appState.AddSignalHandler("enter", enterSig) 114 | 115 | leaveSig := window.Connect("leave-notify-event", func(window *gtk.Window, e *gdk.Event) { 116 | DispathLeaveEvent(window, e, appState) 117 | }) 118 | appState.AddSignalHandler("leave", leaveSig) 119 | } 120 | 121 | func DispathLeaveEvent(window *gtk.Window, e *gdk.Event, appState *state.State) { 122 | isInWindow := true 123 | if e != nil { 124 | event := gdk.EventCrossingNewFromEvent(e) 125 | isInWindow = event.Detail() == 3 || event.Detail() == 4 126 | } 127 | 128 | if !isInWindow { 129 | return 130 | } 131 | 132 | timer := appState.GetDockHideTimer() 133 | 134 | timer.Run(appState.Settings.AutoHideDeley, func() { 135 | layershell.SetLayer(window, layershell.LAYER_SHELL_LAYER_BOTTOM) 136 | }) 137 | } 138 | 139 | func DisableAutoLayer(appState *state.State) { 140 | detectArea := appState.GetDetectArea() 141 | if detectArea != nil { 142 | detectArea.Destroy() 143 | appState.SetDetectArea(nil) 144 | } 145 | 146 | window := appState.GetWindow() 147 | 148 | appState.RemoveSignalHandler("enter", window) 149 | appState.RemoveSignalHandler("leave", window) 150 | } 151 | -------------------------------------------------------------------------------- /internal/item/popup.go: -------------------------------------------------------------------------------- 1 | package item 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/gotk3/gotk3/gtk" 9 | "github.com/gotk3/gotk3/pango" 10 | 11 | "hypr-dock/internal/pkg/desktop" 12 | "hypr-dock/internal/pkg/utils" 13 | "hypr-dock/internal/placeholders" 14 | "hypr-dock/internal/settings" 15 | "hypr-dock/pkg/ipc" 16 | ) 17 | 18 | func (item *Item) WindowsMenu() (*gtk.Menu, error) { 19 | menu, err := gtk.MenuNew() 20 | if err != nil { 21 | log.Println(err) 22 | } 23 | 24 | desktopData := desktop.New(item.ClassName) 25 | 26 | AddWindowsItemToMenu(menu, item.Windows, desktopData) 27 | 28 | menu.SetName("windows-menu") 29 | menu.ShowAll() 30 | 31 | return menu, nil 32 | } 33 | 34 | func (item *Item) ContextMenu(settings settings.Settings) (*gtk.Menu, error) { 35 | menu, err := gtk.MenuNew() 36 | if err != nil { 37 | log.Println(err) 38 | } 39 | 40 | desktopData := item.DesktopData 41 | 42 | AddWindowsItemToMenu(menu, item.Windows, desktopData) 43 | 44 | if item.Instances != 0 { 45 | separator, err := gtk.SeparatorMenuItemNew() 46 | if err == nil { 47 | menu.Append(separator) 48 | } else { 49 | log.Println(err) 50 | } 51 | } 52 | 53 | if item.Actions != nil { 54 | for _, action := range item.Actions { 55 | exec := func() { 56 | utils.Launch(action.Exec) 57 | } 58 | 59 | var actionMenuItem *gtk.MenuItem 60 | var err error 61 | 62 | if action.Icon == "" { 63 | actionMenuItem, err = BuildContextItem(action.Name, exec) 64 | } else { 65 | actionMenuItem, err = BuildContextItem(action.Name, exec, action.Icon) 66 | } 67 | 68 | if err == nil { 69 | menu.Append(actionMenuItem) 70 | } else { 71 | log.Println(err) 72 | } 73 | } 74 | 75 | separator, err := gtk.SeparatorMenuItemNew() 76 | if err == nil { 77 | menu.Append(separator) 78 | } else { 79 | log.Println(err) 80 | } 81 | } 82 | 83 | launchMenuItem, err := BuildLaunchMenuItem(item, desktopData.Exec) 84 | if err == nil { 85 | menu.Append(launchMenuItem) 86 | } else { 87 | log.Println(err) 88 | } 89 | 90 | pinMenuItem, err := BuildPinMenuItem(item, settings) 91 | if err == nil { 92 | menu.Append(pinMenuItem) 93 | } else { 94 | log.Println(err) 95 | } 96 | 97 | if item.Instances == 1 { 98 | closeMenuItem, err := BuildContextItem("Close", func() { 99 | ipc.Hyprctl("dispatch closewindow address:" + item.Windows[0]["Address"]) 100 | }, "close-symbolic") 101 | if err == nil { 102 | menu.Append(closeMenuItem) 103 | } else { 104 | log.Println(err) 105 | } 106 | } 107 | 108 | menu.SetName("context-menu") 109 | menu.ShowAll() 110 | 111 | return menu, nil 112 | } 113 | 114 | func AddWindowsItemToMenu(menu *gtk.Menu, windows []map[string]string, desktopData *desktop.Desktop) { 115 | for _, window := range windows { 116 | menuItem, err := BuildContextItem(window["Title"], func() { 117 | go ipc.Hyprctl("dispatch focuswindow address:" + window["Address"]) 118 | }, desktopData.Icon) 119 | 120 | if err != nil { 121 | log.Println(err) 122 | continue 123 | } 124 | 125 | menu.Append(menuItem) 126 | } 127 | } 128 | 129 | func BuildLaunchMenuItem(item *Item, exec string) (*gtk.MenuItem, error) { 130 | if item.Instances != 0 && item.DesktopData.SingleWindow { 131 | return nil, errors.New("") 132 | } 133 | 134 | labelText := item.DesktopData.Name 135 | if item.Instances != 0 { 136 | labelText = "New Window - " + labelText 137 | } 138 | 139 | launchMenuItem, err := BuildContextItem(labelText, func() { 140 | placeholders.Run(exec) 141 | }, item.DesktopData.Icon) 142 | 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | return launchMenuItem, nil 148 | } 149 | 150 | func BuildPinMenuItem(item *Item, settings settings.Settings) (*gtk.MenuItem, error) { 151 | labelText := "Pin" 152 | if item.IsPinned() { 153 | labelText = "Unpin" 154 | } 155 | 156 | menuItem, err := BuildContextItem(labelText, func() { 157 | item.TogglePin(settings) 158 | }) 159 | 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | return menuItem, nil 165 | } 166 | 167 | func BuildContextItem(labelText string, connectFunc func(), iconName ...string) (*gtk.MenuItem, error) { 168 | size := 16 169 | spacing := 6 170 | 171 | menuItem, err := gtk.MenuItemNew() 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | menuItem.SetName("menu-item") 177 | 178 | hbox, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, spacing) 179 | if err != nil { 180 | return nil, err 181 | } 182 | 183 | hbox.SetName("hbox") 184 | /* Hack (HELP ME)*/ 185 | /* stackoverflow.com/questions/48452717/how-to-replace-the-deprecated-gtk3-gtkimagemenuitem */ 186 | utils.AddStyle(hbox, fmt.Sprintf("#hbox {margin-left: %dpx;}", 0-(size+spacing))) 187 | 188 | label, err := gtk.LabelNew(labelText) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | label.SetEllipsize(pango.ELLIPSIZE_END) 194 | label.SetMaxWidthChars(30) 195 | 196 | if len(iconName) > 0 { 197 | icon, err := utils.CreateImage(iconName[0], size) 198 | if err == nil { 199 | hbox.Add(icon) 200 | } 201 | } else { 202 | label.SetMarginStart(size + spacing) 203 | } 204 | 205 | if connectFunc != nil { 206 | menuItem.Connect("activate", func() { 207 | connectFunc() 208 | }) 209 | } 210 | 211 | hbox.Add(label) 212 | menuItem.SetReserveIndicator(false) 213 | menuItem.Add(hbox) 214 | 215 | return menuItem, nil 216 | } 217 | -------------------------------------------------------------------------------- /internal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "slices" 8 | 9 | "github.com/gotk3/gotk3/gtk" 10 | 11 | "hypr-dock/internal/btnctl" 12 | "hypr-dock/internal/hypr/hyprOpt" 13 | "hypr-dock/internal/item" 14 | "hypr-dock/internal/pkg/utils" 15 | "hypr-dock/internal/state" 16 | "hypr-dock/pkg/ipc" 17 | ) 18 | 19 | func BuildApp(appState *state.State) *gtk.Box { 20 | settings := appState.GetSettings() 21 | orientation := appState.GetOrientation() 22 | 23 | app, err := gtk.BoxNew(orientation, 0) 24 | if err != nil { 25 | log.Println("BuildApp() | app | gtk.BoxNew()") 26 | log.Fatal(err) 27 | } 28 | 29 | addWindowMarginRule(app, appState) 30 | app.SetName("app") 31 | 32 | itemsBox, _ := gtk.BoxNew(orientation, settings.Spacing) 33 | itemsBox.SetName("items-box") 34 | 35 | switch orientation { 36 | case gtk.ORIENTATION_HORIZONTAL: 37 | itemsBox.SetMarginEnd(int(float64(settings.Spacing) * 0.8)) 38 | itemsBox.SetMarginStart(int(float64(settings.Spacing) * 0.8)) 39 | case gtk.ORIENTATION_VERTICAL: 40 | itemsBox.SetMarginBottom(int(float64(settings.Spacing) * 0.8)) 41 | itemsBox.SetMarginTop(int(float64(settings.Spacing) * 0.8)) 42 | } 43 | 44 | appState.SetItemsBox(itemsBox) 45 | renderItems(appState) 46 | app.Add(itemsBox) 47 | 48 | return app 49 | } 50 | 51 | func renderItems(appState *state.State) { 52 | clients, _ := ipc.GetClients() 53 | 54 | for _, className := range *appState.GetPinned() { 55 | InitNewItemInClass(className, appState) 56 | } 57 | 58 | for _, ipcClient := range clients { 59 | InitNewItemInIPC(ipcClient, appState) 60 | } 61 | 62 | ipc.DispatchEvent("hd>>dock-render-finish") 63 | } 64 | 65 | func InitNewItemInIPC(ipcClient ipc.Client, appState *state.State) { 66 | list := appState.GetList() 67 | className := ipcClient.Class 68 | if !slices.Contains(*appState.GetPinned(), className) && list.Get(className) == nil { 69 | InitNewItemInClass(className, appState) 70 | } 71 | 72 | list.Get(className).UpdateState(ipcClient, appState.GetSettings()) 73 | appState.GetWindow().ShowAll() 74 | } 75 | 76 | func InitNewItemInClass(className string, appState *state.State) { 77 | list := appState.GetList() 78 | item, err := item.New(className, appState.GetSettings()) 79 | if err != nil { 80 | log.Println(err) 81 | return 82 | } 83 | 84 | btnctl.Dispatch(item, appState) 85 | 86 | item.List = list.GetMap() 87 | item.PinnedList = appState.GetPinned() 88 | list.Add(className, item) 89 | 90 | appState.GetItemsBox().Add(item.ButtonBox) 91 | appState.GetWindow().ShowAll() 92 | } 93 | 94 | func RemoveApp(address string, appState *state.State) { 95 | item, windowIndex, err := searchByAddress(address, appState) 96 | if err != nil { 97 | log.Println(err) 98 | return 99 | } 100 | 101 | className := item.ClassName 102 | if item.Instances == 1 && !slices.Contains(*appState.GetPinned(), className) { 103 | item.Remove() 104 | return 105 | } 106 | 107 | item.RemoveLastInstance(windowIndex, appState.GetSettings()) 108 | 109 | appState.GetWindow().ShowAll() 110 | } 111 | 112 | func searchByAddress(address string, appState *state.State) (*item.Item, int, error) { 113 | for _, item := range appState.GetList().GetMap() { 114 | for windowIndex, window := range item.Windows { 115 | if window["Address"] == address { 116 | return item, windowIndex, nil 117 | } 118 | } 119 | } 120 | 121 | err := errors.New("Window not found: " + address) 122 | return nil, 0, err 123 | } 124 | 125 | func ChangeWindowTitle(address string, title string, appState *state.State) { 126 | for _, data := range appState.GetList().GetMap() { 127 | for _, appWindow := range data.Windows { 128 | if appWindow["Address"] == address { 129 | appWindow["Title"] = title 130 | } 131 | } 132 | } 133 | } 134 | 135 | func addWindowMarginRule(app *gtk.Box, appState *state.State) { 136 | settings := appState.GetSettings() 137 | position := settings.Position 138 | var marginProvider *gtk.CssProvider 139 | 140 | switch settings.SystemGapUsed { 141 | case "true": 142 | margin, err := hyprOpt.GetGap() 143 | if err != nil { 144 | log.Println(err, "\nSet margin in config") 145 | applyWindowMarginCSS(app, position, settings.Margin) 146 | } 147 | 148 | marginProvider = applyWindowMarginCSS(app, position, margin[0]) 149 | 150 | hyprOpt.GapChangeEvent(func(gap int) { 151 | utils.RemoveStyleProvider(app, marginProvider) 152 | marginProvider = applyWindowMarginCSS(app, position, gap) 153 | log.Println("Window margins updated successfully: ", gap) 154 | }) 155 | case "false": 156 | applyWindowMarginCSS(app, position, settings.Margin) 157 | } 158 | } 159 | 160 | func applyWindowMarginCSS(app *gtk.Box, position string, margin int) *gtk.CssProvider { 161 | css := fmt.Sprintf("#app {margin-%s: %dpx;}", position, margin) 162 | 163 | marginProvider, err := gtk.CssProviderNew() 164 | if err != nil { 165 | log.Printf("Failed to create CSS provider: %v", err) 166 | return nil 167 | } 168 | 169 | appStyleContext, err := app.GetStyleContext() 170 | if err != nil { 171 | log.Printf("Failed to get style context: %v", err) 172 | return nil 173 | } 174 | 175 | appStyleContext.AddProvider(marginProvider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) 176 | 177 | err = marginProvider.LoadFromData(css) 178 | if err != nil { 179 | log.Printf("Failed to load CSS data: %v", err) 180 | return nil 181 | } 182 | 183 | return marginProvider 184 | } 185 | -------------------------------------------------------------------------------- /docs/tasks/ipc/dockIPC-task.md: -------------------------------------------------------------------------------- 1 | # **Проектная документация модуля dockIPC** 2 | 3 | ## **1. Концепция модуля** 4 | 5 | ### **1.1 Назначение** 6 | Модуль `dockIPC` предоставляет минималистичный API для организации межпроцессного взаимодействия (IPC) через Unix domain sockets. Основные цели: 7 | - Создание стабильного канала коммуникации между демоном и клиентами 8 | - Поддержка синхронного запрос-ответ взаимодействия 9 | - Обеспечение простой интеграции без сложных зависимостей 10 | 11 | ### **1.2 Ключевые принципы** 12 | 1. **Минимализм** - только базовые функции для IPC 13 | 2. **Идемпотентность** - повторные вызовы дают одинаковый результат 14 | 3. **Атомарность** - каждая операция либо завершается полностью, либо не выполняется 15 | 4. **Потокобезопасность** - корректная работа в многопоточной среде 16 | 17 | --- 18 | 19 | ## **2. Архитектурные компоненты** 20 | 21 | ### **2.1 Серверная часть** 22 | #### **Алгоритм работы:** 23 | 1. **Инициализация сокета** 24 | - Удаление существующего файла сокета (атомарная операция) 25 | - Создание нового Unix domain socket 26 | - Установка прав доступа (0666) 27 | 28 | 2. **Цикл обработки соединений** 29 | - Принятие входящего соединения (блокирующая операция) 30 | - Запуск обработчика соединения в отдельной goroutine 31 | - Чтение команды из сокета (максимальный размер - 4КБ) 32 | - Вызов пользовательского обработчика 33 | - Отправка результата обратно клиенту 34 | 35 | 3. **Обработка ошибок** 36 | - Автоматическое закрытие соединений при ошибках 37 | - Преобразование ошибок обработчика в стандартный формат 38 | 39 | ### **2.2 Клиентская часть** 40 | #### **Алгоритм работы:** 41 | 1. **Установка соединения** 42 | - Попытка подключения к существующему сокету 43 | - Таймаут по умолчанию (системный) 44 | 45 | 2. **Передача команды** 46 | - Запись строки команды в сокет 47 | - Добавление терминатора `\n` для четкого разделения команд 48 | 49 | 3. **Получение ответа** 50 | - Чтение данных из сокета (блокирующая операция) 51 | - Возврат сырых бинарных данных 52 | 53 | --- 54 | 55 | ## **3. Взаимодействие компонентов** 56 | 57 | ```mermaid 58 | sequenceDiagram 59 | participant Client 60 | participant Server 61 | participant Handler 62 | 63 | Client->>Server: Установка соединения (Dial) 64 | Client->>Server: Отправка команды + \n 65 | Server->>Handler: Вызов обработчика с командой 66 | Handler->>Server: Возврат данных/ошибки 67 | Server->>Client: Отправка сырого ответа 68 | Client->>Server: Закрытие соединения 69 | ``` 70 | 71 | ### **3.1 Особенности взаимодействия** 72 | 1. **Синхронная модель** - клиент блокируется до получения ответа 73 | 2. **Статический буфер** - фиксированный размер 4КБ для простоты 74 | 3. **Гарантия доставки** - либо полный ответ, либо ошибка 75 | 76 | --- 77 | 78 | ## **4. Алгоритмы обработки** 79 | 80 | ### **4.1 Обработка соединений (Server)** 81 | 1. **Мультиплексирование**: 82 | - Каждое соединение обрабатывается в отдельной goroutine 83 | - Нет ограничения на количество одновременных соединений 84 | 85 | 2. **Управление ресурсами**: 86 | - Деферы для гарантированного закрытия соединений 87 | - Автоматическое восстановление после ошибок 88 | 89 | 3. **Формат обмена**: 90 | - Команды - строки в UTF-8 91 | - Ответы - произвольные бинарные данные 92 | 93 | ### **4.2 Обработка ошибок** 94 | 1. **Типы ошибок**: 95 | - Ошибки соединения (файл сокета не существует) 96 | - Ошибки чтения/записи 97 | - Ошибки пользовательского обработчика 98 | 99 | 2. **Стратегии**: 100 | - Автоматическое повторное создание сокета 101 | - Преобразование ошибок в стандартный формат 102 | - Гарантированное освобождение ресурсов 103 | 104 | --- 105 | 106 | ## **5. Концепция реализации** 107 | 108 | ### **5.1 Сервер** 109 | **Ключевые идеи:** 110 | 1. **Один сокет - много соединений**: 111 | - Основной цикл принимает соединения 112 | - Каждое соединение - отдельный экземпляр обработчика 113 | 114 | 2. **Изоляция состояний**: 115 | - Нет разделяемого состояния между обработчиками 116 | - Все зависимости инжектятся через замыкания 117 | 118 | 3. **Гибкость обработки**: 119 | - Пользовательский обработчик может реализовывать любую логику 120 | - Поддержка как текстовых, так и бинарных протоколов 121 | 122 | ### **5.2 Клиент** 123 | **Ключевые идеи:** 124 | 1. **Простота использования**: 125 | - Одна функция для отправки команд 126 | - Прозрачное управление соединением 127 | 128 | 2. **Детерминированность**: 129 | - Четкое разделение команд через `\n` 130 | - Предсказуемый размер буфера 131 | 132 | 3. **Расширяемость**: 133 | - Возможность добавления таймаутов 134 | - Поддержка разных стратегий сериализации 135 | 136 | --- 137 | 138 | ## **6. Ограничения и допущения** 139 | 140 | 1. **Размер сообщений**: 141 | - Максимальный размер - 4КБ (можно увеличить при необходимости) 142 | - Нет поддержки потоковой передачи 143 | 144 | 2. **Безопасность**: 145 | - Нет шифрования передаваемых данных 146 | - Аутентификация через права файловой системы 147 | 148 | 3. **Производительность**: 149 | - Оптимизировано для low-throughput коммуникации 150 | - Не подходит для high-frequency RPC 151 | 152 | --- 153 | 154 | ## **7. Перспективы развития** 155 | 156 | 1. **Добавление**: 157 | - Таймаутов соединения 158 | - Поддержки потоковой передачи 159 | - Механизма аутентификации 160 | 161 | 2. **Оптимизация**: 162 | - Пула соединений 163 | - Буферизированного ввода/вывода 164 | - Более эффективного аллокатора памяти 165 | 166 | Модуль специально спроектирован для постепенного расширения функциональности при сохранении базовых принципов простоты и надежности. -------------------------------------------------------------------------------- /internal/item/item.go: -------------------------------------------------------------------------------- 1 | package item 2 | 3 | import ( 4 | "log" 5 | "slices" 6 | 7 | "github.com/gotk3/gotk3/gdk" 8 | "github.com/gotk3/gotk3/gtk" 9 | 10 | "hypr-dock/internal/pkg/cfg" 11 | "hypr-dock/internal/pkg/desktop" 12 | "hypr-dock/internal/pkg/indicator" 13 | "hypr-dock/internal/pkg/utils" 14 | "hypr-dock/internal/settings" 15 | 16 | "hypr-dock/pkg/ipc" 17 | ) 18 | 19 | type Item struct { 20 | Instances int 21 | Windows []map[string]string 22 | DesktopData *desktop.Desktop 23 | DesktopDataV2 *desktop.Desktop2 24 | Actions []*desktop.Action 25 | ClassName string 26 | Button *gtk.Button 27 | ButtonBox *gtk.Box 28 | IndicatorImage *gtk.Image 29 | List map[string]*Item 30 | PinnedList *[]string 31 | } 32 | 33 | func New(className string, settings settings.Settings) (*Item, error) { 34 | desktopData := desktop.New(className) 35 | desktopDataV2, err := desktop.NewV2(className) 36 | if err != nil { 37 | log.Println(err) 38 | } 39 | 40 | orientation := gtk.ORIENTATION_VERTICAL 41 | switch settings.Position { 42 | case "left", "right": 43 | orientation = gtk.ORIENTATION_HORIZONTAL 44 | } 45 | 46 | item, err := gtk.BoxNew(orientation, 0) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | indicatorImage, err := indicator.New(0, settings) 52 | if err == nil { 53 | appendInducator(item, indicatorImage, settings.Position) 54 | } else { 55 | log.Println(err) 56 | } 57 | 58 | button, err := gtk.ButtonNew() 59 | if err == nil { 60 | image, err := utils.CreateImage(desktopData.Icon, settings.IconSize) 61 | if err == nil { 62 | button.SetImage(image) 63 | } else { 64 | log.Println(err) 65 | } 66 | 67 | button.SetName(className) 68 | 69 | button.SetTooltipText(desktopData.Name) 70 | 71 | display, err := gdk.DisplayGetDefault() 72 | if err == nil { 73 | pointer, _ := gdk.CursorNewFromName(display, "pointer") 74 | arrow, _ := gdk.CursorNewFromName(display, "default") 75 | 76 | button.Connect("enter-notify-event", func() { 77 | win, _ := button.GetWindow() 78 | if win != nil { 79 | win.SetCursor(pointer) 80 | } 81 | }) 82 | 83 | button.Connect("leave-notify-event", func() { 84 | win, _ := button.GetWindow() 85 | if win != nil { 86 | win.SetCursor(arrow) 87 | } 88 | }) 89 | } 90 | 91 | item.Add(button) 92 | } else { 93 | log.Println(err) 94 | } 95 | 96 | actions, err := desktop.GetAppActions(className) 97 | if err != nil { 98 | log.Println(err) 99 | } 100 | 101 | return &Item{ 102 | IndicatorImage: indicatorImage, 103 | Button: button, 104 | ButtonBox: item, 105 | DesktopData: desktopData, 106 | DesktopDataV2: desktopDataV2, 107 | Actions: actions, 108 | Instances: 0, 109 | ClassName: className, 110 | List: nil, 111 | PinnedList: nil, 112 | }, nil 113 | } 114 | 115 | func (item *Item) RemoveLastInstance(windowIndex int, settings settings.Settings) { 116 | if item.IndicatorImage != nil { 117 | item.IndicatorImage.Destroy() 118 | } 119 | 120 | newImage, err := indicator.New(item.Instances-1, settings) 121 | if err == nil { 122 | appendInducator(item.ButtonBox, newImage, settings.Position) 123 | } 124 | 125 | item.Instances -= 1 126 | item.Windows = utils.RemoveFromSlice(item.Windows, windowIndex) 127 | item.IndicatorImage = newImage 128 | 129 | if item.Instances == 0 { 130 | item.Button.SetTooltipText(item.DesktopData.Name) 131 | } 132 | } 133 | 134 | func (item *Item) UpdateState(ipcClient ipc.Client, settings settings.Settings) { 135 | appWindow := map[string]string{ 136 | "Address": ipcClient.Address, 137 | "Title": ipcClient.Title, 138 | } 139 | 140 | if item.IndicatorImage != nil { 141 | item.IndicatorImage.Destroy() 142 | } 143 | 144 | indicatorImage, err := indicator.New(item.Instances+1, settings) 145 | if err == nil { 146 | // item.ButtonBox.Add(indicatorImage) 147 | appendInducator(item.ButtonBox, indicatorImage, settings.Position) 148 | } 149 | 150 | item.Windows = append(item.Windows, appWindow) 151 | item.IndicatorImage = indicatorImage 152 | item.Instances += 1 153 | 154 | if item.Instances != 0 { 155 | item.Button.SetTooltipText("") 156 | } 157 | } 158 | 159 | func (item *Item) IsPinned() bool { 160 | return slices.Contains(*item.PinnedList, item.ClassName) 161 | } 162 | 163 | func (item *Item) TogglePin(settings settings.Settings) { 164 | 165 | if item.IsPinned() { 166 | utils.RemoveFromSliceByValue(item.PinnedList, item.ClassName) 167 | if item.Instances == 0 { 168 | item.ButtonBox.Destroy() 169 | delete(item.List, item.ClassName) 170 | } 171 | log.Println("Remove:", item.ClassName) 172 | } else { 173 | utils.AddToSlice(item.PinnedList, item.ClassName) 174 | log.Println("Add:", item.ClassName) 175 | } 176 | 177 | err := cfg.ChangeJsonPinnedApps(*item.PinnedList, settings.PinnedPath) 178 | if err != nil { 179 | log.Println("Error: ", err) 180 | } else { 181 | log.Println("File", settings.PinnedPath, "saved successfully!", item.ClassName) 182 | } 183 | } 184 | 185 | func (item *Item) Remove() { 186 | item.ButtonBox.Destroy() 187 | delete(item.List, item.ClassName) 188 | } 189 | 190 | func appendInducator(parent *gtk.Box, child *gtk.Image, pos string) { 191 | switch pos { 192 | case "left", "right": 193 | buf := child.GetPixbuf() 194 | newBuf, err := buf.RotateSimple(gdk.PIXBUF_ROTATE_COUNTERCLOCKWISE) 195 | if err != nil { 196 | return 197 | } 198 | child.SetFromPixbuf(newBuf) 199 | } 200 | 201 | switch pos { 202 | case "left", "top": 203 | parent.PackStart(child, false, false, 0) 204 | parent.ReorderChild(child, 0) 205 | case "bottom", "right": 206 | parent.PackEnd(child, false, false, 0) 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /internal/pvwidget/pvwidget.go: -------------------------------------------------------------------------------- 1 | package pvwidget 2 | 3 | import ( 4 | "fmt" 5 | "hypr-dock/internal/hysc" 6 | "hypr-dock/internal/item" 7 | "hypr-dock/internal/pkg/utils" 8 | "hypr-dock/internal/settings" 9 | "hypr-dock/pkg/ipc" 10 | "log" 11 | "sync" 12 | 13 | "github.com/gotk3/gotk3/gdk" 14 | "github.com/gotk3/gotk3/glib" 15 | "github.com/gotk3/gotk3/gtk" 16 | "github.com/gotk3/gotk3/pango" 17 | ) 18 | 19 | func New(item *item.Item, settings settings.Settings, onReady func(w, h int)) (box *gtk.Box, err error) { 20 | padding := settings.PreviewStyle.Padding 21 | 22 | wrapper, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, settings.ContextPos) 23 | if err != nil { 24 | return nil, err 25 | } 26 | wrapper.SetName("pv-wrap") 27 | 28 | var ( 29 | totalWidth int 30 | readyCount int 31 | commonHeight int 32 | mutex sync.Mutex 33 | ) 34 | 35 | for _, window := range item.Windows { 36 | windowBox, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) 37 | if err != nil { 38 | log.Println(err) 39 | continue 40 | } 41 | 42 | windowBox.SetName("pv-item") 43 | 44 | eventBox, err := gtk.EventBoxNew() 45 | if err != nil { 46 | log.Println(err) 47 | continue 48 | } 49 | 50 | eventBox.SetName("pv-event-box") 51 | 52 | windowBoxContent, err := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) 53 | if err != nil { 54 | log.Println(err) 55 | continue 56 | } 57 | 58 | windowBoxContent.SetMarginBottom(padding) 59 | windowBoxContent.SetMarginEnd(padding) 60 | windowBoxContent.SetMarginStart(padding) 61 | windowBoxContent.SetMarginTop(padding / 2) 62 | 63 | titleBox, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5) 64 | if err != nil { 65 | log.Println(err) 66 | continue 67 | } 68 | 69 | titleBox.SetMarginBottom(padding / 2) 70 | 71 | icon, err := utils.CreateImage(item.DesktopData.Icon, 16) 72 | if err != nil { 73 | log.Println(err) 74 | continue 75 | } 76 | 77 | label, err := gtk.LabelNew(window["Title"]) 78 | if err != nil { 79 | log.Println(err) 80 | continue 81 | } 82 | 83 | label.SetEllipsize(pango.ELLIPSIZE_END) 84 | label.SetXAlign(0) 85 | label.SetHExpand(true) 86 | // label.SetMarginBottom(2) 87 | 88 | closeBtn, err := gtk.ButtonNewFromIconName("close", gtk.ICON_SIZE_SMALL_TOOLBAR) 89 | if err != nil { 90 | log.Println(err) 91 | continue 92 | } 93 | closeBtn.SetName("close-btn") 94 | utils.AddStyle(closeBtn, "#close-btn {padding: 0;}") 95 | 96 | eventBox.Connect("button-press-event", func(eb *gtk.EventBox, e *gdk.Event) { 97 | go ipc.Hyprctl("dispatch focuswindow address:" + window["Address"]) 98 | go ipc.DispatchEvent("hd>>focus-window") 99 | }) 100 | 101 | context, err := windowBox.GetStyleContext() 102 | if err == nil { 103 | eventBox.Connect("enter-notify-event", func() { 104 | context.AddClass("hover") 105 | }) 106 | eventBox.Connect("leave-notify-event", func(eb *gtk.EventBox, e *gdk.Event) { 107 | event := gdk.EventCrossingNewFromEvent(e) 108 | isInWindow := event.Detail() == 3 || event.Detail() == 0 109 | 110 | if isInWindow { 111 | context.RemoveClass("hover") 112 | } 113 | }) 114 | } 115 | 116 | display, err := gdk.DisplayGetDefault() 117 | if err == nil { 118 | pointer, _ := gdk.CursorNewFromName(display, "pointer") 119 | arrow, _ := gdk.CursorNewFromName(display, "default") 120 | 121 | eventBox.Connect("enter-notify-event", func() { 122 | win, _ := eventBox.GetWindow() 123 | if win != nil { 124 | win.SetCursor(pointer) 125 | } 126 | }) 127 | 128 | eventBox.Connect("leave-notify-event", func(eb *gtk.EventBox, e *gdk.Event) { 129 | event := gdk.EventCrossingNewFromEvent(e) 130 | win, _ := eventBox.GetWindow() 131 | 132 | if win != nil && event.Detail() != 2 { 133 | win.SetCursor(arrow) 134 | } 135 | }) 136 | } 137 | 138 | stream, err := hysc.StreamNew(window["Address"]) 139 | if err != nil { 140 | log.Println(err) 141 | continue 142 | } 143 | 144 | stream.OnReady(func(s *hysc.Size) { 145 | if s == nil { 146 | return 147 | } 148 | 149 | closeBtn.Connect("button-press-event", func() { 150 | go ipc.Hyprctl("dispatch closewindow address:" + window["Address"]) 151 | if item.Instances == 1 { 152 | go ipc.DispatchEvent("hd>>focus-window") 153 | return 154 | } 155 | 156 | mutex.Lock() 157 | defer mutex.Unlock() 158 | 159 | totalWidth = totalWidth - s.W - padding*2 - settings.ContextPos 160 | 161 | go ipc.DispatchEvent(fmt.Sprintf("hd>>close-window>>%d", totalWidth)) 162 | windowBox.Destroy() 163 | wrapper.ShowAll() 164 | }) 165 | 166 | glib.IdleAdd(func() { 167 | mutex.Lock() 168 | defer mutex.Unlock() 169 | 170 | totalWidth += s.W 171 | readyCount++ 172 | commonHeight = s.H 173 | 174 | if readyCount == len(item.Windows) { 175 | totalWidth = totalWidth + settings.ContextPos*(len(item.Windows)-1) + 2*padding*len(item.Windows) 176 | commonHeight = commonHeight + 2*padding + 20 177 | 178 | onReady(totalWidth, commonHeight) 179 | } 180 | }) 181 | }) 182 | 183 | stream.SetHScale(settings.PreviewStyle.Size) 184 | stream.SetBorderRadius(settings.PreviewStyle.BorderRadius) 185 | 186 | if settings.Preview == "live" { 187 | err = stream.Start(settings.PreviewAdvanced.FPS, settings.PreviewAdvanced.BufferSize) 188 | } else { 189 | err = stream.CaptureFrame() 190 | } 191 | 192 | if err != nil { 193 | log.Println(err) 194 | continue 195 | } 196 | 197 | titleBox.Add(icon) 198 | titleBox.Add(label) 199 | titleBox.Add(closeBtn) 200 | 201 | windowBoxContent.Add(titleBox) 202 | windowBoxContent.Add(stream) 203 | 204 | eventBox.Add(windowBoxContent) 205 | windowBox.Add(eventBox) 206 | wrapper.Add(windowBox) 207 | } 208 | 209 | return wrapper, nil 210 | } 211 | -------------------------------------------------------------------------------- /internal/pvctl/pvctl.go: -------------------------------------------------------------------------------- 1 | package pvctl 2 | 3 | import ( 4 | "fmt" 5 | "hypr-dock/internal/item" 6 | layerinfo "hypr-dock/internal/layerInfo" 7 | "hypr-dock/internal/pkg/popup" 8 | "hypr-dock/internal/pkg/timer" 9 | "hypr-dock/internal/pvwidget" 10 | "hypr-dock/internal/settings" 11 | "hypr-dock/pkg/ipc" 12 | "log" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/gotk3/gotk3/gdk" 17 | "github.com/gotk3/gotk3/glib" 18 | "github.com/gotk3/gotk3/gtk" 19 | ) 20 | 21 | type PV struct { 22 | active bool 23 | 24 | showTimer *timer.Timer 25 | hideTimer *timer.Timer 26 | moveTimer *timer.Timer 27 | 28 | className string 29 | popup *popup.Popup 30 | } 31 | 32 | func New(settings settings.Settings) *PV { 33 | return &PV{ 34 | className: "90348d332fvecs324csd4", 35 | 36 | showTimer: timer.New(), 37 | hideTimer: timer.New(), 38 | moveTimer: timer.New(), 39 | 40 | popup: popup.New(), 41 | } 42 | } 43 | 44 | func (pv *PV) Show(item *item.Item, settings settings.Settings) { 45 | if pv == nil || item == nil { 46 | fmt.Println("pv is nil:", pv == nil, "|", "item is nil:", item == nil) 47 | return 48 | } 49 | 50 | hide := func() { 51 | glib.IdleAdd(func() { 52 | pv.Hide() 53 | }) 54 | pv.SetActive(false) 55 | } 56 | 57 | ipc.AddEventListener("hd>>focus-window", func(e string) { 58 | pv.showTimer.Stop() 59 | hide() 60 | }, true) 61 | 62 | if settings.Position == "top" || settings.Position == "bottom" { 63 | ipc.AddEventListener("hd>>close-window", func(e string) { 64 | eventDetail := strings.TrimPrefix(e, "hd>>close-window>>") 65 | w, _ := strconv.Atoi(eventDetail) 66 | x, y, _ := getCord(item.Button, settings) 67 | 68 | pv.popup.Move(x-w/2, y) 69 | }, true) 70 | } 71 | 72 | pv.popup.SetWinCallBack(func(w *gtk.Window) error { 73 | w.Connect("enter-notify-event", func() { 74 | pv.hideTimer.Stop() 75 | ipc.DispatchEvent("hd>>pv-pointer-enter") 76 | }) 77 | w.Connect("leave-notify-event", func(w *gtk.Window, e *gdk.Event) { 78 | event := gdk.EventCrossingNewFromEvent(e) 79 | isInWindow := event.Detail() == 3 || event.Detail() == 4 80 | 81 | if !isInWindow { 82 | return 83 | } 84 | pv.hideTimer.Run(settings.PreviewAdvanced.HideDelay, hide) 85 | ipc.DispatchEvent("hd>>pv-pointer-leave") 86 | }) 87 | return nil 88 | }) 89 | 90 | widget, err := pvwidget.New(item, settings, func(w, h int) { 91 | setCord(w, h, item, settings, func(x, y int, startx, starty string) { 92 | pv.popup.Open(x, y, startx, starty) 93 | }) 94 | }) 95 | if err != nil { 96 | log.Println(err) 97 | return 98 | } 99 | 100 | pv.popup.Set(widget) 101 | } 102 | 103 | func (pv *PV) Hide() { 104 | pv.popup.Close() 105 | } 106 | 107 | func (pv *PV) Change(item *item.Item, settings settings.Settings) { 108 | if pv == nil || item == nil { 109 | fmt.Println("pv is nil:", pv == nil, "|", "item is nil:", item == nil) 110 | return 111 | } 112 | 113 | hide := func() { 114 | glib.IdleAdd(func() { 115 | pv.Hide() 116 | }) 117 | pv.SetActive(false) 118 | } 119 | 120 | pv.popup.SetWinCallBack(func(w *gtk.Window) error { 121 | w.Connect("enter-notify-event", func() { 122 | pv.hideTimer.Stop() 123 | }) 124 | w.Connect("leave-notify-event", func() { 125 | pv.hideTimer.Run(settings.PreviewAdvanced.HideDelay, hide) 126 | }) 127 | return nil 128 | }) 129 | 130 | widget, err := pvwidget.New(item, settings, func(w, h int) { 131 | setCord(w, h, item, settings, func(x, y int, startx, starty string) { 132 | pv.popup.Move(x, y) 133 | }) 134 | }) 135 | if err != nil { 136 | log.Println(err) 137 | return 138 | } 139 | 140 | pv.popup.Set(widget) 141 | } 142 | 143 | func (pv *PV) SetActive(flag bool) { 144 | pv.active = flag 145 | } 146 | 147 | func (pv *PV) GetActive() bool { 148 | return pv.active 149 | } 150 | 151 | func (pv *PV) GetShowTimer() *timer.Timer { 152 | return pv.showTimer 153 | } 154 | 155 | func (pv *PV) GetHideTimer() *timer.Timer { 156 | return pv.hideTimer 157 | } 158 | 159 | func (pv *PV) GetMoveTimer() *timer.Timer { 160 | return pv.moveTimer 161 | } 162 | 163 | func (pv *PV) HasClassChanged(className string) bool { 164 | return pv.className != className 165 | } 166 | 167 | func (pv *PV) SetCurrentClass(className string) { 168 | pv.className = className 169 | } 170 | 171 | func getCord(v *gtk.Button, settings settings.Settings) (int, int, error) { 172 | margin := settings.ContextPos 173 | pos := settings.Position 174 | 175 | dock, err := layerinfo.GetDock() 176 | if err != nil { 177 | log.Println(err) 178 | } 179 | 180 | var x int 181 | var y int 182 | 183 | switch pos { 184 | case "bottom": 185 | x = dock.X + v.GetAllocation().GetX() + v.GetAllocatedWidth()/2 186 | y = margin 187 | case "left": 188 | x = margin 189 | y = dock.Y + v.GetAllocation().GetY() + v.GetAllocatedHeight()/2 190 | case "top": 191 | x = dock.X + v.GetAllocation().GetX() + v.GetAllocatedWidth()/2 192 | y = margin 193 | case "right": 194 | x = margin 195 | y = dock.Y + v.GetAllocation().GetY() + v.GetAllocatedHeight()/2 196 | } 197 | 198 | switch pos { 199 | case "bottom", "top": 200 | y = y + dock.H 201 | case "left", "right": 202 | x = x + dock.W 203 | } 204 | 205 | return x, y, err 206 | } 207 | 208 | func setCord(w, h int, item *item.Item, settings settings.Settings, callBack func(x, y int, startx, starty string)) { 209 | x, y, _ := getCord(item.Button, settings) 210 | var startx, starty string 211 | 212 | switch settings.Position { 213 | case "bottom": 214 | startx = "left" 215 | starty = "bottom" 216 | case "right": 217 | startx = "right" 218 | starty = "top" 219 | case "left": 220 | startx = "left" 221 | starty = "top" 222 | case "top": 223 | startx = "left" 224 | starty = "top" 225 | } 226 | 227 | switch settings.Position { 228 | case "top", "bottom": 229 | x = x - w/2 230 | case "left", "right": 231 | y = y - h/2 232 | } 233 | 234 | callBack(x, y, startx, starty) 235 | } 236 | -------------------------------------------------------------------------------- /internal/pkg/cfg/cfg.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "hypr-dock/internal/pkg/utils" 5 | "hypr-dock/internal/pkg/validate" 6 | "log" 7 | "os" 8 | 9 | "github.com/goccy/go-json" 10 | "github.com/pkg/errors" 11 | "github.com/tailscale/hujson" 12 | ) 13 | 14 | type Config struct { 15 | CurrentTheme string 16 | IconSize int 17 | Layer string 18 | Position string 19 | Blur string 20 | Spacing int 21 | AutoHideDeley int 22 | SystemGapUsed string 23 | Margin int 24 | ContextPos int 25 | Preview string 26 | PreviewAdvanced struct { 27 | FPS int 28 | BufferSize int 29 | ShowDelay int 30 | HideDelay int 31 | MoveDelay int 32 | } 33 | PreviewStyle struct { 34 | Size int 35 | BorderRadius int 36 | Padding int 37 | } 38 | } 39 | 40 | type ThemeConfig struct { 41 | Blur string 42 | Spacing int 43 | PreviewStyle struct { 44 | Size int 45 | BorderRadius int 46 | Padding int 47 | } 48 | } 49 | 50 | type ItemList struct { 51 | Pinned []string 52 | } 53 | 54 | func GetDefaultConfig() Config { 55 | return Config{ 56 | CurrentTheme: "lotos", 57 | IconSize: 21, 58 | Layer: "auto", 59 | Position: "bottom", 60 | Blur: "true", 61 | Spacing: 8, 62 | SystemGapUsed: "true", 63 | AutoHideDeley: 400, 64 | Margin: 8, 65 | ContextPos: 5, 66 | Preview: "none", 67 | PreviewAdvanced: struct { 68 | FPS int 69 | BufferSize int 70 | ShowDelay int 71 | HideDelay int 72 | MoveDelay int 73 | }{ 74 | FPS: 30, 75 | BufferSize: 5, 76 | ShowDelay: 600, 77 | HideDelay: 300, 78 | MoveDelay: 200, 79 | }, 80 | PreviewStyle: struct { 81 | Size int 82 | BorderRadius int 83 | Padding int 84 | }{ 85 | Size: 120, 86 | BorderRadius: 0, 87 | Padding: 10, 88 | }, 89 | } 90 | } 91 | 92 | func ReadConfig(jsoncFile string, themesDir string) Config { 93 | // Read jsonc 94 | config := Config{} 95 | err := ReadJsonc(jsoncFile, &config) 96 | if err != nil { 97 | log.Println(err) 98 | log.Println("Load default config") 99 | return GetDefaultConfig() 100 | } 101 | 102 | // Set default values ​​if not specified 103 | if config.CurrentTheme == "" { 104 | config.CurrentTheme = GetDefaultConfig().CurrentTheme 105 | log.Println("The theme is not set, the default theme is currently used - \"lotos\"") 106 | } 107 | 108 | if !validate.Layer(config.Layer, false) { 109 | config.Layer = GetDefaultConfig().Layer 110 | } 111 | 112 | if !validate.Position(config.Position, false) { 113 | config.Position = GetDefaultConfig().Position 114 | } 115 | 116 | if !validate.Blur(config.Blur, false) { 117 | config.Blur = GetDefaultConfig().Blur 118 | } 119 | 120 | if !validate.SystemGapUsed(config.SystemGapUsed, false) { 121 | config.SystemGapUsed = GetDefaultConfig().SystemGapUsed 122 | } 123 | 124 | if !validate.Preview(config.Preview, false) { 125 | config.Preview = GetDefaultConfig().Preview 126 | } 127 | 128 | if config.PreviewAdvanced.FPS == 0 { 129 | config.PreviewAdvanced.FPS = GetDefaultConfig().PreviewAdvanced.FPS 130 | } 131 | 132 | if config.PreviewAdvanced.BufferSize < 1 || config.PreviewAdvanced.BufferSize > 20 { 133 | config.PreviewAdvanced.BufferSize = GetDefaultConfig().PreviewAdvanced.BufferSize 134 | } 135 | 136 | if config.Spacing < 1 { 137 | config.Spacing = GetDefaultConfig().Spacing 138 | } 139 | 140 | if config.IconSize < 1 { 141 | config.IconSize = GetDefaultConfig().IconSize 142 | } 143 | 144 | return config 145 | } 146 | 147 | func ReadTheme(jsoncFile string, config Config) *ThemeConfig { 148 | // Read jsonc 149 | themeConfig := ThemeConfig{} 150 | err := ReadJsonc(jsoncFile, &themeConfig) 151 | if err != nil { 152 | log.Println(err) 153 | log.Println("Load default config") 154 | return nil 155 | } 156 | 157 | // Set default values ​​if not specified 158 | if !validate.Blur(config.Blur, false) { 159 | themeConfig.Blur = config.Blur 160 | } 161 | 162 | if themeConfig.Spacing < 0 { 163 | themeConfig.Spacing = config.Spacing 164 | } 165 | 166 | if themeConfig.PreviewStyle.Size < 20 { 167 | themeConfig.PreviewStyle.Size = GetDefaultConfig().PreviewStyle.Size 168 | } 169 | 170 | return &themeConfig 171 | } 172 | 173 | func ReadItemList(jsonFile string) []string { 174 | itemList := ItemList{} 175 | 176 | if !utils.FileExists(jsonFile) { 177 | itemList.Pinned = CreateEmptyPinnedFile(jsonFile) 178 | return itemList.Pinned 179 | } 180 | 181 | err := ReadJson(jsonFile, &itemList) 182 | if err != nil { 183 | log.Fatal(err) 184 | } 185 | 186 | return itemList.Pinned 187 | } 188 | 189 | func ReadJsonc(jsoncFile string, v interface{}) error { 190 | file, err := os.ReadFile(jsoncFile) 191 | if err != nil { 192 | return errors.Wrapf(err, "file %q not found", jsoncFile) 193 | } 194 | 195 | // Парсим JSONC 196 | standardized, err := hujson.Standardize(file) 197 | if err != nil { 198 | return errors.Wrapf(err, "failed to standardize JSONC") 199 | } 200 | 201 | if err := json.Unmarshal(standardized, &v); err != nil { 202 | return errors.Wrapf(err, "file %q has a syntax error", jsoncFile) 203 | } 204 | 205 | return nil 206 | } 207 | 208 | func ChangeJsonPinnedApps(apps []string, jsonFile string) error { 209 | itemList := ItemList{ 210 | Pinned: apps, 211 | } 212 | 213 | if err := WriteItemList(jsonFile, itemList); err != nil { 214 | log.Println("Error", jsonFile, "writing: ", err) 215 | return err 216 | } 217 | 218 | return nil 219 | } 220 | 221 | func ReadJson(jsonFile string, v interface{}) error { 222 | file, err := os.Open(jsonFile) 223 | if err != nil { 224 | return err 225 | } 226 | defer file.Close() 227 | 228 | decoder := json.NewDecoder(file) 229 | if err := decoder.Decode(&v); err != nil { 230 | return err 231 | } 232 | 233 | return nil 234 | } 235 | 236 | func CreateEmptyPinnedFile(jsonFile string) []string { 237 | initialData := ItemList{ 238 | Pinned: []string{}, 239 | } 240 | 241 | if err := WriteItemList(jsonFile, initialData); err != nil { 242 | log.Fatalf("Failed to create file %q: %v", jsonFile, err) 243 | return nil 244 | } 245 | 246 | return initialData.Pinned 247 | } 248 | 249 | func WriteItemList(jsonFile string, data ItemList) error { 250 | file, err := os.Create(jsonFile) 251 | if err != nil { 252 | return errors.Wrapf(err, "failed to create file %q", jsonFile) 253 | } 254 | defer file.Close() 255 | 256 | encoder := json.NewEncoder(file) 257 | encoder.SetIndent("", " ") 258 | if err := encoder.Encode(data); err != nil { 259 | return errors.Wrapf(err, "failed to encode data to file %q", jsonFile) 260 | } 261 | 262 | return nil 263 | } 264 | -------------------------------------------------------------------------------- /docs/tasks/cli/hypr-dock-ctl-example.md: -------------------------------------------------------------------------------- 1 | Давай разберем концептуальный состав, функционал и примерные концепции реализации для cli утилиты. Утилита будет называться hypr-dock-ctl. Ее главная и единственная задача это отправлять команды в сокет собирая их из аргументов вызова. Например запрос вида hypr-dock-ctl layer get —json утилита превращает в строку j/layer get; запрос hypr-dock-ctl layer set <данные которые передаются следующими аргументами> превращает в строку “layer set данные которые передаются следующими аргументами”. Эти строки должны передаваться в сокет и получать ответ сервера. Если произошла ошибка код завершения берётся из ответа сервера, если ошибок нет, то код 0 2 | 3 | # **Концепция утилиты hypr-dock-ctl** 4 | 5 | ## **1. Назначение и функционал** 6 | Утилита `hypr-dock-ctl` служит универсальным клиентом для взаимодействия с демоном через IPC-сокет. Её ключевые функции: 7 | 8 | 1. **Трансляция команд** 9 | Преобразует аргументы CLI в строку для сокета: 10 | ``` 11 | hypr-dock-ctl <команда> <действие> [данные] [--json] 12 | → 13 | j/<команда> <действие> [данные] # с --json 14 | <команда> <действие> [данные] # без --json 15 | ``` 16 | 17 | 2. **Передача запросов** 18 | Отправляет сформированную строку в Unix-domain сокет (`/tmp/hypr-dock.sock`). 19 | 20 | 3. **Обработка ответов** 21 | - Выводит сырой ответ сервера (текст/JSON) 22 | - Возвращает код завершения: 23 | - `0` при успехе 24 | - Код ошибки из ответа (если есть) 25 | - `1` при ошибках соединения 26 | 27 | --- 28 | 29 | ## **2. Формат команд** 30 | 31 | ### **2.1 Структура вызова** 32 | ``` 33 | hypr-dock-ctl [data...] [--json] 34 | ``` 35 | 36 | | Часть | Описание | Обязательность | 37 | |------------|-----------------------------------|----------------| 38 | | `command` | Основная команда (например `layer`) | Да | 39 | | `action` | Действие (`get`, `set`, `list`) | Да | 40 | | `data` | Аргументы через пробел | Нет | 41 | | `--json` | Флаг JSON-формата | Нет | 42 | 43 | ### **2.2 Примеры** 44 | ```bash 45 | # Текстовый запрос 46 | hypr-dock-ctl layer get 47 | 48 | # JSON-запрос 49 | hypr-dock-ctl window list --json 50 | 51 | # Команда с данными 52 | hypr-dock-ctl config set theme dark 53 | 54 | # Команда с составными данными 55 | hypr-dock-ctl notification create "Hello" "Message text" 56 | ``` 57 | 58 | --- 59 | 60 | ## **3. Обработка ответов** 61 | 62 | ### **3.1 Успешный ответ** 63 | - **Текстовый режим**: выводит данные как есть 64 | ``` 65 | workspace: main 66 | windows: 3 67 | ``` 68 | - **JSON-режим**: выводит сырой JSON 69 | ```json 70 | {"workspace":"main","windows":3} 71 | ``` 72 | - **Код завершения**: `0` 73 | 74 | ### **3.2 Ошибка** 75 | - **Текстовый режим**: 76 | ``` 77 | error: Invalid command [2] 78 | ``` 79 | - **JSON-режим**: 80 | ```json 81 | {"error":"Invalid command","code":2} 82 | ``` 83 | - **Код завершения**: извлекается из ответа (`[2]` или `"code":2`) 84 | 85 | ### **3.3 Ошибки соединения** 86 | - Вывод: `error: Connection failed` 87 | - Код: `1` 88 | 89 | --- 90 | 91 | ## **4. Концепция реализации (Go)** 92 | 93 | ### **4.1 Основные компоненты** 94 | ```go 95 | package main 96 | 97 | import ( 98 | "fmt" 99 | "net" 100 | "os" 101 | "strings" 102 | ) 103 | 104 | func main() { 105 | // 1. Парсинг аргументов 106 | args, useJSON := parseArgs(os.Args[1:]) 107 | 108 | // 2. Формирование запроса 109 | request := buildRequest(args, useJSON) 110 | 111 | // 3. Отправка в сокет 112 | response, err := sendToSocket(request, "/tmp/hypr-dock.sock") 113 | if err != nil { 114 | fmt.Println("error: Connection failed") 115 | os.Exit(1) 116 | } 117 | 118 | // 4. Обработка ответа 119 | exitCode := handleResponse(response, useJSON) 120 | os.Exit(exitCode) 121 | } 122 | ``` 123 | 124 | ### **4.2 Ключевые функции** 125 | 1. **Парсинг аргументов**: 126 | ```go 127 | func parseArgs(args []string) (cmdParts []string, useJSON bool) { 128 | for i, arg := range args { 129 | if arg == "--json" { 130 | return append(args[:i], args[i+1:]...), true 131 | } 132 | } 133 | return args, false 134 | } 135 | ``` 136 | 137 | 2. **Построение запроса**: 138 | ```go 139 | func buildRequest(parts []string, jsonFlag bool) string { 140 | req := strings.Join(parts, " ") 141 | if jsonFlag { 142 | return "j/" + req 143 | } 144 | return req 145 | } 146 | ``` 147 | 148 | 3. **Отправка в сокет**: 149 | ```go 150 | func sendToSocket(request, socketPath string) (string, error) { 151 | conn, err := net.Dial("unix", socketPath) 152 | if err != nil { 153 | return "", err 154 | } 155 | defer conn.Close() 156 | 157 | _, err = conn.Write([]byte(request + "\n")) 158 | if err != nil { 159 | return "", err 160 | } 161 | 162 | buf := make([]byte, 4096) 163 | n, err := conn.Read(buf) 164 | return string(buf[:n]), err 165 | } 166 | ``` 167 | 168 | 4. **Обработка ответа**: 169 | ```go 170 | func handleResponse(response string, isJSON bool) int { 171 | if isJSON { 172 | var resp struct{ Code int `json:"code"` } 173 | if json.Unmarshal([]byte(response), &resp) == nil && resp.Code != 0 { 174 | fmt.Println(response) 175 | return resp.Code 176 | } 177 | } else if strings.HasPrefix(response, "error: ") { 178 | parts := strings.Split(response, "[") 179 | if len(parts) > 1 { 180 | if code, err := strconv.Atoi(strings.TrimSuffix(parts[1], "]")); err == nil { 181 | fmt.Println(response) 182 | return code 183 | } 184 | } 185 | } 186 | 187 | fmt.Print(response) 188 | return 0 189 | } 190 | ``` 191 | 192 | --- 193 | 194 | ## **5. Примеры работы** 195 | 196 | ### **Успешный запрос** 197 | ```bash 198 | $ hypr-dock-ctl workspace get 199 | workspace: main 200 | active: true 201 | 202 | $ echo $? 203 | 0 204 | ``` 205 | 206 | ### **Ошибка** 207 | ```bash 208 | $ hypr-dock-ctl invalid cmd 209 | error: Unknown command [2] 210 | 211 | $ echo $? 212 | 2 213 | ``` 214 | 215 | ### **JSON-запрос** 216 | ```bash 217 | $ hypr-dock-ctl window list --json 218 | {"windows":["term","browser"]} 219 | 220 | $ echo $? 221 | 0 222 | ``` 223 | 224 | --- 225 | 226 | ## **6. Преимущества архитектуры** 227 | 1. **Минимализм**: делает ровно одну задачу (передача команд) 228 | 2. **Гибкость**: поддерживает любые команды и данные 229 | 3. **Совместимость**: 230 | - Работает с существующими bash-скриптами 231 | - Поддерживает JSON для сложных клиентов 232 | 4. **Прозрачность**: выводит сырой ответ сервера без изменений 233 | 234 | Такой дизайн соответствует Unix-философии "делать одну вещь и делать её хорошо". -------------------------------------------------------------------------------- /README_RU.md: -------------------------------------------------------------------------------- 1 | # hypr-dock 2 | ### Интерактивная док-панель для Hyprland 3 | 4 | 250725_16h02m52s_screenshot 5 | 250725_16h03m09s_screenshot 6 | 7 | [![YouTube](https://img.shields.io/badge/YouTube-Видео-FF0000?logo=youtube)](https://youtu.be/HHUZWHfNAl0?si=ZrRv2ggnPBEBS5oY) 8 | [![AUR](https://img.shields.io/badge/AUR-Package-1793D1?logo=arch-linux)](https://aur.archlinux.org/packages/hypr-dock) 9 | 10 | ## Установка 11 | 12 | ### Зависимости 13 | 14 | - `go` (make) 15 | - `gtk3` 16 | - `gtk-layer-shell` 17 | 18 | ### Установка 19 | ```bash 20 | git clone https://github.com/lotos-linux/hypr-dock.git 21 | cd hypr-dock 22 | make get 23 | make build 24 | make install 25 | ``` 26 | 27 | ### Удаление 28 | ```bash 29 | make uninstall 30 | ``` 31 | 32 | ### Обновление 33 | ```bash 34 | make update 35 | ``` 36 | 37 | ### Локальный запуск (dev mode) 38 | ```bash 39 | make exec 40 | ``` 41 | 42 | ## Запуск 43 | 44 | ### Параметры запуска: 45 | 46 | ```text 47 | -config string 48 | config file (default "~/.config/hypr-dock") 49 | -dev 50 | enable developer mode 51 | -theme string 52 | theme dir (default "lotos") 53 | ``` 54 | #### Все параметры являются необязательными. 55 | 56 | Конфигурация и темы по умолчания ставяться в `~/.config/hypr-dock` 57 | ### Добавьте запуск в `hyprland.conf`: 58 | 59 | ```text 60 | exec-once = hypr-dock 61 | bind = Super, D, exec, hypr-dock 62 | ``` 63 | 64 | #### Док поддерживает только один запущенный экземпляр, так что повторный запуск закроет предыдующий. 65 | 66 | ## Настройка 67 | 68 | ### В `config.jsonc` доступны такие параметры 69 | 70 | ```jsonc 71 | { 72 | "CurrentTheme": "lotos", 73 | 74 | // Icon size (px) (default 23) 75 | "IconSize": 23, 76 | 77 | // Window overlay layer height (auto, exclusive-top, exclusive-bottom, background, bottom, top, overlay) (default "auto") 78 | "Layer": "auto", 79 | 80 | // Window position on screen (top, bottom, left, right) (default "bottom") 81 | "Position": "bottom", 82 | 83 | // Delay before hiding the dock (ms) (default 400) 84 | "AutoHideDeley": 400, // *Only for "Layer": "auto"* 85 | 86 | // Use system gap (true, false) (default "true") 87 | "SystemGapUsed": "true", 88 | 89 | // Indent from the edge of the screen (px) (default 8) 90 | "Margin": 8, 91 | 92 | // Distance of the context menu from the window (px) (default 0) 93 | "ContextPos": 5, 94 | 95 | 96 | // Window thumbnail mode selection (none, live, static) (default "none") 97 | "Preview": "none", 98 | /* 99 | "none" - disabled (text menus) 100 | "static" - last window frame (stable) 101 | "live" - window streaming (unstable) !EXPEREMENTAL! 102 | 103 | !WARNING! 104 | BY SETTING "Preview" TO "live" OR "static", YOU AGREE TO THE CAPTURE 105 | OF WINDOW CONTENTS. 106 | THE "HYPR-DOCK" PROGRAM DOES NOT COLLECT, STORE, OR TRANSMIT ANY DATA. 107 | WINDOW CAPTURE OCCURS ONLY FOR THE DURATION OF THE THUMBNAIL DISPLAY! 108 | 109 | Source code: https://github.com/lotos-linux/hypr-dock 110 | */ 111 | 112 | "PreviewAdvanced": { 113 | // Live preview fps (0 - ∞) (default 30) 114 | "FPS": 30, 115 | 116 | // Live preview bufferSize (1 - 20) (default 5) 117 | "BufferSize": 5, 118 | 119 | // Popup show/hide/move delays (ms) 120 | "ShowDelay": 600, // (default 600) 121 | "HideDelay": 300, // (default 300) 122 | "MoveDelay": 200 // (default 200) 123 | } 124 | } 125 | ``` 126 | #### Если параметр не указан значение будет выставлено по умолчанию 127 | 128 | ## Разберем неочевидные параметры 129 | ### Layer 130 | - При `"Layer": "auto"` слой дока находиться под всеми окнами, но если увести курсор мыши к краю экрана - док поднимается над ними 131 | - При `"Layer": "exclusive-top"` включается эксклюзивный режим на верхнем слое. Тайлинговые и плавающие окна не будут перекрывать док. 132 | - При `"Layer": "exclusive-bottom"` включается эксклюзивный режим на нижнем слое. Тайлинговые окна не будут перекрывать док. Плавающие окна будут поверх дока. 133 | 134 | ### SystemGapUsed 135 | - При `"SystemGapUsed": "true"` док будет задавать для себя отступ от края экрана беря значение из конфигурации `hyprland`, а конкретно значения `general:gaps_out`, при этом док динамически будет подхватывать изменение конфигурации `hyprland` 136 | - При `"SystemGapUsed": "false"` отступ от края экрана будет задаваться параметром `Margin` 137 | 138 | ### PreviewAdvanced 139 | - `ShowDelay`, `HideDelay`, `MoveDelay` - задержки действий попапа превью в милисекундах 140 | - `FPS`, `BufferSize` - используются только при `"Preview":"live"` 141 | 142 | > Внимание! 143 | > Живое превью ведет себя не стабильно. 144 | > Пока что не рекомендую ставить значение `"Preview": "live"` 145 | 146 | 147 | #### Настройки внешнего вида превью происхрдит через файлы темы 148 | 149 | 150 | 151 | ### Также есть файл `pinned.json` с закрепленными приложениями 152 | #### Например 153 | ```json 154 | { 155 | "Pinned": [ 156 | "firefox", 157 | "org.telegram.desktop", 158 | "code-oss", 159 | "kitty" 160 | ] 161 | } 162 | ``` 163 | Вы можете менять его в ручную. Но зачем? ¯\_(ツ)_/¯ 164 | 165 | ## Темы 166 | 167 | #### Темы находяться в папке `~/.config/hypr-dock/themes/` 168 | 169 | ### Тема состоит из 170 | - `[название_темы].jsonc` например `lotos.jsonc` 171 | - `style.css` 172 | - Папка с `svg` файлами для индикации количества запущенных приложения (смотрите [themes_RU.md](https://github.com/lotos-linux/hypr-dock/blob/main/docs/customize/themes_RU.md)) 173 | 174 | ### Конфиг темы 175 | ```jsonc 176 | { 177 | // Blur window ("true", "false") (default "on") 178 | "Blur": "true", 179 | 180 | // Distance between elements (px) (default 9) 181 | "Spacing": 5, 182 | 183 | // Preview settings 184 | "PreviewStyle": { 185 | // Size (px) (default 120) 186 | "Size": 120, 187 | 188 | // Image/Stream border-radius (px) (default 0) 189 | "BorderRadius": 0, 190 | 191 | // Popup padding (px) (default 10) 192 | "Padding": 10 193 | } 194 | } 195 | ``` 196 | #### Файл `style.css` крутите как хотите 197 | 198 | ## Использованные библиотки 199 | - [github.com/akshaybharambe14/go-jsonc](https://github.com/akshaybharambe14/go-jsonc) v1.0.0 200 | - [github.com/allan-simon/go-singleinstance](https://github.com/allan-simon/go-singleinstance) v0.0.0-20210120080615-d0997106ab37 201 | - [github.com/dlasky/gotk3-layershell](https://github.com/dlasky/gotk3-layershell) v0.0.0-20240515133811-5c5115f0d774 202 | - [github.com/goccy/go-json](https://github.com/goccy/go-json) v0.10.3 203 | - [github.com/gotk3/gotk3](https://github.com/gotk3/gotk3) v0.6.3 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hypr-dock 2 | 3 | ### Interactive Dock Panel for Hyprland 4 | 5 | Translations: [`Русский`](https://github.com/lotos-linux/hypr-dock/blob/main/README_RU.md) 6 | 7 | 250725_16h02m52s_screenshot 8 | 250725_16h03m09s_screenshot 9 | 10 | [![YouTube](https://img.shields.io/badge/YouTube-Video-FF0000?logo=youtube)](https://youtu.be/HHUZWHfNAl0?si=ZrRv2ggnPBEBS5oY) 11 | [![AUR](https://img.shields.io/badge/AUR-Package-1793D1?logo=arch-linux)](https://aur.archlinux.org/packages/hypr-dock) 12 | 13 | ## Installation 14 | 15 | ### Dependencies 16 | 17 | - go (make) 18 | - gtk3 19 | - gtk-layer-shell 20 | 21 | ### Install 22 | ```bash 23 | git clone https://github.com/lotos-linux/hypr-dock.git 24 | cd hypr-dock 25 | make get 26 | make build 27 | make install 28 | ``` 29 | 30 | ### Uninstall 31 | ```bash 32 | make uninstall 33 | ``` 34 | 35 | ### Update 36 | ```bash 37 | make update 38 | ``` 39 | 40 | ### Local run (dev mode) 41 | ```bash 42 | make exec 43 | ``` 44 | 45 | ## Launching 46 | 47 | ### Launch Parameters: 48 | ```text 49 | -config string 50 | config file (default "~/.config/hypr-dock") 51 | -dev 52 | enable developer mode 53 | -theme string 54 | theme dir (default "lotos") 55 | ``` 56 | #### All parameters are optional. 57 | 58 | The default configuration and themes are installed in `~/.config/hypr-dock` 59 | 60 | ### Add the following to hyprland.conf for autostart: 61 | ```text 62 | exec-once = hypr-dock 63 | bind = Super, D, exec, hypr-dock 64 | ``` 65 | 66 | #### The dock supports only one running instance, so launching it again will close the previous instance. 67 | 68 | ## Configuration 69 | 70 | ### The following parameters are available in config.jsonc: 71 | ```jsonc 72 | { 73 | "CurrentTheme": "lotos", 74 | 75 | // Icon size (px) (default 23) 76 | "IconSize": 23, 77 | 78 | // Window overlay layer height (auto, exclusive-top, exclusive-bottom, background, bottom, top, overlay) (default "auto") 79 | "Layer": "auto", 80 | 81 | // Window position on screen (top, bottom, left, right) (default "bottom") 82 | "Position": "bottom", 83 | 84 | // Delay before hiding the dock (ms) (default 400) 85 | "AutoHideDeley": 400, // *Only for "Layer": "auto"* 86 | 87 | // Use system gap (true, false) (default "true") 88 | "SystemGapUsed": "true", 89 | 90 | // Indent from the edge of the screen (px) (default 8) 91 | "Margin": 8, 92 | 93 | // Distance of the context menu from the window (px) (default 0) 94 | "ContextPos": 5, 95 | 96 | 97 | // Window thumbnail mode selection (none, live, static) (default "none") 98 | "Preview": "static", 99 | /* 100 | "none" - disabled (text menus) 101 | "static" - last window frame (stable) 102 | "live" - window streaming (unstable) !EXPEREMENTAL! 103 | 104 | !WARNING! 105 | BY SETTING "Preview" TO "live" OR "static", YOU AGREE TO THE CAPTURE 106 | OF WINDOW CONTENTS. 107 | THE "HYPR-DOCK" PROGRAM DOES NOT COLLECT, STORE, OR TRANSMIT ANY DATA. 108 | WINDOW CAPTURE OCCURS ONLY FOR THE DURATION OF THE THUMBNAIL DISPLAY! 109 | 110 | Source code: https://github.com/lotos-linux/hypr-dock 111 | */ 112 | 113 | "PreviewAdvanced": { 114 | // Live preview fps (0 - ∞) (default 30) 115 | "FPS": 30, 116 | 117 | // Live preview bufferSize (1 - 20) (default 5) 118 | "BufferSize": 5, 119 | 120 | // Popup show/hide/move delays (ms) 121 | "ShowDelay": 600, // (default 600) 122 | "HideDelay": 300, // (default 300) 123 | "MoveDelay": 200 // (default 200) 124 | } 125 | } 126 | ``` 127 | #### If a parameter is not specified, the default value will be used. 128 | 129 | ## Explanation of Non-Obvious Parameters 130 | ### Layer 131 | - With `"Layer": "auto"` the dock layer is below all windows, but if you move the mouse cursor to the edge of the screen, the dock rises above them. 132 | - With `"Layer": "exclusive-top"` - exclusive mode is enabled on the top layer. Neither tiled nor floating windows will overlap the dock. 133 | - With `"Layer": "exclusive-bottom"` - exclusive mode is enabled on the bottom layer. Tiled windows won't overlap the dock. Floating windows will appear above the dock. 134 | ### SystemGapUsed 135 | - With `"SystemGapUsed": "true"` the dock will set its margin from the edge of the screen based on the hyprland configuration, specifically the `general:gaps_out` value. The dock will dynamically adapt to changes in the hyprland configuration. 136 | - With `"SystemGapUsed": "false"` the margin from the edge of the screen will be set by the `Margin` parameter. 137 | 138 | ### PreviewAdvanced 139 | - `ShowDelay`, `HideDelay`, `MoveDelay` - delays for preview popup actions in milliseconds. 140 | - `FPS`, `BufferSize` - only used with `"Preview":"live"` 141 | 142 | > Warning! 143 | > Live preview behaves unpredictably. 144 | > Currently, it is not recommended to set `"Preview": "live"` 145 | 146 | #### Настройки внешнего вида превью происхрдит через файлы темы 147 | 148 | ### There is also a pinned.json file for pinned applications 149 | #### Example: 150 | ```json 151 | { 152 | "Pinned": [ 153 | "firefox", 154 | "org.telegram.desktop", 155 | "code-oss", 156 | "kitty" 157 | ] 158 | } 159 | ``` 160 | You can edit it manually. But why? ¯\_(ツ)_/¯ 161 | 162 | ## Themes 163 | 164 | #### Themes are located in the `~/.config/hypr-dock/themes/` folder 165 | 166 | ### A theme consists of: 167 | - `[theme_name].jsonc`, for example `lotos.jsonc` 168 | - `style.css` 169 | - A folder with `svg` files for indicating the number of running applications (more [themes.md](https://github.com/lotos-linux/hypr-dock/blob/main/docs/customize/themes.md)) 170 | 171 | ### Theme Config 172 | ```jsonc 173 | { 174 | // Blur window ("true", "false") (default "on") 175 | "Blur": "true", 176 | 177 | // Distance between elements (px) (default 9) 178 | "Spacing": 5, 179 | 180 | // Preview settings 181 | "PreviewStyle": { 182 | // Size (px) (default 120) 183 | "Size": 120, 184 | 185 | // Image/Stream border-radius (px) (default 0) 186 | "BorderRadius": 0, 187 | 188 | // Popup padding (px) (default 10) 189 | "Padding": 10 190 | } 191 | } 192 | ``` 193 | #### Feel free to customize the style.css file as you like. 194 | 195 | ## Libraries Used 196 | - [github.com/akshaybharambe14/go-jsonc](https://github.com/akshaybharambe14/go-jsonc) v1.0.0 197 | - [github.com/allan-simon/go-singleinstance](https://github.com/allan-simon/go-singleinstance) v0.0.0-20210120080615-d0997106ab37 198 | - [github.com/dlasky/gotk3-layershell](https://github.com/dlasky/gotk3-layershell) v0.0.0-20240515133811-5c5115f0d774 199 | - [github.com/goccy/go-json](https://github.com/goccy/go-json) v0.10.3 200 | - [github.com/gotk3/gotk3](https://github.com/gotk3/gotk3) v0.6.3 201 | -------------------------------------------------------------------------------- /internal/hysc/hysc.go: -------------------------------------------------------------------------------- 1 | package hysc 2 | 3 | import ( 4 | "fmt" 5 | "hypr-dock/pkg/wl" 6 | "log" 7 | 8 | "github.com/gotk3/gotk3/gdk" 9 | "github.com/gotk3/gotk3/glib" 10 | "github.com/gotk3/gotk3/gtk" 11 | "github.com/hashicorp/go-hclog" 12 | ) 13 | 14 | type Stream struct { 15 | address string 16 | handle uint64 17 | 18 | size *Size 19 | scaleMode *ScaleMode 20 | interpType gdk.InterpType 21 | 22 | effects map[string]func(p *gdk.Pixbuf) error 23 | masks map[string]func(p *gdk.Pixbuf) error 24 | 25 | readyHandler func(*Size) 26 | frameHandler func(*Size) 27 | errorHandler func(error) 28 | 29 | *gtk.Image 30 | } 31 | 32 | type ScaleMode struct { 33 | scaleW int 34 | scaleH int 35 | scaleF float64 36 | } 37 | 38 | type Size struct { 39 | W, H int 40 | } 41 | 42 | type Cord struct { 43 | X, Y int 44 | } 45 | 46 | func StreamAndStart(address string, fps int) (*Stream, error) { 47 | s, err := StreamNew(address) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | err = s.Start(fps) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | return s, nil 58 | } 59 | 60 | func StreamNew(address string) (*Stream, error) { 61 | handle, err := getHandle(address) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | widget, err := gtk.ImageNew() 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | return &Stream{ 72 | address: address, 73 | handle: handle, 74 | 75 | size: nil, 76 | scaleMode: nil, 77 | interpType: gdk.INTERP_BILINEAR, 78 | 79 | effects: make(map[string]func(p *gdk.Pixbuf) error, 0), 80 | masks: make(map[string]func(p *gdk.Pixbuf) error, 0), 81 | 82 | readyHandler: nil, 83 | frameHandler: nil, 84 | errorHandler: func(err error) { 85 | log.Println(err) 86 | }, 87 | 88 | Image: widget, 89 | }, nil 90 | } 91 | 92 | func (s *Stream) Start(fps int, buferSize ...int) error { 93 | app, err := wl.NewApp(hclog.Default()) 94 | if err != nil { 95 | return fmt.Errorf("failed to wayland connection: %v", err) 96 | } 97 | 98 | var bufSize int 99 | if len(buferSize) == 0 { 100 | bufSize = 5 101 | } else { 102 | bufSize = buferSize[0] 103 | } 104 | 105 | stream, err := app.StartStream(s.handle, fps, bufSize) 106 | if err != nil { 107 | app.Close() 108 | return fmt.Errorf("failed to start stream: %v", err) 109 | } 110 | 111 | go func() { 112 | for frame := range stream.Frames { 113 | glib.IdleAdd(func() { 114 | pixbuf, err := nRGBAtoPixbuf(frame) 115 | if err != nil { 116 | s.errorHandler(fmt.Errorf("failed to convert pixbuf: %v", err)) 117 | return 118 | } 119 | 120 | if s == nil { 121 | app.Close() 122 | return 123 | } 124 | 125 | if s.scaleMode != nil { 126 | pixbuf, err = s.scale(pixbuf) 127 | if err != nil { 128 | s.errorHandler(fmt.Errorf("failed to scale pixbuf: %v", err)) 129 | return 130 | } 131 | } 132 | 133 | for id, effect := range s.effects { 134 | err = effect(pixbuf) 135 | if err != nil { 136 | s.errorHandler(fmt.Errorf("'%s' effect error: %v", id, err)) 137 | return 138 | } 139 | } 140 | 141 | for id, mask := range s.masks { 142 | err = mask(pixbuf) 143 | if err != nil { 144 | s.errorHandler(fmt.Errorf("'%s' mask error: %v", id, err)) 145 | return 146 | } 147 | } 148 | 149 | size := &Size{ 150 | W: pixbuf.GetWidth(), 151 | H: pixbuf.GetHeight(), 152 | } 153 | 154 | s.SetFromPixbuf(pixbuf) 155 | 156 | if s.size == nil && s.readyHandler != nil { 157 | s.readyHandler(size) 158 | } 159 | 160 | if s.frameHandler != nil { 161 | s.frameHandler(size) 162 | } 163 | 164 | s.size = size 165 | }) 166 | } 167 | }() 168 | 169 | s.Connect("destroy", func() { 170 | stream.Stop() 171 | app.Close() 172 | s = nil 173 | }) 174 | 175 | return nil 176 | } 177 | 178 | func (s *Stream) CaptureFrame() error { 179 | app, err := wl.NewApp(hclog.Default()) 180 | if err != nil { 181 | return fmt.Errorf("failed to wayland connection: %v", err) 182 | } 183 | defer app.Close() 184 | 185 | frame, err := app.CaptureFrame(s.handle) 186 | if err != nil { 187 | app.Close() 188 | return fmt.Errorf("failed to capture frame: %v", err) 189 | } 190 | 191 | pixbuf, err := nRGBAtoPixbuf(frame) 192 | if err != nil { 193 | return fmt.Errorf("failed to convert pixbuf: %v", err) 194 | } 195 | 196 | if s.scaleMode != nil { 197 | pixbuf, err = s.scale(pixbuf) 198 | if err != nil { 199 | return fmt.Errorf("failed to scale pixbuf: %v", err) 200 | } 201 | } 202 | 203 | for id, effect := range s.effects { 204 | err = effect(pixbuf) 205 | if err != nil { 206 | return fmt.Errorf("'%s' effect error: %v", id, err) 207 | } 208 | } 209 | 210 | for id, mask := range s.masks { 211 | err = mask(pixbuf) 212 | if err != nil { 213 | return fmt.Errorf("'%s' mask error: %v", id, err) 214 | } 215 | } 216 | 217 | size := &Size{ 218 | W: pixbuf.GetWidth(), 219 | H: pixbuf.GetHeight(), 220 | } 221 | 222 | s.SetFromPixbuf(pixbuf) 223 | 224 | if s.readyHandler != nil { 225 | s.readyHandler(size) 226 | } 227 | 228 | s.size = size 229 | 230 | return nil 231 | } 232 | 233 | func (s *Stream) SetWScale(width int) { 234 | s.scaleMode = &ScaleMode{scaleW: width} 235 | } 236 | 237 | func (s *Stream) SetHScale(height int) { 238 | s.scaleMode = &ScaleMode{scaleH: height} 239 | } 240 | 241 | func (s *Stream) SetFScale(factor float64) { 242 | s.scaleMode = &ScaleMode{scaleF: factor} 243 | } 244 | 245 | func (s *Stream) SetFixedSize(width int, height int) { 246 | s.scaleMode = &ScaleMode{ 247 | scaleW: width, 248 | scaleH: height, 249 | } 250 | } 251 | 252 | func (s *Stream) ResetSize() { 253 | s.scaleMode = nil 254 | } 255 | 256 | func (s *Stream) AddCustomEffect(id string, effectHandler func(p *gdk.Pixbuf) error) { 257 | if effectHandler == nil { 258 | return 259 | } 260 | 261 | s.effects[id] = effectHandler 262 | } 263 | 264 | func (s *Stream) RemoveCustomEffect(id string) bool { 265 | _, exist := s.effects[id] 266 | if exist { 267 | delete(s.effects, id) 268 | } 269 | 270 | return exist 271 | } 272 | 273 | func (s *Stream) AddCustomMask(id string, maskHandler func(p *gdk.Pixbuf) error) { 274 | if maskHandler == nil { 275 | return 276 | } 277 | 278 | s.masks[id] = maskHandler 279 | } 280 | 281 | func (s *Stream) RemoveCustomMask(id string) bool { 282 | _, exist := s.masks[id] 283 | if exist { 284 | delete(s.masks, id) 285 | } 286 | 287 | return exist 288 | } 289 | 290 | func (s *Stream) SetBorderRadius(radius int) { 291 | id := "system-border-radius:hysc00" 292 | 293 | if radius <= 0 { 294 | s.RemoveCustomMask(id) 295 | return 296 | } 297 | 298 | s.AddCustomMask(id, func(p *gdk.Pixbuf) error { 299 | w, h := p.GetWidth(), p.GetHeight() 300 | size := Size{radius, radius} 301 | 302 | ApplyVector(p, Cord{0, 0}, size, func(pixel Cord) float64 { 303 | return radiusmask(pixel, Cord{radius, radius}, float64(radius)) 304 | }) 305 | ApplyVector(p, Cord{w - radius, 0}, size, func(pixel Cord) float64 { 306 | return radiusmask(pixel, Cord{0, radius}, float64(radius)) 307 | }) 308 | ApplyVector(p, Cord{0, h - radius}, size, func(pixel Cord) float64 { 309 | return radiusmask(pixel, Cord{radius, 0}, float64(radius)) 310 | }) 311 | ApplyVector(p, Cord{w - radius, h - radius}, size, func(pixel Cord) float64 { 312 | return radiusmask(pixel, Cord{0, 0}, float64(radius)) 313 | }) 314 | return nil 315 | }) 316 | } 317 | 318 | func (s *Stream) SetInterpType(interpType gdk.InterpType) { 319 | s.interpType = interpType 320 | } 321 | 322 | func (s *Stream) OnReady(handler func(*Size)) { 323 | s.readyHandler = handler 324 | } 325 | 326 | func (s *Stream) OnFrame(handler func(*Size)) { 327 | s.frameHandler = handler 328 | } 329 | 330 | func (s *Stream) OnError(handler func(error)) { 331 | s.errorHandler = handler 332 | } 333 | 334 | func (s *Stream) scale(pixbuf *gdk.Pixbuf) (*gdk.Pixbuf, error) { 335 | origWidth := pixbuf.GetWidth() 336 | origHeight := pixbuf.GetHeight() 337 | 338 | // fixed dimensions 339 | if s.scaleMode.scaleH > 0 && s.scaleMode.scaleW > 0 { 340 | return pixbuf.ScaleSimple( 341 | s.scaleMode.scaleW, 342 | s.scaleMode.scaleH, 343 | s.interpType, 344 | ) 345 | } 346 | 347 | // fixed height (width proportional) 348 | if s.scaleMode.scaleH > 0 { 349 | newHeight := s.scaleMode.scaleH 350 | ratio := float64(origWidth) / float64(origHeight) 351 | newWidth := int(float64(newHeight) * ratio) 352 | 353 | return pixbuf.ScaleSimple( 354 | newWidth, 355 | newHeight, 356 | s.interpType, 357 | ) 358 | } 359 | 360 | // fixed width (height proportional) 361 | if s.scaleMode.scaleW > 0 { 362 | newWidth := s.scaleMode.scaleW 363 | ratio := float64(origHeight) / float64(origWidth) 364 | newHeight := int(float64(newWidth) * ratio) 365 | 366 | return pixbuf.ScaleSimple( 367 | newWidth, 368 | newHeight, 369 | s.interpType, 370 | ) 371 | } 372 | 373 | // scaling by factor 374 | if s.scaleMode.scaleF > 0 { 375 | factor := s.scaleMode.scaleF 376 | newWidth := int(float64(origWidth) * factor) 377 | newHeight := int(float64(origHeight) * factor) 378 | 379 | return pixbuf.ScaleSimple( 380 | newWidth, 381 | newHeight, 382 | s.interpType, 383 | ) 384 | } 385 | 386 | // no scaling 387 | return pixbuf, nil 388 | } 389 | -------------------------------------------------------------------------------- /pkg/wl/app.go: -------------------------------------------------------------------------------- 1 | package wl 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | "time" 8 | 9 | "github.com/hashicorp/go-hclog" 10 | "github.com/pdf/go-wayland/client" 11 | "golang.org/x/sys/unix" 12 | ) 13 | 14 | type App struct { 15 | display *client.Display 16 | registry *client.Registry 17 | shm *client.Shm 18 | tl *HyprlandToplevelExportManagerV1 19 | log hclog.Logger 20 | } 21 | 22 | type shmPool struct { 23 | *client.ShmPool 24 | fd int 25 | data []byte 26 | } 27 | 28 | type FrameStream struct { 29 | Frames chan *image.NRGBA 30 | stop chan struct{} 31 | } 32 | 33 | func NewApp(log hclog.Logger) (*App, error) { 34 | display, err := client.Connect(``) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | registry, err := display.GetRegistry() 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | app := &App{ 45 | display: display, 46 | registry: registry, 47 | log: log.Named(`wl`), 48 | } 49 | display.SetErrorHandler(app.handleDisplayError) 50 | registry.SetGlobalHandler(app.handleRegistryGlobal) 51 | 52 | // init registry 53 | if err := app.roundTrip(); err != nil { 54 | return nil, err 55 | } 56 | 57 | // get events 58 | if err := app.roundTrip(); err != nil { 59 | return nil, err 60 | } 61 | 62 | return app, nil 63 | } 64 | 65 | func (a *App) StartStream(handle uint64, fps int, bufferSize int) (*FrameStream, error) { 66 | stream := &FrameStream{ 67 | Frames: make(chan *image.NRGBA, bufferSize), 68 | stop: make(chan struct{}), 69 | } 70 | 71 | go func() { 72 | ticker := time.NewTicker(time.Second / time.Duration(fps)) 73 | defer ticker.Stop() 74 | 75 | for { 76 | select { 77 | case <-stream.stop: 78 | close(stream.Frames) 79 | return 80 | case <-ticker.C: 81 | frame, err := a.CaptureFrame(handle) 82 | if err != nil { 83 | a.log.Trace("Capture error: %v", err) 84 | continue 85 | } 86 | 87 | select { 88 | case stream.Frames <- frame: 89 | default: 90 | a.log.Trace("Buffer full") 91 | } 92 | } 93 | } 94 | }() 95 | 96 | return stream, nil 97 | } 98 | 99 | func (s *FrameStream) Stop() { 100 | close(s.stop) 101 | } 102 | 103 | func (a *App) CaptureFrame(handle uint64) (*image.NRGBA, error) { 104 | if a.tl == nil { 105 | return nil, fmt.Errorf(`toplevel export manager not available`) 106 | } 107 | 108 | frame, err := a.tl.CaptureToplevel(0, uint32(handle)) 109 | if err != nil { 110 | return nil, err 111 | } 112 | defer func() { 113 | if err := frame.Destroy(); err != nil { 114 | a.log.Error(`failed destroying frame`, `err`, err) 115 | } 116 | }() 117 | 118 | formats := make([]HyprlandToplevelExportFrameV1BufferEvent, 0) 119 | done := make(chan struct{}) 120 | ready := make(chan struct{}) 121 | failed := make(chan error, 1) 122 | frame.SetBufferHandler(func(evt HyprlandToplevelExportFrameV1BufferEvent) { 123 | formats = append(formats, evt) 124 | }) 125 | frame.SetBufferDoneHandler(func(evt HyprlandToplevelExportFrameV1BufferDoneEvent) { 126 | close(done) 127 | }) 128 | frame.SetReadyHandler(func(evt HyprlandToplevelExportFrameV1ReadyEvent) { 129 | close(ready) 130 | }) 131 | frame.SetFailedHandler(func(evt HyprlandToplevelExportFrameV1FailedEvent) { 132 | failed <- fmt.Errorf(`frame failed`) 133 | }) 134 | 135 | if err := a.roundTrip(); err != nil { 136 | return nil, err 137 | } 138 | 139 | select { 140 | case <-done: 141 | case err := <-failed: 142 | return nil, err 143 | } 144 | 145 | if len(formats) == 0 { 146 | return nil, fmt.Errorf(`no buffer formats`) 147 | } 148 | 149 | a.log.Debug("Available buffer formats:", "count", len(formats)) 150 | for i, format := range formats { 151 | a.log.Debug(fmt.Sprintf("Format %d:", i), 152 | "width", format.Width, 153 | "height", format.Height, 154 | "stride", format.Stride, 155 | "format", format.Format, 156 | "shm_format_name", client.ShmFormat(format.Format).String(), 157 | ) 158 | } 159 | 160 | var selected *HyprlandToplevelExportFrameV1BufferEvent 161 | OUTER: 162 | for _, format := range formats { 163 | switch client.ShmFormat(format.Format) { 164 | case client.ShmFormatArgb8888: 165 | selected = &format 166 | break OUTER 167 | case client.ShmFormatXrgb8888: 168 | selected = &format 169 | break OUTER 170 | } 171 | } 172 | 173 | if selected == nil { 174 | return nil, fmt.Errorf(`no suitable buffer format`) 175 | } 176 | 177 | pool, err := a.createShmPool(int32(selected.Height * selected.Stride)) 178 | if err != nil { 179 | return nil, err 180 | } 181 | defer func() { 182 | if err := pool.Close(); err != nil { 183 | a.log.Error(`failed closing SHM pool`, `err`, err) 184 | } 185 | }() 186 | 187 | buf, err := pool.CreateBuffer(0, int32(selected.Width), int32(selected.Height), int32(selected.Stride), selected.Format) 188 | if err != nil { 189 | return nil, err 190 | } 191 | defer func() { 192 | if err := buf.Destroy(); err != nil { 193 | a.log.Error(`failed destroying buffer`, `err`, err) 194 | } 195 | }() 196 | 197 | if err := frame.Copy(buf, 1); err != nil { 198 | return nil, err 199 | } 200 | 201 | if err := a.roundTrip(); err != nil { 202 | return nil, err 203 | } 204 | 205 | select { 206 | case <-ready: 207 | case err := <-failed: 208 | return nil, err 209 | } 210 | 211 | data := pool.Data() 212 | img := image.NewNRGBA(image.Rect(0, 0, int(selected.Width), int(selected.Height))) 213 | if len(img.Pix) < int(selected.Height)*int(selected.Stride) { 214 | return nil, fmt.Errorf(`image buffer too small`) 215 | } 216 | for y := range int(selected.Height) { 217 | for x := range int(selected.Width) { 218 | pix := data[y*int(selected.Stride)+(x*4) : y*int(selected.Stride)+(x*4)+4] 219 | col := color.NRGBA{} 220 | switch client.ShmFormat(selected.Format) { 221 | case client.ShmFormatArgb8888: 222 | col.A = pix[3] 223 | col.R = pix[2] 224 | col.G = pix[1] 225 | col.B = pix[0] 226 | case client.ShmFormatXrgb8888: 227 | col.A = 0xff 228 | col.R = pix[2] 229 | col.G = pix[1] 230 | col.B = pix[0] 231 | } 232 | img.SetNRGBA(x, y, col) 233 | } 234 | } 235 | 236 | return img, nil 237 | } 238 | 239 | func (a *App) Close() error { 240 | if a.tl != nil { 241 | if err := a.tl.Destroy(); err != nil { 242 | return err 243 | } 244 | } 245 | if a.shm != nil { 246 | if err := a.shm.Release(); err != nil { 247 | return nil 248 | } 249 | } 250 | if a.registry != nil { 251 | if err := a.registry.Destroy(); err != nil { 252 | return err 253 | } 254 | } 255 | if a.display != nil { 256 | if err := a.display.Destroy(); err != nil { 257 | return err 258 | } 259 | } 260 | return nil 261 | } 262 | 263 | func (p *shmPool) Data() []byte { 264 | return p.data 265 | } 266 | 267 | func (p *shmPool) Close() error { 268 | if err := unix.Munmap(p.data); err != nil { 269 | return err 270 | } 271 | if err := p.Destroy(); err != nil { 272 | return err 273 | } 274 | if err := unix.Close(p.fd); err != nil { 275 | return err 276 | } 277 | return nil 278 | } 279 | 280 | func (a *App) handleDisplayError(evt client.DisplayErrorEvent) { 281 | a.log.Trace("Display error occurred", "error", evt) 282 | 283 | err := a.reconnect() 284 | if err != nil { 285 | a.log.Trace("Reconnection failed", "error", err) 286 | } 287 | 288 | a.log.Trace("Successfully reconnected to Wayland display") 289 | } 290 | 291 | func (a *App) handleShmFormat(evt client.ShmFormatEvent) { 292 | a.log.Trace(`reported available SHM format`, `format`, client.ShmFormat(evt.Format)) 293 | } 294 | 295 | func (a *App) handleRegistryGlobal(evt client.RegistryGlobalEvent) { 296 | a.log.Trace(`global object`, `name`, evt.Name, `interface`, evt.Interface, `version`, evt.Version) 297 | 298 | switch evt.Interface { 299 | case `wl_shm`: 300 | shm := client.NewShm(a.display.Context()) 301 | if err := a.registry.Bind(evt.Name, evt.Interface, evt.Version, shm); err != nil { 302 | a.log.Error(`failed binding SHM`, `err`, err) 303 | return 304 | } 305 | shm.SetFormatHandler(a.handleShmFormat) 306 | a.shm = shm 307 | case `hyprland_toplevel_export_manager_v1`: 308 | tl := NewHyprlandToplevelExportManagerV1(a.display.Context()) 309 | if err := a.registry.Bind(evt.Name, evt.Interface, evt.Version, tl); err != nil { 310 | a.log.Error(`failed binding toplevel export manager`, `err`, err) 311 | return 312 | } 313 | a.tl = tl 314 | } 315 | } 316 | 317 | func (a *App) reconnect() error { 318 | a.Close() 319 | 320 | display, err := client.Connect("") 321 | if err != nil { 322 | return fmt.Errorf("failed to reconnect: %w", err) 323 | } 324 | 325 | registry, err := display.GetRegistry() 326 | if err != nil { 327 | return fmt.Errorf("failed to get registry: %w", err) 328 | } 329 | 330 | a.display = display 331 | a.registry = registry 332 | 333 | display.SetErrorHandler(a.handleDisplayError) 334 | registry.SetGlobalHandler(a.handleRegistryGlobal) 335 | 336 | if err := a.roundTrip(); err != nil { 337 | return err 338 | } 339 | 340 | return a.roundTrip() 341 | } 342 | 343 | func (a *App) createShmPool(size int32) (*shmPool, error) { 344 | fd, err := unix.MemfdSecret(0) 345 | if err != nil { 346 | return nil, fmt.Errorf(`failed creating memfd: %w`, err) 347 | } 348 | if err := unix.Ftruncate(fd, int64(size)); err != nil { 349 | return nil, fmt.Errorf(`failed truncating memfd: %w`, err) 350 | } 351 | 352 | data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) 353 | if err != nil { 354 | return nil, fmt.Errorf(`failed mmapping memfd: %w`, err) 355 | } 356 | 357 | pool, err := a.shm.CreatePool(fd, int32(size)) 358 | if err != nil { 359 | return nil, fmt.Errorf(`failed creating SHM pool: %w`, err) 360 | } 361 | 362 | return &shmPool{ 363 | ShmPool: pool, 364 | fd: fd, 365 | data: data, 366 | }, nil 367 | } 368 | 369 | func (a *App) roundTrip() error { 370 | cb, err := a.display.Sync() 371 | if err != nil { 372 | return err 373 | } 374 | defer func() { 375 | if err := cb.Destroy(); err != nil { 376 | a.log.Error(`failed destroying callback`, `err`, err) 377 | } 378 | }() 379 | 380 | done := make(chan struct{}) 381 | cb.SetDoneHandler(func(_ client.CallbackDoneEvent) { 382 | close(done) 383 | }) 384 | 385 | for { 386 | select { 387 | case <-done: 388 | return nil 389 | default: 390 | if err := a.display.Context().Dispatch(); err != nil { 391 | a.log.Trace(`dispatch error`, `err`, err) 392 | } 393 | } 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /pkg/wl/hyprland_toplevel_export.go: -------------------------------------------------------------------------------- 1 | // Generated by go-wayland-scanner 2 | // https://github.com/pdf/go-wayland/cmd/go-wayland-scanner 3 | // XML file : https://github.com/hyprwm/hyprland-protocols/raw/refs/heads/main/protocols/hyprland-toplevel-export-v1.xml 4 | // 5 | // hyprland_toplevel_export_v1 Protocol Copyright: 6 | // 7 | // Copyright © 2022 Vaxry 8 | // All rights reserved. 9 | // 10 | // Redistribution and use in source and binary forms, with or without 11 | // modification, are permitted provided that the following conditions are met: 12 | // 13 | // 1. Redistributions of source code must retain the above copyright notice, this 14 | // list of conditions and the following disclaimer. 15 | // 16 | // 2. Redistributions in binary form must reproduce the above copyright notice, 17 | // this list of conditions and the following disclaimer in the documentation 18 | // and/or other materials provided with the distribution. 19 | // 20 | // 3. Neither the name of the copyright holder nor the names of its 21 | // contributors may be used to endorse or promote products derived from 22 | // this software without specific prior written permission. 23 | // 24 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 25 | // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 26 | // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 27 | // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 28 | // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 29 | // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 30 | // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 31 | // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 32 | // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 33 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | 35 | package wl 36 | 37 | import "github.com/pdf/go-wayland/client" 38 | 39 | // HyprlandToplevelExportManagerV1 : manager to inform clients and begin capturing 40 | // 41 | // This object is a manager which offers requests to start capturing from a 42 | // source. 43 | type HyprlandToplevelExportManagerV1 struct { 44 | client.BaseProxy 45 | } 46 | 47 | // NewHyprlandToplevelExportManagerV1 : manager to inform clients and begin capturing 48 | // 49 | // This object is a manager which offers requests to start capturing from a 50 | // source. 51 | func NewHyprlandToplevelExportManagerV1(ctx *client.Context) *HyprlandToplevelExportManagerV1 { 52 | hyprlandToplevelExportManagerV1 := &HyprlandToplevelExportManagerV1{} 53 | ctx.Register(hyprlandToplevelExportManagerV1) 54 | return hyprlandToplevelExportManagerV1 55 | } 56 | 57 | // CaptureToplevel : capture a toplevel 58 | // 59 | // Capture the next frame of a toplevel. (window) 60 | // 61 | // The captured frame will not contain any server-side decorations and will 62 | // ignore the compositor-set geometry, like e.g. rounded corners. 63 | // 64 | // It will contain all the subsurfaces and popups, however the latter will be clipped 65 | // to the geometry of the base surface. 66 | // 67 | // The handle parameter refers to the address of the window as seen in `hyprctl clients`. 68 | // For example, for d161e7b0 it would be 3512854448. 69 | // 70 | // overlayCursor: composite cursor onto the frame 71 | // handle: the handle of the toplevel (window) to be captured 72 | func (i *HyprlandToplevelExportManagerV1) CaptureToplevel(overlayCursor int32, handle uint32) (*HyprlandToplevelExportFrameV1, error) { 73 | frame := NewHyprlandToplevelExportFrameV1(i.Context()) 74 | const opcode = 0 75 | const _reqBufLen = 8 + 4 + 4 + 4 76 | var _reqBuf [_reqBufLen]byte 77 | l := 0 78 | client.PutUint32(_reqBuf[l:4], i.ID()) 79 | l += 4 80 | client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) 81 | l += 4 82 | client.PutUint32(_reqBuf[l:l+4], frame.ID()) 83 | l += 4 84 | client.PutUint32(_reqBuf[l:l+4], uint32(overlayCursor)) 85 | l += 4 86 | client.PutUint32(_reqBuf[l:l+4], uint32(handle)) 87 | err := i.Context().WriteMsg(_reqBuf[:], nil) 88 | return frame, err 89 | } 90 | 91 | // Destroy : destroy the manager 92 | // 93 | // All objects created by the manager will still remain valid, until their 94 | // appropriate destroy request has been called. 95 | func (i *HyprlandToplevelExportManagerV1) Destroy() error { 96 | defer i.Context().Unregister(i) 97 | const opcode = 1 98 | const _reqBufLen = 8 99 | var _reqBuf [_reqBufLen]byte 100 | l := 0 101 | client.PutUint32(_reqBuf[l:4], i.ID()) 102 | l += 4 103 | client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) 104 | err := i.Context().WriteMsg(_reqBuf[:], nil) 105 | return err 106 | } 107 | 108 | // CaptureToplevelWithWlrToplevelHandle : capture a toplevel 109 | // 110 | // Same as capture_toplevel, but with a zwlr_foreign_toplevel_handle_v1 handle. 111 | // 112 | // overlayCursor: composite cursor onto the frame 113 | // handle: the zwlr_foreign_toplevel_handle_v1 handle of the toplevel to be captured 114 | func (i *HyprlandToplevelExportManagerV1) CaptureToplevelWithWlrToplevelHandle(overlayCursor int32, handle *ZwlrForeignToplevelHandleV1) (*HyprlandToplevelExportFrameV1, error) { 115 | frame := NewHyprlandToplevelExportFrameV1(i.Context()) 116 | const opcode = 2 117 | const _reqBufLen = 8 + 4 + 4 + 4 118 | var _reqBuf [_reqBufLen]byte 119 | l := 0 120 | client.PutUint32(_reqBuf[l:4], i.ID()) 121 | l += 4 122 | client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) 123 | l += 4 124 | client.PutUint32(_reqBuf[l:l+4], frame.ID()) 125 | l += 4 126 | client.PutUint32(_reqBuf[l:l+4], uint32(overlayCursor)) 127 | l += 4 128 | client.PutUint32(_reqBuf[l:l+4], handle.ID()) 129 | err := i.Context().WriteMsg(_reqBuf[:], nil) 130 | return frame, err 131 | } 132 | 133 | // HyprlandToplevelExportFrameV1 : a frame ready for copy 134 | // 135 | // This object represents a single frame. 136 | // 137 | // When created, a series of buffer events will be sent, each representing a 138 | // supported buffer type. The "buffer_done" event is sent afterwards to 139 | // indicate that all supported buffer types have been enumerated. The client 140 | // will then be able to send a "copy" request. If the capture is successful, 141 | // the compositor will send a "flags" followed by a "ready" event. 142 | // 143 | // wl_shm buffers are always supported, ie. the "buffer" event is guaranteed to be sent. 144 | // 145 | // If the capture failed, the "failed" event is sent. This can happen anytime 146 | // before the "ready" event. 147 | // 148 | // Once either a "ready" or a "failed" event is received, the client should 149 | // destroy the frame. 150 | type HyprlandToplevelExportFrameV1 struct { 151 | client.BaseProxy 152 | bufferHandler HyprlandToplevelExportFrameV1BufferHandlerFunc 153 | damageHandler HyprlandToplevelExportFrameV1DamageHandlerFunc 154 | flagsHandler HyprlandToplevelExportFrameV1FlagsHandlerFunc 155 | readyHandler HyprlandToplevelExportFrameV1ReadyHandlerFunc 156 | failedHandler HyprlandToplevelExportFrameV1FailedHandlerFunc 157 | linuxDmabufHandler HyprlandToplevelExportFrameV1LinuxDmabufHandlerFunc 158 | bufferDoneHandler HyprlandToplevelExportFrameV1BufferDoneHandlerFunc 159 | } 160 | 161 | // NewHyprlandToplevelExportFrameV1 : a frame ready for copy 162 | // 163 | // This object represents a single frame. 164 | // 165 | // When created, a series of buffer events will be sent, each representing a 166 | // supported buffer type. The "buffer_done" event is sent afterwards to 167 | // indicate that all supported buffer types have been enumerated. The client 168 | // will then be able to send a "copy" request. If the capture is successful, 169 | // the compositor will send a "flags" followed by a "ready" event. 170 | // 171 | // wl_shm buffers are always supported, ie. the "buffer" event is guaranteed to be sent. 172 | // 173 | // If the capture failed, the "failed" event is sent. This can happen anytime 174 | // before the "ready" event. 175 | // 176 | // Once either a "ready" or a "failed" event is received, the client should 177 | // destroy the frame. 178 | func NewHyprlandToplevelExportFrameV1(ctx *client.Context) *HyprlandToplevelExportFrameV1 { 179 | hyprlandToplevelExportFrameV1 := &HyprlandToplevelExportFrameV1{} 180 | ctx.Register(hyprlandToplevelExportFrameV1) 181 | return hyprlandToplevelExportFrameV1 182 | } 183 | 184 | // Copy : copy the frame 185 | // 186 | // Copy the frame to the supplied buffer. The buffer must have the 187 | // correct size, see hyprland_toplevel_export_frame_v1.buffer and 188 | // hyprland_toplevel_export_frame_v1.linux_dmabuf. The buffer needs to have a 189 | // supported format. 190 | // 191 | // If the frame is successfully copied, a "flags" and a "ready" event is 192 | // sent. Otherwise, a "failed" event is sent. 193 | // 194 | // This event will wait for appropriate damage to be copied, unless the ignore_damage 195 | // arg is set to a non-zero value. 196 | func (i *HyprlandToplevelExportFrameV1) Copy(buffer *client.Buffer, ignoreDamage int32) error { 197 | const opcode = 0 198 | const _reqBufLen = 8 + 4 + 4 199 | var _reqBuf [_reqBufLen]byte 200 | l := 0 201 | client.PutUint32(_reqBuf[l:4], i.ID()) 202 | l += 4 203 | client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) 204 | l += 4 205 | client.PutUint32(_reqBuf[l:l+4], buffer.ID()) 206 | l += 4 207 | client.PutUint32(_reqBuf[l:l+4], uint32(ignoreDamage)) 208 | err := i.Context().WriteMsg(_reqBuf[:], nil) 209 | return err 210 | } 211 | 212 | // Destroy : delete this object, used or not 213 | // 214 | // Destroys the frame. This request can be sent at any time by the client. 215 | func (i *HyprlandToplevelExportFrameV1) Destroy() error { 216 | defer i.Context().Unregister(i) 217 | const opcode = 1 218 | const _reqBufLen = 8 219 | var _reqBuf [_reqBufLen]byte 220 | l := 0 221 | client.PutUint32(_reqBuf[l:4], i.ID()) 222 | l += 4 223 | client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) 224 | err := i.Context().WriteMsg(_reqBuf[:], nil) 225 | return err 226 | } 227 | 228 | type HyprlandToplevelExportFrameV1Error uint32 229 | 230 | // HyprlandToplevelExportFrameV1Error : 231 | const ( 232 | // HyprlandToplevelExportFrameV1ErrorAlreadyUsed : the object has already been used to copy a wl_buffer 233 | HyprlandToplevelExportFrameV1ErrorAlreadyUsed HyprlandToplevelExportFrameV1Error = 0 234 | // HyprlandToplevelExportFrameV1ErrorInvalidBuffer : buffer attributes are invalid 235 | HyprlandToplevelExportFrameV1ErrorInvalidBuffer HyprlandToplevelExportFrameV1Error = 1 236 | ) 237 | 238 | func (e HyprlandToplevelExportFrameV1Error) Name() string { 239 | switch e { 240 | case HyprlandToplevelExportFrameV1ErrorAlreadyUsed: 241 | return "already_used" 242 | case HyprlandToplevelExportFrameV1ErrorInvalidBuffer: 243 | return "invalid_buffer" 244 | default: 245 | return "" 246 | } 247 | } 248 | 249 | func (e HyprlandToplevelExportFrameV1Error) Value() string { 250 | switch e { 251 | case HyprlandToplevelExportFrameV1ErrorAlreadyUsed: 252 | return "0" 253 | case HyprlandToplevelExportFrameV1ErrorInvalidBuffer: 254 | return "1" 255 | default: 256 | return "" 257 | } 258 | } 259 | 260 | func (e HyprlandToplevelExportFrameV1Error) String() string { 261 | return e.Name() + "=" + e.Value() 262 | } 263 | 264 | type HyprlandToplevelExportFrameV1Flags uint32 265 | 266 | // HyprlandToplevelExportFrameV1Flags : 267 | const ( 268 | // HyprlandToplevelExportFrameV1FlagsYInvert : contents are y-inverted 269 | HyprlandToplevelExportFrameV1FlagsYInvert HyprlandToplevelExportFrameV1Flags = 1 270 | ) 271 | 272 | func (e HyprlandToplevelExportFrameV1Flags) Name() string { 273 | switch e { 274 | case HyprlandToplevelExportFrameV1FlagsYInvert: 275 | return "y_invert" 276 | default: 277 | return "" 278 | } 279 | } 280 | 281 | func (e HyprlandToplevelExportFrameV1Flags) Value() string { 282 | switch e { 283 | case HyprlandToplevelExportFrameV1FlagsYInvert: 284 | return "1" 285 | default: 286 | return "" 287 | } 288 | } 289 | 290 | func (e HyprlandToplevelExportFrameV1Flags) String() string { 291 | return e.Name() + "=" + e.Value() 292 | } 293 | 294 | // HyprlandToplevelExportFrameV1BufferEvent : wl_shm buffer information 295 | // 296 | // Provides information about wl_shm buffer parameters that need to be 297 | // used for this frame. This event is sent once after the frame is created 298 | // if wl_shm buffers are supported. 299 | type HyprlandToplevelExportFrameV1BufferEvent struct { 300 | Format uint32 301 | Width uint32 302 | Height uint32 303 | Stride uint32 304 | } 305 | type HyprlandToplevelExportFrameV1BufferHandlerFunc func(HyprlandToplevelExportFrameV1BufferEvent) 306 | 307 | // SetBufferHandler : sets handler for HyprlandToplevelExportFrameV1BufferEvent 308 | func (i *HyprlandToplevelExportFrameV1) SetBufferHandler(f HyprlandToplevelExportFrameV1BufferHandlerFunc) { 309 | i.bufferHandler = f 310 | } 311 | 312 | // HyprlandToplevelExportFrameV1DamageEvent : carries the coordinates of the damaged region 313 | // 314 | // This event is sent right before the ready event when ignore_damage was 315 | // not set. It may be generated multiple times for each copy 316 | // request. 317 | // 318 | // The arguments describe a box around an area that has changed since the 319 | // last copy request that was derived from the current screencopy manager 320 | // instance. 321 | // 322 | // The union of all regions received between the call to copy 323 | // and a ready event is the total damage since the prior ready event. 324 | type HyprlandToplevelExportFrameV1DamageEvent struct { 325 | X uint32 326 | Y uint32 327 | Width uint32 328 | Height uint32 329 | } 330 | type HyprlandToplevelExportFrameV1DamageHandlerFunc func(HyprlandToplevelExportFrameV1DamageEvent) 331 | 332 | // SetDamageHandler : sets handler for HyprlandToplevelExportFrameV1DamageEvent 333 | func (i *HyprlandToplevelExportFrameV1) SetDamageHandler(f HyprlandToplevelExportFrameV1DamageHandlerFunc) { 334 | i.damageHandler = f 335 | } 336 | 337 | // HyprlandToplevelExportFrameV1FlagsEvent : frame flags 338 | // 339 | // Provides flags about the frame. This event is sent once before the 340 | // "ready" event. 341 | type HyprlandToplevelExportFrameV1FlagsEvent struct { 342 | Flags uint32 343 | } 344 | type HyprlandToplevelExportFrameV1FlagsHandlerFunc func(HyprlandToplevelExportFrameV1FlagsEvent) 345 | 346 | // SetFlagsHandler : sets handler for HyprlandToplevelExportFrameV1FlagsEvent 347 | func (i *HyprlandToplevelExportFrameV1) SetFlagsHandler(f HyprlandToplevelExportFrameV1FlagsHandlerFunc) { 348 | i.flagsHandler = f 349 | } 350 | 351 | // HyprlandToplevelExportFrameV1ReadyEvent : indicates frame is available for reading 352 | // 353 | // Called as soon as the frame is copied, indicating it is available 354 | // for reading. This event includes the time at which presentation happened 355 | // at. 356 | // 357 | // The timestamp is expressed as tv_sec_hi, tv_sec_lo, tv_nsec triples, 358 | // each component being an unsigned 32-bit value. Whole seconds are in 359 | // tv_sec which is a 64-bit value combined from tv_sec_hi and tv_sec_lo, 360 | // and the additional fractional part in tv_nsec as nanoseconds. Hence, 361 | // for valid timestamps tv_nsec must be in [0, 999999999]. The seconds part 362 | // may have an arbitrary offset at start. 363 | // 364 | // After receiving this event, the client should destroy the object. 365 | type HyprlandToplevelExportFrameV1ReadyEvent struct { 366 | TvSecHi uint32 367 | TvSecLo uint32 368 | TvNsec uint32 369 | } 370 | type HyprlandToplevelExportFrameV1ReadyHandlerFunc func(HyprlandToplevelExportFrameV1ReadyEvent) 371 | 372 | // SetReadyHandler : sets handler for HyprlandToplevelExportFrameV1ReadyEvent 373 | func (i *HyprlandToplevelExportFrameV1) SetReadyHandler(f HyprlandToplevelExportFrameV1ReadyHandlerFunc) { 374 | i.readyHandler = f 375 | } 376 | 377 | // HyprlandToplevelExportFrameV1FailedEvent : frame copy failed 378 | // 379 | // This event indicates that the attempted frame copy has failed. 380 | // 381 | // After receiving this event, the client should destroy the object. 382 | type HyprlandToplevelExportFrameV1FailedEvent struct{} 383 | type HyprlandToplevelExportFrameV1FailedHandlerFunc func(HyprlandToplevelExportFrameV1FailedEvent) 384 | 385 | // SetFailedHandler : sets handler for HyprlandToplevelExportFrameV1FailedEvent 386 | func (i *HyprlandToplevelExportFrameV1) SetFailedHandler(f HyprlandToplevelExportFrameV1FailedHandlerFunc) { 387 | i.failedHandler = f 388 | } 389 | 390 | // HyprlandToplevelExportFrameV1LinuxDmabufEvent : linux-dmabuf buffer information 391 | // 392 | // Provides information about linux-dmabuf buffer parameters that need to 393 | // be used for this frame. This event is sent once after the frame is 394 | // created if linux-dmabuf buffers are supported. 395 | type HyprlandToplevelExportFrameV1LinuxDmabufEvent struct { 396 | Format uint32 397 | Width uint32 398 | Height uint32 399 | } 400 | type HyprlandToplevelExportFrameV1LinuxDmabufHandlerFunc func(HyprlandToplevelExportFrameV1LinuxDmabufEvent) 401 | 402 | // SetLinuxDmabufHandler : sets handler for HyprlandToplevelExportFrameV1LinuxDmabufEvent 403 | func (i *HyprlandToplevelExportFrameV1) SetLinuxDmabufHandler(f HyprlandToplevelExportFrameV1LinuxDmabufHandlerFunc) { 404 | i.linuxDmabufHandler = f 405 | } 406 | 407 | // HyprlandToplevelExportFrameV1BufferDoneEvent : all buffer types reported 408 | // 409 | // This event is sent once after all buffer events have been sent. 410 | // 411 | // The client should proceed to create a buffer of one of the supported 412 | // types, and send a "copy" request. 413 | type HyprlandToplevelExportFrameV1BufferDoneEvent struct{} 414 | type HyprlandToplevelExportFrameV1BufferDoneHandlerFunc func(HyprlandToplevelExportFrameV1BufferDoneEvent) 415 | 416 | // SetBufferDoneHandler : sets handler for HyprlandToplevelExportFrameV1BufferDoneEvent 417 | func (i *HyprlandToplevelExportFrameV1) SetBufferDoneHandler(f HyprlandToplevelExportFrameV1BufferDoneHandlerFunc) { 418 | i.bufferDoneHandler = f 419 | } 420 | 421 | func (i *HyprlandToplevelExportFrameV1) Dispatch(opcode uint32, fd int, data []byte) { 422 | switch opcode { 423 | case 0: 424 | if i.bufferHandler == nil { 425 | return 426 | } 427 | var e HyprlandToplevelExportFrameV1BufferEvent 428 | l := 0 429 | e.Format = client.Uint32(data[l : l+4]) 430 | l += 4 431 | e.Width = client.Uint32(data[l : l+4]) 432 | l += 4 433 | e.Height = client.Uint32(data[l : l+4]) 434 | l += 4 435 | e.Stride = client.Uint32(data[l : l+4]) 436 | 437 | i.bufferHandler(e) 438 | case 1: 439 | if i.damageHandler == nil { 440 | return 441 | } 442 | var e HyprlandToplevelExportFrameV1DamageEvent 443 | l := 0 444 | e.X = client.Uint32(data[l : l+4]) 445 | l += 4 446 | e.Y = client.Uint32(data[l : l+4]) 447 | l += 4 448 | e.Width = client.Uint32(data[l : l+4]) 449 | l += 4 450 | e.Height = client.Uint32(data[l : l+4]) 451 | 452 | i.damageHandler(e) 453 | case 2: 454 | if i.flagsHandler == nil { 455 | return 456 | } 457 | var e HyprlandToplevelExportFrameV1FlagsEvent 458 | l := 0 459 | e.Flags = client.Uint32(data[l : l+4]) 460 | 461 | i.flagsHandler(e) 462 | case 3: 463 | if i.readyHandler == nil { 464 | return 465 | } 466 | var e HyprlandToplevelExportFrameV1ReadyEvent 467 | l := 0 468 | e.TvSecHi = client.Uint32(data[l : l+4]) 469 | l += 4 470 | e.TvSecLo = client.Uint32(data[l : l+4]) 471 | l += 4 472 | e.TvNsec = client.Uint32(data[l : l+4]) 473 | 474 | i.readyHandler(e) 475 | case 4: 476 | if i.failedHandler == nil { 477 | return 478 | } 479 | var e HyprlandToplevelExportFrameV1FailedEvent 480 | 481 | i.failedHandler(e) 482 | case 5: 483 | if i.linuxDmabufHandler == nil { 484 | return 485 | } 486 | var e HyprlandToplevelExportFrameV1LinuxDmabufEvent 487 | l := 0 488 | e.Format = client.Uint32(data[l : l+4]) 489 | l += 4 490 | e.Width = client.Uint32(data[l : l+4]) 491 | l += 4 492 | e.Height = client.Uint32(data[l : l+4]) 493 | 494 | i.linuxDmabufHandler(e) 495 | case 6: 496 | if i.bufferDoneHandler == nil { 497 | return 498 | } 499 | var e HyprlandToplevelExportFrameV1BufferDoneEvent 500 | 501 | i.bufferDoneHandler(e) 502 | } 503 | } 504 | --------------------------------------------------------------------------------