pax_global_header00006660000000000000000000000064147400753210014515gustar00rootroot0000000000000052 comment=8ecd84c65ff832702f0fc8543a691406bc653c8a nwg-piotr-nwg-dock-8ecd84c/000077500000000000000000000000001474007532100156565ustar00rootroot00000000000000nwg-piotr-nwg-dock-8ecd84c/.github/000077500000000000000000000000001474007532100172165ustar00rootroot00000000000000nwg-piotr-nwg-dock-8ecd84c/.github/FUNDING.yml000066400000000000000000000000411474007532100210260ustar00rootroot00000000000000github: nwg-piotr liberapay: nwg nwg-piotr-nwg-dock-8ecd84c/.github/ISSUE_TEMPLATE/000077500000000000000000000000001474007532100214015ustar00rootroot00000000000000nwg-piotr-nwg-dock-8ecd84c/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012201474007532100240660ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - Linux distribution - go version - gtk-layer-shell version **Additional context** Add any other context about the problem here. nwg-piotr-nwg-dock-8ecd84c/.gitignore000066400000000000000000000004651474007532100176530ustar00rootroot00000000000000# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Binaries built with make bin # Dependency directories (remove the comment below to include it) # vendor/ /.idea nwg-piotr-nwg-dock-8ecd84c/LICENSE000066400000000000000000000020551474007532100166650ustar00rootroot00000000000000MIT License Copyright (c) 2021 Piotr Miller Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. nwg-piotr-nwg-dock-8ecd84c/Makefile000066400000000000000000000013131474007532100173140ustar00rootroot00000000000000PREFIX ?= /usr DESTDIR ?= get: go get github.com/gotk3/gotk3 go get github.com/gotk3/gotk3/gdk go get github.com/gotk3/gotk3/glib go get github.com/dlasky/gotk3-layershell/layershell go get github.com/joshuarubin/go-sway go get github.com/allan-simon/go-singleinstance go get "github.com/sirupsen/logrus" build: go build -v -o bin/nwg-dock . install: -pkill -f nwg-dock sleep 1 mkdir -p $(DESTDIR)$(PREFIX)/share/nwg-dock mkdir -p $(DESTDIR)$(PREFIX)/bin cp -r images $(DESTDIR)$(PREFIX)/share/nwg-dock cp config/* $(DESTDIR)$(PREFIX)/share/nwg-dock cp bin/nwg-dock $(DESTDIR)$(PREFIX)/bin uninstall: rm -r $(DESTDIR)$(PREFIX)/share/nwg-dock rm $(DESTDIR)$(PREFIX)/bin/nwg-dock run: go run . nwg-piotr-nwg-dock-8ecd84c/README.md000066400000000000000000000143061474007532100171410ustar00rootroot00000000000000# nwg-dock This application is a part of the [nwg-shell](https://nwg-piotr.github.io/nwg-shell) project. **Contributing:** please read the [general contributing rules for the nwg-shell project](https://nwg-piotr.github.io/nwg-shell/contribution). For **Hyprland support** please use [nwg-dock-hyprland](https://github.com/nwg-piotr/nwg-dock-hyprland). Fully configurable (w/ command line arguments and css) dock, written in Go, aimed exclusively at [sway](https://github.com/swaywm/sway) Wayland compositor. It features pinned buttons, task buttons, the workspace switcher and the launcher button. The latter by default starts [nwg-drawer](https://github.com/nwg-piotr/nwg-drawer) or `nwggrid` (application grid) - if found. In the picture(s) below the dock has been shown together with [nwg-panel](https://github.com/nwg-piotr/nwg-panel). Screenshot [![Packaging status](https://repology.org/badge/vertical-allrepos/nwg-dock.svg)](https://repology.org/project/nwg-dock/versions) ## Installation ### Requirements - `go`>=1.20 (just to build) - `gtk3` - `gtk-layer-shell` - [nwg-drawer](https://github.com/nwg-piotr/nwg-drawer) or [nwg-launchers](https://github.com/nwg-piotr/nwg-launchers): optionally. You may use another launcher (see help), or none at all. The launcher button won't show up, if so. ### Steps 1. Clone the repository, cd into it. 2. Install golang libraries with `make get`. First time it may take ages, be patient. 3. `make build` 4. `sudo make install` ## Running Either start the dock permanently in the sway config file, ```text exec nwg-dock [arguments] ``` or assign the command to some key binding. Running the command again kills the existing program instance, so that you could use the same key to open and close the dock. ## Running the dock residently If you run the program with the `-d` or `-r` argument (preferably in autostart), it will be running residently. ```text exec_always nwg-dock -d ``` or ```text exec_always nwg-dock -r ``` ### `-d` for autohiDe Move the mouse pointer to expected dock location for the dock to show up. It will be hidden a second after you leave the window. Invisible hot spots will be created on all your outputs, unless you specify one with the `-o` argument. ### `-r` for just Resident No hotspot will be created. To show/hide the dock, bind the `exec nwg-dock` command to some key or button. How about the `Menu` key, which is usually useless? Re-execution of the same command hides the dock. If a resident instance found, the `nwg-dock` command w/o arguments sends `SIGUSR1` to it. Actually `pkill -USR1 nwg-dock` could be used instead. This also works in autohiDe mode. Re-execution of the command with the `-d` or `-r` argument won't kill the running instance. If the dock is running residently, another instance will just exit with 0 code. In case you'd like to terminate it anyway, you need to `pkill -f nwg-dock`. *NOTE: you need to kill the running instance before reloading sway, if you've just changed the arguments you auto-start the dock with.* ```txt $ nwg-dock -h Usage of nwg-dock: -a string Alignment in full width/height: "start", "center" or "end" (default "center") -c string Command assigned to the launcher button -d auto-hiDe: show dock when hotspot hovered, close when left or a button clicked -debug turn on debug messages -f take Full screen width/height -g string quote-delimited, space-separated app_id list to iGnore in the dock -hd int Hotspot Delay [ms]; the smaller, the faster mouse pointer needs to enter hotspot for the dock to appear; set 0 to disable (default 20) -i int Icon size (default 48) -l string Layer "overlay", "top" or "bottom" (default "overlay") -lp string Launcher button position, 'start' or 'end' (default "end") -mb int Margin Bottom -ml int Margin Left -mr int Margin Right -mt int Margin Top -nolauncher don't show the launcher button -nows don't show the workspace switcher -o string name of Output to display the dock on -p string Position: "bottom", "top" or "left" (default "bottom") -r Leave the program resident, but w/o hotspot -s string Styling: css file name (default "style.css") -v display Version information -w int number of Workspaces you use (default 8) -x set eXclusive zone: move other windows aside; overrides the "-l" argument ``` Screenshot
## Styling Edit `~/.config/nwg-dock/style.css` to your taste. ## Troubleshooting ### An application icon is not displayed The only thing the dock knows about the app is it's app_id. ```text $ swaymsg -t get_tree (...) #13: con "piotr@msi:~" (xdg_shell, pid: 8512, app_id: "foot") ``` Now it'll look for an icon named 'foot'. If that fails, it'll look for a .desktop file named 'foot.desktop', which should contain the icon name or path. If this fails as well, no icon will be displayed. I've added workarounds for some most common exceptions, but it's impossible to predict every single application misbehaviour. This is either programmers fault (improper class name), or bad packaging (.desktop file name different from the application class name). If some app has no icon in the dock: 1. check the app class name (`swaymsg -t get_tree`); 2. find the app's .desktop file; 3. copy it to ~/.local/share/applications/` and rename to .desktop. If the .desktop file contains proper icon definition (`Icon=`), it should work now. ## Credits This program uses some great libraries: - [gotk3](https://github.com/gotk3/gotk3) Copyright (c) 2013-2014 Conformal Systems LLC, Copyright (c) 2015-2018 gotk3 contributors - [gotk3-layershell](https://github.com/dlasky/gotk3-layershell) by [@dlasky](https://github.com/dlasky/gotk3-layershell/commits?author=dlasky) - many thanks for writing this software, and for patience with my requests! - [go-sway](https://github.com/joshuarubin/go-sway) Copyright (c) 2019 Joshua Rubin - [go-singleinstance](github.com/allan-simon/go-singleinstance) Copyright (c) 2015 Allan Simon nwg-piotr-nwg-dock-8ecd84c/config/000077500000000000000000000000001474007532100171235ustar00rootroot00000000000000nwg-piotr-nwg-dock-8ecd84c/config/hotspot.css000066400000000000000000000001011474007532100213250ustar00rootroot00000000000000window { background-color: rgba (0, 0, 0, 0); border: none } nwg-piotr-nwg-dock-8ecd84c/config/style.css000066400000000000000000000012111474007532100207700ustar00rootroot00000000000000window { /*background-color: rgba (0, 0, 0, 0.8);*/ background: #36364f; /*opacity: 0.9;*/ border-radius: 15px; border-style: none; border-width: 1px; border-color: rgba (156, 142, 122, 0.7) } #box { /* Define attributes of the box surrounding icons here */ padding: 10px } button, image { background: none; border-style: none; box-shadow: none; color: #999 } button { /*padding-left: 4px; padding-right: 4px;*/ padding: 4px; margin-left: 4px; margin-right: 4px; color: #eee; font-size: 12px } button:hover { background-color: rgba (255, 255, 255, 0.15); border-radius: 2px; } button:focus { box-shadow: 0 0 2px; } nwg-piotr-nwg-dock-8ecd84c/go.mod000066400000000000000000000010171474007532100167630ustar00rootroot00000000000000module github.com/nwg-piotr/nwg-dock go 1.23 require ( github.com/allan-simon/go-singleinstance v0.0.0-20210120080615-d0997106ab37 github.com/dlasky/gotk3-layershell v0.0.0-20240515133811-5c5115f0d774 github.com/gotk3/gotk3 v0.6.5-0.20240618185848-ff349ae13f56 github.com/joshuarubin/go-sway v1.2.0 github.com/sirupsen/logrus v1.9.3 ) require ( github.com/joshuarubin/lifecycle v1.1.4 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect ) nwg-piotr-nwg-dock-8ecd84c/go.sum000066400000000000000000000077171474007532100170250ustar00rootroot00000000000000github.com/allan-simon/go-singleinstance v0.0.0-20210120080615-d0997106ab37 h1:28uU3TtuvQ6KRndxg9TrC868jBWmSKgh0GTXkACCXmA= github.com/allan-simon/go-singleinstance v0.0.0-20210120080615-d0997106ab37/go.mod h1:6AXRstqK+32jeFmw89QGL2748+dj34Av4xc/I9oo9BY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlasky/gotk3-layershell v0.0.0-20240515133811-5c5115f0d774 h1:o87OVL4olQBlVwN3+NSVQpS6gj9FWUYtxOfHXWZigUE= github.com/dlasky/gotk3-layershell v0.0.0-20240515133811-5c5115f0d774/go.mod h1:JHLx2Wz4mAPVwn4PFhC69ydwyHP4A3wQvlg7HKVVc1U= github.com/gotk3/gotk3 v0.6.1/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= github.com/gotk3/gotk3 v0.6.5-0.20240618185848-ff349ae13f56 h1:eR+xxC8qqKuPMTucZqaklBxLIT7/4L7dzhlwKMrDbj8= github.com/gotk3/gotk3 v0.6.5-0.20240618185848-ff349ae13f56/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= github.com/joshuarubin/go-sway v1.2.0 h1:t3eqW504//uj9PDwFf0+IVfkD+WoOGaDX5gYIe0BHyM= github.com/joshuarubin/go-sway v1.2.0/go.mod h1:qcDd6f25vJ0++wICwA1BainIcRC67p2Mb4lsrZ0k3/k= github.com/joshuarubin/lifecycle v1.0.0/go.mod h1:sRy++ATvR9Ee21tkRdFkQeywAWvDsue66V70K0Dnl54= github.com/joshuarubin/lifecycle v1.1.4 h1:9ZjvYSsWax9DC3Jpz6vGf/0KnU8FNMjh0/vJ3SpSBRQ= github.com/joshuarubin/lifecycle v1.1.4/go.mod h1:QqHrqwMPMA9dbJY3XgIyVLhzHMSGOFrcCAQ59bke1mo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nwg-piotr-nwg-dock-8ecd84c/images/000077500000000000000000000000001474007532100171235ustar00rootroot00000000000000nwg-piotr-nwg-dock-8ecd84c/images/1.svg000066400000000000000000000170461474007532100200140ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/10.svg000066400000000000000000000202721474007532100200670ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/11.svg000066400000000000000000000174731474007532100201010ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/12.svg000066400000000000000000000203701474007532100200700ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/13.svg000066400000000000000000000207521474007532100200750ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/14.svg000066400000000000000000000175301474007532100200760ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/15.svg000066400000000000000000000203511474007532100200720ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/16.svg000066400000000000000000000207151474007532100200770ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/17.svg000066400000000000000000000174231474007532100201020ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/18.svg000066400000000000000000000213111474007532100200720ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/19.svg000066400000000000000000000207151474007532100201020ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/2.svg000066400000000000000000000177501474007532100200170ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/20.svg000066400000000000000000000211731474007532100200710ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/3.svg000066400000000000000000000203051474007532100200060ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/4.svg000066400000000000000000000171051474007532100200130ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/5.svg000066400000000000000000000177271474007532100200260ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/6.svg000066400000000000000000000202711474007532100200130ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/7.svg000066400000000000000000000167771474007532100200340ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/8.svg000066400000000000000000000206711474007532100200210ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/9.svg000066400000000000000000000202751474007532100200220ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/grid.svg000066400000000000000000000101111474007532100205630ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/icon-missing.svg000066400000000000000000000154241474007532100222510ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/task-empty.svg000066400000000000000000000030741474007532100217460ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/task-multiple.svg000066400000000000000000000037101474007532100224400ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/images/task-single.svg000066400000000000000000000033761474007532100220760ustar00rootroot00000000000000 image/svg+xml nwg-piotr-nwg-dock-8ecd84c/main.go000066400000000000000000000455631474007532100171460ustar00rootroot00000000000000package main import ( "context" "flag" "fmt" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" log "github.com/sirupsen/logrus" "github.com/allan-simon/go-singleinstance" "github.com/dlasky/gotk3-layershell/layershell" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" ) const version = "0.4.3" type WindowState int const ( WindowShow WindowState = iota WindowHide ) var ( appDirs []string dataHome string configDirectory string pinnedFile string pinned []string oldTasks []task mainBox *gtk.Box src glib.SourceHandle refreshMainBoxChannel chan struct{} = make(chan struct{}, 1) outerOrientation, innerOrientation gtk.Orientation widgetAnchor, menuAnchor gdk.Gravity imgSizeScaled int currentWsNum, targetWsNum int64 win *gtk.Window windowStateChannel chan WindowState = make(chan WindowState, 1) detectorEnteredAt int64 appIdsToIgnore []string ) // Flags var cssFileName = flag.String("s", "style.css", "Styling: css file name") var targetOutput = flag.String("o", "", "name of Output to display the dock on") var displayVersion = flag.Bool("v", false, "display Version information") var autohide = flag.Bool("d", false, "auto-hiDe: show dock when hotspot hovered, close when left or a button clicked") var full = flag.Bool("f", false, "take Full screen width/height") var ignoreAppIds = flag.String("g", "", "quote-delimited, space-separated app_id list to iGnore in the dock") var numWS = flag.Int64("w", 8, "number of Workspaces you use") var position = flag.String("p", "bottom", "Position: \"bottom\", \"top\" or \"left\"") var exclusive = flag.Bool("x", false, "set eXclusive zone: move other windows aside; overrides the \"-l\" argument") var imgSize = flag.Int("i", 48, "Icon size") var layer = flag.String("l", "overlay", "Layer \"overlay\", \"top\" or \"bottom\"") var launcherCmd = flag.String("c", "", "Command assigned to the launcher button") var launcherPos = flag.String("lp", "end", "Launcher button position, 'start' or 'end'") var alignment = flag.String("a", "center", "Alignment in full width/height: \"start\", \"center\" or \"end\"") var marginTop = flag.Int("mt", 0, "Margin Top") var marginLeft = flag.Int("ml", 0, "Margin Left") var marginRight = flag.Int("mr", 0, "Margin Right") var marginBottom = flag.Int("mb", 0, "Margin Bottom") var hotspotDelay = flag.Int64("hd", 20, "Hotspot Delay [ms]; the smaller, the faster mouse pointer needs to enter hotspot for the dock to appear; set 0 to disable") var noWs = flag.Bool("nows", false, "don't show the workspace switcher") var noLauncher = flag.Bool("nolauncher", false, "don't show the launcher button") var resident = flag.Bool("r", false, "Leave the program resident, but w/o hotspot") var debug = flag.Bool("debug", false, "turn on debug messages") func buildMainBox(tasks []task, vbox *gtk.Box) { mainBox.Destroy() mainBox, _ = gtk.BoxNew(innerOrientation, 0) if *alignment == "start" { vbox.PackStart(mainBox, false, true, 0) } else if *alignment == "end" { vbox.PackEnd(mainBox, false, true, 0) } else { vbox.PackStart(mainBox, true, false, 0) } var err error pinned, err = loadTextFile(pinnedFile) if err != nil { pinned = nil } var allItems []string for _, cntPin := range pinned { if !isIn(allItems, cntPin) { allItems = append(allItems, cntPin) } } for _, cntTask := range tasks { if !isIn(allItems, cntTask.ID) && !strings.Contains(*launcherCmd, cntTask.ID) { allItems = append(allItems, cntTask.ID) } } divider := 1 if len(allItems) > 0 { divider = len(allItems) } // scale icons down when their number increases if *imgSize*6/(divider) < *imgSize { overflow := (len(allItems) - 6) / 3 imgSizeScaled = *imgSize * 6 / (6 + overflow) } else { imgSizeScaled = *imgSize } if *launcherPos == "start" { button := launcherButton() if button != nil { mainBox.PackStart(button, false, false, 0) } } var alreadyAdded []string for _, pin := range pinned { if !inTasks(tasks, pin) { if !isIn(appIdsToIgnore, pin) { button := pinnedButton(pin) mainBox.PackStart(button, false, false, 0) } else { log.Debugf("Ignoring pin '%s'", pin) } } else { instances := taskInstances(pin, tasks) task := instances[0] if !isIn(appIdsToIgnore, task.ID) { if len(instances) == 1 { button := taskButton(task, instances) mainBox.PackStart(button, false, false, 0) } else if !isIn(alreadyAdded, task.ID) { button := taskButton(task, instances) mainBox.PackStart(button, false, false, 0) alreadyAdded = append(alreadyAdded, task.ID) taskMenu(task.ID, instances) } else { continue } } else { log.Debugf("Ignoring instance '%s'", task.ID) } } } alreadyAdded = nil for _, task := range tasks { if !inPinned(task.ID) { instances := taskInstances(task.ID, tasks) if !isIn(appIdsToIgnore, task.ID) { if len(instances) == 1 { button := taskButton(task, instances) mainBox.PackStart(button, false, false, 0) } else if !isIn(alreadyAdded, task.ID) { button := taskButton(task, instances) mainBox.PackStart(button, false, false, 0) alreadyAdded = append(alreadyAdded, task.ID) taskMenu(task.ID, instances) } else { continue } } } } if !*noWs { wsButton, e := gtk.ButtonNew() if e != nil { return } wsPixbuf, e := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, fmt.Sprintf("nwg-dock/images/%v.svg", currentWsNum)), imgSizeScaled, imgSizeScaled) if e == nil { wsImage, _ := gtk.ImageNewFromPixbuf(wsPixbuf) wsButton.SetImage(wsImage) wsButton.SetAlwaysShowImage(true) wsButton.AddEvents(int(gdk.SCROLL_MASK)) wsUpdateChannel := getWorkspaceChangesChannel(context.Background()) go func() { for { activeWorkspace := <-wsUpdateChannel targetWsNum = activeWorkspace glib.TimeoutAdd(0, func() bool { wsPixbuf, e = gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, fmt.Sprintf("nwg-dock/images/%v.svg", activeWorkspace)), imgSizeScaled, imgSizeScaled) if e == nil { wsImage, _ = gtk.ImageNewFromPixbuf(wsPixbuf) wsButton.SetImage(wsImage) } else { log.Warnf("Unable set set workspace image: %v", activeWorkspace) } return false }) } }() wsButton.Connect("clicked", func() { focusWorkspace(targetWsNum) }) wsButton.Connect("enter-notify-event", cancelClose) wsButton.Connect("scroll-event", func(btn *gtk.Button, e *gdk.Event) bool { event := gdk.EventScrollNewFromEvent(e) if event.Direction() == gdk.SCROLL_UP { if targetWsNum < *numWS && targetWsNum < 20 { targetWsNum++ } else { targetWsNum = 1 } wsUpdateChannel <- targetWsNum return true } else if event.Direction() == gdk.SCROLL_DOWN { if targetWsNum > 1 { targetWsNum-- } else { targetWsNum = *numWS } wsUpdateChannel <- targetWsNum return true } return false }) } if wsButton != nil { mainBox.PackStart(wsButton, false, false, 0) } } if *launcherPos == "end" { button := launcherButton() if button != nil { mainBox.PackStart(button, false, false, 0) } } mainBox.ShowAll() } func setupHotSpot(monitor gdk.Monitor, dockWindow *gtk.Window) gtk.Window { w, h := dockWindow.GetSize() win, _ := gtk.WindowNew(gtk.WINDOW_TOPLEVEL) layershell.InitForWindow(win) layershell.SetMonitor(win, &monitor) var box *gtk.Box if *position == "bottom" || *position == "top" { box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) } else { box, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) } win.Add(box) detectorBox, _ := gtk.EventBoxNew() _ = detectorBox.SetProperty("name", "detector-box") if *position == "bottom" { box.PackStart(detectorBox, false, false, 0) } else { box.PackEnd(detectorBox, false, false, 0) } detectorBox.Connect("enter-notify-event", func() { detectorEnteredAt = time.Now().UnixNano() / 1000000 }) hotspotBox, _ := gtk.EventBoxNew() _ = hotspotBox.SetProperty("name", "hotspot-box") if *position == "bottom" { box.PackStart(hotspotBox, false, false, 0) } else { box.PackEnd(hotspotBox, false, false, 0) } hotspotBox.Connect("enter-notify-event", func() { hotspotEnteredAt := time.Now().UnixNano() / 1000000 delay := hotspotEnteredAt - detectorEnteredAt layershell.SetMonitor(dockWindow, &monitor) if delay <= *hotspotDelay || *hotspotDelay == 0 { log.Debugf("Delay %v < %v ms, let's show the window!", delay, *hotspotDelay) dockWindow.Hide() dockWindow.Show() } else { log.Debugf("Delay %v > %v ms, don't show the window :/", delay, *hotspotDelay) } }) if *position == "bottom" || *position == "top" { detectorBox.SetSizeRequest(w, h/3) hotspotBox.SetSizeRequest(w, 2) if *position == "bottom" { layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_BOTTOM, true) } else { layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_TOP, true) } layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_LEFT, *full) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_RIGHT, *full) } if *position == "left" { detectorBox.SetSizeRequest(w/3, h) hotspotBox.SetSizeRequest(2, h) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_LEFT, true) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_TOP, *full) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_BOTTOM, *full) } layershell.SetLayer(win, layershell.LAYER_SHELL_LAYER_TOP) layershell.SetMargin(win, layershell.LAYER_SHELL_EDGE_TOP, *marginTop) layershell.SetMargin(win, layershell.LAYER_SHELL_EDGE_LEFT, *marginLeft) layershell.SetMargin(win, layershell.LAYER_SHELL_EDGE_RIGHT, *marginRight) layershell.SetMargin(win, layershell.LAYER_SHELL_EDGE_BOTTOM, *marginBottom) layershell.SetExclusiveZone(win, -1) return *win } func main() { flag.Parse() if *debug { log.SetLevel(log.DebugLevel) } if *autohide && *resident { log.Warn("autohiDe and Resident arguments are mutually exclusive, ignoring -d!") *autohide = false } if *displayVersion { fmt.Printf("nwg-dock version %s\n", version) os.Exit(0) } if *autohide { log.Info("Starting in autohiDe mode") } if *resident { log.Info("Starting in resident mode") } if *ignoreAppIds != "" { log.Infof("Ignoring app_ids: '%s'", *ignoreAppIds) appIdsToIgnore = strings.Split(*ignoreAppIds, " ") } // Gentle SIGTERM handler thanks to reiki4040 https://gist.github.com/reiki4040/be3705f307d3cd136e85 // v0.2: we also need to support SIGUSR from now on. signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGUSR1) go func() { for { s := <-signalChan switch s { case syscall.SIGTERM: log.Info("SIGTERM received, bye bye!") gtk.MainQuit() case syscall.SIGUSR1: if *resident || *autohide { // As win.Show() called from inside a goroutine randomly crashes GTK, // let's just set e helper variable here. We'll be checking it with glib.TimeoutAdd. if !win.IsVisible() { log.Debug("SIGUSR1 received, showing the window") windowStateChannel <- WindowShow } else { log.Debug("SIGUSR1 received, hiding the window") windowStateChannel <- WindowHide } } else { log.Info("SIGUSR1 received, and I'm not resident, bye bye!") gtk.MainQuit() } default: log.Warn("Unknown signal") } } }() // Unless we are in autohide/resident mode, we probably want the same key/mouse binding to turn the dock off. // Since v0.2 we can't just send SIGKILL if running instance found. We'll send SIGUSR1 instead. // If it's running with `-r` or `-d` flag, it'll show the window. If not - it will die. // Use md5-hashed $USER name to create unique lock files for multiple users lockFilePath := fmt.Sprintf("%s/nwg-dock-%s.lock", tempDir(), md5Hash(os.Getenv("USER"))) lockFile, err := singleinstance.CreateLockFile(lockFilePath) if err != nil { pid, err := readTextFile(lockFilePath) if err == nil { i, err := strconv.Atoi(pid) if err == nil { if *autohide || *resident { log.Info("Running instance found, terminating...") } else { _ = syscall.Kill(i, syscall.SIGUSR1) log.Info("Sending SIGUSR1 to running instance and bye, bye!") } } } os.Exit(0) } defer lockFile.Close() if !*noLauncher && *launcherCmd == "" { if isCommand("nwg-drawer") { *launcherCmd = "nwg-drawer" } else if isCommand("nwggrid") { *launcherCmd = "nwggrid -p" } if *launcherCmd != "" { log.Infof("Using auto-detected launcher command: '%s'", *launcherCmd) } else { log.Info("Neither 'nwg-drawer' nor 'nwggrid' command found, and no other launcher specified; hiding the launcher button.") } } dataHome = getDataHome() configDirectory = configDir() // if it doesn't exist: createDir(configDirectory) if !pathExists(fmt.Sprintf("%s/style.css", configDirectory)) { err := copyFile(filepath.Join(dataHome, "nwg-dock/style.css"), fmt.Sprintf("%s/style.css", configDirectory)) if err != nil { log.Warnf("Error copying file: %s", err) } } cacheDirectory := cacheDir() if cacheDirectory == "" { log.Panic("Couldn't determine cache directory location") } pinnedFile = filepath.Join(cacheDirectory, "nwg-dock-pinned") cssFile := filepath.Join(configDirectory, *cssFileName) appDirs = getAppDirs() gtk.Init(nil) cssProvider, _ := gtk.CssProviderNew() err = cssProvider.LoadFromPath(cssFile) if err != nil { log.Warnf("%s file not found, using GTK styling\n", cssFile) } else { log.Printf("Using style: %s\n", cssFile) screen, _ := gdk.ScreenGetDefault() gtk.AddProviderForScreen(screen, cssProvider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) } win, err = gtk.WindowNew(gtk.WINDOW_TOPLEVEL) if err != nil { log.Fatal("Unable to create window:", err) } layershell.InitForWindow(win) var output2mon map[string]*gdk.Monitor if *targetOutput != "" { // We want to assign layershell to a monitor, but we only know the output name! output2mon, err = mapOutputs() if err == nil { layershell.SetMonitor(win, output2mon[*targetOutput]) } else { log.Warn(fmt.Sprintf("%s", err)) } } if *exclusive { layershell.AutoExclusiveZoneEnable(win) *layer = "top" } if *position == "bottom" || *position == "top" { if *position == "bottom" { layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_BOTTOM, true) widgetAnchor = gdk.GDK_GRAVITY_NORTH menuAnchor = gdk.GDK_GRAVITY_SOUTH } else { layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_TOP, true) widgetAnchor = gdk.GDK_GRAVITY_SOUTH menuAnchor = gdk.GDK_GRAVITY_NORTH } outerOrientation = gtk.ORIENTATION_VERTICAL innerOrientation = gtk.ORIENTATION_HORIZONTAL layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_LEFT, *full) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_RIGHT, *full) } if *position == "left" { layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_LEFT, true) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_TOP, *full) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_BOTTOM, *full) outerOrientation = gtk.ORIENTATION_HORIZONTAL innerOrientation = gtk.ORIENTATION_VERTICAL widgetAnchor = gdk.GDK_GRAVITY_EAST menuAnchor = gdk.GDK_GRAVITY_WEST } if *layer == "top" { layershell.SetLayer(win, layershell.LAYER_SHELL_LAYER_TOP) } else if *layer == "bottom" { layershell.SetLayer(win, layershell.LAYER_SHELL_LAYER_BOTTOM) } else { layershell.SetLayer(win, layershell.LAYER_SHELL_LAYER_OVERLAY) layershell.SetExclusiveZone(win, -1) } layershell.SetMargin(win, layershell.LAYER_SHELL_EDGE_TOP, *marginTop) layershell.SetMargin(win, layershell.LAYER_SHELL_EDGE_LEFT, *marginLeft) layershell.SetMargin(win, layershell.LAYER_SHELL_EDGE_RIGHT, *marginRight) layershell.SetMargin(win, layershell.LAYER_SHELL_EDGE_BOTTOM, *marginBottom) win.Connect("destroy", func() { gtk.MainQuit() }) // Close the window on leave, but not immediately, to avoid accidental closes win.Connect("leave-notify-event", func() { if *autohide { src = glib.TimeoutAdd(uint(1000), func() bool { win.Hide() src = 0 return false }) } }) win.Connect("enter-notify-event", func() { cancelClose() }) outerBox, _ := gtk.BoxNew(outerOrientation, 0) _ = outerBox.SetProperty("name", "box") win.Add(outerBox) alignmentBox, _ := gtk.BoxNew(innerOrientation, 0) outerBox.PackStart(alignmentBox, true, true, 0) mainBox, _ = gtk.BoxNew(innerOrientation, 0) // We'll pack mainBox later, in buildMainBox tasks, err := listTasks() if err != nil { log.Fatal("Couldn't list tasks:", err) } oldTasks = tasks var oldWsNum int64 buildMainBox(tasks, alignmentBox) refreshMainBox := func(currentTasks []task, forceRefresh bool) { if forceRefresh || (len(currentTasks) != len(oldTasks) || currentWsNum != oldWsNum) { glib.TimeoutAdd(0, func() bool { log.Debug("refreshing...") buildMainBox(currentTasks, alignmentBox) oldTasks = currentTasks oldWsNum = currentWsNum targetWsNum = currentWsNum return false }) } } go func() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() taskChannel, err := getTaskChangesChannel(ctx) if err != nil { log.Fatal("Unable to process sway tasks:", err) } for { select { // Refresh if any pin/unpin action happened case <-refreshMainBoxChannel: currentTasks, err := listTasks() if err != nil { log.Fatal("Unable to retrieve task list from sway!") } refreshMainBox(currentTasks, true) // Refresh if the state of the workspace changes case currentTasks := <-taskChannel: refreshMainBox(currentTasks, false) } } }() win.ShowAll() if *autohide { glib.TimeoutAdd(uint(500), win.Hide) mRefProvider, _ := gtk.CssProviderNew() if err := mRefProvider.LoadFromPath(filepath.Join(dataHome, "nwg-dock/hotspot.css")); err != nil { log.Errorf("%s", err) } if *targetOutput == "" { // hot spots on all displays monitors, _ := listMonitors() for _, monitor := range monitors { win := setupHotSpot(monitor, win) ctx, _ := win.GetStyleContext() ctx.AddProvider(mRefProvider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) win.ShowAll() } } else { // hot spot on the selected display only monitor := output2mon[*targetOutput] win := setupHotSpot(*monitor, win) ctx, _ := win.GetStyleContext() ctx.AddProvider(mRefProvider, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) win.ShowAll() } } go func() { for { windowState := <-windowStateChannel glib.TimeoutAdd(0, func() bool { if windowState == WindowShow && win != nil && !win.IsVisible() { win.ShowAll() } if windowState == WindowHide && win != nil && win.IsVisible() { win.Hide() } return false }) } }() gtk.Main() } nwg-piotr-nwg-dock-8ecd84c/tools.go000066400000000000000000000664341474007532100173620ustar00rootroot00000000000000package main import ( "context" "crypto/md5" "encoding/hex" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "time" log "github.com/sirupsen/logrus" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" "github.com/joshuarubin/go-sway" ) var descendants []sway.Node type task struct { conID int64 ID string // will be created out of app_id or window class Name string PID uint32 WsNum int64 } func taskInstances(ID string, tasks []task) []task { var found []task for _, t := range tasks { if strings.Contains(strings.ToUpper(t.ID), strings.ToUpper(ID)) { found = append(found, t) } } return found } type TaskChange struct { Change sway.WindowEventChange Task *task } type swayEventHandler struct { taskUpdateChannel chan TaskChange workspaceUpdateChannel chan int64 } func (t swayEventHandler) Workspace(ctx context.Context, event sway.WorkspaceEvent) { if event.Change == "focus" { // TODO: sway.WorkspaceEvent.Current should contain a Workspace, but contains Node, // this may be an error of the used library ... t.workspaceUpdateChannel <- 0 } } func (t swayEventHandler) Mode(ctx context.Context, event sway.ModeEvent) {} func (t swayEventHandler) BarConfigUpdate(ctx context.Context, event sway.BarConfigUpdateEvent) {} func (t swayEventHandler) Binding(ctx context.Context, event sway.BindingEvent) {} func (t swayEventHandler) Shutdown(ctx context.Context, event sway.ShutdownEvent) {} func (t swayEventHandler) Tick(ctx context.Context, event sway.TickEvent) {} func (t swayEventHandler) BarStateUpdate(ctx context.Context, event sway.BarStateUpdateEvent) {} func (t swayEventHandler) BarStatusUpdate(ctx context.Context, event sway.BarStateUpdateEvent) {} func (t swayEventHandler) Input(ctx context.Context, event sway.InputEvent) {} func (t swayEventHandler) Window(ctx context.Context, window sway.WindowEvent) { if window.Change == "new" || window.Change == "close" { t.taskUpdateChannel <- TaskChange{ Change: window.Change, // TODO: gather enough details form sway.WindowEvent to create the task // structure and pass it on for smarter modifying the task array Task: nil, } } } // TODO: The channel should *not* return a []task, but rather a TaskChange event which should // be used to modify the list in the frontend ... func getTaskChangesChannel(ctx context.Context) (chan []task, error) { taskArrayChannel := make(chan []task, 1) eventHandler := swayEventHandler{ taskUpdateChannel: make(chan TaskChange, 1), } go func() { // Blocks execution until we cancel the context if err := sway.Subscribe(ctx, eventHandler, sway.EventTypeWindow); err != nil { log.Fatal("Unable to subscribe to sway event:", err) } }() // Pretty hacky, but is simply used to convert a TaskChange to a task struct go func() { for { <-eventHandler.taskUpdateChannel tasks, err := listTasks() if err != nil { log.Errorf("Unable to process tasks from sway: %s", err.Error()) return } taskArrayChannel <- tasks } }() return taskArrayChannel, nil } func getWorkspaceChangesChannel(ctx context.Context) chan int64 { workspaceUpdateChannel := make(chan int64, 1) eventHandler := swayEventHandler{ workspaceUpdateChannel: make(chan int64, 1), } go func() { // Blocks execution until we cancel the context if err := sway.Subscribe(ctx, eventHandler, sway.EventTypeWorkspace); err != nil { log.Fatal("Unable to subscribe to sway event:", err) } }() go func() { ipc, _ := sway.New(ctx) for { <-eventHandler.workspaceUpdateChannel workspaces, _ := ipc.GetWorkspaces(ctx) for _, workspace := range workspaces { if workspace.Focused { workspaceUpdateChannel <- workspace.Num break } } } }() return workspaceUpdateChannel } // list sway tree, return tasks sorted by workspace numbers func listTasks() ([]task, error) { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() client, err := sway.New(ctx) if err != nil { return nil, err } tree, err := client.GetTree(ctx) if err != nil { return nil, err } workspaces, _ := client.GetWorkspaces(ctx) if err != nil { return nil, err } // In order not to add a separate function, let's set the global currentWsNum variable we need here for _, ws := range workspaces { if ws.Focused { currentWsNum = ws.Num break } } // all nodes in the tree nodes := tree.Nodes // find outputs in all nodes var outputs []*sway.Node for _, n := range nodes { if n.Type == "output" && !strings.HasPrefix(n.Name, "__") { outputs = append(outputs, n) } } // find workspaces in outputs var workspaceNodes []*sway.Node for _, o := range outputs { nodes = o.Nodes for _, n := range nodes { if n.Type == "workspace" { workspaceNodes = append(workspaceNodes, n) } } } var tasks []task // find cons in workspaces recursively for _, w := range workspaceNodes { wsNum := workspaceNum(workspaces, w.Name) descendants = nil for _, con := range w.Nodes { findDescendants(*con) } // create tasks from cons which represent tasks for _, con := range descendants { t, err := createTask(con, wsNum) if err == nil { tasks = append(tasks, *t) } else { log.Warn(err) } } fNodes := w.FloatingNodes for _, con := range fNodes { t, err := createTask(*con, wsNum) if err == nil { tasks = append(tasks, *t) } else { log.Warn(err) } } } sort.Slice(tasks, func(i int, j int) bool { return tasks[i].WsNum < tasks[j].WsNum }) return tasks, nil } func findDescendants(con sway.Node) { if len(con.Nodes) > 0 { for _, node := range con.Nodes { findDescendants(*node) } } else { descendants = append(descendants, con) } } func createTask(con sway.Node, wsNum int64) (*task, error) { t := &task{} t.conID = con.ID if con.AppID != nil { t.ID = *con.AppID } else if con.WindowProperties != nil { wp := *con.WindowProperties t.ID = wp.Class } else { return nil, errors.New("damaged Node data received, task skipped") } t.Name = con.Name if con.PID != nil { t.PID = *con.PID } else { return nil, errors.New("damaged Node data received, task skipped") } t.WsNum = wsNum return t, nil } func workspaceNum(workspaces []sway.Workspace, name string) int64 { for _, ws := range workspaces { if ws.Name == name { return ws.Num } } return 0 } func pinnedButton(ID string) *gtk.Box { box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) button, _ := gtk.ButtonNew() box.PackStart(button, false, false, 0) image, err := createImage(ID, imgSizeScaled) if err != nil { pixbuf, err := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/icon-missing.svg"), imgSizeScaled, imgSizeScaled) if err == nil { image, _ = gtk.ImageNewFromPixbuf(pixbuf) } else { image, _ = gtk.ImageNew() } } if image != nil { button.SetImage(image) button.SetImagePosition(gtk.POS_TOP) button.SetAlwaysShowImage(true) } button.SetTooltipText(getName(ID)) pixbuf, _ := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/task-empty.svg"), imgSizeScaled, imgSizeScaled/8) img, _ := gtk.ImageNewFromPixbuf(pixbuf) box.PackStart(img, false, false, 0) button.Connect("clicked", func() { launch(ID) }) button.Connect("button-release-event", func(btn *gtk.Button, e *gdk.Event) bool { btnEvent := gdk.EventButtonNewFromEvent(e) if btnEvent.Button() == 1 { launch(ID) return true } else if btnEvent.Button() == 3 { contextMenu := pinnedMenuContext(ID) contextMenu.PopupAtWidget(button, widgetAnchor, menuAnchor, nil) return true } return false }) button.Connect("enter-notify-event", cancelClose) return box } func pinnedMenuContext(taskID string) gtk.Menu { menu, _ := gtk.MenuNew() menuItem, _ := gtk.MenuItemNewWithLabel("Unpin") menuItem.Connect("activate", func() { unpinTask(taskID) }) menu.Append(menuItem) menu.ShowAll() return *menu } func launcherButton() *gtk.Button { if !*noLauncher && *launcherCmd != "" { button, _ := gtk.ButtonNew() pixbuf, err := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/grid.svg"), imgSizeScaled, imgSizeScaled) if err == nil { image, _ := gtk.ImageNewFromPixbuf(pixbuf) button.SetImage(image) button.SetAlwaysShowImage(true) button.Connect("clicked", func() { elements := strings.Split(*launcherCmd, " ") cmd := exec.Command(elements[0], elements[1:]...) go func() { err := cmd.Run() if err != nil { log.Warnf("Unable to start program: %s", err.Error()) } }() if *autohide { win.Hide() } }) button.Connect("enter-notify-event", cancelClose) } else { log.Errorf("Unable to show grid button: %s", err.Error()) } return button } return nil } /* Window on-leave-notify event hides the dock with glib Timeout 1000 ms. We might have left the window by accident, so let's clear the timeout if window re-entered. Furthermore - hovering a button triggers window on-leave-notify event, and the timeout needs to be cleared as well. */ func cancelClose() { if src > 0 { glib.SourceRemove(src) src = 0 } } func taskButton(t task, instances []task) *gtk.Box { box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) button, _ := gtk.ButtonNew() box.PackStart(button, false, false, 0) image, err := createImage(t.ID, imgSizeScaled) if err != nil { pixbuf, err := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/icon-missing.svg"), imgSizeScaled, imgSizeScaled) if err == nil { image, _ = gtk.ImageNewFromPixbuf(pixbuf) } else { image, _ = gtk.ImageNew() } } if image != nil { button.SetImage(image) button.SetImagePosition(gtk.POS_TOP) button.SetAlwaysShowImage(true) } button.SetTooltipText(getName(t.ID)) var img *gtk.Image if len(instances) < 2 { pixbuf, _ := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/task-single.svg"), imgSizeScaled, imgSizeScaled/8) img, _ = gtk.ImageNewFromPixbuf(pixbuf) } else { pixbuf, _ := gdk.PixbufNewFromFileAtSize(filepath.Join(dataHome, "nwg-dock/images/task-multiple.svg"), imgSizeScaled, imgSizeScaled/8) img, _ = gtk.ImageNewFromPixbuf(pixbuf) } box.PackStart(img, false, false, 0) button.Connect("enter-notify-event", cancelClose) if len(instances) == 1 { button.Connect("event", func(btn *gtk.Button, e *gdk.Event) bool { btnEvent := gdk.EventButtonNewFromEvent(e) /* EVENT_BUTTON_PRESS would be more obvious, but it causes the misbehaviour: if con is located on an external display, after pressing the button, the conID value "freezes", and stays the same for all taskButtons, until the right mouse click. A gotk3 bug or WTF? */ if btnEvent.Type() == gdk.EVENT_BUTTON_RELEASE || btnEvent.Type() == gdk.EVENT_TOUCH_END { if btnEvent.Button() == 1 || btnEvent.Type() == gdk.EVENT_TOUCH_END { focusCon(t.conID) return true } else if btnEvent.Button() == 3 { contextMenu := taskMenuContext(t.ID, instances) contextMenu.PopupAtWidget(button, widgetAnchor, menuAnchor, nil) return true } } return false }) } else { button.Connect("button-release-event", func(btn *gtk.Button, e *gdk.Event) bool { btnEvent := gdk.EventButtonNewFromEvent(e) if btnEvent.Button() == 1 { menu := taskMenu(t.ID, instances) menu.PopupAtWidget(button, widgetAnchor, menuAnchor, nil) return true } else if btnEvent.Button() == 3 { contextMenu := taskMenuContext(t.ID, instances) contextMenu.PopupAtWidget(button, widgetAnchor, menuAnchor, nil) return true } return false }) } return box } func taskMenu(taskID string, instances []task) gtk.Menu { menu, _ := gtk.MenuNew() iconName, _ := getIcon(taskID) for _, instance := range instances { menuItem, _ := gtk.MenuItemNew() hbox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) image, _ := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_MENU) hbox.PackStart(image, false, false, 0) title := instance.Name if len(title) > 20 { title = title[:20] } label, _ := gtk.LabelNew(fmt.Sprintf("%s (%v)", title, instance.WsNum)) hbox.PackStart(label, false, false, 0) menuItem.Add(hbox) menu.Append(menuItem) conID := instance.conID menuItem.Connect("activate", func() { focusCon(conID) }) } menu.ShowAll() return *menu } func taskMenuContext(taskID string, instances []task) gtk.Menu { menu, _ := gtk.MenuNew() iconName, err := getIcon(taskID) if err != nil { log.Warnf("%s %s", err, taskID) } for _, instance := range instances { menuItem, _ := gtk.MenuItemNew() hbox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) image, _ := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_MENU) hbox.PackStart(image, false, false, 0) title := instance.Name if len(title) > 20 { title = title[:20] } label, _ := gtk.LabelNew(fmt.Sprintf("%s (%v)", title, instance.WsNum)) hbox.PackStart(label, false, false, 0) menuItem.Add(hbox) menu.Append(menuItem) submenu, _ := gtk.MenuNew() subitem, _ := gtk.MenuItemNewWithLabel("Close") submenu.Append(subitem) conID := instance.conID subitem.Connect("activate", func() { killCon(conID) }) for i := 1; i < int(*numWS)+1; i++ { subitem, _ := gtk.MenuItemNewWithLabel(fmt.Sprintf("To WS %v", i)) target := i subitem.Connect("activate", func() { con2WS(conID, target) }) submenu.Append(subitem) } menuItem.SetSubmenu(submenu) } separator, _ := gtk.SeparatorMenuItemNew() menu.Append(separator) item, _ := gtk.MenuItemNewWithLabel("New window") item.Connect("activate", func() { launch(taskID) }) menu.Append(item) pinItem, _ := gtk.MenuItemNew() if !inPinned(taskID) { pinItem.SetLabel("Pin") pinItem.Connect("activate", func() { log.Infof("pin %s", taskID) pinTask(taskID) }) } else { pinItem.SetLabel("Unpin") pinItem.Connect("activate", func() { log.Infof("unpin %s", taskID) unpinTask(taskID) }) } menu.Append(pinItem) menu.ShowAll() return *menu } func inPinned(taskID string) bool { for _, id := range pinned { if strings.TrimSpace(taskID) == strings.TrimSpace(id) { return true } } return false } func inTasks(tasks []task, pinID string) bool { for _, task := range tasks { if strings.TrimSpace(task.ID) == strings.TrimSpace(pinID) { return true } } return false } func createImage(appID string, size int) (*gtk.Image, error) { name, err := getIcon(appID) if err != nil { name = appID } pixbuf, err := createPixbuf(name, size) if err != nil { return nil, err } image, _ := gtk.ImageNewFromPixbuf(pixbuf) return image, nil } func createPixbuf(icon string, size int) (*gdk.Pixbuf, error) { if strings.HasPrefix(icon, "/") { pixbuf, err := gdk.PixbufNewFromFileAtSize(icon, size, size) if err != nil { log.Errorf("%s", err) return nil, err } return pixbuf, nil } iconTheme, err := gtk.IconThemeGetDefault() if err != nil { log.Fatal("Couldn't get default theme: ", err) } pixbuf, err := iconTheme.LoadIcon(icon, size, gtk.ICON_LOOKUP_FORCE_SIZE) if err != nil { ico, err := getIcon(icon) if err != nil { return nil, err } if strings.HasPrefix(ico, "/") { pixbuf, err := gdk.PixbufNewFromFileAtSize(ico, size, size) if err != nil { return nil, err } return pixbuf, nil } pixbuf, err := iconTheme.LoadIcon(ico, size, gtk.ICON_LOOKUP_FORCE_SIZE) if err != nil { return nil, err } return pixbuf, nil } return pixbuf, nil } func cacheDir() string { if os.Getenv("XDG_CACHE_HOME") != "" { return os.Getenv("XDG_CONFIG_HOME") } if os.Getenv("HOME") != "" && pathExists(filepath.Join(os.Getenv("HOME"), ".cache")) { p := filepath.Join(os.Getenv("HOME"), ".cache") return p } return "" } func tempDir() string { if os.Getenv("TMPDIR") != "" { return os.Getenv("TMPDIR") } else if os.Getenv("TEMP") != "" { return os.Getenv("TEMP") } else if os.Getenv("TMP") != "" { return os.Getenv("TMP") } return "/tmp" } func readTextFile(path string) (string, error) { bytes, err := os.ReadFile(path) if err != nil { return "", err } return string(bytes), nil } func configDir() string { if os.Getenv("XDG_CONFIG_HOME") != "" { return (fmt.Sprintf("%s/nwg-dock", os.Getenv("XDG_CONFIG_HOME"))) } return fmt.Sprintf("%s/.config/nwg-dock", os.Getenv("HOME")) } func createDir(dir string) { if _, err := os.Stat(dir); os.IsNotExist(err) { err := os.MkdirAll(dir, os.ModePerm) if err == nil { log.Infof("Creating dir: %s", dir) } } } func copyFile(src, dst string) error { log.Infof("Copying file: %s", dst) var err error var srcfd *os.File var dstfd *os.File var srcinfo os.FileInfo if srcfd, err = os.Open(src); err != nil { return err } defer srcfd.Close() if dstfd, err = os.Create(dst); err != nil { return err } defer dstfd.Close() if _, err = io.Copy(dstfd, srcfd); err != nil { return err } if srcinfo, err = os.Stat(src); err != nil { return err } return os.Chmod(dst, srcinfo.Mode()) } func getDataHome() string { if os.Getenv("XDG_DATA_HOME") != "" { return os.Getenv("XDG_DATA_HOME") } return "/usr/share/" } func getAppDirs() []string { var dirs []string xdgDataDirs := "" home := os.Getenv("HOME") xdgDataHome := os.Getenv("XDG_DATA_HOME") if os.Getenv("XDG_DATA_DIRS") != "" { xdgDataDirs = os.Getenv("XDG_DATA_DIRS") } else { xdgDataDirs = "/usr/local/share/:/usr/share/" } if xdgDataHome != "" { dirs = append(dirs, filepath.Join(xdgDataHome, "applications")) } else if home != "" { dirs = append(dirs, filepath.Join(home, ".local/share/applications")) } for _, d := range strings.Split(xdgDataDirs, ":") { dirs = append(dirs, filepath.Join(d, "applications")) } flatpakDirs := []string{filepath.Join(home, ".local/share/flatpak/exports/share/applications"), "/var/lib/flatpak/exports/share/applications"} for _, d := range flatpakDirs { if !isIn(dirs, d) { dirs = append(dirs, d) } } return dirs } func isIn(slice []string, val string) bool { for _, item := range slice { if item == val { return true } } return false } func getIcon(appName string) (string, error) { appName = strings.Split(appName, " ")[0] if strings.HasPrefix(strings.ToUpper(appName), "GIMP") { return "gimp", nil } p := "" for _, d := range appDirs { path := filepath.Join(d, fmt.Sprintf("%s.desktop", appName)) if pathExists(path) { p = path break } else if pathExists(strings.ToLower(path)) { p = strings.ToLower(path) break } } /* Some apps' app_id varies from their .desktop file name, e.g. 'gimp-2.9.9' or 'pamac-manager'. Let's try to find a matching .desktop file name */ if !strings.HasPrefix(appName, "/") && p == "" { // skip icon paths given instead of names p = searchDesktopDirs(appName) } if p != "" { lines, err := loadTextFile(p) if err != nil { return "", err } for _, line := range lines { if strings.HasPrefix(strings.ToUpper(line), "ICON") { return strings.Split(line, "=")[1], nil } } } return "", errors.New(fmt.Sprintf("couldn't find the icon for %s", appName)) } func searchDesktopDirs(badAppID string) string { b4Separator := strings.Split(badAppID, "-")[0] for _, d := range appDirs { items, _ := os.ReadDir(d) for _, item := range items { if strings.Contains(item.Name(), b4Separator) { //Let's check items starting from 'org.' first if strings.Count(item.Name(), ".") > 1 && strings.HasSuffix(item.Name(), fmt.Sprintf("%s.desktop", badAppID)) { return filepath.Join(d, item.Name()) } } } } // exceptions like "class": "VirtualBox Manager" & virtualbox.desktop b4Separator = strings.Split(badAppID, " ")[0] for _, d := range appDirs { items, _ := os.ReadDir(d) for _, item := range items { if strings.Contains(strings.ToUpper(item.Name()), strings.ToUpper(b4Separator)) { return filepath.Join(d, item.Name()) } } } return "" } func getExec(appName string) (string, error) { cmd := appName if strings.HasPrefix(strings.ToUpper(appName), "GIMP") { cmd = "gimp" } for _, d := range appDirs { files, _ := os.ReadDir(d) path := "" for _, f := range files { if strings.HasSuffix(f.Name(), ".desktop") { if f.Name() == fmt.Sprintf("%s.desktop", appName) || f.Name() == fmt.Sprintf("%s.desktop", strings.ToLower(appName)) { path = filepath.Join(d, f.Name()) break } } } // as above in getIcon - for tasks w/ improper app_id if path == "" { path = searchDesktopDirs(appName) } if path != "" { lines, err := loadTextFile(path) if err != nil { return "", err } for _, line := range lines { if strings.HasPrefix(strings.ToUpper(line), "EXEC") { l := line[5:] cutAt := strings.Index(l, "%") if cutAt != -1 { l = l[:cutAt-1] } cmd = l break } } return cmd, nil } } return cmd, nil } func getName(appName string) string { name := appName for _, d := range appDirs { files, _ := os.ReadDir(d) path := "" for _, f := range files { if strings.HasSuffix(f.Name(), ".desktop") { if f.Name() == fmt.Sprintf("%s.desktop", appName) || f.Name() == fmt.Sprintf("%s.desktop", strings.ToLower(appName)) { path = filepath.Join(d, f.Name()) break } } } if path != "" { lines, err := loadTextFile(path) if err != nil { return name } for _, line := range lines { if strings.HasPrefix(strings.ToUpper(line), "NAME") { name = line[5:] break } } } } return name } func pathExists(name string) bool { if _, err := os.Stat(name); err != nil { if os.IsNotExist(err) { return false } } return true } func loadTextFile(path string) ([]string, error) { bytes, err := os.ReadFile(path) if err != nil { return nil, err } lines := strings.Split(string(bytes), "\n") var output []string for _, line := range lines { line = strings.TrimSpace(line) if line != "" { output = append(output, line) } } return output, nil } func pinTask(itemID string) { for _, item := range pinned { if item == itemID { println(item, "already pinned") return } } pinned = append(pinned, itemID) savePinned() refreshMainBoxChannel <- struct{}{} } func unpinTask(itemID string) { pinned = remove(pinned, itemID) savePinned() refreshMainBoxChannel <- struct{}{} } func remove(s []string, r string) []string { for i, v := range s { if v == r { return append(s[:i], s[i+1:]...) } } return s } func savePinned() { f, err := os.OpenFile(pinnedFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { log.Fatal(err) } defer f.Close() for _, line := range pinned { if line != "" { _, err := f.WriteString(line + "\n") if err != nil { log.Errorf("Error saving pinned", err) } } } } func launch(ID string) { command, err := getExec(ID) if err != nil { log.Errorf("%s", err) } // remove quotation marks if any if strings.Contains(command, "\"") { command = strings.ReplaceAll(command, "\"", "") } elements := strings.Split(command, " ") // find prepended env variables, if any envVarsNum := strings.Count(command, "=") var envVars []string cmdIdx := -1 if envVarsNum > 0 { for idx, item := range elements { if strings.Contains(item, "=") { envVars = append(envVars, item) } else if !strings.HasPrefix(item, "-") && cmdIdx == -1 { cmdIdx = idx } } } if cmdIdx == -1 { cmdIdx = 0 } var args []string for _, arg := range elements[1+cmdIdx:] { if !strings.Contains(arg, "=") { args = append(args, arg) } } cmd := exec.Command(elements[cmdIdx], elements[1+cmdIdx:]...) // set env variables if len(envVars) > 0 { cmd.Env = os.Environ() cmd.Env = append(cmd.Env, envVars...) } msg := fmt.Sprintf("env vars: %s; command: '%s'; args: %s\n", envVars, elements[cmdIdx], args) log.Info(msg) if err := cmd.Start(); err != nil { log.Error("Unable to launch command!", err.Error()) } if *autohide { win.Hide() } } func focusCon(conID int64) { cmd := fmt.Sprintf("[con_id=%v] focus", conID) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() client, err := sway.New(ctx) if err != nil { log.Panic(err) } if _, err = client.RunCommand(ctx, cmd); err != nil { log.Errorf("Unable to focus to con %v: %s", conID, err.Error()) } if *autohide { src = glib.TimeoutAdd(uint(1000), func() bool { win.Hide() return false }) } } func focusWorkspace(num int64) { cmd := fmt.Sprintf("workspace number %v", num) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() client, err := sway.New(ctx) if err != nil { log.Panic(err) } if _, err = client.RunCommand(ctx, cmd); err != nil { log.Errorf("Unable to focus to workspace %v: %s", num, err.Error()) } if *autohide { src = glib.TimeoutAdd(uint(1000), func() bool { win.Hide() return false }) } } func killCon(conID int64) { cmd := fmt.Sprintf("[con_id=%v] kill", conID) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() client, err := sway.New(ctx) if err != nil { log.Panic(err) } if _, err = client.RunCommand(ctx, cmd); err != nil { log.Errorf("Unable to kill con %v: %s", conID, err.Error()) } if *autohide { src = glib.TimeoutAdd(uint(1000), func() bool { win.Hide() return false }) } } func con2WS(conID int64, wsNum int) { cmd := fmt.Sprintf("[con_id=%v] move to workspace number %v", conID, wsNum) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() client, err := sway.New(ctx) if err != nil { log.Panic(err) } if _, err = client.RunCommand(ctx, cmd); err != nil { log.Errorf("Unable to move to workspace %v: %s", wsNum, err.Error()) } refreshMainBoxChannel <- struct{}{} if *autohide { src = glib.TimeoutAdd(uint(1000), func() bool { win.Hide() return false }) } } // Returns map output name -> gdk.Monitor func mapOutputs() (map[string]*gdk.Monitor, error) { result := make(map[string]*gdk.Monitor) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() client, err := sway.New(ctx) if err != nil { return nil, err } outputs, err := client.GetOutputs(ctx) if err != nil { return nil, err } display, err := gdk.DisplayGetDefault() if err != nil { return nil, err } num := display.GetNMonitors() for i := 0; i < num; i++ { monitor, _ := display.GetMonitor(i) geometry := monitor.GetGeometry() // assign output to monitor on the basis of the same x, y coordinates for _, output := range outputs { if int(output.Rect.X) == geometry.GetX() && int(output.Rect.Y) == geometry.GetY() { result[output.Name] = monitor } } } return result, nil } func listMonitors() ([]gdk.Monitor, error) { var monitors []gdk.Monitor display, err := gdk.DisplayGetDefault() if err != nil { return nil, err } num := display.GetNMonitors() for i := 0; i < num; i++ { monitor, _ := display.GetMonitor(i) monitors = append(monitors, *monitor) } return monitors, nil } // Returns output of a CLI command with optional arguments func getCommandOutput(command string) string { out, err := exec.Command("sh", "-c", command).Output() if err != nil { return "" } return strings.TrimSpace(string(out)) } func isCommand(command string) bool { cmd := strings.Fields(command)[0] return getCommandOutput(fmt.Sprintf("command -v %s ", cmd)) != "" } func md5Hash(text string) string { hash := md5.Sum([]byte(text)) return hex.EncodeToString(hash[:]) }