diff --git a/.builds/amd64_freebsd.yml b/.builds/amd64_freebsd.yml index a9b87bd..ac42404 100644 --- a/.builds/amd64_freebsd.yml +++ b/.builds/amd64_freebsd.yml @@ -2,11 +2,13 @@ arch: amd64 environment: PROJECT_DIR: '~/go/src/git.sr.ht/~tslocum' PROJECT_NAME: 'gmenu' - CGO_ENABLED: '0' GO111MODULE: 'on' image: freebsd/latest packages: + - pkgconf - go + - gtk3 + - glib sources: - https://git.sr.ht/~tslocum/gmenu tasks: @@ -14,8 +16,11 @@ tasks: mkdir -p $PROJECT_DIR mv $PROJECT_NAME $PROJECT_DIR/$PROJECT_NAME - test: | - cd $PROJECT_DIR/$PROJECT_NAME/cmd/gmenu + cd $PROJECT_DIR/$PROJECT_NAME go test -v -cover ./... - - build: | + - build-gmenu: | cd $PROJECT_DIR/$PROJECT_NAME/cmd/gmenu go build + - build-gtkmenu: | + cd $PROJECT_DIR/$PROJECT_NAME/cmd/gtkmenu + go build diff --git a/.builds/amd64_linux_alpine.yml b/.builds/amd64_linux_alpine.yml index d150a29..0f8630e 100644 --- a/.builds/amd64_linux_alpine.yml +++ b/.builds/amd64_linux_alpine.yml @@ -2,11 +2,13 @@ arch: x86_64 environment: PROJECT_DIR: '~/go/src/git.sr.ht/~tslocum' PROJECT_NAME: 'gmenu' - CGO_ENABLED: '0' GO111MODULE: 'on' image: alpine/edge packages: - go + - glib + - glib-dev + - gtk+3.0-dev sources: - https://git.sr.ht/~tslocum/gmenu tasks: @@ -14,8 +16,11 @@ tasks: mkdir -p $PROJECT_DIR mv $PROJECT_NAME $PROJECT_DIR/$PROJECT_NAME - test: | - cd $PROJECT_DIR/$PROJECT_NAME/cmd/gmenu + cd $PROJECT_DIR/$PROJECT_NAME go test -v -cover ./... - - build: | + - build-gmenu: | cd $PROJECT_DIR/$PROJECT_NAME/cmd/gmenu go build + - build-gtkmenu: | + cd $PROJECT_DIR/$PROJECT_NAME/cmd/gtkmenu + go build diff --git a/.gitignore b/.gitignore index 45e014f..f922f7c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ *.sh gmenu !cmd/gmenu/ +!pkg/gmenu/ gtkmenu !cmd/gtkmenu/ vendor/ diff --git a/CHANGELOG b/CHANGELOG index 8da6a6f..9ea9e07 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,2 +1,9 @@ +0.2.0: +- Added GTK interface +- Added Support for link (URL shortcut) desktop entries + +0.1.1: +- Added fuzzy string search + 0.1.0: - Initial release diff --git a/README.md b/README.md index 050fe6b..e269e02 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ Desktop application launcher ### Console ``` -go get git.sr.ht/~tslocum/gmenu/cmd/gmenu +GO111MODULE=on go get git.sr.ht/~tslocum/gmenu/cmd/gmenu ``` ### GUI ``` -go get git.sr.ht/~tslocum/gmenu/cmd/gtkmenu +GO111MODULE=on go get git.sr.ht/~tslocum/gmenu/cmd/gtkmenu ``` ## Usage @@ -25,6 +25,8 @@ go get git.sr.ht/~tslocum/gmenu/cmd/gtkmenu ``` Usage of ./gmenu: + -browser string + browser command -data-dirs string application data directories (default: $XDG_DATA_DIRS) -mouse @@ -32,19 +34,31 @@ Usage of ./gmenu: -no-details hide application details -no-generic - hide generic names + hide application generic names + -terminal string + terminal command ``` ### GUI ``` Usage of ./gtkmenu: + -browser string + browser command -data-dirs string application data directories (default: $XDG_DATA_DIRS) -height int window height (default 200) + -no-details + hide application details -no-generic - hide generic names + hide application generic names + -no-icons + hide application icons + -resizable + allow window to be resized + -terminal string + terminal command -width int window width (default 800) ``` diff --git a/cmd/gtkmenu/gui_list.go b/cmd/gtkmenu/gui_list.go index 37bd7aa..947d737 100644 --- a/cmd/gtkmenu/gui_list.go +++ b/cmd/gtkmenu/gui_list.go @@ -2,24 +2,38 @@ package main import ( "flag" + "fmt" + "html" "log" "os" + "path" "strconv" "strings" + "github.com/gotk3/gotk3/pango" + "git.sr.ht/~tslocum/desktop" "git.sr.ht/~tslocum/gmenu/pkg/gmenu" + "github.com/gotk3/gotk3/gdk" "github.com/kballard/go-shellquote" - "github.com/tslocum/gotk3/gdk" "github.com/tslocum/gotk3/gtk" + gtkfork "github.com/tslocum/gotk3/gtk" +) + +const ( + iconSize = 48 + iconMargin = 4 + iconMarginStart = 2 + labelMarginStart = 4 + labelMarginTop = 8 + labelMarginTopComment = 4 ) var execLabel *gtk.Label func initList(container *gtk.Box) { inputView = newTextView() - inputView.SetHExpand(false) - inputView.SetVExpand(false) + setNoExpand(&inputView.Widget) inputView.SetProperty("accepts-tab", false) inputView.SetProperty("wrap-mode", gtk.WRAP_CHAR) inputView.SetProperty("cursor-visible", false) @@ -47,7 +61,7 @@ func initList(container *gtk.Box) { listBox.SetSelectionMode(gtk.SELECTION_BROWSE) listBox.SetHExpand(false) - _, err = listBox.Connect("button-press-event", func(listBox *gtk.ListBox, ev *gdk.Event) { + _, err = listBox.Connect("button-press-event", func(listBox *gtkfork.ListBox, ev *gdk.Event) { mouseEvent := &gdk.EventButton{ev} if mouseEvent.Type() == gdk.EVENT_2BUTTON_PRESS { err := listSelect(inputView) @@ -71,20 +85,118 @@ func initList(container *gtk.Box) { log.Fatal("failed to create ListBoxRow:", err) } - l, err := gtk.LabelNew(entry.Label) + row.SetName("#" + strconv.Itoa(i)) + + container := newBox(gtk.ORIENTATION_HORIZONTAL) + + if !config.HideAppIcons { + s, _ := container.GetScreen() + theme, err := gtk.IconThemeGetForScreen(*s) + if err != nil { + log.Fatal("failed to get icon theme:", err) + } + + var ( + pbuf *gdk.Pixbuf + img *gtk.Image + ) + + if entry.Entry != nil && entry.Icon != "" { + if path.IsAbs(entry.Icon) { + pbuf, err = gdk.PixbufNewFromFileAtSize(entry.Icon, iconSize, iconSize) + } else { + pbuf, err = theme.LoadIcon(entry.Icon, iconSize, gtk.ICON_LOOKUP_USE_BUILTIN) + } + } + if pbuf == nil || err != nil { + var icon string + if entry.Entry == nil { + icon = "utilities-terminal" + } else if entry.Type == desktop.Application { + icon = "application-x-executable" + } else { + icon = "text-html" + } + pbuf, err = theme.LoadIcon(icon, iconSize, gtk.ICON_LOOKUP_USE_BUILTIN) + } + if err != nil { + // Failed to load icon + + img, err = gtk.ImageNew() + if err == nil { + img.SetSizeRequest(iconSize, iconSize) + } + } else { + if pbuf.GetWidth() != iconSize && pbuf.GetHeight() != iconSize { + pbuf, _ = pbuf.ScaleSimple(iconSize, iconSize, gdk.INTERP_BILINEAR) + } + + img, err = gtk.ImageNewFromPixbuf(pbuf) + } + if err != nil { + log.Fatal("failed to create Icon:", err) + } + + img.SetMarginStart(iconMarginStart) + img.SetMarginTop(iconMargin) + img.SetMarginEnd(iconMargin) + img.SetMarginBottom(iconMargin) + container.PackStart(img, false, false, 0) + } + + labelContainer := newBox(gtk.ORIENTATION_VERTICAL) + labelContainer.SetMarginStart(labelMarginStart) + + l, err := gtk.LabelNew(fmt.Sprintf("%s", html.EscapeString(entry.Label))) if err != nil { log.Fatal("failed to create Label:", err) } + l.SetUseMarkup(true) l.SetHAlign(gtk.ALIGN_START) + setNoExpand(&l.Widget) + l.SetLineWrap(false) + l.SetSingleLineMode(true) + l.SetEllipsize(pango.ELLIPSIZE_END) - row.SetName("#" + strconv.Itoa(i)) - - if i == lastEntry { - execLabel = l + if !config.HideAppIcons { + l.SetMarginTop(labelMarginTop) } - row.Add(l) + labelContainer.PackStart(l, false, false, 0) + + if entry.Entry == nil || (entry.Entry != nil && entry.Comment != "") { + comment := "" + if entry.Entry != nil { + comment = entry.Comment + } + l, err := gtk.LabelNew(comment) + if err != nil { + log.Fatal("failed to create Label:", err) + } + + l.SetHAlign(gtk.ALIGN_START) + setNoExpand(&l.Widget) + l.SetLineWrap(false) + l.SetSingleLineMode(true) + l.SetEllipsize(pango.ELLIPSIZE_END) + + if !config.HideAppIcons { + l.SetMarginTop(labelMarginTopComment) + } + + labelContainer.Add(l) + + if i == lastEntry { + execLabel = l + } + } + + setNoExpand(&labelContainer.Widget) + container.Add(labelContainer) + + setNoExpand(&container.Widget) + row.Add(container) listBox.Add(row) } @@ -160,6 +272,11 @@ func selectedIndex() int { return rowID(listBox.GetSelectedRow()) } +func setNoExpand(v *gtk.Widget) { + v.SetHExpand(false) + v.SetVExpand(false) +} + func selectedEntry() *desktop.Entry { i := selectedIndex() if len(gmenu.FilteredEntries) == 0 || i < 0 || i > len(gmenu.FilteredEntries)-1 { diff --git a/cmd/gtkmenu/main.go b/cmd/gtkmenu/main.go index b036ff3..23ebd4f 100644 --- a/cmd/gtkmenu/main.go +++ b/cmd/gtkmenu/main.go @@ -19,6 +19,9 @@ type Config struct { gmenu.Config Width, Height int + Resizable bool + + HideAppIcons bool } var ( @@ -32,17 +35,28 @@ func init() { flag.IntVar(&config.Width, "width", 800, "window width") flag.IntVar(&config.Height, "height", 200, "window height") + flag.BoolVar(&config.Resizable, "resizable", false, "allow window to be resized") + flag.BoolVar(&config.HideAppIcons, "no-icons", false, "hide application icons") } func main() { - flag.Parse() - - application, err := gtk.ApplicationNew(appID, glib.APPLICATION_FLAGS_NONE) + application, err := gtk.ApplicationNew(appID, glib.APPLICATION_HANDLES_COMMAND_LINE) if err != nil { log.Fatal("failed to create application:", err) } + flag.Parse() - _, err = application.Connect("activate", func() { onActivate(application) }) + _, err = application.Connect("command-line", func() { + flag.Parse() + + onActivate(application) + }) + if err != nil { + log.Fatal("failed to connect while creating application:", err) + } + _, err = application.Connect("activate", func() { + onActivate(application) + }) if err != nil { log.Fatal("failed to connect while creating application:", err) } @@ -51,17 +65,18 @@ func main() { } func onActivate(application *gtk.Application) { - appWindow, err := gtk.ApplicationWindowNew(application) + w, err := gtk.ApplicationWindowNew(application) + if err != nil { log.Fatal("failed to create application window:", err) } - - _, err = appWindow.Connect("destroy", func() { + _, err = w.Connect("destroy", func() { os.Exit(0) }) if err != nil { log.Fatal("failed to create application window:", err) } + w.SetTitle("gmenu") gmenu.LoadEntries(&config.Config) gmenu.FilterEntries() @@ -75,28 +90,23 @@ func onActivate(application *gtk.Application) { gmenu.Entries = append(gmenu.Entries, nil) gmenu.Names = append(gmenu.Names, "") - gmenu.FilteredEntries = append(gmenu.FilteredEntries, &gmenu.ListEntry{Label: "", Entry: nil}) + gmenu.FilteredEntries = append(gmenu.FilteredEntries, &gmenu.ListEntry{Label: "Shell command", Entry: nil}) container := newBox(gtk.ORIENTATION_VERTICAL) initList(container) - appWindow.Add(container) + w.Add(container) - appWindow.SetTitle("gmenu") + w.SetResizable(config.Resizable) + w.SetSizeRequest(config.Width, config.Height) + w.SetPosition(gtk.WIN_POS_CENTER) - appWindow.SetResizable(false) - appWindow.SetSizeRequest(config.Width, config.Height) - appWindow.SetDefaultSize(config.Width, config.Height) - appWindow.SetPosition(gtk.WIN_POS_CENTER) + w.SetDecorated(false) + w.SetBorderWidth(0) + w.Stick() + w.SetKeepAbove(true) - appWindow.SetDecorated(false) - appWindow.SetBorderWidth(0) - appWindow.Stick() - appWindow.SetKeepAbove(true) - - appWindow.ShowAll() - - _, err = appWindow.Connect("key-press-event", func(win *gtk.ApplicationWindow, ev *gdk.Event) bool { + _, err = w.Connect("key-press-event", func(win *gtk.ApplicationWindow, ev *gdk.Event) bool { keyEvent := &gdk.EventKey{ev} switch keyEvent.KeyVal() { case gdk.KEY_Up, gdk.KEY_Down: @@ -135,4 +145,6 @@ func onActivate(application *gtk.Application) { if err != nil { log.Fatal("failed to connect key-press-event:", err) } + + w.ShowAll() } diff --git a/goreleaser.yml b/goreleaser.yml index 4d988d0..5152ab2 100644 --- a/goreleaser.yml +++ b/goreleaser.yml @@ -13,6 +13,8 @@ builds: ldflags: - -s -w -X git.sr.ht/~tslocum/gmenu/pkg/gmenu.Version={{.Version}} goos: + - darwin + - freebsd - linux - windows goarch: diff --git a/pkg/gmenu/config.go b/pkg/gmenu/config.go new file mode 100644 index 0000000..3f260d2 --- /dev/null +++ b/pkg/gmenu/config.go @@ -0,0 +1,29 @@ +package gmenu + +type Config struct { + DataDirs string + + HideGenericNames bool + HideAppDetails bool + + terminalCommand string + browserCommand string +} + +func (c *Config) TerminalCommand() string { + if c.terminalCommand == "" { + c.terminalCommand = "i3-sensible-terminal" + } + + return c.terminalCommand +} + +func (c *Config) BrowserCommand() string { + if c.browserCommand == "" { + c.browserCommand = "xdg-open" + } + + return c.browserCommand +} + +var Version string diff --git a/pkg/gmenu/gmenu.go b/pkg/gmenu/gmenu.go new file mode 100644 index 0000000..848eccb --- /dev/null +++ b/pkg/gmenu/gmenu.go @@ -0,0 +1,219 @@ +package gmenu + +import ( + "flag" + "fmt" + "log" + "sort" + "strings" + + "git.sr.ht/~tslocum/desktop" + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/pkg/errors" +) + +var ( + Entries []*desktop.Entry + Names []string + + FilteredEntries []*ListEntry + + inputBuffer = make(chan string, 3) + input string + inputFlushed = make(chan bool) +) + +type ListEntry struct { + *desktop.Entry + + Label string +} + +type InputUpdateHandler func(input string) + +func SharedInit(c *Config) { + log.SetFlags(0) + + flag.StringVar(&c.DataDirs, "data-dirs", "", "application data directories (default: $XDG_DATA_DIRS)") + flag.BoolVar(&c.HideGenericNames, "no-generic", false, "hide application generic names") + flag.BoolVar(&c.HideAppDetails, "no-details", false, "hide application details") + flag.StringVar(&c.terminalCommand, "terminal", "", "terminal command") + flag.StringVar(&c.browserCommand, "browser", "", "browser command") +} + +func HandleInput(u InputUpdateHandler) { + var ( + in string + ok bool + ) + +inputLoop: + for { + select { + case in, ok = <-inputBuffer: + if !ok { + break inputLoop + } + + input = in + u(input) + } + } + + inputFlushed <- true +} + +func LoadEntries(c *Config) { + var err error + Entries, Names, err = DesktopEntries(c) + if err != nil { + log.Fatal(err) + } +} + +func SetInput(i string) { + inputBuffer <- i +} + +func CloseInput() { + close(inputBuffer) + <-inputFlushed +} + +func FilterEntries() { + FilteredEntries = nil + if input == "" { + for i, l := range Names { + FilteredEntries = append(FilteredEntries, &ListEntry{Label: l, Entry: Entries[i]}) + } + + sort.Slice(FilteredEntries, SortEmpty) + } else { + b := strings.ToLower(input) + + matches := fuzzy.RankFindFold(b, Names) + sort.Sort(matches) + + for _, match := range matches { + FilteredEntries = append(FilteredEntries, &ListEntry{Label: Names[match.OriginalIndex], Entry: Entries[match.OriginalIndex]}) + } + + sort.Slice(FilteredEntries, SortFiltered) + } +} + +func DesktopEntries(c *Config) ([]*desktop.Entry, []string, error) { + var dirs []string + if c.DataDirs != "" { + dirs = strings.Split(c.DataDirs, ":") + } else { + dirs = desktop.DataDirs() + } + + allEntries, err := desktop.Scan(dirs) + if err != nil { + return nil, nil, err + } + + var ( + desktopEntries []*desktop.Entry + desktopNames []string + ) + + for _, entries := range allEntries { + for _, entry := range entries { + switch entry.Type { + case desktop.Application: + if entry.Exec == "" { + continue + } + case desktop.Link: + if entry.URL == "" { + continue + } + default: + continue // Unsupported entry type + } + + if entry.Name != "" { + desktopEntries = append(desktopEntries, entry) + desktopNames = append(desktopNames, entry.Name) + } + if !c.HideGenericNames && entry.GenericName != "" { + desktopEntries = append(desktopEntries, entry) + desktopNames = append(desktopNames, entry.GenericName) + } + } + } + + return desktopEntries, desktopNames, nil +} + +func Sort(i, j int) bool { + if input == "" { + return SortEmpty(i, j) + } + + return SortFiltered(i, j) +} + +func SortEmpty(i, j int) bool { + ilower := strings.ToLower(FilteredEntries[i].Label) + jlower := strings.ToLower(FilteredEntries[j].Label) + + if FilteredEntries[i].Entry == nil && FilteredEntries[j].Entry != nil { + return true + } else if ilower != jlower { + return ilower < jlower + } else { + return i < j + } +} + +func SortFiltered(i, j int) bool { + ilower := strings.ToLower(FilteredEntries[i].Label) + if FilteredEntries[i].Entry == nil { + ilower = "" + } + + jlower := strings.ToLower(FilteredEntries[j].Label) + if FilteredEntries[j].Entry == nil { + jlower = "" + } + + ipre := strings.HasPrefix(ilower, input) + jpre := strings.HasPrefix(jlower, input) + + icon := strings.Contains(ilower, input) + jcon := strings.Contains(jlower, input) + + imatch := fuzzy.MatchFold(input, ilower) + jmatch := fuzzy.MatchFold(input, jlower) + + if ipre != jpre { + return ipre && !jpre + } else if icon != jcon { + return icon && !jcon + } else if imatch != jmatch { + return imatch && !jmatch + } else if (FilteredEntries[i].Entry == nil) != (FilteredEntries[j].Entry == nil) { + return FilteredEntries[i].Entry == nil + } else if ilower != jlower { + return ilower < jlower + } else { + return i < j + } +} + +func Run(config *Config, execute string, path string, runInTerminal bool, waitUntilFinished bool) error { + execute = strings.TrimSpace(execute) + + fmt.Println(execute) + + runScript, err := desktop.RunScript(execute) + if err != nil { + return errors.Wrap(err, "failed to create run script") + } + + return run(config, runScript, path, waitUntilFinished, runInTerminal) +} diff --git a/pkg/gmenu/run_unix.go b/pkg/gmenu/run_unix.go new file mode 100644 index 0000000..417c25f --- /dev/null +++ b/pkg/gmenu/run_unix.go @@ -0,0 +1,44 @@ +// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris + +package gmenu + +import ( + "os" + "os/exec" + "syscall" + + "github.com/pkg/errors" +) + +func run(config *Config, runScript string, path string, waitUntilFinished, runInTerminal bool) error { + var cmd *exec.Cmd + if runInTerminal { + cmd = exec.Command(config.TerminalCommand(), "-e", runScript) + } else { + cmd = exec.Command("/usr/bin/env", "bash", "-c", runScript) + } + + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pgid: 0} + cmd.Dir = path + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Start() + if err != nil { + return errors.Wrap(err, "failed to start command") + } + + if !waitUntilFinished { + return nil + } + + err = cmd.Wait() + _, isExitErr := err.(*exec.ExitError) + if err != nil && !isExitErr { + return errors.Wrap(err, "failed to execute command") + } + + return nil +} diff --git a/pkg/gmenu/run_windows.go b/pkg/gmenu/run_windows.go new file mode 100644 index 0000000..af01db6 --- /dev/null +++ b/pkg/gmenu/run_windows.go @@ -0,0 +1,8 @@ +// +build windows + +package gmenu + +func run(config *Config, runScript string, path string, waitUntilFinished, runInTerminal bool) error { + // TODO + return nil +}