~chrisppy/beagles

1cd50769c1e8542d677970d462354ff0b37fbf32 — Chris Palmer 16 days ago f7fb55e
Updates to config to catch unknown elements
5 files changed, 778 insertions(+), 644 deletions(-)

A config/browser.go
M config/config.go
A config/keycommands.go
A config/podcast.go
A config/theme.go
A config/browser.go => config/browser.go +109 -0
@@ 0,0 1,109 @@
// This file is part of beagles.
//
// Copyright © 2020-2021 Chris Palmer <chris@red-oxide.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package config

import (
	"fmt"

	"git.sr.ht/~emersion/go-scfg"
)

// Browser config
type Browser struct {
	HTTP   *Command
	Gemini *Command
}

func parseBrowser(cfg *Config, dir *scfg.Directive) map[error]bool {
	errors := make(map[error]bool)
	cfg.Browser = &Browser{}
	if len(dir.Params) < 1 {
		errors[fmt.Errorf("protocol http or gemini is required for browser")] = false
		return errors
	}

	var isGemini, isHTTP bool
	switch dir.Params[0] {
	case "gemini":
		isGemini = true
	case "http":
		isHTTP = true
	default:
		errors[fmt.Errorf("unknown protocol provided for browser")] = false
		return errors
	}

	var foundNav bool
	for _, d := range dir.Children {
		switch n := d.Name; n {
		case "navigate":
			foundNav = true
			if err := validateDirective(n, d, multiple); err != nil {
				errors[err] = false
				continue
			}

			if len(d.Params) < 2 {
				errors[fmt.Errorf("at least 2 elements are required for navigate browser")] = false
				continue
			}

			var found bool
			var bin string
			args := make([]string, len(d.Params)-1)
			for i, a := range d.Params {
				if i == 0 {
					bin = a
					continue
				}

				args[i-1] = a
				if a == "[URL]" {
					found = true
				}
			}

			cmd := &Command{
				Bin:  bin,
				Args: args,
			}

			if isHTTP {
				if !found {
					errors[fmt.Errorf(`[URL] must be one of the args provided for browser "http"`)] = false
				} else {
					cfg.Browser.HTTP = cmd
				}
			} else if isGemini {
				if !found {
					errors[fmt.Errorf(`[URL] must be one of the args provided for browser "http"`)] = false
				} else {
					cfg.Browser.Gemini = cmd
				}
			}
		default:
			errors[fmt.Errorf("unknown directive within browser %q", n)] = false
		}
	}

	if !foundNav {
		errors[fmt.Errorf("navigate is a required directive for browser")] = false
	}

	return errors
}

M config/config.go => config/config.go +67 -644
@@ 22,82 22,26 @@ import (
	"io/ioutil"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"git.sr.ht/~emersion/go-scfg"
	"github.com/gdamore/tcell/v2"
	"gitlab.com/tslocum/cbind"
)

type params int

const (
	one params = iota
	none params = iota
	one
	multiple
)

const (
	// config names
	autoUpdateInterval = "auto-update-interval"

	// config key command names
	keyCommands      = "key-commands"
	cmdMode          = "cmd-mode"
	enter            = "enter"
	escape           = "escape"
	left             = "left"
	down             = "down"
	up               = "up"
	right            = "right"
	pageDown         = "page-down"
	pageUp           = "page-up"
	download         = "download"
	markFavorite     = "mark-favorite"
	markRead         = "mark-read"
	markUnfavorite   = "unmark-favorite"
	markUnread       = "mark-unread"
	openURL          = "open-url"
	play             = "play"
	listPage         = "list-page"
	subscriptionPage = "subscription-page"
	favoritePage     = "favorite-page"
	helpPage         = "help-page"
	searchFeed       = "search-feed"
	searchItem       = "search-item"

	// config browser names
	browser  = "browser"
	navigate = "navigate"

	// config podcast names
	podcast        = "podcast"
	autoDownload   = "auto-download"
	externalPlayer = "external-player"

	// config theme names
	theme           = "theme"
	audioIcon       = "audio-icon"
	errorColor      = "error-color"
	readColor       = "read-color"
	separatorColor  = "separator-color"
	backgroundColor = "background-color"
	foregroundColor = "foreground-color"
	commandLine     = "command-line"
	content         = "content"
	tree            = "tree"
	list            = "list"
	titleBar        = "title-bar"
	statusLine      = "status-line"
)

// Config contains all relevant configuration data for the application
type Config struct {
	AutoUpdateInterval time.Duration
	Browser            *Browser
	Theme              Theme
	Podcast            Podcast
	Podcast            *Podcast
	CMDMode            *KeyCMD
	Enter              *KeyCMD
	Escape             *KeyCMD


@@ 122,52 66,12 @@ type Config struct {
	HelpPage           *KeyCMD
}

// Theme config
type Theme struct {
	AudioIcon      string
	ErrColor       tcell.Color
	SeparatorColor tcell.Color
	ReadColor      tcell.Color
	CMDBGColor     tcell.Color
	CMDFGColor     tcell.Color
	StatusBGColor  tcell.Color
	StatusFGColor  tcell.Color
	TitleBGColor   tcell.Color
	TitleFGColor   tcell.Color
	ListBGColor    tcell.Color
	ListFGColor    tcell.Color
	TreeBGColor    tcell.Color
	TreeFGColor    tcell.Color
	ContentBGColor tcell.Color
	ContentFGColor tcell.Color
}

// KeyCMD contains everything needs to set the KeyBinding
type KeyCMD struct {
	Text string
	Rune rune
	Key  tcell.Key
	Mod  tcell.ModMask
}

// Command config
type Command struct {
	Bin  string
	Args []string
}

// Podcast config
type Podcast struct {
	AutoDownload   bool
	ExternalPlayer *Command
}

// Browser config
type Browser struct {
	HTTP   *Command
	Gemini *Command
}

// Load will load the config into the application, if it does not exist it will
// create a default one.  If an error occurs reading the config, it will use
// the default one.


@@ 185,586 89,105 @@ func Load(configDir string) (*Config, error) {
		return nil, err
	}

	errors := make(map[error]bool)
	c := &Config{}

	if val, err := getValue(autoUpdateInterval, cfg, false, one); err != nil {
		errors[err] = false
	} else if val == nil || val[0] == "" {
		c.AutoUpdateInterval = 0
	} else {
		t, err := time.ParseDuration(val[0])
		if err != nil {
			errors[err] = false
		} else {
			c.AutoUpdateInterval = t
		}
	}

	if val, err := processPodcast(cfg); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		c.Podcast = *val
	}

	if val, err := processBrowser(cfg); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		c.Browser = val
	c := &Config{
		AutoUpdateInterval: 0,
	}

	if val, err := processKeyCommands(cfg); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		c.CMDMode = val[cmdMode]
		c.Enter = val[enter]
		c.Escape = val[escape]
		c.HelpPage = val[helpPage]
		c.ListPage = val[listPage]
		c.SubscriptionPage = val[subscriptionPage]
		c.FavoritePage = val[favoritePage]
		c.NavLeft = val[left]
		c.NavUp = val[up]
		c.NavDown = val[down]
		c.NavRight = val[right]
		c.NavPageUp = val[pageUp]
		c.NavPageDown = val[pageDown]
		c.Play = val[play]
		c.Download = val[download]
		c.OpenURL = val[openURL]
		c.MarkRead = val[markRead]
		c.MarkUnread = val[markUnread]
		c.MarkFavorite = val[markFavorite]
		c.UnmarkFavorite = val[markUnfavorite]
		c.SearchFeed = val[searchFeed]
		c.SearchItem = val[searchItem]
	}

	if val, err := processTheme(cfg); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		c.Theme = *val
	}

	var eb strings.Builder
	for err := range errors {
		eb.WriteString(fmt.Sprintf("%s\n", err.Error()))
	}

	if eb.String() != "" {
		return nil, fmt.Errorf("errors found in the config:\n%s", eb.String())
	}

	return c, nil
}

func createKeyCMD(key string) (*KeyCMD, error) {
	m, k, c, err := cbind.Decode(key)
	if err != nil {
	if err := parse(c, cfg); err != nil {
		return nil, err
	}

	return &KeyCMD{
		Text: key,
		Key:  k,
		Rune: c,
		Mod:  m,
	}, nil
	return c, nil
}

func processBrowser(cfg scfg.Block) (*Browser, map[error]bool) {
func parse(cfg *Config, block scfg.Block) error {
	c := Config{}
	errors := make(map[error]bool)

	d := cfg.GetAll(browser)
	if d == nil {
		return nil, nil
	}

	b := &Browser{}
	strs := make(map[string]bool)
	for _, a := range d {
		if len(a.Params) < 1 {
			errors[fmt.Errorf("value is required for %s", browser)] = false
			continue
		}

		p := a.Params[0]

		if _, ok := strs[p]; ok {
			errors[fmt.Errorf("%s has a duplicate %s", browser, p)] = false
			continue
		}

		ac := a.Children

		v, err := getValue(navigate, ac, true, multiple)
		if err != nil {
			errors[err] = false
			continue
		} else if len(v) < 2 {
			errors[fmt.Errorf("at least 2 elements are required for %s", browser)] = false
			continue
		}

		var found bool
		var bin string
		args := make([]string, len(v)-1)

		for i, a := range v {
			if i == 0 {
				bin = a
				continue
			}

			args[i-1] = a
			if a == "[URL]" {
				found = true
	var fkeys, fTheme bool

	for _, dir := range block {
		switch n := dir.Name; n {
		case "auto-update-interval":
			if err := validateDirective(n, dir, one); err != nil {
				errors[err] = false
			} else if dir.Params != nil && dir.Params[0] != "" {
				t, err := time.ParseDuration(dir.Params[0])
				if err != nil {
					errors[err] = false
				} else {
					c.AutoUpdateInterval = t
				}
			}
		}

		switch p {
		case "http":
			if !found {
				errors[fmt.Errorf(`[URL] must be one of the args provided for %s "http"`, browser)] = false
			} else {
				b.HTTP = &Command{
					Bin:  bin,
					Args: args,
		case "browser":
			if err := parseBrowser(cfg, dir); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			}
		case "gemini":
			if !found {
				errors[fmt.Errorf(`[URL] must be one of the args provided for %s "gemini"`, browser)] = false
			} else {
				b.Gemini = &Command{
					Bin:  bin,
					Args: args,
		case "key-commands":
			fkeys = true
			if err := parseKeyCommands(cfg, dir); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			}

		default:
			errors[fmt.Errorf("unsupported protocol provided for %s", browser)] = false
		}
	}

	return b, errors
}

func processPodcast(cfg scfg.Block) (*Podcast, map[error]bool) {
	errors := make(map[error]bool)

	d := cfg.Get(podcast)
	if d == nil {
		return &Podcast{AutoDownload: false}, nil
	}

	dc := d.Children

	p := &Podcast{}

	if v, err := getValue(autoDownload, dc, false, one); err != nil {
		errors[err] = false
	} else if v == nil || v[0] == "" {
		p.AutoDownload = false
	} else {
		if b, err := strconv.ParseBool(v[0]); err != nil {
			errors[err] = false
		} else {
			p.AutoDownload = b
		}
	}

	if v, err := getValue(externalPlayer, dc, false, multiple); err != nil {
		errors[err] = false
	} else if len(v) < 2 {
		errors[fmt.Errorf("at least 2 elements are required for %s", externalPlayer)] = false
	} else {
		e := &Command{
			Args: make([]string, len(v)-1),
		}

		found := false
		for i, a := range v {
			if i == 0 {
				e.Bin = a
				continue
		case "podcast":
			if err := parsePodcast(cfg, dir); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			}

			e.Args[i-1] = a
			if a == "[FILE]" {
				found = true
		case "theme":
			fTheme = true
			if err := parseTheme(cfg, dir); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			}
		default:
			errors[fmt.Errorf("unknown directive %q", n)] = false
		}

		if !found {
			errors[fmt.Errorf("[FILE] must be one of the args provided for %s", externalPlayer)] = false
		} else {
			p.ExternalPlayer = e
		}
	}

	return p, errors
}

func processKeyCommands(cfg scfg.Block) (map[string]*KeyCMD, map[error]bool) {
	keys := make(map[string]*KeyCMD)
	errors := make(map[error]bool)

	d := cfg.Get(keyCommands)
	if d == nil {
		errors[fmt.Errorf("%s is a required", keyCommands)] = false
		return nil, errors
	}

	dc := d.Children

	if k, err := createKeyCMD(":"); err != nil {
		errors[err] = false
	} else {
		keys[cmdMode] = k
	}

	if k, err := createKeyCMD("Enter"); err != nil {
		errors[err] = false
	} else {
		keys[enter] = k
	}

	if k, err := createKeyCMD("Esc"); err != nil {
		errors[err] = false
	} else {
		keys[escape] = k
	}

	if k, err := createKeyCMD("?"); err != nil {
		errors[err] = false
	} else {
		keys[searchFeed] = k
	}

	if k, err := createKeyCMD("/"); err != nil {
		errors[err] = false
	} else {
		keys[searchItem] = k
	}

	if v, err := getValue(helpPage, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[helpPage] = k
	}

	if v, err := getValue(down, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[down] = k
	}

	if v, err := getValue(download, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[download] = k
	}

	if v, err := getValue(favoritePage, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[favoritePage] = k
	}

	if v, err := getValue(left, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[left] = k
	}

	if v, err := getValue(listPage, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[listPage] = k
	}

	if v, err := getValue(markFavorite, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[markFavorite] = k
	}

	if v, err := getValue(markRead, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[markRead] = k
	}

	if v, err := getValue(markUnfavorite, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[markUnfavorite] = k
	}

	if v, err := getValue(markUnread, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[markUnread] = k
	}

	if v, err := getValue(openURL, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[openURL] = k
	}

	if v, err := getValue(pageDown, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[pageDown] = k
	}

	if v, err := getValue(pageUp, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[pageUp] = k
	}

	if v, err := getValue(play, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[play] = k
	}

	if v, err := getValue(right, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[right] = k
	}

	if v, err := getValue(subscriptionPage, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[subscriptionPage] = k
	}

	if v, err := getValue(up, dc, true, one); err != nil {
		errors[err] = false
	} else if k, err := createKeyCMD(v[0]); err != nil {
		errors[err] = false
	} else {
		keys[up] = k
	}

	return keys, errors
}

func processTheme(cfg scfg.Block) (*Theme, map[error]bool) {
	errors := make(map[error]bool)

	d := cfg.Get(theme)
	if d == nil {
		errors[fmt.Errorf("%s is a required", theme)] = false
		return nil, errors
	}

	dc := d.Children

	t := &Theme{}

	if v, err := getValue(audioIcon, dc, true, one); err != nil {
		errors[err] = false
	} else {
		t.AudioIcon = v[0]
	}

	bg := ""
	if v, err := getValue(backgroundColor, dc, true, one); err != nil {
		errors[err] = false
	} else {
		bg = v[0]
	}

	fg := ""
	if v, err := getValue(foregroundColor, dc, true, one); err != nil {
		errors[err] = false
	} else {
		fg = v[0]
	}

	if v, err := getValue(errorColor, dc, true, one); err != nil {
		errors[err] = false
	} else {
		t.ErrColor = tcell.GetColor(v[0])
	}

	if v, err := getValue(readColor, dc, true, one); err != nil {
		errors[err] = false
	} else {
		t.ReadColor = tcell.GetColor(v[0])
	}

	if v, err := getValue(separatorColor, dc, true, one); err != nil {
		errors[err] = false
	} else {
		t.SeparatorColor = tcell.GetColor(v[0])
	}

	if bga, fga, err := getChildTheme(titleBar, bg, fg, dc, false, one); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		t.TitleBGColor = tcell.GetColor(bga)
		t.TitleFGColor = tcell.GetColor(fga)
	}

	if bga, fga, err := getChildTheme(statusLine, bg, fg, dc, false, one); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		t.StatusBGColor = tcell.GetColor(bga)
		t.StatusFGColor = tcell.GetColor(fga)
	}

	if bga, fga, err := getChildTheme(commandLine, bg, fg, dc, false, one); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		t.CMDBGColor = tcell.GetColor(bga)
		t.CMDFGColor = tcell.GetColor(fga)
	}

	if bga, fga, err := getChildTheme(content, bg, fg, dc, false, one); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		t.ContentBGColor = tcell.GetColor(bga)
		t.ContentFGColor = tcell.GetColor(fga)
	}

	if bga, fga, err := getChildTheme(list, bg, fg, dc, false, one); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		t.ListBGColor = tcell.GetColor(bga)
		t.ListFGColor = tcell.GetColor(fga)
	if !fkeys {
		errors[fmt.Errorf("key-commands is a required directive")] = false
	}

	if bga, fga, err := getChildTheme(tree, bg, fg, dc, false, one); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		t.TreeBGColor = tcell.GetColor(bga)
		t.TreeFGColor = tcell.GetColor(fga)
	if !fTheme {
		errors[fmt.Errorf("theme is a required directive")] = false
	}

	return t, errors
}

func getChildTheme(name string, bg string, fg string, c scfg.Block, required bool, p params) (string, string, map[error]bool) {
	errors := make(map[error]bool)

	d := c.GetAll(name)
	if d == nil || len(d) < 1 {
		if required {
			errors[fmt.Errorf("%s is a required", name)] = false
			return "", "", errors
		}
		return bg, fg, nil
	} else if p == one && len(d) > 1 {
		errors[fmt.Errorf("more than one %s exists", name)] = false
		return "", "", errors
	}

	dc := d[0].Children

	bga := ""
	if v, err := getValue(backgroundColor, dc, true, one); err != nil {
		errors[fmt.Errorf("%s: %s", name, err.Error())] = false
	} else {
		bga = v[0]
	var eb strings.Builder
	for err := range errors {
		eb.WriteString(fmt.Sprintf("%s\n", err.Error()))
	}

	fga := ""
	if v, err := getValue(foregroundColor, dc, true, one); err != nil {
		errors[fmt.Errorf("%s: %s", name, err.Error())] = false
	} else {
		fga = v[0]
	if eb.String() != "" {
		return fmt.Errorf("errors found in the config:\n%s", eb.String())
	}

	return bga, fga, errors
	return nil
}

func getValue(name string, b scfg.Block, required bool, p params) ([]string, error) {
	d := b.GetAll(name)
	if d == nil || len(d) < 1 {
		if !required {
			return nil, nil
		}
		return nil, fmt.Errorf("%s is required", name)
	} else if len(d) > 1 {
		return nil, fmt.Errorf("more than one %s exists", name)
	}

	o := d[0]

func validateDirective(name string, dir *scfg.Directive, p params) error {
	switch p {
	case none:
		if dir.Params != nil || len(dir.Params) > 0 {
			return fmt.Errorf("no value should be present for %s", name)
		}
	case one:
		if o.Params == nil || len(o.Params) == 0 {
			return nil, fmt.Errorf("a value must be present for %s", name)
		if dir.Params == nil || len(dir.Params) == 0 {
			return fmt.Errorf("a value must be present for %s", name)
		}
		if len(o.Params) > 1 {
			return nil, fmt.Errorf("only one value is allowed for %s", name)
		if len(dir.Params) > 1 {
			return fmt.Errorf("only one value is allowed for %s", name)
		}
	case multiple:
		if o.Params == nil || len(o.Params) == 0 {
			return nil, fmt.Errorf("value(s) must be present for %s", name)
		if dir.Params == nil || len(dir.Params) == 0 {
			return fmt.Errorf("value(s) must be present for %s", name)
		}
	}

	return o.Params, nil
	return nil
}

func defaultConfig() []byte {

A config/keycommands.go => config/keycommands.go +279 -0
@@ 0,0 1,279 @@
// This file is part of beagles.
//
// Copyright © 2020-2021 Chris Palmer <chris@red-oxide.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package config

import (
	"fmt"

	"git.sr.ht/~emersion/go-scfg"
	"github.com/gdamore/tcell/v2"
	"gitlab.com/tslocum/cbind"
)

// KeyCMD contains everything needs to set the KeyBinding
type KeyCMD struct {
	Text string
	Rune rune
	Key  tcell.Key
	Mod  tcell.ModMask
}

func createKeyCMD(key string) (*KeyCMD, error) {
	m, k, c, err := cbind.Decode(key)
	if err != nil {
		return nil, err
	}

	return &KeyCMD{
		Text: key,
		Key:  k,
		Rune: c,
		Mod:  m,
	}, nil
}

func parseKeyCommands(cfg *Config, dir *scfg.Directive) map[error]bool {
	errors := make(map[error]bool)

	if k, err := createKeyCMD(":"); err != nil {
		errors[err] = false
	} else {
		cfg.CMDMode = k
	}

	if k, err := createKeyCMD("Enter"); err != nil {
		errors[err] = false
	} else {
		cfg.Enter = k
	}

	if k, err := createKeyCMD("Esc"); err != nil {
		errors[err] = false
	} else {
		cfg.Escape = k
	}

	if k, err := createKeyCMD("?"); err != nil {
		errors[err] = false
	} else {
		cfg.SearchFeed = k
	}

	if k, err := createKeyCMD("/"); err != nil {
		errors[err] = false
	} else {
		cfg.SearchItem = k
	}

	for _, d := range dir.Children {
		switch n := d.Name; n {
		case "down":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.NavDown = k
			}
		case "download":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.Download = k
			}
		case "favorite-page":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.FavoritePage = k
			}
		case "help-page":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.HelpPage = k
			}
		case "left":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.NavLeft = k
			}
		case "list-page":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.ListPage = k
			}
		case "mark-favorite":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.MarkFavorite = k
			}
		case "mark-read":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.MarkRead = k
			}
		case "mark-unread":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.MarkUnread = k
			}
		case "open-url":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.OpenURL = k
			}
		case "page-down":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.NavPageDown = k
			}
		case "page-up":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.NavPageUp = k
			}
		case "play":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.Play = k
			}
		case "right":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.NavRight = k
			}
		case "subscription-page":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.SubscriptionPage = k
			}
		case "unmark-favorite":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.UnmarkFavorite = k
			}
		case "up":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if k, err := createKeyCMD(d.Params[0]); err != nil {
				errors[err] = false
			} else {
				cfg.NavUp = k
			}
		default:
			errors[fmt.Errorf("unknown directive within keycommands %q", n)] = false
		}
	}

	if cfg.NavDown == nil {
		errors[fmt.Errorf("down is a required directive for key-commands")] = false
	}
	if cfg.Download == nil {
		errors[fmt.Errorf("download is a required directive for key-commands")] = false
	}
	if cfg.FavoritePage == nil {
		errors[fmt.Errorf("favorite-page is a required directive for key-commands")] = false
	}
	if cfg.HelpPage == nil {
		errors[fmt.Errorf("help-page is a required directive for key-commands")] = false
	}
	if cfg.NavLeft == nil {
		errors[fmt.Errorf("left is a required directive for key-commands")] = false
	}
	if cfg.ListPage == nil {
		errors[fmt.Errorf("list-page is a required directive for key-commands")] = false
	}
	if cfg.MarkFavorite == nil {
		errors[fmt.Errorf("mark-favorite is a required directive for key-commands")] = false
	}
	if cfg.MarkRead == nil {
		errors[fmt.Errorf("mark-read is a required directive for key-commands")] = false
	}
	if cfg.MarkUnread == nil {
		errors[fmt.Errorf("mark-unread is a required directive for key-commands")] = false
	}
	if cfg.OpenURL == nil {
		errors[fmt.Errorf("open-url is a required directive for key-commands")] = false
	}
	if cfg.NavPageDown == nil {
		errors[fmt.Errorf("page-down is a required directive for key-commands")] = false
	}
	if cfg.NavPageUp == nil {
		errors[fmt.Errorf("page-up is a required directive for key-commands")] = false
	}
	if cfg.Play == nil {
		errors[fmt.Errorf("play is a required directive for key-commands")] = false
	}
	if cfg.NavRight == nil {
		errors[fmt.Errorf("right is a required directive for key-commands")] = false
	}
	if cfg.SubscriptionPage == nil {
		errors[fmt.Errorf("subscription-page is a required directive for key-commands")] = false
	}
	if cfg.UnmarkFavorite == nil {
		errors[fmt.Errorf("unmark-favorite is a required directive for key-commands")] = false
	}
	if cfg.NavUp == nil {
		errors[fmt.Errorf("up is a required directive for key-commands")] = false
	}

	return errors
}

A config/podcast.go => config/podcast.go +84 -0
@@ 0,0 1,84 @@
// This file is part of beagles.
//
// Copyright © 2020-2021 Chris Palmer <chris@red-oxide.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package config

import (
	"fmt"
	"strconv"

	"git.sr.ht/~emersion/go-scfg"
)

// Podcast config
type Podcast struct {
	AutoDownload   bool
	ExternalPlayer *Command
}

func parsePodcast(cfg *Config, dir *scfg.Directive) map[error]bool {
	errors := make(map[error]bool)
	cfg.Podcast = &Podcast{}

	for _, d := range dir.Children {
		switch n := d.Name; n {
		case "auto-download":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else if d.Params != nil && d.Params[0] != "" {
				if b, err := strconv.ParseBool(d.Params[0]); err != nil {
					errors[err] = false
				} else {
					cfg.Podcast.AutoDownload = b
				}
			}
		case "external-player":
			if err := validateDirective(n, d, multiple); err != nil {
				errors[err] = false
			} else if len(d.Params) < 2 {
				errors[fmt.Errorf("at least 2 elements are required for external-player")] = false
			} else {
				e := &Command{
					Args: make([]string, len(d.Params)-1),
				}

				found := false
				for i, a := range d.Params {
					if i == 0 {
						e.Bin = a
						continue
					}

					e.Args[i-1] = a
					if a == "[FILE]" {
						found = true
					}
				}

				if !found {
					errors[fmt.Errorf("[FILE] must be one of the args provided for external-player")] = false
				} else {
					cfg.Podcast.ExternalPlayer = e
				}
			}
		default:
			errors[fmt.Errorf("unknown directive within podcast %q", n)] = false
		}
	}

	return errors
}

A config/theme.go => config/theme.go +239 -0
@@ 0,0 1,239 @@
// This file is part of beagles.
//
// Copyright © 2020-2021 Chris Palmer <chris@red-oxide.org>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package config

import (
	"fmt"

	"git.sr.ht/~emersion/go-scfg"
	"github.com/gdamore/tcell/v2"
)

// Theme config
type Theme struct {
	AudioIcon      string
	ErrColor       tcell.Color
	SeparatorColor tcell.Color
	ReadColor      tcell.Color
	CMDBGColor     tcell.Color
	CMDFGColor     tcell.Color
	StatusBGColor  tcell.Color
	StatusFGColor  tcell.Color
	TitleBGColor   tcell.Color
	TitleFGColor   tcell.Color
	ListBGColor    tcell.Color
	ListFGColor    tcell.Color
	TreeBGColor    tcell.Color
	TreeFGColor    tcell.Color
	ContentBGColor tcell.Color
	ContentFGColor tcell.Color
}

func parseTheme(cfg *Config, dir *scfg.Directive) map[error]bool {
	errors := make(map[error]bool)
	cfg.Theme = Theme{}

	var bg, fg string
	var faudio, fread, ferr, fsep, ftitle, fstat, fcmd, fcnt, flist, ftree bool
	for _, d := range dir.Children {
		switch n := d.Name; n {
		case "audio-icon":
			faudio = true
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else {
				cfg.Theme.AudioIcon = d.Params[0]
			}
		case "background-color":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else {
				bg = d.Params[0]
			}
		case "command-line":
			fcmd = true
			if bga, fga, err := parseChildTheme(n, d); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			} else {
				cfg.Theme.CMDBGColor = tcell.GetColor(bga)
				cfg.Theme.CMDFGColor = tcell.GetColor(fga)
			}
		case "content":
			fcnt = true
			if bga, fga, err := parseChildTheme(n, d); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			} else {
				cfg.Theme.ContentBGColor = tcell.GetColor(bga)
				cfg.Theme.ContentFGColor = tcell.GetColor(fga)
			}
		case "error-color":
			ferr = true
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else {
				cfg.Theme.ErrColor = tcell.GetColor(d.Params[0])
			}
		case "foreground-color":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else {
				fg = d.Params[0]
			}
		case "list":
			flist = true
			if bga, fga, err := parseChildTheme(n, d); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			} else {
				cfg.Theme.ListBGColor = tcell.GetColor(bga)
				cfg.Theme.ListFGColor = tcell.GetColor(fga)
			}
		case "read-color":
			fread = true
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else {
				cfg.Theme.ReadColor = tcell.GetColor(d.Params[0])
			}
		case "separator-color":
			fsep = true
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else {
				cfg.Theme.SeparatorColor = tcell.GetColor(d.Params[0])
			}
		case "status-bar":
			fstat = true
			if bga, fga, err := parseChildTheme(n, d); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			} else {
				cfg.Theme.StatusBGColor = tcell.GetColor(bga)
				cfg.Theme.StatusFGColor = tcell.GetColor(fga)
			}
		case "title-bar":
			ftitle = true
			if bga, fga, err := parseChildTheme(n, d); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			} else {
				cfg.Theme.TitleBGColor = tcell.GetColor(bga)
				cfg.Theme.TitleFGColor = tcell.GetColor(fga)
			}
		case "tree":
			ftree = true
			if bga, fga, err := parseChildTheme(n, d); len(err) > 0 {
				for k, v := range err {
					errors[k] = v
				}
			} else {
				cfg.Theme.TreeBGColor = tcell.GetColor(bga)
				cfg.Theme.TreeFGColor = tcell.GetColor(fga)
			}
		default:
			errors[fmt.Errorf("unknown directive within theme %q", n)] = false
		}
	}

	if bg == "" {
		errors[fmt.Errorf("background-color is a required directive for theme")] = false
	}
	if fg == "" {
		errors[fmt.Errorf("foreground-color is a required directive for theme")] = false
	}
	if !faudio {
		errors[fmt.Errorf("audio-icon is a required directive for theme")] = false
	}
	if !ferr {
		errors[fmt.Errorf("error-color is a required directive for theme")] = false
	}
	if !fread {
		errors[fmt.Errorf("read-color is a required directive for theme")] = false
	}
	if !fsep {
		errors[fmt.Errorf("separator-color is a required directive for theme")] = false
	}

	if !fcmd && bg != "" && fg != "" {
		cfg.Theme.CMDBGColor = tcell.GetColor(bg)
		cfg.Theme.CMDFGColor = tcell.GetColor(fg)
	}
	if !fcnt && bg != "" && fg != "" {
		cfg.Theme.ContentBGColor = tcell.GetColor(bg)
		cfg.Theme.ContentFGColor = tcell.GetColor(fg)
	}
	if !flist && bg != "" && fg != "" {
		cfg.Theme.ListBGColor = tcell.GetColor(bg)
		cfg.Theme.ListFGColor = tcell.GetColor(fg)
	}
	if !fstat && bg != "" && fg != "" {
		cfg.Theme.StatusBGColor = tcell.GetColor(bg)
		cfg.Theme.StatusFGColor = tcell.GetColor(fg)
	}
	if !ftitle && bg != "" && fg != "" {
		cfg.Theme.TitleBGColor = tcell.GetColor(bg)
		cfg.Theme.TitleFGColor = tcell.GetColor(fg)
	}
	if !ftree && bg != "" && fg != "" {
		cfg.Theme.TreeBGColor = tcell.GetColor(bg)
		cfg.Theme.TreeFGColor = tcell.GetColor(fg)
	}

	return errors
}

func parseChildTheme(name string, dir *scfg.Directive) (string, string, map[error]bool) {
	errors := make(map[error]bool)

	var bg, fg string
	for _, d := range dir.Children {
		switch n := d.Name; n {
		case "background-color":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else {
				bg = d.Params[0]
			}
		case "foreground-color":
			if err := validateDirective(n, d, one); err != nil {
				errors[err] = false
			} else {
				fg = d.Params[0]
			}
		default:
			errors[fmt.Errorf("unknown directive within %s %q", name, n)] = false
		}
	}

	if bg == "" {
		errors[fmt.Errorf("background-color is a required directive for %s", name)] = false
	}

	if fg == "" {
		errors[fmt.Errorf("foreground-color is a required directive for %s", name)] = false
	}

	return bg, fg, errors
}