commit 8e194e278c115410a5c94f515bb4391a3ed36b32 Author: Gregory Eremin Date: Sat Jun 15 15:47:49 2019 +0200 Initial commit diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..5b4c992 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "log" + + menu "github.com/localhots/themenu" +) + +func main() { + menu.Main() + log.Println("Shut down") +} diff --git a/exec.go b/exec.go new file mode 100644 index 0000000..977eaf5 --- /dev/null +++ b/exec.go @@ -0,0 +1,26 @@ +package menu + +import ( + "log" + "os/exec" + "strings" +) + +type output struct { + code int + body string +} + +func execCommand(command string) (string, error) { + if command == "todo" { + return "", nil + } + + log.Println("Command", command) + cmd := exec.Command("bash", "-c", command) + out, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/fonts/fonts.go b/fonts/fonts.go new file mode 100644 index 0000000..bb3fac3 --- /dev/null +++ b/fonts/fonts.go @@ -0,0 +1,49 @@ +package fonts + +import ( + "os" + "os/user" + "path/filepath" + "strings" +) + +// Find looks up font file location by the name. +func Find(name string) (string, error) { + // Normalize font file name + fileName := strings.Replace(name, " ", "-", -1) + ".ttf" + + for _, dir := range fontDirs() { + dir, err := expandHome(dir) + if err != nil { + return "", err + } + + path := filepath.Join(dir, fileName) + if fileExist(path) { + return path, nil + } + } + + return "", nil +} + +func expandHome(path string) (string, error) { + if !strings.HasPrefix(path, "~/") { + return path, nil + } + + u, err := user.Current() + if err != nil { + return "", err + } + return filepath.Join(u.HomeDir, path[2:]), nil +} + +func fileExist(path string) bool { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return false + } + } + return true +} diff --git a/fonts/fonts_darwin.go b/fonts/fonts_darwin.go new file mode 100644 index 0000000..7c8c353 --- /dev/null +++ b/fonts/fonts_darwin.go @@ -0,0 +1,13 @@ +// +build darwin + +package fonts + +// Docs: https://support.apple.com/en-us/HT201722 +func fontDirs() []string { + return []string{ + ".", // Current directory + "~/Library/Fonts", // User + "/Library/Fonts", // Local + "/System/Library/Fonts", // System + } +} diff --git a/fonts/fonts_fallback.go b/fonts/fonts_fallback.go new file mode 100644 index 0000000..e3dfb40 --- /dev/null +++ b/fonts/fonts_fallback.go @@ -0,0 +1,9 @@ +// +build !linux,!darwin + +package fonts + +func fontDirs() []string { + return []string{ + ".", // Current directory + } +} \ No newline at end of file diff --git a/fonts/fonts_linux.go b/fonts/fonts_linux.go new file mode 100644 index 0000000..1a51618 --- /dev/null +++ b/fonts/fonts_linux.go @@ -0,0 +1,15 @@ +// +build linux + +package fonts + +func fontDirs() []string { + return []string{ + ".", // Current directory + "~/.fonts", // User + "~/.fonts/truetype", // User + "~/.local/share/fonts", // User + "~/.local/share/fonts/truetype", // User + "/usr/share/fonts", // System + "/usr/share/fonts/truetype", // System + } +} diff --git a/item.go b/item.go new file mode 100644 index 0000000..37d0e12 --- /dev/null +++ b/item.go @@ -0,0 +1,227 @@ +package menu + +import ( + "log" + "strings" + "time" + + "github.com/juju/errors" + "github.com/veandco/go-sdl2/sdl" +) + +type listItem struct { + Label string `json:"label"` + ActionKey string `json:"key"` + Keep bool `json:"keep"` + Command string `json:"command"` + Condition *ifCondition `json:"if"` + Items []listItem `json:"items"` + + active bool + + subLabel string +} + +type ifCondition struct { + Command string `json:"command"` + Output map[string]listItem `json:"output"` + + cachedOutput *string + busy bool +} + +func (li *listItem) render(r *sdl.Renderer, offsetY int32) error { + if err := drawItemBackground(r, offsetY, li.active); err != nil { + return errors.Annotate(err, "draw item background") + } + if err := drawItemLabel(r, offsetY, *li); err != nil { + return errors.Annotate(err, "draw item label") + } + if err := drawActionKeyLabel(r, offsetY, *li); err != nil { + return errors.Annotate(err, "draw item label") + } + + return nil +} + +func (li *listItem) call() error { + defer func() { + if err := recover(); err != nil { + log.Printf("Action failed: %v", err) + } + }() + + switch { + case li.Condition != nil: + if li.Condition.cachedOutput == nil { + return nil + } + + out := *li.Condition.cachedOutput + if match, ok := li.Condition.Output[out]; ok { + _, err := execCommand(match.Command) + return err + } + + log.Println("no match", out) + return nil + case li.Command != "": + _, err := execCommand(li.Command) + return err + default: + return nil + } +} + +func (li *listItem) label() string { + if li.subLabel != "" { + return li.Label + " " + li.subLabel + } + return li.Label +} + +func drawItemBackground(r *sdl.Renderer, offsetY int32, active bool) error { + if err := setDrawColor(r, theme.ItemBackground); err != nil { + return errors.Annotate(err, "set item background color") + } + + actionKeyFrame := &sdl.Rect{ + X: int32(paddingX + borderWidth), + Y: offsetY, + W: int32(itemHeight()), + H: int32(itemHeight()), + } + var err error + if active { + err = r.DrawRect(actionKeyFrame) + } else { + err = r.FillRect(actionKeyFrame) + } + if err != nil { + return errors.Annotate(err, "draw item") + } + + itemBackground := &sdl.Rect{ + X: int32(borderWidth + paddingX + itemHeight() + paddingX), + Y: offsetY, + W: int32(windowWidth - 3*paddingX - itemHeight() - 2*borderWidth), + H: int32(itemHeight()), + } + + if active { + err = r.DrawRect(itemBackground) + } else { + err = r.FillRect(itemBackground) + } + if err != nil { + return errors.Annotate(err, "draw item") + } + + return nil +} + +func drawItemLabel(r *sdl.Renderer, offsetY int32, li listItem) error { + label := li.Label + if li.Condition != nil { + if li.Condition.cachedOutput != nil { + out := *li.Condition.cachedOutput + if match, ok := li.Condition.Output[out]; ok { + label = match.Label + } + } + if li.Condition.busy { + label += " [busy]" + } + } + color := theme.ItemText + if li.Condition != nil && li.Condition.busy { + color = theme.TextBusy + } + + labelTexture, err := renderText(r, label, color) + if err != nil { + return errors.Annotate(err, "draw item label") + } + defer labelTexture.Destroy() + lw, lh, err := font.SizeUTF8(label) + if err != nil { + return err + } + + const magicNumber = 3 + itemLabel := &sdl.Rect{ + X: int32(paddingX*2+itemHeight()+(itemHeight()-lh)/2) + magicNumber, + Y: offsetY + int32(itemHeight()-lh)/2, + W: int32(lw), + H: int32(lh), + } + if err := r.Copy(labelTexture, nil, itemLabel); err != nil { + return errors.Annotate(err, "render item label") + } + return nil +} + +func drawActionKeyLabel(r *sdl.Renderer, offsetY int32, li listItem) error { + label := strings.ToUpper(li.ActionKey) + color := theme.ItemText + if li.Condition != nil && li.Condition.busy { + color = theme.TextBusy + } + + labelTexture, err := renderText(r, label, color) + if err != nil { + return errors.Annotate(err, "draw item label") + } + defer labelTexture.Destroy() + lw, lh, err := font.SizeUTF8(label) + if err != nil { + return err + } + + itemLabel := &sdl.Rect{ + X: int32(paddingX + (itemHeight()-lw)/2), + Y: offsetY + int32(itemHeight()-lh)/2, + W: int32(lw), + H: int32(lh), + } + if err := r.Copy(labelTexture, nil, itemLabel); err != nil { + return errors.Annotate(err, "render item label") + } + return nil +} + +func renderText(r *sdl.Renderer, text string, c sdl.Color) (t *sdl.Texture, err error) { + fs, err := font.RenderUTF8Blended(text, c) + if err != nil { + return nil, err + } + defer fs.Free() + + t, err = r.CreateTextureFromSurface(fs) + if err != nil { + return nil, err + } + + return +} + +func (c *ifCondition) evaluate() { + fn := func() { + c.busy = true + out, err := execCommand(c.Command) + if err != nil { + log.Printf("Error evaluating condition: %v", err) + } else { + out = strings.TrimSpace(out) + c.cachedOutput = &out + } + c.busy = false + } + fn() + + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for range t.C { + fn() + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..93c8114 --- /dev/null +++ b/main.go @@ -0,0 +1,147 @@ +package menu + +import ( + "encoding/json" + "flag" + "io/ioutil" + "log" + "time" + + "github.com/veandco/go-sdl2/sdl" + "github.com/veandco/go-sdl2/ttf" +) + +var frameLimit = 60 + +// Main is the main function. +func Main() { + conf := flag.String("config", "config.json", "Path to config file") + flag.IntVar(&windowWidth, "width", windowWidth, "Window width") + flag.IntVar(&windowHeight, "height", windowHeight, "Window height") + flag.IntVar(&borderWidth, "border", borderWidth, "Border width") + flag.IntVar(&frameLimit, "fps", frameLimit, "FPS limit") + flag.StringVar(&fontName, "fontname", fontName, "Font name (must be TrueType)") + flag.IntVar(&fontSize, "fontsize", fontSize, "Font size") + flag.Parse() + + log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile) + log.Println("Loading config") + items, err := getItems(*conf) + if err != nil { + log.Fatalf("Failed to read config file: %v", err) + } + keepConditionsEvaluated(items) + + log.Println("Loading font") + if err := ttf.Init(); err != nil { + log.Fatalf("Failed to initialize TrueType package: %v", err) + } + defer ttf.Quit() + if err := useFont(fontName); err != nil { + log.Fatalf("Failed to open font: %v", err) + } + + log.Println("Initializing SDL") + if err := sdl.Init(sdl.INIT_EVENTS); err != nil { + log.Fatalf("Failed to initialize sdl: %v", err) + } + defer sdl.Quit() + sdl.SetHint(sdl.HINT_RENDER_SCALE_QUALITY, "1") + + log.Println("Creating window") + sdlWindow, err := sdl.CreateWindow("menu", + sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, + int32(windowWidth), int32(windowHeight), + sdl.WINDOW_SHOWN|sdl.WINDOW_BORDERLESS|sdl.WINDOW_ALLOW_HIGHDPI|sdl.WINDOW_OPENGL, + ) + if err != nil { + log.Fatalf("Failed to create window: %v", err) + } + defer sdlWindow.Destroy() + + log.Println("Creating renderer") + renderer, err := sdl.CreateRenderer(sdlWindow, -1, sdl.RENDERER_ACCELERATED|sdl.RENDERER_PRESENTVSYNC) + if err != nil { + log.Fatalf("Failed to create renderer: %v", err) + } + defer renderer.Destroy() + + w := window{ + window: sdlWindow, + allItems: items, + curItems: items, + } + w.eventLoop(renderer) +} + +func (w *window) eventLoop(r *sdl.Renderer) { + // Throttle rendering + ticker := time.NewTicker(time.Second / time.Duration(frameLimit)) + defer ticker.Stop() + + for range ticker.C { + event := sdl.PollEvent() + switch tevt := event.(type) { + case *sdl.QuitEvent: + log.Println("Shutting down") + return + case *sdl.KeyboardEvent: + if err := w.handleKeyEvent(tevt); err != nil { + log.Printf("Failed to process key event: %v", err) + } + } + + if err := w.render(r); err != nil { + log.Printf("Failed to render window: %v", err) + } + // Ta-dah! + r.Present() + } +} + +func (w *window) handleKeyEvent(e *sdl.KeyboardEvent) error { + if e.Keysym.Sym == sdl.K_ESCAPE { + w.curItems = w.allItems + return nil + } + + // Try and transform a key code into a string containing a letter + key := string(rune(e.Keysym.Sym)) + for i, itm := range w.curItems { + if itm.ActionKey == key { + if len(itm.Items) > 0 { + w.curItems = itm.Items + return nil + } + + w.curItems[i].active = (e.State == sdl.PRESSED) + if e.State == sdl.RELEASED { + if itm.Condition == nil || !itm.Condition.busy { + go w.curItems[i].call() + } + } + } + } + + return nil +} + +func getItems(path string) ([]listItem, error) { + b, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + var items []listItem + err = json.Unmarshal(b, &items) + return items, err +} + +func keepConditionsEvaluated(items []listItem) { + for _, item := range items { + if item.Condition != nil { + go item.Condition.evaluate() + } + keepConditionsEvaluated(item.Items) + } +} diff --git a/theme.go b/theme.go new file mode 100644 index 0000000..723cd50 --- /dev/null +++ b/theme.go @@ -0,0 +1,37 @@ +package menu + +import ( + "github.com/veandco/go-sdl2/sdl" +) + +type colorScheme struct { + Background sdl.Color + ItemBackground sdl.Color + ItemText sdl.Color + TextBusy sdl.Color +} + +var theme = themeDracula + +var themeGrayScale = colorScheme{ + Background: sdl.Color{R: 20, G: 20, B: 20, A: 255}, + ItemBackground: sdl.Color{R: 60, G: 60, B: 60, A: 255}, + ItemText: sdl.Color{R: 240, G: 240, B: 240, A: 255}, +} + +var themeBlueOnBlack = colorScheme{ + Background: sdl.Color{R: 20, G: 20, B: 20, A: 255}, + ItemBackground: sdl.Color{R: 0, G: 100, B: 200, A: 255}, + ItemText: sdl.Color{R: 255, G: 255, B: 255, A: 255}, +} + +var themeDracula = colorScheme{ + Background: rgb(40, 42, 54), + ItemBackground: rgb(68, 71, 90), + ItemText: rgb(248, 248, 242), + TextBusy: rgb(255, 184, 108), +} + +func rgb(r, g, b uint8) sdl.Color { + return sdl.Color{R: r, G: g, B: b, A: 255} +} diff --git a/window.go b/window.go new file mode 100644 index 0000000..d521e0e --- /dev/null +++ b/window.go @@ -0,0 +1,114 @@ +package menu + +import ( + "github.com/juju/errors" + "github.com/localhots/themenu/fonts" + "github.com/veandco/go-sdl2/sdl" + "github.com/veandco/go-sdl2/ttf" +) + +const ( + paddingX = 8 + paddingY = 8 +) + +var ( + windowWidth = 600 + windowHeight = 800 + borderWidth = 1 + + fontName = "InconsolataGo Regular" + fontSize = 24 + font *ttf.Font +) + +type window struct { + window *sdl.Window + allItems, curItems []listItem +} + +func (w window) render(r *sdl.Renderer) error { + // Resize window + windowHeight = borderWidth*2 + // Border + len(w.curItems)*itemHeight() + // Backgrounds + (len(w.curItems)+1)*paddingY // Paddings + w.window.SetSize(int32(windowWidth), int32(windowHeight)) + + // Reset background + if err := drawWindowBackground(r); err != nil { + return errors.Annotate(err, "draw window background") + } + if err := drawWindowBorder(r, int32(borderWidth)); err != nil { + return errors.Annotate(err, "draw window border") + } + + // Draw items + for i, itm := range w.curItems { + offsetY := int32(i*(itemHeight()+paddingY) + paddingY + borderWidth) + if err := itm.render(r, offsetY); err != nil { + return errors.Annotate(err, "render item") + } + } + + return nil +} + +func drawWindowBackground(r *sdl.Renderer) error { + if err := setDrawColor(r, theme.Background); err != nil { + return errors.Annotate(err, "set background color") + } + backgroundFrame := &sdl.Rect{ + X: 0, + Y: 0, + W: int32(windowWidth), + H: int32(windowHeight), + } + if err := r.FillRect(backgroundFrame); err != nil { + return errors.Annotate(err, "draw background") + } + return nil +} + +func drawWindowBorder(r *sdl.Renderer, width int32) error { + if err := setDrawColor(r, theme.ItemBackground); err != nil { + return errors.Annotate(err, "set border color") + } + for i := int32(1); i <= width; i++ { + borderFrame := &sdl.Rect{ + X: i, + Y: i, + W: int32(windowWidth) - i*2, + H: int32(windowHeight) - i*2, + } + if err := r.DrawRect(borderFrame); err != nil { + return errors.Annotate(err, "draw border") + } + } + + return nil +} + +func useFont(name string) error { + fontPath, err := fonts.Find(name) + if err != nil { + return err + } + if fontPath == "" { + return errors.New("font not found") + } + + font, err = ttf.OpenFont(fontPath, fontSize) + if err != nil { + return errors.Annotatef(err, "load font %s", name) + } + + return nil +} + +func itemHeight() int { + return fontSize + 2*paddingY +} + +func setDrawColor(r *sdl.Renderer, c sdl.Color) error { + return r.SetDrawColor(c.R, c.G, c.B, c.A) +}