package main import ( "flag" "fmt" "os" "os/signal" "path" "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.3.0" var ( appDirs []string configDirectory string pinnedFile string pinned []string id2entry map[string]desktopEntry preferredApps map[string]interface{} exclusions []string ) 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 Category 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 ( win *gtk.Window resultWindow *gtk.ScrolledWindow fileSearchResults []string searchEntry *gtk.SearchEntry phrase string fileSearchResultFlowBox *gtk.FlowBox userDirsMap map[string]string appFlowBox *gtk.FlowBox appSearchResultWrapper *gtk.Box fileSearchResultWrapper *gtk.Box pinnedFlowBox *gtk.FlowBox pinnedFlowBoxWrapper *gtk.Box categoriesWrapper *gtk.Box catButtons []*gtk.Button statusLabel *gtk.Label status string ignore string desktopTrigger bool pinnedItemsChanged chan interface{} = make(chan interface{}, 1) ) func defaultStringIfBlank(s, fallback string) string { s = strings.TrimSpace(s) // os.Getenv("TERM") returns "linux" instead of empty string, if program has been started // from a key binding defined in the config file. See #23. if s == "" || s == "linux" { return fallback } return s } // Flags var cssFileName = flag.String("s", "drawer.css", "Styling: css file name") var targetOutput = flag.String("o", "", "name of the Output to display the drawer on (sway only)") var displayVersion = flag.Bool("v", false, "display Version information") var overlay = flag.Bool("ovl", false, "use OVerLay layer") var gtkTheme = flag.String("g", "", "GTK theme name") var iconSize = flag.Int("is", 64, "Icon Size") 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 fsColumns = flag.Uint("fscol", 2, "File Search result COLumns") var columnsNumber = flag.Uint("c", 6, "number of Columns") var itemSpacing = flag.Uint("spacing", 20, "icon spacing") var lang = flag.String("lang", "", "force lang, e.g. \"en\", \"pl\"") var fileManager = flag.String("fm", "thunar", "File Manager") var term = flag.String("term", defaultStringIfBlank(os.Getenv("TERM"), "foot"), "Terminal emulator") var nameLimit = flag.Int("fslen", 80, "File Search name LENgth Limit") var noCats = flag.Bool("nocats", false, "Disable filtering by category") var noFS = flag.Bool("nofs", false, "Disable file search") var resident = flag.Bool("r", false, "Leave the program resident in memory") var debug = flag.Bool("d", false, "Turn on Debug messages") func main() { timeStart := time.Now() flag.Parse() if *debug { log.SetLevel(log.DebugLevel) } if *displayVersion { fmt.Printf("nwg-drawer version %s\n", version) os.Exit(0) } // Gentle SIGTERM handler thanks to reiki4040 https://gist.github.com/reiki4040/be3705f307d3cd136e85 // v0.2: we also need to support SIGUSR from now on showWindowChannel := make(chan interface{}, 1) 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 { // 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") showWindowChannel <- struct{}{} } else { log.Debug("SIGUSR1 received, hiding the window") restoreStateAndHide() } } else { log.Info("SIGUSR1 received, and I'm not resident, bye bye") gtk.MainQuit() } default: log.Info("Unknown signal") } } }() // If running instance found, we want it to show the window. The new instance will send SIGUSR1 and die // (equivalent of `pkill -USR1 nwg-drawer`). // Otherwise the command may behave in two ways: // 1. kill the running non-residennt instance and exit; // 2. die if a resident instance found. lockFilePath := path.Join(tempDir(), "nwg-drawer.lock") lockFile, err := singleinstance.CreateLockFile(lockFilePath) if err != nil { pid, err := readTextFile(lockFilePath) if err == nil { i, err := strconv.Atoi(pid) if err == nil { if *resident { log.Warnf("Resident instance already running (PID %v)", i) } else { log.Infof("Showing resident instance (PID %v)", i) syscall.Kill(i, syscall.SIGUSR1) } } } os.Exit(0) } defer lockFile.Close() log.Infof("term: %s", *term) // LANGUAGE if *lang == "" && os.Getenv("LANG") != "" { *lang = strings.Split(os.Getenv("LANG"), ".")[0] } log.Info(fmt.Sprintf("lang: %s", *lang)) // ENVIRONMENT configDirectory = configDir() // Placing the drawer config files in the nwg-panel config directory was a mistake. // Let's move them to their own location. oldConfigDirectory, err := oldConfigDir() if err == nil { for _, p := range []string{"drawer.css", "preferred-apps.json"} { if pathExists(path.Join(oldConfigDirectory, p)) { log.Infof("File %s found in stale location, moving to %s", p, configDirectory) if !pathExists(path.Join(configDirectory, p)) { err = os.Rename(path.Join(oldConfigDirectory, p), path.Join(configDirectory, p)) if err == nil { log.Info("Success") } else { log.Warn(err) } } else { log.Warnf("Failed moving %s to %s: path already exists!", path.Join(oldConfigDirectory, p), path.Join(configDirectory, p)) } } } } // Copy default style sheet if not found if !pathExists(filepath.Join(configDirectory, "drawer.css")) { copyFile(filepath.Join(getDataHome(), "nwg-drawer/drawer.css"), filepath.Join(configDirectory, "drawer.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 savePinned() } log.Info(fmt.Sprintf("Found %v pinned items", len(pinned))) if !strings.HasPrefix(*cssFileName, "/") { *cssFileName = filepath.Join(configDirectory, *cssFileName) } appDirs = getAppDirs() setUpCategories() desktopFiles := listDesktopFiles() log.Info(fmt.Sprintf("Found %v desktop files", len(desktopFiles))) status = parseDesktopFiles(desktopFiles) // For opening files we use xdg-open. As its configuration is PITA, we may override some associations // in the ~/.config/nwg-panel/preferred-apps.json file. paFile := path.Join(configDirectory, "preferred-apps.json") if pathExists(paFile) { preferredApps, err = loadPreferredApps(paFile) if err != nil { log.Infof("Custom associations file %s not found or invalid", paFile) } else { log.Infof("Found %v associations in %s", len(preferredApps), paFile) } } else { log.Infof("%s file not found", paFile) } // Load user-defined paths excluded from file search exFile := path.Join(configDirectory, "excluded-dirs") if pathExists(exFile) { exclusions, err = loadTextFile(exFile) if err != nil { log.Infof("Search exclusions file %s not found %s", exFile, err) } else { log.Infof("Found %v search exclusions in %s", len(exclusions), exFile) } } else { log.Infof("%s file not found", exFile) } // USER INTERFACE gtk.Init(nil) settings, _ := gtk.SettingsGetDefault() if *gtkTheme != "" { err = settings.SetProperty("gtk-theme-name", *gtkTheme) if err != nil { log.Error("Unable to set theme:", err) } else { log.Infof("User demanded theme: %s", *gtkTheme) } } else { settings.SetProperty("gtk-application-prefer-dark-theme", true) log.Info("Preferring dark theme variants") } cssProvider, _ := gtk.CssProviderNew() err = cssProvider.LoadFromPath(*cssFileName) if err != nil { log.Errorf("ERROR: %s css file not found or erroneous. Using GTK styling.", *cssFileName) log.Errorf("%s", err) } else { log.Info(fmt.Sprintf("Using style from %s", *cssFileName)) 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) } if wayland() { 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 { log.Errorf("%s", err) } } layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_BOTTOM, true) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_TOP, true) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_LEFT, true) layershell.SetAnchor(win, layershell.LAYER_SHELL_EDGE_RIGHT, true) if *overlay { layershell.SetLayer(win, layershell.LAYER_SHELL_LAYER_OVERLAY) layershell.SetExclusiveZone(win, -1) } else { 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) bool { key := &gdk.EventKey{Event: event} if key.KeyVal() == gdk.KEY_Escape { s, _ := searchEntry.GetText() if s != "" { searchEntry.GrabFocus() searchEntry.SetText("") } else { if !*resident { gtk.MainQuit() } else { restoreStateAndHide() } } return true } return false }) win.Connect("key-press-event", func(window *gtk.Window, event *gdk.Event) bool { key := &gdk.EventKey{Event: event} switch key.KeyVal() { case gdk.KEY_downarrow, gdk.KEY_Up, gdk.KEY_Down, gdk.KEY_Left, gdk.KEY_Right, gdk.KEY_Tab, gdk.KEY_Return, gdk.KEY_Page_Up, gdk.KEY_Page_Down, gdk.KEY_Home, gdk.KEY_End: return false default: if !searchEntry.IsFocus() { searchEntry.GrabFocusWithoutSelecting() } return false } }) /* In case someone REALLY needed to use X11 - for some stupid Zoom meeting or something, this allows the drawer to behave properly on Openbox, and possibly somewhere else. For sure not on i3. This feature is not really supported and will stay undocumented. */ if !wayland() { log.Info("Not Wayland, oh really?") win.SetDecorated(false) win.Maximize() } // Set up UI outerVBox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) win.Add(outerVBox) searchBoxWrapper, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) outerVBox.PackStart(searchBoxWrapper, false, false, 10) searchEntry = setUpSearchEntry() searchEntry.SetMaxWidthChars(30) searchBoxWrapper.PackStart(searchEntry, true, false, 0) if !*noCats { categoriesWrapper, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) categoriesButtonBox := setUpCategoriesButtonBox() categoriesWrapper.PackStart(categoriesButtonBox, true, false, 0) outerVBox.PackStart(categoriesWrapper, false, false, 0) } pinnedWrapper, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) outerVBox.PackStart(pinnedWrapper, false, false, 0) pinnedFlowBoxWrapper, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) outerVBox.PackStart(pinnedFlowBoxWrapper, false, false, 0) pinnedFlowBox = setUpPinnedFlowBox() resultWindow, _ = gtk.ScrolledWindowNew(nil, nil) resultWindow.SetEvents(int(gdk.ALL_EVENTS_MASK)) resultWindow.SetPolicy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) resultWindow.Connect("button-release-event", func(sw *gtk.ScrolledWindow, e *gdk.Event) bool { btnEvent := gdk.EventButtonNewFromEvent(e) if btnEvent.Button() == 3 { if !*resident { gtk.MainQuit() } else { restoreStateAndHide() } return true } return false }) outerVBox.PackStart(resultWindow, true, true, 10) resultsWrapper, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) resultWindow.Add(resultsWrapper) appSearchResultWrapper, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) resultsWrapper.PackStart(appSearchResultWrapper, false, false, 0) appFlowBox = setUpAppsFlowBox(nil, "") // Focus 1st pinned item if any, otherwise focus 1st found app icon var button gtk.IWidget if pinnedFlowBox.GetChildren().Length() > 0 { button, err = pinnedFlowBox.GetChildAtIndex(0).GetChild() } else { button, err = appFlowBox.GetChildAtIndex(0).GetChild() } if err == nil { button.ToWidget().GrabFocus() } userDirsMap = mapXdgUserDirs() placeholder, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) resultsWrapper.PackStart(placeholder, true, true, 0) placeholder.SetSizeRequest(20, 20) if !*noFS { wrapper, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) fileSearchResultWrapper, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) fileSearchResultWrapper.SetProperty("name", "files-box") wrapper.PackStart(fileSearchResultWrapper, true, false, 0) resultsWrapper.PackEnd(wrapper, false, false, 10) } statusLineWrapper, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) outerVBox.PackStart(statusLineWrapper, false, false, 10) statusLabel, _ = gtk.LabelNew(status) statusLineWrapper.PackStart(statusLabel, true, false, 0) win.ShowAll() if !*noFS { fileSearchResultWrapper.SetSizeRequest(appFlowBox.GetAllocatedWidth(), 1) fileSearchResultWrapper.Hide() } if !*noCats { categoriesWrapper.SetSizeRequest(1, categoriesWrapper.GetAllocatedHeight()*2) } if *resident { win.Hide() } t := time.Now() log.Info(fmt.Sprintf("UI created in %v ms. Thank you for your patience.", t.Sub(timeStart).Milliseconds())) // Check if showing the window has been requested (SIGUSR1) go func() { for { select { case <-showWindowChannel: log.Debug("Showing window") glib.TimeoutAdd(0, func() bool { if win != nil && !win.IsVisible() { // Refresh files before displaying the root window // some .desktop file changed if desktopTrigger { log.Debug(".desktop file changed") desktopFiles = listDesktopFiles() status = parseDesktopFiles(desktopFiles) appFlowBox = setUpAppsFlowBox(nil, "") desktopTrigger = false } // Show window and focus the search box win.ShowAll() if fileSearchResultWrapper != nil { fileSearchResultWrapper.Hide() } // focus 1st element b := appFlowBox.GetChildAtIndex(0) if b != nil { button, err := b.GetChild() if err == nil { button.ToWidget().GrabFocus() } } } return false }) case <-pinnedItemsChanged: glib.TimeoutAdd(0, func() bool { log.Debug("pinned file changed") pinned, _ = loadTextFile(pinnedFile) pinnedFlowBox = setUpPinnedFlowBox() return false }) } } }() go watchFiles() gtk.Main() } func restoreStateAndHide() { timeStart1 := time.Now() win.Hide() // clear search searchEntry.SetText("") // clear category filter (in gotk3 it means: rebuild, as we have no filtering here) appFlowBox = setUpAppsFlowBox(nil, "") for _, btn := range catButtons { btn.SetImagePosition(gtk.POS_LEFT) btn.SetSizeRequest(0, 0) } // scroll to the top resultWindow.GetVAdjustment().SetValue(0) t := time.Now() log.Debugf(fmt.Sprintf("UI hidden and restored in the backgroud in %v ms", t.Sub(timeStart1).Milliseconds())) }