diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..395ca0f --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +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 + +build: + go build -o bin/nwg-menu *.go + +install: + mkdir -p /usr/share/nwg-menu + cp -r desktop-directories /usr/share/nwg-menu + cp menu-start.css /usr/share/nwg-menu + cp bin/nwg-menu /usr/bin + +uninstall: + rm /usr/bin/nwg-menu + +run: + go run *.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..33bca97 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/nwg-piotr/nwg-menu + +go 1.16 + +require ( + github.com/allan-simon/go-singleinstance v0.0.0-20210120080615-d0997106ab37 + github.com/dlasky/gotk3-layershell v0.0.0-20210331230524-5cca0b819261 + github.com/gotk3/gotk3 v0.5.3-0.20210223154815-289cfb6dbf32 + github.com/joshuarubin/go-sway v0.0.4 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2ba66ba --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.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 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlasky/gotk3-layershell v0.0.0-20210331230524-5cca0b819261 h1:eoXn91ckLWKMXmQKX34UHEF2XMyQpRnnP80fDiu+kys= +github.com/dlasky/gotk3-layershell v0.0.0-20210331230524-5cca0b819261/go.mod h1:d56Gslp3IaiT8lqxD/lO1Msz1wYgD8D/HTKHgSdg9tU= +github.com/gotk3/gotk3 v0.5.3-0.20210223154815-289cfb6dbf32 h1:wE6C/HgLUBHi8YhHlCEulrmQMntVl4PFdh3kA0sWyAY= +github.com/gotk3/gotk3 v0.5.3-0.20210223154815-289cfb6dbf32/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= +github.com/joshuarubin/go-sway v0.0.3 h1:uuY+dAMz+iAJvso+DP7TSRczDWhaV47nEPHJoRDOqjA= +github.com/joshuarubin/go-sway v0.0.3/go.mod h1:qcDd6f25vJ0++wICwA1BainIcRC67p2Mb4lsrZ0k3/k= +github.com/joshuarubin/go-sway v0.0.4 h1:dpmIwQ/LytG+oMrjmaVKdk1aPdW2feXK/+wAcLKIx4A= +github.com/joshuarubin/go-sway v0.0.4/go.mod h1:qcDd6f25vJ0++wICwA1BainIcRC67p2Mb4lsrZ0k3/k= +github.com/joshuarubin/lifecycle v1.0.0 h1:N/lPEC8f+dBZ1Tn99vShqp36LwB+LI7XNAiNadZeLUQ= +github.com/joshuarubin/lifecycle v1.0.0/go.mod h1:sRy++ATvR9Ee21tkRdFkQeywAWvDsue66V70K0Dnl54= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +go.uber.org/atomic v1.3.2 h1:2Oa65PReHzfn29GpvgsYwloV9AVFHPDk8tYxt2c2tr4= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84 h1:IqXQ59gzdXv58Jmm2xn0tSOR9i6HqroaOFRQ3wR/dJQ= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..126e04e --- /dev/null +++ b/main.go @@ -0,0 +1,366 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "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.0.3" + +var ( + appDirs []string + configDirectory string + pinnedFile string + pinned []string + leftBox *gtk.Box + rightBox *gtk.Box + src glib.SourceHandle + imgSizeScaled int + currentWsNum, targetWsNum int64 + win *gtk.Window + id2entry map[string]desktopEntry +) + +var categoryNames = [...]string{ + "utility", + "development", + "game", + "graphics", + "internet-and-network", + "office", + "audio-video", + "system-tools", + "other", +} + +type category struct { + Name string + DisplayName string + Icon string +} + +var categories []category + +type desktopEntry struct { + DesktopID string + Name string + NameLoc string + Comment string + CommentLoc string + Icon string + Exec string + Terminal bool + NoDisplay bool +} + +// slices below will hold DesktopID strings +var ( + listUtility []string + listDevelopment []string + listGame []string + listGraphics []string + listInternetAndNetwork []string + listOffice []string + listAudioVideo []string + listSystemTools []string + listOther []string +) + +var desktopEntries []desktopEntry + +// UI elements +var ( + categoriesListBox *gtk.ListBox + userDirsListBox *gtk.ListBox + pinnedListBox *gtk.ListBox + resultWrapper *gtk.Box + resultWindow *gtk.ScrolledWindow + fileSearchResults map[string]string + fileSearchResultWindow *gtk.ScrolledWindow + backButton *gtk.Box + searchEntry *gtk.SearchEntry + phrase string + resultListBox *gtk.ListBox + fileSearchResultListBox *gtk.ListBox + buttonsWrapper *gtk.Box + buttonBox *gtk.EventBox + confirmationBox *gtk.Box + userDirsMap map[string]string +) + +// Flags +var cssFileName = flag.String("s", "menu-start.css", "Styling: css file name") +var targetOutput = flag.String("o", "", "name of the Output to display the menu on") +var displayVersion = flag.Bool("v", false, "display Version information") +var autohide = flag.Bool("d", false, "auto-hiDe: close window when left") +var valign = flag.String("va", "bottom", "Vertical Alignment: \"bottom\" or \"top\"") +var halign = flag.String("ha", "left", "Horizontal Alignment: \"left\" or \"right\"") +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 iconSizeLarge = flag.Int("isl", 32, "Icon Size Large") +var iconSizeSmall = flag.Int("iss", 16, "Icon Size Small") +var itemPadding = flag.Uint("padding", 2, "vertical item padding") +var lang = flag.String("lang", "", "force lang, e.g. \"en\", \"pl\"") +var fileManager = flag.String("fm", "thunar", "File Manager") +var term = flag.String("term", "alacritty", "Terminal emulator") +var windowWidth = flag.Int("width", 0, "window width") +var windowHeigth = flag.Int("height", 0, "window height") +var cmdLock = flag.String("cmd-lock", "swaylock -f -c 000000", "screen lock command") +var cmdLogout = flag.String("cmd-logout", "swaymsg exit", "logout command") +var cmdRestart = flag.String("cmd-restart", "systemctl reboot", "reboot command") +var cmdShutdown = flag.String("cmd-shutdown", "systemctl -i poweroff", "shutdown command") + +func main() { + timeStart := time.Now() + flag.Parse() + + if *displayVersion { + fmt.Printf("nwg-menu version %s\n", version) + os.Exit(0) + } + + // Gentle SIGTERM handler thanks to reiki4040 https://gist.github.com/reiki4040/be3705f307d3cd136e85 + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGTERM) + go func() { + for { + s := <-signalChan + if s == syscall.SIGTERM { + println("SIGTERM received, bye bye!") + gtk.MainQuit() + } + } + }() + + // We want the same key/mouse binding to turn the dock off: kill the running instance and exit. + lockFilePath := fmt.Sprintf("%s/nwg-menu.lock", tempDir()) + 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 { + println("Running instance found, sending SIGTERM and exiting...") + syscall.Kill(i, syscall.SIGTERM) + } else { + println("Already running") + }*/ + println("Running instance found, sending SIGTERM and exiting...") + syscall.Kill(i, syscall.SIGTERM) + } + } + os.Exit(0) + } + defer lockFile.Close() + + // LANGUAGE + if *lang == "" && os.Getenv("LANG") != "" { + *lang = strings.Split(os.Getenv("LANG"), ".")[0] + } + println(fmt.Sprintf("lang: %s", *lang)) + + // ENVIRONMENT + configDirectory = configDir() + + if !pathExists(filepath.Join(configDirectory, "menu-start.css")) { + copyFile("/usr/share/nwg-menu/menu-start.css", filepath.Join(configDirectory, "menu-start.css")) + } + + cacheDirectory := cacheDir() + if cacheDirectory == "" { + log.Panic("Couldn't determine cache directory location") + } + + // DATA + pinnedFile = filepath.Join(cacheDirectory, "nwg-pin-cache") + pinned, err = loadTextFile(pinnedFile) + if err != nil { + pinned = nil + } + + cssFile := filepath.Join(configDirectory, *cssFileName) + + appDirs = getAppDirs() + + setUpCategories() + + desktopFiles := listDesktopFiles() + println(fmt.Sprintf("Found %v desktop files", len(desktopFiles))) + + parseDesktopFiles(desktopFiles) + + // USER INTERFACE + gtk.Init(nil) + + cssProvider, _ := gtk.CssProviderNew() + + err = cssProvider.LoadFromPath(cssFile) + if err != nil { + println(fmt.Sprintf("ERROR: %s css file not found or erroneous. Using GTK styling.", cssFile)) + println(fmt.Sprintf(">>> %s", err)) + } else { + println(fmt.Sprintf("Using style from %s", 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 { + monitor := output2mon[*targetOutput] + layershell.SetMonitor(win, monitor) + + } else { + println(err) + } + } + + if *valign == "bottom" { + layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_BOTTOM, true) + } else { + layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_TOP, true) + } + + if *halign == "left" { + layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_LEFT, true) + } else { + layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_RIGHT, true) + } + + 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.SetKeyboardMode(win, layershell.LAYER_SHELL_KEYBOARD_MODE_EXCLUSIVE) + + win.Connect("destroy", func() { + gtk.MainQuit() + }) + + win.Connect("key-release-event", func(window *gtk.Window, event *gdk.Event) { + key := &gdk.EventKey{Event: event} + if key.KeyVal() == gdk.KEY_Escape { + s, _ := searchEntry.GetText() + if s != "" { + clearSearchResult() + searchEntry.GrabFocus() + searchEntry.SetText("") + } else { + if resultWindow == nil || !resultWindow.GetVisible() { + gtk.MainQuit() + } else { + clearSearchResult() + } + } + } + }) + + // Close the window on leave, but not immediately, to avoid accidental closes + win.Connect("leave-notify-event", func() { + if *autohide { + src, err = glib.TimeoutAdd(uint(1000), func() bool { + gtk.MainQuit() + return false + }) + } + }) + + win.Connect("enter-notify-event", func() { + cancelClose() + }) + + outerBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + win.Add(outerBox) + + alignmentBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + //alignmentBox.SetHomogeneous(true) + outerBox.PackStart(alignmentBox, true, true, 0) + + leftBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + alignmentBox.PackStart(leftBox, false, false, 10) + + leftColumn, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + leftBox.PackStart(leftColumn, false, false, 0) + + searchEntry = setUpSearchEntry() + if *valign == "top" { + leftColumn.PackStart(searchEntry, false, false, 10) + } + + pinnedListBox = setUpPinnedListBox() + leftColumn.PackStart(pinnedListBox, false, false, 10) + + /*sep, _ := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL) + leftColumn.PackStart(sep, false, false, 10)*/ + + categoriesListBox = setUpCategoriesListBox() + leftColumn.PackStart(categoriesListBox, false, false, 10) + + if *valign != "top" { + leftColumn.PackEnd(searchEntry, false, false, 10) + } + + rightBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + alignmentBox.PackStart(rightBox, true, true, 10) + + rightColumn, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + + rightBox.PackStart(rightColumn, true, true, 0) + + userDirsListBox = setUpUserDirsList() + rightColumn.PackStart(userDirsListBox, false, true, 10) + + backButton = setUpBackButton() + rightColumn.PackStart(backButton, false, false, 10) + + resultWrapper, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + rightColumn.PackStart(resultWrapper, true, true, 0) + + buttonsWrapper, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + + buttonBox = setUpButtonBox() + buttonsWrapper.PackStart(buttonBox, false, false, 10) + rightColumn.PackEnd(buttonsWrapper, false, true, 0) + + //win.SetSizeRequest(0, *windowHeigth) + + win.ShowAll() + + backButton.Hide() + + pinnedListBox.UnselectAll() + categoriesListBox.UnselectAll() + searchEntry.GrabFocus() + t := time.Now() + println(fmt.Sprintf("UI created in %v ms. Thanks for watching.", t.Sub(timeStart).Milliseconds())) + gtk.Main() +} diff --git a/menu-start.css b/menu-start.css new file mode 100644 index 0000000..874514e --- /dev/null +++ b/menu-start.css @@ -0,0 +1,22 @@ +window { + background-color: rgba (36, 47, 79, 0.92); + color: #eeeeee +} + +list { + background: none; + border-radius: 15px +} + +entry { + background-color: rgba (0, 0, 0, 0.2) +} + +button { + background: none; + border: none +} + +button:hover { + background-color: rgba (255, 255, 255, 0.1) +} \ No newline at end of file diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..88b14a9 --- /dev/null +++ b/tools.go @@ -0,0 +1,683 @@ +package main + +import ( + "context" + "fmt" + "io" + "io/fs" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/glib" + "github.com/gotk3/gotk3/gtk" + "github.com/joshuarubin/go-sway" +) + +/* +Window on-leave-notify event hides the window 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 widget 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 inPinned(taskID string) bool { + for _, id := range pinned { + if strings.TrimSpace(taskID) == strings.TrimSpace(id) { + return true + } + } + return false +} + +func createPixbuf(icon string, size int) (*gdk.Pixbuf, error) { + iconTheme, err := gtk.IconThemeGetDefault() + if err != nil { + log.Fatal("Couldn't get default theme: ", err) + } + + if strings.Contains(icon, "/") { + pixbuf, err := gdk.PixbufNewFromFileAtSize(icon, size, size) + if err != nil { + println(err) + return nil, err + } + return pixbuf, nil + + } else if strings.HasSuffix(icon, ".svg") || strings.HasSuffix(icon, ".png") || strings.HasSuffix(icon, ".xpm") { + // for enties like "Icon=netflix-desktop.svg" + icon = strings.Split(icon, ".")[0] + } + + pixbuf, err := iconTheme.LoadIcon(icon, size, gtk.ICON_LOOKUP_FORCE_SIZE) + + if err != nil { + if strings.HasPrefix(icon, "/") { + pixbuf, err := gdk.PixbufNewFromFileAtSize(icon, size, size) + if err != nil { + return nil, err + } + return pixbuf, nil + } + + pixbuf, err := iconTheme.LoadIcon(icon, size, gtk.ICON_LOOKUP_FORCE_SIZE) + if err != nil { + return nil, err + } + return pixbuf, nil + } + return pixbuf, nil +} + +func mapXdgUserDirs() map[string]string { + result := make(map[string]string) + home := os.Getenv("HOME") + + result["home"] = home + result["documents"] = filepath.Join(home, "Documents") + result["downloads"] = filepath.Join(home, "Downloads") + result["music"] = filepath.Join(home, "Music") + result["pictures"] = filepath.Join(home, "Pictures") + result["videos"] = filepath.Join(home, "Videos") + + userDirsFile := filepath.Join(home, ".config/user-dirs.dirs") + if pathExists(userDirsFile) { + println(fmt.Sprintf("Using XDG user dirs from %s", userDirsFile)) + lines, _ := loadTextFile(userDirsFile) + for _, l := range lines { + if strings.HasPrefix(l, "XDG_DOCUMENTS_DIR") { + result["documents"] = getUserDir(home, l) + continue + } + if strings.HasPrefix(l, "XDG_DOWNLOAD_DIR") { + result["downloads"] = getUserDir(home, l) + continue + } + if strings.HasPrefix(l, "XDG_MUSIC_DIR") { + result["music"] = getUserDir(home, l) + continue + } + if strings.HasPrefix(l, "XDG_PICTURES_DIR") { + result["pictures"] = getUserDir(home, l) + continue + } + if strings.HasPrefix(l, "XDG_VIDEOS_DIR") { + result["videos"] = getUserDir(home, l) + } + } + } else { + println(fmt.Sprintf("%s file not found, using defaults", userDirsFile)) + } + + return result +} + +func getUserDir(home, line string) string { + // line is supposed to look like XDG_DOCUMENTS_DIR="$HOME/Dokumenty" + result := strings.Split(line, "=")[1] + result = strings.Replace(result, "$HOME", home, 1) + + // trim "" + return result[1 : len(result)-1] +} + +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 := ioutil.ReadFile(path) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +func configDir() string { + if os.Getenv("XDG_CONFIG_HOME") != "" { + dir := fmt.Sprintf("%s/nwg-panel", os.Getenv("XDG_CONFIG_HOME")) + createDir(dir) + return (fmt.Sprintf("%s/nwg-panel", os.Getenv("XDG_CONFIG_HOME"))) + } + dir := fmt.Sprintf("%s/.config/nwg-panel", os.Getenv("HOME")) + createDir(dir) + return dir +} + +func createDir(dir string) { + if _, err := os.Stat(dir); os.IsNotExist(err) { + err := os.MkdirAll(dir, os.ModePerm) + if err == nil { + fmt.Println("Creating dir:", dir) + } + } +} + +func copyFile(src, dst string) error { + fmt.Println("Copying file:", 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 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 listFiles(dir string) ([]fs.FileInfo, error) { + files, err := ioutil.ReadDir(dir) + if err == nil { + return files, nil + } + return nil, err +} + +func listDesktopFiles() []string { + var paths []string + for _, dir := range appDirs { + dirs, err := listFiles(dir) + if err == nil { + for _, file := range dirs { + parts := strings.Split(file.Name(), ".") + if parts[len(parts)-1] == "desktop" { + paths = append(paths, filepath.Join(dir, file.Name())) + } + } + } + } + return paths +} + +func setUpCategories() { + path := "/usr/share/nwg-menu/desktop-directories" + var other category + + for _, cName := range categoryNames { + fileName := fmt.Sprintf("%s.directory", cName) + lines, err := loadTextFile(filepath.Join(path, fileName)) + if err == nil { + var cat category + cat.Name = cName + + name := "" + nameLoc := "" + icon := "" + + for _, l := range lines { + if strings.HasPrefix(l, "Name=") { + name = strings.Split(l, "=")[1] + continue + } + if strings.HasPrefix(l, fmt.Sprintf("Name[%s]=", strings.Split(*lang, "_")[0])) { + nameLoc = strings.Split(l, "=")[1] + continue + } + if strings.HasPrefix(l, "Icon=") { + icon = strings.Split(l, "=")[1] + continue + } + } + + if nameLoc == "" { + for _, l := range lines { + if strings.HasPrefix(l, fmt.Sprintf("Name[%s]=", *lang)) { + nameLoc = strings.Split(l, "=")[1] + break + } + } + } + if nameLoc != "" { + cat.DisplayName = nameLoc + } else { + cat.DisplayName = name + } + cat.Icon = icon + + // We want "other" to be the last one. Let's append it when already sorted + if fileName != "other.directory" { + categories = append(categories, cat) + } else { + other = cat + } + } + } + sort.Slice(categories, func(i, j int) bool { + return categories[i].DisplayName < categories[j].DisplayName + }) + categories = append(categories, other) +} + +func parseDesktopFiles(desktopFiles []string) { + id2entry = make(map[string]desktopEntry) + var added []string + skipped := 0 + hidden := 0 + for _, file := range desktopFiles { + lines, err := loadTextFile(file) + if err == nil { + parts := strings.Split(file, "/") + desktopID := parts[len(parts)-1] + name := "" + nameLoc := "" + comment := "" + commentLoc := "" + icon := "" + exec := "" + terminal := false + noDisplay := false + + categories := "" + + for _, l := range lines { + if strings.HasPrefix(l, "[") && l != "[Desktop Entry]" { + break + } + if strings.HasPrefix(l, "Name=") { + name = strings.Split(l, "=")[1] + continue + } + if strings.HasPrefix(l, fmt.Sprintf("Name[%s]=", strings.Split(*lang, "_")[0])) { + nameLoc = strings.Split(l, "=")[1] + continue + } + if strings.HasPrefix(l, "Comment=") { + comment = strings.Split(l, "=")[1] + continue + } + if strings.HasPrefix(l, fmt.Sprintf("Comment[%s]=", strings.Split(*lang, "_")[0])) { + commentLoc = strings.Split(l, "=")[1] + continue + } + if strings.HasPrefix(l, "Icon=") { + icon = strings.Split(l, "=")[1] + continue + } + if strings.HasPrefix(l, "Exec=") { + exec = strings.Split(l, "Exec=")[1] + disallowed := [2]string{"\"", "'"} + for _, char := range disallowed { + if strings.Contains(exec, char) { + exec = strings.Replace(exec, char, "", -1) + } + } + continue + } + if strings.HasPrefix(l, "Categories=") { + categories = strings.Split(l, "Categories=")[1] + continue + } + if l == "Terminal=true" { + terminal = true + continue + } + if l == "NoDisplay=true" { + noDisplay = true + hidden++ + continue + } + } + + // if name[ln] not found, let's try to find name[ln_LN] + if nameLoc == "" { + nameLoc = name + } + if commentLoc == "" { + commentLoc = comment + } + + if !isIn(added, desktopID) { + added = append(added, desktopID) + + var entry desktopEntry + entry.DesktopID = desktopID + entry.Name = name + entry.NameLoc = nameLoc + entry.Comment = comment + entry.CommentLoc = commentLoc + entry.Icon = icon + entry.Exec = exec + entry.Terminal = terminal + entry.NoDisplay = noDisplay + desktopEntries = append(desktopEntries, entry) + + id2entry[entry.DesktopID] = entry + + assignToLists(entry.DesktopID, categories) + + } else { + skipped++ + } + } + } + println(fmt.Sprintf("Skipped %v duplicates; %v .desktop entries hidden by \"NoDisplay=true\"", skipped, hidden)) +} + +// freedesktop Main Categories list consists of 13 entries. Let's contract it to 8+1 ("Other"). +func assignToLists(desktopID, categories string) { + cats := strings.Split(categories, ";") + assigned := false + for _, cat := range cats { + if cat == "Utility" && !isIn(listUtility, desktopID) { + listUtility = append(listUtility, desktopID) + assigned = true + continue + } + if cat == "Development" && !isIn(listDevelopment, desktopID) { + listDevelopment = append(listDevelopment, desktopID) + assigned = true + continue + } + if cat == "Game" && !isIn(listGame, desktopID) { + listGame = append(listGame, desktopID) + assigned = true + continue + } + if cat == "Graphics" && !isIn(listGraphics, desktopID) { + listGraphics = append(listGraphics, desktopID) + assigned = true + continue + } + if cat == "Network" && !isIn(listInternetAndNetwork, desktopID) { + listInternetAndNetwork = append(listInternetAndNetwork, desktopID) + assigned = true + continue + } + if isIn([]string{"Office", "Science", "Education"}, cat) && !isIn(listOffice, desktopID) { + listOffice = append(listOffice, desktopID) + assigned = true + continue + } + if isIn([]string{"AudioVideo", "Audio", "Video"}, cat) && !isIn(listAudioVideo, desktopID) { + listAudioVideo = append(listAudioVideo, desktopID) + assigned = true + continue + } + if isIn([]string{"Settings", "System", "DesktopSettings", "PackageManager"}, cat) && !isIn(listSystemTools, desktopID) { + listSystemTools = append(listSystemTools, desktopID) + assigned = true + continue + } + } + if categories != "" && !assigned && !isIn(listOther, desktopID) { + listOther = append(listOther, desktopID) + } +} + +func isIn(slice []string, val string) bool { + for _, item := range slice { + if item == val { + return true + } + } + return false +} + +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 := ioutil.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 pinItem(itemID string) { + for _, item := range pinned { + if item == itemID { + println(item, "already pinned") + return + } + } + pinned = append(pinned, itemID) + savePinned() + println(itemID, "pinned") + + row := setUpPinnedListBoxRow(itemID) + pinnedListBox.Add(row) + pinnedListBox.ShowAll() +} + +func unpinItem(itemID string) { + if isIn(pinned, itemID) { + pinned = remove(pinned, itemID) + savePinned() + println(itemID, "unpinned") + } +} + +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 { + println("Error saving pinned", err) + } + } + } +} + +func launch(command string, terminal bool) { + // trim % and everything afterwards + if strings.Contains(command, "%") { + cutAt := strings.Index(command, "%") + if cutAt != -1 { + command = command[:cutAt-1] + } + } + + elements := strings.Split(command, " ") + + // find prepended env variables, if any + envVarsNum := strings.Count(command, "=") + var envVars []string + + cmdIdx := 0 + lastEnvVarIdx := 0 + + if envVarsNum > 0 { + for idx, item := range elements { + if strings.Contains(item, "=") { + lastEnvVarIdx = idx + envVars = append(envVars, item) + } + } + cmdIdx = lastEnvVarIdx + 1 + } + + cmd := exec.Command(elements[cmdIdx], elements[1+cmdIdx:]...) + + if terminal { + args := []string{"-e", elements[cmdIdx]} + cmd = exec.Command(*term, args...) + } + + // set env variables + if len(envVars) > 0 { + cmd.Env = os.Environ() + for _, envVar := range envVars { + cmd.Env = append(cmd.Env, envVar) + } + } + + msg := fmt.Sprintf("env vars: %s; command: '%s'; args: %s\n", envVars, elements[cmdIdx], elements[1+cmdIdx:]) + println(msg) + + go cmd.Run() + + glib.TimeoutAdd(uint(150), func() bool { + gtk.MainQuit() + return false + }) +} + +func open(filePath string) { + cmd := exec.Command(*fileManager, filePath) + go cmd.Run() + + glib.TimeoutAdd(uint(150), func() bool { + gtk.MainQuit() + 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 +} diff --git a/uicomponents.go b/uicomponents.go new file mode 100644 index 0000000..fcc7e0e --- /dev/null +++ b/uicomponents.go @@ -0,0 +1,682 @@ +package main + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" +) + +func setUpPinnedListBox() *gtk.ListBox { + listBox, _ := gtk.ListBoxNew() + + if len(pinned) > 0 { + for _, desktopID := range pinned { + row := setUpPinnedListBoxRow(desktopID) + listBox.Add(row) + } + } + + listBox.Connect("enter-notify-event", func() { + cancelClose() + restoreButtonBox() + }) + + return listBox +} + +func setUpPinnedListBoxRow(desktopID string) *gtk.ListBoxRow { + entry := id2entry[desktopID] + + row, _ := gtk.ListBoxRowNew() + row.SetSelectable(false) + row.SetCanFocus(false) + vBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + + // We need gtk.EventBox to detect mouse event + eventBox, _ := gtk.EventBoxNew() + hBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) + eventBox.Add(hBox) + vBox.PackStart(eventBox, false, false, *itemPadding) + + pixbuf, _ := createPixbuf(entry.Icon, *iconSizeLarge) + img, err := gtk.ImageNewFromPixbuf(pixbuf) + if err != nil { + println(err, entry.Icon) + } + hBox.PackStart(img, false, false, 0) + lbl, _ := gtk.LabelNew("") + name := "" + if entry.NameLoc != "" { + name = entry.NameLoc + } else { + name = entry.Name + } + if len(name) > 35 { + name = fmt.Sprintf("%s...", name[:32]) + } + lbl.SetText(name) + hBox.PackStart(lbl, false, false, 0) + row.Add(vBox) + + row.Connect("activate", func() { + launch(entry.Exec, entry.Terminal) + }) + + eventBox.Connect("button-release-event", func(row *gtk.ListBoxRow, e *gdk.Event) bool { + btnEvent := gdk.EventButtonNewFromEvent(e) + if btnEvent.Button() == 1 { + launch(entry.Exec, entry.Terminal) + return true + } else if btnEvent.Button() == 3 { + unpinItem(entry.DesktopID) + row.Destroy() + return true + } + return false + }) + + return row +} + +func setUpCategoriesListBox() *gtk.ListBox { + listBox, _ := gtk.ListBoxNew() + for _, cat := range categories { + if isSupposedToShowUp(cat.Name) { + row, _ := gtk.ListBoxRowNew() + row.SetCanFocus(false) + row.SetSelectable(false) + vBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + eventBox, _ := gtk.EventBoxNew() + hBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) + eventBox.Add(hBox) + vBox.PackStart(eventBox, false, false, *itemPadding) + + connectCategoryListBox(cat.Name, eventBox, row) + + pixbuf, _ := createPixbuf(cat.Icon, *iconSizeLarge) + img, _ := gtk.ImageNewFromPixbuf(pixbuf) + hBox.PackStart(img, false, false, 0) + + lbl, _ := gtk.LabelNew(cat.DisplayName) + hBox.PackStart(lbl, false, false, 0) + + pixbuf, _ = createPixbuf("pan-end-symbolic", *iconSizeSmall) + img, _ = gtk.ImageNewFromPixbuf(pixbuf) + hBox.PackEnd(img, false, false, 0) + + row.Add(vBox) + listBox.Add(row) + } + } + listBox.Connect("enter-notify-event", func() { + cancelClose() + restoreButtonBox() + }) + return listBox +} + +func isSupposedToShowUp(catName string) bool { + result := catName == "utility" && notEmpty(listUtility) || + catName == "development" && notEmpty(listDevelopment) || + catName == "game" && notEmpty(listGame) || + catName == "graphics" && notEmpty(listGraphics) || + catName == "internet-and-network" && notEmpty(listInternetAndNetwork) || + catName == "office" && notEmpty(listOffice) || + catName == "audio-video" && notEmpty(listAudioVideo) || + catName == "system-tools" && notEmpty(listSystemTools) || + catName == "other" && notEmpty(listOther) + + return result +} + +func notEmpty(listCategory []string) bool { + if len(listCategory) == 0 { + return false + } + for _, desktopID := range listCategory { + entry := id2entry[desktopID] + if entry.NoDisplay == false { + return true + } + } + return false +} + +func connectCategoryListBox(catName string, eventBox *gtk.EventBox, row *gtk.ListBoxRow) { + var listCategory []string + + switch catName { + case "utility": + listCategory = listUtility + case "development": + listCategory = listDevelopment + case "game": + listCategory = listGame + case "graphics": + listCategory = listGraphics + case "internet-and-network": + listCategory = listInternetAndNetwork + case "office": + listCategory = listOffice + case "audio-video": + listCategory = listAudioVideo + case "system-tools": + listCategory = listSystemTools + default: + listCategory = listOther + } + + eventBox.Connect("button-release-event", func(eb *gtk.EventBox, e *gdk.Event) bool { + btnEvent := gdk.EventButtonNewFromEvent(e) + if btnEvent.Button() == 1 { + searchEntry.SetText("") + clearSearchResult() + row.SetSelectable(true) + row.SetCanFocus(false) + categoriesListBox.SelectRow(row) + listBox := setUpCategoryListBox(listCategory) + if resultWindow != nil { + resultWindow.Destroy() + } + resultWindow, _ = gtk.ScrolledWindowNew(nil, nil) + resultWindow.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) + resultWindow.Connect("enter-notify-event", func() { + cancelClose() + }) + resultWrapper.PackStart(resultWindow, true, true, 0) + resultWindow.Add(listBox) + + userDirsListBox.Hide() + resultWindow.ShowAll() + + return true + } + return false + }) +} + +func setUpBackButton() *gtk.Box { + vBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + hBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 10) + vBox.PackStart(hBox, false, false, 0) + button, _ := gtk.ButtonNew() + button.SetCanFocus(false) + pixbuf, _ := createPixbuf("arrow-left", *iconSizeLarge) + image, _ := gtk.ImageNewFromPixbuf(pixbuf) + button.SetImage(image) + button.SetAlwaysShowImage(true) + button.Connect("enter-notify-event", func() { + cancelClose() + }) + button.Connect("clicked", func(btn *gtk.Button) { + clearSearchResult() + searchEntry.GrabFocus() + searchEntry.SetText("") + }) + hBox.PackEnd(button, false, true, 0) + + /*sep, _ := gtk.SeparatorNew(gtk.ORIENTATION_HORIZONTAL) + sep.SetCanFocus(false) + vBox.Add(sep)*/ + + return vBox +} + +func setUpCategoryListBox(listCategory []string) *gtk.ListBox { + listBox, _ := gtk.ListBoxNew() + + for _, desktopID := range listCategory { + entry := id2entry[desktopID] + name := entry.NameLoc + if name == "" { + name = entry.Name + } + if len(name) > 30 { + name = fmt.Sprintf("%s...", name[:27]) + } + if !entry.NoDisplay { + row, _ := gtk.ListBoxRowNew() + row.SetSelectable(false) + vBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 10) + eventBox, _ := gtk.EventBoxNew() + hBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) + eventBox.Add(hBox) + vBox.PackStart(eventBox, false, false, *itemPadding) + + ID := entry.DesktopID + eventBox.Connect("button-release-event", func(row *gtk.ListBoxRow, e *gdk.Event) bool { + btnEvent := gdk.EventButtonNewFromEvent(e) + if btnEvent.Button() == 1 { + launch(entry.Exec, entry.Terminal) + return true + } else if btnEvent.Button() == 3 { + pinItem(ID) + } + return false + }) + + pixbuf, _ := createPixbuf(entry.Icon, *iconSizeLarge) + img, _ := gtk.ImageNewFromPixbuf(pixbuf) + hBox.PackStart(img, false, false, 0) + + lbl, _ := gtk.LabelNew(name) + hBox.PackStart(lbl, false, false, 0) + + row.Add(vBox) + listBox.Add(row) + } + } + backButton.Show() + return listBox +} + +func setUpCategorySearchResult(searchPhrase string) *gtk.ListBox { + listBox, _ := gtk.ListBoxNew() + + resultWindow, _ = gtk.ScrolledWindowNew(nil, nil) + resultWindow.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) + resultWindow.Connect("enter-notify-event", func() { + cancelClose() + restoreButtonBox() + }) + resultWrapper.PackStart(resultWindow, true, true, 0) + + counter := 0 + for _, entry := range desktopEntries { + if len(searchPhrase) == 1 && counter > 9 { + break + } else if len(searchPhrase) == 2 && counter > 14 { + break + } + if !entry.NoDisplay && (strings.Contains(strings.ToLower(entry.NameLoc), strings.ToLower(searchPhrase)) || + strings.Contains(strings.ToLower(entry.CommentLoc), strings.ToLower(searchPhrase)) || + strings.Contains(strings.ToLower(entry.Comment), strings.ToLower(searchPhrase))) { + + counter++ + + row, _ := gtk.ListBoxRowNew() + row.SetSelectable(false) + vBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 10) + eventBox, _ := gtk.EventBoxNew() + hBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) + eventBox.Add(hBox) + vBox.PackStart(eventBox, false, false, *itemPadding) + + exec := entry.Exec + term := entry.Terminal + ID := entry.DesktopID + row.Connect("activate", func() { + launch(exec, term) + }) + eventBox.Connect("button-release-event", func(row *gtk.EventBox, e *gdk.Event) bool { + btnEvent := gdk.EventButtonNewFromEvent(e) + if btnEvent.Button() == 1 { + launch(exec, term) + return true + } else if btnEvent.Button() == 3 { + pinItem(ID) + } + return false + }) + + pixbuf, _ := createPixbuf(entry.Icon, *iconSizeLarge) + img, _ := gtk.ImageNewFromPixbuf(pixbuf) + hBox.PackStart(img, false, false, 0) + + name := entry.NameLoc + if len(name) > 45 { + name = fmt.Sprintf("%s...", name[:42]) + } + + lbl, _ := gtk.LabelNew(name) + hBox.PackStart(lbl, false, false, 0) + + row.Add(vBox) + listBox.Add(row) + + } + } + resultWindow.Add(listBox) + resultWindow.ShowAll() + return listBox +} + +func setUpFileSearchResult() *gtk.ListBox { + listBox, _ := gtk.ListBoxNew() + if fileSearchResultWindow != nil { + fileSearchResultWindow.Destroy() + } + fileSearchResultWindow, _ = gtk.ScrolledWindowNew(nil, nil) + fileSearchResultWindow.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) + fileSearchResultWindow.Connect("enter-notify-event", func() { + cancelClose() + restoreButtonBox() + }) + resultWrapper.PackStart(fileSearchResultWindow, true, true, 0) + + fileSearchResultWindow.Add(listBox) + fileSearchResultWindow.ShowAll() + + return listBox +} + +func walk(path string, d fs.DirEntry, e error) error { + if e != nil { + return e + } + if !d.IsDir() { + parts := strings.Split(path, "/") + fileName := parts[len(parts)-1] + if strings.Contains(strings.ToLower(fileName), strings.ToLower(phrase)) { + fileSearchResults[fileName] = path + } + } + return nil +} + +func setUpSearchEntry() *gtk.SearchEntry { + searchEntry, _ := gtk.SearchEntryNew() + searchEntry.Connect("enter-notify-event", func() { + cancelClose() + restoreButtonBox() + }) + searchEntry.Connect("search-changed", func() { + phrase, _ = searchEntry.GetText() + if len(phrase) > 0 { + userDirsListBox.Hide() + backButton.Show() + + if resultWindow != nil { + resultWindow.Destroy() + } + resultListBox = setUpCategorySearchResult(phrase) + if resultListBox.GetChildren().Length() == 0 { + resultWindow.Hide() + } + + if len(phrase) > 2 { + if fileSearchResultWindow != nil { + fileSearchResultWindow.Destroy() + } + fileSearchResultListBox = setUpFileSearchResult() + for key := range userDirsMap { + if key != "home" { + fileSearchResults = make(map[string]string) + if len(fileSearchResults) == 0 { + fileSearchResultListBox.Show() + } + filepath.WalkDir(userDirsMap[key], walk) + searchUserDir(key) + } + } + if fileSearchResultListBox.GetChildren().Length() == 0 { + fileSearchResultWindow.Hide() + } + } else { + if fileSearchResultWindow != nil { + fileSearchResultWindow.Destroy() + } + } + + } else { + clearSearchResult() + userDirsListBox.ShowAll() + } + + }) + searchEntry.Connect("focus-in-event", func() { + searchEntry.SetText("") + }) + + return searchEntry +} + +func searchUserDir(dir string) { + fileSearchResults = make(map[string]string) + filepath.WalkDir(userDirsMap[dir], walk) + if len(fileSearchResults) > 0 { + row := setUpUserDirsListRow(fmt.Sprintf("folder-%s", dir), "", dir, userDirsMap) + fileSearchResultListBox.Add(row) + fileSearchResultListBox.ShowAll() + + for fileName, path := range fileSearchResults { + row := setUpUserFileSearchResultRow(fileName, path) + fileSearchResultListBox.Add(row) + } + fileSearchResultListBox.ShowAll() + } +} + +func setUpUserDirsList() *gtk.ListBox { + listBox, _ := gtk.ListBoxNew() + userDirsMap = mapXdgUserDirs() + + row := setUpUserDirsListRow("folder-home", "Home", "home", userDirsMap) + listBox.Add(row) + row = setUpUserDirsListRow("folder-documents", "", "documents", userDirsMap) + listBox.Add(row) + row = setUpUserDirsListRow("folder-downloads", "", "downloads", userDirsMap) + listBox.Add(row) + row = setUpUserDirsListRow("folder-music", "", "music", userDirsMap) + listBox.Add(row) + row = setUpUserDirsListRow("folder-pictures", "", "pictures", userDirsMap) + listBox.Add(row) + row = setUpUserDirsListRow("folder-videos", "", "videos", userDirsMap) + listBox.Add(row) + + listBox.Connect("enter-notify-event", func() { + cancelClose() + restoreButtonBox() + }) + + return listBox +} + +func setUpUserDirsListRow(iconName, displayName, entryName string, userDirsMap map[string]string) *gtk.ListBoxRow { + if displayName == "" { + parts := strings.Split(userDirsMap[entryName], "/") + displayName = parts[(len(parts) - 1)] + } + row, _ := gtk.ListBoxRowNew() + //row.SetCanFocus(false) + row.SetSelectable(false) + vBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + eventBox, _ := gtk.EventBoxNew() + hBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) + eventBox.Add(hBox) + vBox.PackStart(eventBox, false, false, *itemPadding*3) + + img, _ := gtk.ImageNewFromIconName(iconName, gtk.ICON_SIZE_DND) + hBox.PackStart(img, false, false, 0) + + if len(displayName) > 45 { + displayName = fmt.Sprintf("%s...", displayName[:42]) + } + lbl, _ := gtk.LabelNew(displayName) + hBox.PackStart(lbl, false, false, 0) + row.Add(vBox) + + row.Connect("activate", func() { + launch(fmt.Sprintf("%s %s", *fileManager, userDirsMap[entryName]), false) + }) + + eventBox.Connect("button-release-event", func(row *gtk.ListBoxRow, e *gdk.Event) bool { + btnEvent := gdk.EventButtonNewFromEvent(e) + if btnEvent.Button() == 1 { + launch(fmt.Sprintf("%s %s", *fileManager, userDirsMap[entryName]), false) + return true + } + return false + }) + + return row +} + +func setUpUserFileSearchResultRow(fileName, filePath string) *gtk.ListBoxRow { + row, _ := gtk.ListBoxRowNew() + //row.SetCanFocus(false) + row.SetSelectable(false) + vBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + eventBox, _ := gtk.EventBoxNew() + hBox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + eventBox.Add(hBox) + vBox.PackStart(eventBox, false, false, *itemPadding) + + if len(fileName) > 45 { + fileName = fmt.Sprintf("%s...", fileName[:42]) + } + lbl, _ := gtk.LabelNew(fileName) + hBox.PackStart(lbl, false, false, 0) + row.Add(vBox) + + row.Connect("activate", func() { + open(filePath) + }) + + eventBox.Connect("button-release-event", func(row *gtk.ListBoxRow, e *gdk.Event) bool { + btnEvent := gdk.EventButtonNewFromEvent(e) + if btnEvent.Button() == 1 { + open(filePath) + return true + } + return false + }) + + return row +} + +func setUpButtonBox() *gtk.EventBox { + eventBox, _ := gtk.EventBoxNew() + wrapperHbox, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + wrapperHbox.PackStart(box, true, true, 10) + eventBox.Add(wrapperHbox) + + btn, _ := gtk.ButtonNew() + pixbuf, _ := createPixbuf("system-lock-screen", *iconSizeLarge) + img, _ := gtk.ImageNewFromPixbuf(pixbuf) + btn.SetImage(img) + btn.SetCanFocus(false) + box.PackStart(btn, true, true, 6) + btn.Connect("clicked", func() { + launch(*cmdLock, false) + //confirmationBox = setUpConfirmationBox("system-lock-screen", *cmdLock) + buttonBox.Hide() + }) + + btn, _ = gtk.ButtonNew() + pixbuf, _ = createPixbuf("system-log-out", *iconSizeLarge) + img, _ = gtk.ImageNewFromPixbuf(pixbuf) + btn.SetImage(img) + btn.SetCanFocus(false) + box.PackStart(btn, true, true, 6) + btn.Connect("clicked", func() { + confirmationBox = setUpConfirmationBox("system-log-out", *cmdLogout) + buttonBox.Hide() + }) + + btn, _ = gtk.ButtonNew() + pixbuf, _ = createPixbuf("system-reboot", *iconSizeLarge) + img, _ = gtk.ImageNewFromPixbuf(pixbuf) + btn.SetImage(img) + btn.SetCanFocus(false) + box.PackStart(btn, true, true, 6) + btn.Connect("clicked", func() { + confirmationBox = setUpConfirmationBox("system-reboot", *cmdRestart) + buttonBox.Hide() + }) + + btn, _ = gtk.ButtonNew() + pixbuf, _ = createPixbuf("system-shutdown", *iconSizeLarge) + img, _ = gtk.ImageNewFromPixbuf(pixbuf) + btn.SetImage(img) + btn.SetCanFocus(false) + box.PackStart(btn, true, true, 6) + btn.Connect("clicked", func() { + confirmationBox = setUpConfirmationBox("system-shutdown", *cmdShutdown) + buttonBox.Hide() + }) + + eventBox.Connect("enter-notify-event", func() { + cancelClose() + }) + + return eventBox +} + +func setUpConfirmationBox(icon string, command string) *gtk.Box { + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + + btn, _ := gtk.ButtonNew() + pixbuf, _ := createPixbuf(icon, *iconSizeLarge) + img, _ := gtk.ImageNewFromPixbuf(pixbuf) + btn.SetImage(img) + btn.SetCanFocus(false) + box.PackEnd(btn, false, false, 6) + btn.Connect("clicked", func() { + defer restoreButtonBox() + launch(command, false) + + }) + btn.Connect("enter-notify-event", func() { + cancelClose() + }) + + btn, _ = gtk.ButtonNew() + pixbuf, _ = createPixbuf("dialog-cancel", *iconSizeLarge) + img, _ = gtk.ImageNewFromPixbuf(pixbuf) + btn.SetImage(img) + btn.SetCanFocus(false) + box.PackEnd(btn, false, false, 6) + btn.Connect("clicked", func() { + restoreButtonBox() + }) + btn.Connect("enter-notify-event", func() { + cancelClose() + }) + + buttonsWrapper.PackEnd(box, false, false, 10) + + box.ShowAll() + w := buttonBox.GetAllocatedWidth() + h := buttonBox.GetAllocatedHeight() + box.SetSizeRequest(w, h) + box.SetHExpand(false) + + return box +} + +func restoreButtonBox() { + if confirmationBox != nil { + confirmationBox.Destroy() + } + if !buttonBox.IsVisible() { + buttonBox.Show() + } +} + +func clearSearchResult() { + if resultWindow != nil { + resultWindow.Destroy() + } + if fileSearchResultWindow != nil { + fileSearchResultWindow.Destroy() + } + if userDirsListBox != nil { + userDirsListBox.ShowAll() + } + if categoriesListBox != nil { + sr := categoriesListBox.GetSelectedRow() + if sr != nil { + categoriesListBox.GetSelectedRow().SetSelectable(false) + } + categoriesListBox.UnselectAll() + } + backButton.Hide() + //searchEntry.SetText("") + //searchEntry.GrabFocus() +}