gtkmenu: Add icon support

This commit is contained in:
Trevor Slocum 2019-07-31 21:53:53 -07:00
parent 5fdce165ba
commit 1bb42b05cc
12 changed files with 505 additions and 42 deletions

View file

@ -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

View file

@ -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

1
.gitignore vendored
View file

@ -3,6 +3,7 @@ dist/
*.sh
gmenu
!cmd/gmenu/
!pkg/gmenu/
gtkmenu
!cmd/gtkmenu/
vendor/

View file

@ -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

View file

@ -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)
```

View file

@ -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("<b>%s</b>", 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 {

View file

@ -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()
}

View file

@ -13,6 +13,8 @@ builds:
ldflags:
- -s -w -X git.sr.ht/~tslocum/gmenu/pkg/gmenu.Version={{.Version}}
goos:
- darwin
- freebsd
- linux
- windows
goarch:

29
pkg/gmenu/config.go Normal file
View file

@ -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

219
pkg/gmenu/gmenu.go Normal file
View file

@ -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)
}

44
pkg/gmenu/run_unix.go Normal file
View file

@ -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
}

8
pkg/gmenu/run_windows.go Normal file
View file

@ -0,0 +1,8 @@
// +build windows
package gmenu
func run(config *Config, runScript string, path string, waitUntilFinished, runInTerminal bool) error {
// TODO
return nil
}