~chrisppy/beagles

b985f54e64df28c935afcac646fcfa8cefde7f6f — Chris Palmer a month ago f5ad6a8 + 5d7a632
Merge branch 'scfg' into main
6 files changed, 804 insertions(+), 356 deletions(-)

M CHANGELOG.md
M config/config.go
M doc/beagles-config.5.scd
M doc/beagles.1.scd
M go.mod
M go.sum
M CHANGELOG.md => CHANGELOG.md +5 -1
@@ 1,7 1,10 @@
# Changelog
# changelog

## [Unreleased]
### Breaking Changes
  - The config.toml file has been deprecated and replaced with a
    [SCFG](https://git.sr.ht/~emersion/scfg) based config file to provide
    better file validation and a cleaner format (~chrisppy)

### Added



@@ 13,6 16,7 @@
  - Changed from using environment variables to POSIX Flags (~chrisppy)

### Fixed
  - man pages have been cleaned up (~chrisppy)

### Removed


M config/config.go => config/config.go +654 -225
@@ 18,14 18,91 @@
package config

import (
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
	"strconv"
	"time"

	"git.sr.ht/~emersion/go-scfg"
	wlog "github.com/DataDrake/waterlog"
	"github.com/gdamore/tcell/v2"
	toml "github.com/pelletier/go-toml"
)

const (
	// Polar Night
	nord0 = "#2e3440"
	//nord1 = "#3b4252"
	//nord2 = "#434c5e"
	//nord3 = "#4c566a"
	// SNow Storm
	nord4 = "#d8dee9"
	nord5 = "#e5e9f0"
	//nord6 = "#eceff4"
	// Frost
	//nord7  = "#8fbcbb"
	nord8 = "#88c0d0"
	//nord9  = "#81a1c1"
	//nord10 = "#5e81ac"
	// Aurora
	nord11 = "#bf616a"
	//nord12 = "#d08770"
	//nord13 = "#ebcb8b"
	//nord14 = "#a3be8c"
	nord15 = "#b48ead"

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

	// config key command names
	keyCommands    = "key-commands"
	cmdMode        = "cmd-mode"
	enter          = "enter"
	escape         = "escape"
	leftArrow      = "left-arrow"
	upArrow        = "up-arrow"
	downArrow      = "down-arrow"
	rightArrow     = "right-arrow"
	helpPage       = "help-page"
	down           = "down"
	download       = "download"
	favorite       = "favorite"
	left           = "left"
	list           = "list"
	markFavorite   = "mark-favorite"
	markRead       = "mark-read"
	markUnfavorite = "mark-unfavorite"
	markUnread     = "mark-unread"
	openURL        = "open-url"
	pageDown       = "page-down"
	pageUp         = "page-up"
	play           = "play"
	right          = "right"
	subscription   = "subscription"
	up             = "up"

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

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

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

// Config contains all relevant configuration data for the application


@@ 87,104 164,30 @@ type KeyCMD struct {
	Key   tcell.Key
}

type theme struct {
	BackgroundColor string            `toml:"background_color"`
	ForegroundColor string            `toml:"foreground_color"`
	ErrorColor      string            `toml:"error_color"`
	Separator       string            `toml:"separator_color"`
	ReadColor       string            `toml:"read_color"`
	CommandLine     *commandLineTheme `toml:"command_line, omitempty"`
	StatusLine      *statusLineTheme  `toml:"status_line, omitempty"`
	TitleLine       *titleLineTheme   `toml:"title_line, omitempty"`
	List            *listTheme        `toml:"list, omitempty"`
	Content         *contentTheme     `toml:"content, omitempty"`
	Tree            *treeTheme        `toml:"tree, omitempty"`
}

type config struct {
	Theme              theme       `toml:"theme"`
	KeyCommands        keyCommands `toml:"key_commands"`
	Podcast            Podcast     `toml:"podcast"`
	Browser            *Browser    `toml:"browser, omitempty"`
	AutoUpdateInterval string      `toml:"auto_update_interval, omitempty"`
}

// KeyCommands config
type keyCommands struct {
	MarkRead       string `toml:"mark_read"`
	MarkUnread     string `toml:"mark_unread"`
	MarkFavorite   string `toml:"mark_favorite"`
	MarkUnfavorite string `toml:"mark_unfavorite"`
	OpenURL        string `toml:"open_url"`
	Download       string `toml:"download"`
	Play           string `toml:"play"`
	Left           string `toml:"left"`
	Up             string `toml:"up"`
	Down           string `toml:"down"`
	Right          string `toml:"right"`
	PageUp         string `toml:"page_up"`
	PageDown       string `toml:"page_down"`
	List           string `toml:"list"`
	Subscription   string `toml:"subscription"`
	Favorite       string `toml:"favorite"`
}

// Podcast config
type Podcast struct {
	AutoDownload   bool
	ExternalPlayer *ExternalPlayer `toml:"external_player, omitempty"`
	ExternalPlayer *ExternalPlayer
}

// ExternalPlayer config
type ExternalPlayer struct {
	Bin  string   `toml:"bin"`
	Args []string `toml:"args"`
	Bin  string
	Args []string
}

// Browser config
type Browser struct {
	HTTP *struct {
		Bin  string   `toml:"bin"`
		Args []string `toml:"args"`
	} `toml:"http, omitempty"`
}

type statusLineTheme struct {
	BackgroundColor string `toml:"background_color"`
	ForegroundColor string `toml:"foreground_color"`
	HTTP *HTTPBrowser
}

type titleLineTheme struct {
	BackgroundColor string `toml:"background_color"`
	ForegroundColor string `toml:"foreground_color"`
// HTTPBrowser config
type HTTPBrowser struct {
	Bin  string
	Args []string
}

type commandLineTheme struct {
	BackgroundColor string `toml:"background_color"`
	ForegroundColor string `toml:"foreground_color"`
}

type treeTheme struct {
	BackgroundColor string `toml:"background_color"`
	ForegroundColor string `toml:"foreground_color"`
}

type listTheme struct {
	BackgroundColor string `toml:"background_color"`
	ForegroundColor string `toml:"foreground_color"`
}

type contentTheme struct {
	BackgroundColor string `toml:"background_color"`
	ForegroundColor string `toml:"foreground_color"`
}

func createKeyCMD(key string, cmds map[string]bool) KeyCMD {
	if _, ok := cmds[key]; ok {
		wlog.Fatalf("%s is used multiple times in the config", key)
	}
	cmds[key] = false

func createKeyCMD(key string) KeyCMD {
	keyCMD := KeyCMD{
		Text: key,
		Key:  tcell.KeyRune,


@@ 210,200 213,626 @@ func createKeyCMD(key string, cmds map[string]bool) KeyCMD {
// create a default one.  If an error occurs reading the config, it will use
// the default one.
func Load(configDir string) *Config {
	cfg := load(configDir)

	cmds := make(map[string]bool)

	theme := Theme{
		ErrColor:       tcell.GetColor(cfg.Theme.ErrorColor),
		SeparatorColor: tcell.GetColor(cfg.Theme.Separator),
		ReadColor:      tcell.GetColor(cfg.Theme.ReadColor),
	path := filepath.Clean(filepath.Join(configDir, "config"))
	if _, err := os.Stat(path); os.IsNotExist(err) {
		if err := ioutil.WriteFile(filepath.Clean(path), []byte(defCfgBytes()), 0600); err != nil {
			return defaultConfig()
		}
		return defaultConfig()
	}

	c := &Config{
		Podcast:        cfg.Podcast,
		Browser:        cfg.Browser,
		Theme:          theme,
		CMDMode:        createKeyCMD(":", cmds),
		Enter:          createKeyCMD("Enter", cmds),
		Escape:         createKeyCMD("Esc", cmds),
		NavLeftArrow:   createKeyCMD("Left", cmds),
		NavUpArrow:     createKeyCMD("Up", cmds),
		NavDownArrow:   createKeyCMD("Down", cmds),
		NavRightArrow:  createKeyCMD("Right", cmds),
		Help:           createKeyCMD("?", cmds),
		List:           createKeyCMD(cfg.KeyCommands.List, cmds),
		Subscription:   createKeyCMD(cfg.KeyCommands.Subscription, cmds),
		Favorite:       createKeyCMD(cfg.KeyCommands.Favorite, cmds),
		NavLeft:        createKeyCMD(cfg.KeyCommands.Left, cmds),
		NavUp:          createKeyCMD(cfg.KeyCommands.Up, cmds),
		NavDown:        createKeyCMD(cfg.KeyCommands.Down, cmds),
		NavRight:       createKeyCMD(cfg.KeyCommands.Right, cmds),
		NavPageUp:      createKeyCMD(cfg.KeyCommands.PageUp, cmds),
		NavPageDown:    createKeyCMD(cfg.KeyCommands.PageDown, cmds),
		Play:           createKeyCMD(cfg.KeyCommands.Play, cmds),
		Download:       createKeyCMD(cfg.KeyCommands.Download, cmds),
		OpenURL:        createKeyCMD(cfg.KeyCommands.OpenURL, cmds),
		MarkRead:       createKeyCMD(cfg.KeyCommands.MarkRead, cmds),
		MarkUnread:     createKeyCMD(cfg.KeyCommands.MarkUnread, cmds),
		MarkFavorite:   createKeyCMD(cfg.KeyCommands.MarkFavorite, cmds),
		MarkUnfavorite: createKeyCMD(cfg.KeyCommands.MarkUnfavorite, cmds),
	cfg, err := scfg.Load(filepath.Clean(path))
	if err != nil {
		return defaultConfig()
	}

	bg := tcell.GetColor(cfg.Theme.BackgroundColor)
	fg := tcell.GetColor(cfg.Theme.ForegroundColor)
	errors := make(map[error]bool)
	c := &Config{}

	if cfg.Theme.CommandLine != nil {
		w := cfg.Theme.CommandLine
		c.Theme.CMDBGColor = tcell.GetColor(w.BackgroundColor)
		c.Theme.CMDFGColor = tcell.GetColor(w.ForegroundColor)
	if val, err := processAutoUpdate(cfg); err != nil {
		errors[err] = false
	} else {
		c.Theme.CMDBGColor = bg
		c.Theme.CMDFGColor = fg
		c.AutoUpdateInterval = val
	}

	if cfg.Theme.StatusLine != nil {
		w := cfg.Theme.StatusLine
		c.Theme.StatusBGColor = tcell.GetColor(w.BackgroundColor)
		c.Theme.StatusFGColor = tcell.GetColor(w.ForegroundColor)
	if val, err := processPodcast(cfg); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		c.Theme.StatusBGColor = bg
		c.Theme.StatusFGColor = fg
		c.Podcast = *val
	}

	if cfg.Theme.TitleLine != nil {
		w := cfg.Theme.TitleLine
		c.Theme.TitleBGColor = tcell.GetColor(w.BackgroundColor)
		c.Theme.TitleFGColor = tcell.GetColor(w.ForegroundColor)
	if val, err := processBrowser(cfg); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		c.Theme.TitleBGColor = bg
		c.Theme.TitleFGColor = fg
		c.Browser = val
	}

	if cfg.Theme.List != nil {
		w := cfg.Theme.List
		c.Theme.ListBGColor = tcell.GetColor(w.BackgroundColor)
		c.Theme.ListFGColor = tcell.GetColor(w.ForegroundColor)
	if val, err := processKeyCommands(cfg); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		c.Theme.ListBGColor = bg
		c.Theme.ListFGColor = fg
		c.CMDMode = val[cmdMode]
		c.Enter = val[enter]
		c.Escape = val[escape]
		c.NavLeftArrow = val[leftArrow]
		c.NavUpArrow = val[upArrow]
		c.NavDownArrow = val[downArrow]
		c.NavRightArrow = val[rightArrow]
		c.Help = val[helpPage]
		c.List = val[list]
		c.Subscription = val[subscription]
		c.Favorite = val[favorite]
		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.MarkUnfavorite = val[markUnfavorite]
	}

	if cfg.Theme.Tree != nil {
		w := cfg.Theme.Tree
		c.Theme.TreeBGColor = tcell.GetColor(w.BackgroundColor)
		c.Theme.TreeFGColor = tcell.GetColor(w.ForegroundColor)
	if val, err := processTheme(cfg); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		c.Theme.TreeBGColor = bg
		c.Theme.TreeFGColor = fg
		c.Theme = *val
	}

	if cfg.Theme.Content != nil {
		w := cfg.Theme.Content
		c.Theme.ContentBGColor = tcell.GetColor(w.BackgroundColor)
		c.Theme.ContentFGColor = tcell.GetColor(w.ForegroundColor)
	} else {
		c.Theme.ContentBGColor = bg
		c.Theme.ContentFGColor = fg
	e := ""
	for err := range errors {
		e = fmt.Sprintf("%s%s\n", e, err.Error())
	}

	c.AutoUpdateInterval, _ = time.ParseDuration(cfg.AutoUpdateInterval)
	if e != "" {
		wlog.Fatal(e)
	}

	return c
}

func load(configDir string) *config {
	dcfg := defaultConfig()
	path := filepath.Clean(filepath.Join(configDir, "config.toml"))
	if _, err := os.Stat(path); os.IsNotExist(err) {
		b, err := toml.Marshal(dcfg)
func processAutoUpdate(cfg scfg.Block) (time.Duration, error) {
	d, err := getValue(autoUpdateInterval, cfg, false)
	if err != nil {
		return 0, err
	} else if d == "" {
		return 0, nil
	}

	return time.ParseDuration(d)
}

func processBrowser(cfg scfg.Block) (*Browser, map[error]bool) {
	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
		}

		c := a.Children

		v, err := getValues(navigate, c, true)
		if err != nil {
			return dcfg
			errors[err] = false
			continue
		} else if len(v) < 2 {
			errors[fmt.Errorf("at least 2 elements are required for %s", browser)] = false
			continue
		}
		if err := ioutil.WriteFile(filepath.Clean(path), b, 0600); err != nil {
			return dcfg

		if p == "http" {
			e := &HTTPBrowser{
				Args: make([]string, len(v)-1),
			}

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

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

			if !found {
				errors[fmt.Errorf("[URL] must be one of the args provided for %s", browser)] = false
			} else {
				b.HTTP = e
			}
		}
		return dcfg
	}

	c, err := ioutil.ReadFile(filepath.Clean(path))
	return b, errors
}

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

	d, err := checkBlock(podcast, cfg, false)
	if err != nil {
		return dcfg
		errors[err] = false
		return nil, errors
	} else if d == nil {
		return &Podcast{AutoDownload: false}, nil
	}

	cfg := &config{}
	if err := toml.Unmarshal(c, cfg); err != nil {
		return dcfg
	c := d.Children

	p := &Podcast{}

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

	return cfg
	if v, err := getValues(externalPlayer, c, false); 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 := &ExternalPlayer{
			Args: make([]string, len(v)-1),
		}

		found := false
		for i, a := range v {
			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 %s", externalPlayer)] = false
		} else {
			p.ExternalPlayer = e
		}
	}

	return p, errors
}

func defaultConfig() *config {
	cltheme := &commandLineTheme{
		BackgroundColor: "#2e3440",
		ForegroundColor: "#d8dee9",
func processKeyCommands(cfg scfg.Block) (map[string]KeyCMD, map[error]bool) {
	keys := make(map[string]KeyCMD)
	errors := make(map[error]bool)

	d, err := checkBlock(keyCommands, cfg, true)
	if err != nil {
		errors[err] = false
		return nil, errors
	}

	keys[cmdMode] = createKeyCMD(":")
	keys[enter] = createKeyCMD("Enter")
	keys[escape] = createKeyCMD("Esc")
	keys[leftArrow] = createKeyCMD("Left")
	keys[upArrow] = createKeyCMD("Up")
	keys[downArrow] = createKeyCMD("Down")
	keys[rightArrow] = createKeyCMD("Right")
	keys[helpPage] = createKeyCMD("?")

	c := d.Children

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

	cnttheme := &contentTheme{
		BackgroundColor: "#2e3440",
		ForegroundColor: "#e5e9f0",
	if v, err := getValue(download, c, true); err != nil {
		errors[err] = false
	} else {
		keys[download] = createKeyCMD(v)
	}

	ltheme := &listTheme{
		BackgroundColor: "#2e3440",
		ForegroundColor: "#e5e9f0",
	if v, err := getValue(favorite, c, true); err != nil {
		errors[err] = false
	} else {
		keys[favorite] = createKeyCMD(v)
	}

	ttheme := &treeTheme{
		BackgroundColor: "#2e3440",
		ForegroundColor: "#e5e9f0",
	if v, err := getValue(left, c, true); err != nil {
		errors[err] = false
	} else {
		keys[left] = createKeyCMD(v)
	}

	extPlay := &ExternalPlayer{
		Bin:  "ffplay",
		Args: []string{"[FILE]"},
	if v, err := getValue(list, c, true); err != nil {
		errors[err] = false
	} else {
		keys[list] = createKeyCMD(v)
	}

	keys := keyCommands{
		MarkRead:       "m",
		MarkUnread:     "u",
		MarkFavorite:   "f",
		MarkUnfavorite: "c",
		OpenURL:        "n",
		Download:       "d",
		Play:           "p",
		Left:           "h",
		Down:           "j",
		Up:             "k",
		Right:          "l",
		PageUp:         "PgUp",
		PageDown:       "PgDn",
		List:           "L",
		Subscription:   "S",
		Favorite:       "F",
	if v, err := getValue(markFavorite, c, true); err != nil {
		errors[err] = false
	} else {
		keys[markFavorite] = createKeyCMD(v)
	}

	pod := Podcast{
		AutoDownload:   true,
		ExternalPlayer: extPlay,
	if v, err := getValue(markRead, c, true); err != nil {
		errors[err] = false
	} else {
		keys[markRead] = createKeyCMD(v)
	}

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

	theme := theme{
		BackgroundColor: "#88c0d0",
		ForegroundColor: "#2e3440",
		ErrorColor:      "#bf616a",
		Separator:       "#88c0d0",
		ReadColor:       "#b48ead",
		CommandLine:     cltheme,
		Content:         cnttheme,
		List:            ltheme,
		Tree:            ttheme,
	if v, err := getValue(markUnread, c, true); err != nil {
		errors[err] = false
	} else {
		keys[markUnread] = createKeyCMD(v)
	}

	return &config{
		Theme:       theme,
		KeyCommands: keys,
		Podcast:     pod,
	if v, err := getValue(openURL, c, true); err != nil {
		errors[err] = false
	} else {
		keys[openURL] = createKeyCMD(v)
	}

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

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

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

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

	if v, err := getValue(subscription, c, true); err != nil {
		errors[err] = false
	} else {
		keys[subscription] = createKeyCMD(v)
	}

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

	return keys, errors
}

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

	d, err := checkBlock(theme, cfg, true)
	if err != nil {
		errors[err] = false
		return nil, errors
	}

	c := d.Children

	t := &Theme{}

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

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

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

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

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

	if bga, fga, err := getChildTheme(titleBar, bg, fg, c); 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, c); 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, c); 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, c); 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, c); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		t.ListBGColor = tcell.GetColor(bga)
		t.ListFGColor = tcell.GetColor(fga)
	}

	if bga, fga, err := getChildTheme(tree, bg, fg, c); len(err) > 0 {
		for k, v := range err {
			errors[k] = v
		}
	} else {
		t.TreeBGColor = tcell.GetColor(bga)
		t.TreeFGColor = tcell.GetColor(fga)
	}

	return t, errors
}

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

	f, err := checkBlock(name, c, false)
	if err != nil {
		errors[err] = false
		return "", "", errors
	} else if f == nil {
		return bg, fg, nil
	}

	e := f.Children

	bga := ""
	if v, err := getValue(backgroundColor, e, true); err != nil {
		errors[err] = false
	} else {
		bga = v
	}

	fga := ""
	if v, err := getValue(foregroundColor, e, true); err != nil {
		errors[err] = false
	} else {
		fga = v
	}

	return bga, fga, errors
}

func getValues(name string, b scfg.Block, required bool) ([]string, error) {
	d, err := checkBlock(name, b, required)
	if err != nil {
		return nil, err
	} else if d == nil {
		if required {
			return nil, err
		}
		return nil, nil
	} else if len(d.Params) < 1 {
		return nil, fmt.Errorf("value(s) missing for %s", name)
	}

	return d.Params, nil
}
func getValue(name string, b scfg.Block, required bool) (string, error) {
	d, err := getValues(name, b, required)
	if err != nil {
		return "", err
	} else if len(d) < 1 || d[0] == "" {
		return "", fmt.Errorf("value(s) missing for %s", name)
	}

	return d[0], nil
}

func checkBlock(name string, b scfg.Block, required bool) (*scfg.Directive, 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)
	}

	return d[0], nil
}

func defaultConfig() *Config {
	return &Config{
		AutoUpdateInterval: 0,
		CMDMode:            createKeyCMD(":"),
		Enter:              createKeyCMD("Enter"),
		Escape:             createKeyCMD("Esc"),
		NavLeftArrow:       createKeyCMD("Left"),
		NavUpArrow:         createKeyCMD("Up"),
		NavDownArrow:       createKeyCMD("Down"),
		NavRightArrow:      createKeyCMD("Right"),
		Help:               createKeyCMD("?"),
		List:               createKeyCMD("L"),
		Subscription:       createKeyCMD("S"),
		Favorite:           createKeyCMD("F"),
		NavLeft:            createKeyCMD("h"),
		NavUp:              createKeyCMD("j"),
		NavDown:            createKeyCMD("k"),
		NavRight:           createKeyCMD("l"),
		NavPageUp:          createKeyCMD("PgUp"),
		NavPageDown:        createKeyCMD("PgDn"),
		Play:               createKeyCMD("p"),
		Download:           createKeyCMD("d"),
		OpenURL:            createKeyCMD("n"),
		MarkRead:           createKeyCMD("m"),
		MarkUnread:         createKeyCMD("u"),
		MarkFavorite:       createKeyCMD("f"),
		MarkUnfavorite:     createKeyCMD("c"),
		Theme: Theme{
			ErrColor:       tcell.GetColor(nord11),
			SeparatorColor: tcell.GetColor(nord8),
			ReadColor:      tcell.GetColor(nord15),
			CMDBGColor:     tcell.GetColor(nord0),
			CMDFGColor:     tcell.GetColor(nord4),
			StatusBGColor:  tcell.GetColor(nord8),
			StatusFGColor:  tcell.GetColor(nord0),
			TitleBGColor:   tcell.GetColor(nord8),
			TitleFGColor:   tcell.GetColor(nord0),
			ListBGColor:    tcell.GetColor(nord0),
			ListFGColor:    tcell.GetColor(nord5),
			TreeBGColor:    tcell.GetColor(nord0),
			TreeFGColor:    tcell.GetColor(nord5),
			ContentBGColor: tcell.GetColor(nord0),
			ContentFGColor: tcell.GetColor(nord5),
		},
		Podcast: Podcast{
			AutoDownload: false,
			ExternalPlayer: &ExternalPlayer{
				Bin:  "ffplay",
				Args: []string{"[FILE]"},
			},
		},
	}
}

func defCfgBytes() []byte {
	return []byte(`auto-update-interval "0"

key-commands {
	down "j"
	download "d"
	favorite "F"
	left "h"
	list "L"
	mark-favorite "f"
	mark-read "m"
	mark-unfavorite "c"
	mark-unread "u"
	open-url "n"
	page-down "PgDn"
	page-up "PgUp"
	play "p"
	right "l"
	subscription "S"
	up "k"
}

podcast {
	auto-download "false"

	external-player "mpv" "--speed=1.5" "[FILE]"
	}
}

theme {
	background-color "#88c0d0"
	error-color "#bf616a"
	foreground-color "#2e3440"
	read-color "#b48ead"
	separator-color "#88c0d0"

	command-line {
		background-color "#2e3440"
		foreground-color "#d8dee9"
	}
	content {
		background-color "#2e3440"
		foreground-color "#e5e9f0"
	}
	list {
		background-color "#2e3440"
		foreground-color "#e5e9f0"
	}
	tree {
		background-color "#2e3440"
		foreground-color "#e5e9f0"
	}
}
`)
}

M doc/beagles-config.5.scd => doc/beagles-config.5.scd +127 -110
@@ 4,172 4,189 @@ beagles-config(5)

beagles-config - configuration file format for *beagles*(1)

# CONFIG FILE
# DESCRIPTION

The config file for beagles is config.toml, by default it is located in++
~/.config/beagles. If no config file is found, a default will be created.

As indicated by the extension, the config file is in the TOML format.
*beagles* allows configuring of many of the core functionality within++
the beagles configuration file (config). By default it is located in++
~/.config/beagles. If no config file is found, a default will be++
created.  The configuration file follows the scfg standard

# OPTIONS

Colors can be specified either as W3C color names or RGB hexadecimal strings
(e.g. #88c0d0).  Also Note that if the object colors are not defined it will,
inherit the colors from *background_color* and *foreground_color*.
*auto-update-interval* "<duration>"
	Time between automatic feed update checks, specified by++
numbers with suffixes to denote the units, e.g. "90m",++
"1h30m45s".  Setting this to "0" disables the feature.

Keys can be either a single character or defined by the KeyNames in the tcell
library (e.g. PgUp).
## THEME

*theme* - object
Theme is defined within a _theme_ _{_ _}_ block.  Colors must be specified++
either as W3C color names or RGB hexadecimal strings(e.g. #88c0d0).++
Also Note that if the object colors are not defined it will, inherit++
the colors from *background-color* and *foreground-color*.  Note this++
is a required block.

	*background_color* - string
		The main background color of the application.
*background-color* "<color>"
	Background color of the application when not set in sub blocks.

	*foreground_color* - string
		The main foreground color of the application.
*foreground-color* "<color>"
	Foreground color of the application when not set in sub blocks.

	*error_color* - string
		The color of error messages.
*error-color* "<color>"
	Text color of error messages.

	*separator_color* - string
		The color of separator objects.
*separator-color* "<color>"
	Color of separator objects.

	*read_color* - string
		The color of items marked as read.
*read-color* "<color>"
	Text color of items marked as read.

	*command_line* - object, optional
*COMMAND LINE*
	Command line is defined within a _command-line_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.

		*background_color* - string
			The background color of the command line.
	*background-color* "<color>"
		Background color of the command line.

		*foreground_color* - string
			The foreground color of the command line.
	*foreground-color* "<color>"
		Foreground color of the command line.

	*status_line* - object, optional

		*background_color* - string
			The background color of the status line.
*STATUS LINE*
	Status line is defined within a _status-line_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.

		*foreground_color* - string
			The foreground color of the status line.
	*background-color* "<color>"
		Background color of the status line.

	*title_line* - object, optional
	*foreground-color* "<color>"
		Foreground color of the status line.

		*background_color* - string
			The background color of the title line.
*TITLE BAR*
	Title bar is defined within a _title-bar_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.

		*foreground_color* - string
			The foreground color of the title line.
	*background-color* "<color>"
		Background color of the title bar.

	*list* - object, optional
	*foreground-color* "<color>"
		Foreground color of the title bar.

		*background_color* - string
			The background color of the list pane.
*LIST*
	List is defined within a _list_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.  This will theme the LIST and++
FAVORITES pages.

		*foreground_color* - string
			The foreground color of the list pane.
	*background-color* "<color>"
		Background color of the list.

	*tree* - object, optional
	*foreground-color* "<color>"
		Foreground color of the list.

		*background_color* - string
			The background color of the tree pane.
*TREE*
	Tree is defined within a _tree_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.  This will theme the SUBSCRIPTION++
page.

		*foreground_color* - string
			The foreground color of the tree pane.
	*background-color* "<color>"
		Background color of the tree.

	*content* - object, optional
	*foreground-color* "<color>"
		Foreground color of the tree.

		*background_color* - string
			The background color of the content pane.
*CONTENT*
	Content is defined within a _content_ _{_ _}_ block inside a++
_theme_ _{_ _}_ block.  This will theme the reading panes++
with the LIST, SUBSCRIPTIONS, FAVORITES, and HELP pages.

		*foreground_color* - string
			The foreground color of the content pane.
	*background-color* "<color>"
		Background color of the content.

*auto_update_interval* - string
	Time between automatic feed update checks, specified by numbers with
	suffixes to denote the units, e.g. "90m", "1h30m45s".  Setting this
	to "0" disables the feature.
	*foreground-color* "<color>"
		Foreground color of the content.

*key_commands* - object
## KEY COMMANDS

	*list* - string
		The key used to switch the list page.
Key commands are defined within a _key-commands_ _{_ _}_ block.  Keys++
can be either a single character or defined by the KeyNames in++
the tcell library (e.g. PgUp).  Note this is a required block.

	*subscription* - string
		The key used to switch the subscription page.

	*favorite* - string
		The key used to switch the favorite page.
*list* "<key>"
	Key used to switch the list page.

	*mark_read* - string
		The key used to make a post as read.
*subscription* "<key>"
	Key used to switch the subscription page.

	*mark_unread* - string
		The key used to make a post as unread.
*favorite* "<key>"
	Key used to switch the favorite page.

	*mark_favorite* - string
		The key used to make a post as favorite.
*mark-read* "<key>"
	Key used to make a post as read.

	*mark_unfavorite* - string
		The key used to make a post as unfavorite.
*mark-unread* "<key>"
	Key used to make a post as unread.

	*open_url* - string
		The key used to open the url of the post.
*mark-favorite* "<key>"
	Key used to make a post as favorite.

	*download* - string
		The key used to download a single podcast.
*mark-unfavorite* "<key>"
	Key used to make a post as unfavorite.

	*play* - string
		The key used to play a podcast.
*open-url* "<key>"
	Key used to open the url of the post.

	*left* - string
		The key, other than the left arrow, to move to the left.
		section
*download* "<key>"
	Key used to download a single podcast.

	*up* - string
		The key, other than the up arrow, to move up in the current
		section
*play* "<key>"
	Key used to play a podcast.

	*down* - string
		The key, other than the down arrow, to move down in the current.
		section
*left* "<key>"
	Key, other than the left arrow, to move to the++
left section.

	*right* - string
		The key, other than the right arrow, to move to the right.
		section
*up* "<key>"
	Key, other than the up arrow, to move up in the++
current	section.

	*page_up* - string
		The key to navigate upward quicker in the current section.
*down* "<key>"
	Key, other than the down arrow, to move down in++
the current section.

	*page_down* - string
		The key to navigate downward quicker in the current section.
*right* "<key>"
	Key, other than the right arrow, to move to the++
right section.

*browser* - object, optional
*page-up* "<key>"
	Key to navigate upward quicker in the current++
section.

	*http* - object, optional
*page-down* "<key>"
	Key to navigate downward quicker in the current++
section.

		*bin* - string
			The program to open the url with.
## BROWSER

		*args* - array
			The arguments to pass to the program, must have [URL] as
			one of the arguments as that will substitute the url to
			open.
Browser is defined within a _browser_ _"http"_ _{_ _}_ block.

*podcast* - object
*navigate* "<command>"
	The command to pass to the program, must have [URL]++
as one of the arguments as that will substitute the++
url to open.

	*auto_download* - boolean
		New podcasts that are found during an update will be downloaded.
## PODCAST

	*external_player* - object, optional
Podcast is defined within a _podcast_ _{_ _}_ block.

		*bin* - string
			The program to play the podcast with.
*auto-download* "true"|"false"
	New podcasts that are found during an update will++
be downloaded.

		*args* - array
			The arguments to pass to the program, must have [FILE]
			as one of the arguments as that will substitute the
			podcast file to play.
*external_player* "<command>"
	The command to pass to the program, must have [FILE]++
as one of the arguments as that will substitute the++
podcast file to play.


# SEE ALSO

M doc/beagles.1.scd => doc/beagles.1.scd +13 -17
@@ 16,13 16,11 @@ or even play the podcasts available on the feed.

# OPTIONS

*-c[dir]*
	Set the config directory.  If not set, use++
	*XDG_CONFIG_HOME*/beagles.
*-c [directory]*
	Set the config directory.  If not set, use *XDG_CONFIG_HOME*/beagles.

*-d[dir]*
	Set the database directory.  If not set, use++
	*XDG_DATA_HOME*/beagles.
*-d [directory]*
	Set the database directory.  If not set, use *XDG_DATA_HOME*/beagles.

*-D*
	Print debug lines.


@@ 30,13 28,11 @@ or even play the podcasts available on the feed.
*-h*
	Print basic usage options.

*-l[directory]*
	Set the logging directory.  If not set, use++
	*XDG_CACHE_HOME*/beagles.
*-l [directory]*
	Set the logging directory.  If not set, use *XDG_CACHE_HOME*/beagles.

*-s[directory]*
	Set the download directory. If not set use++
	*XDG_DATA_HOME*/beagles.
*-s [directory]*
	Set the download directory. If not set use *XDG_DATA_HOME*/beagles.

*-V*
	Print version information


@@ 84,22 80,22 @@ or even play the podcasts available on the feed.

*f*
	mark current post as favorite when in LIST, CONTENT, or
	SUBSCRIPTIONS
SUBSCRIPTIONS

*c*
	mark current post as unfavorite when in LIST, CONTENT,++
	SUBSCRIPTIONS, or FAVORITES
SUBSCRIPTIONS, or FAVORITES

*n*
	open post url when in LIST, CONTENT, SUBSCRIPTIONS, or FAVORITES

*d*
	download a single podcast when in LIST, CONTENT, SUBSCRIPTIONS,++
	or FAVORITES
or FAVORITES

*p*
	play single podcast when in LIST, CONTENT, SUBSCRIPTIONS, or++
	FAVORITES
FAVORITES

*ENTER*
	expand/collapse subscription in SUBSCRIPTIONS


@@ 114,7 110,7 @@ or even play the podcasts available on the feed.

*remove, rm* [url]
	remove the feed, note you can omit the url if you are in the++
	subscription page on a current feed
subscription page on a current feed

*update, up*
	update all feeds

M go.mod => go.mod +1 -1
@@ 3,6 3,7 @@ module git.sr.ht/~chrisppy/beagles
go 1.14

require (
	git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc
	git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3
	github.com/DataDrake/waterlog v1.0.5
	github.com/gdamore/tcell/v2 v2.0.0


@@ 10,7 11,6 @@ require (
	github.com/kr/pretty v0.1.0 // indirect
	github.com/mmcdole/gofeed v1.1.0
	github.com/olekukonko/tablewriter v0.0.4 // indirect
	github.com/pelletier/go-toml v1.8.1
	github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
	github.com/stretchr/testify v1.4.0 // indirect
	gitlab.com/tslocum/cbind v0.1.3

M go.sum => go.sum +4 -2
@@ 1,3 1,5 @@
git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc h1:51BD67xFX+bozd3ZRuOUfalrhx4/nQSh6A9lI08rYOk=
git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc/go.mod h1:t+Ww6SR24yYnXzEWiNlOY0AFo5E9B73X++10lrSpp4U=
git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3 h1:4wDp4BKF7NQqoh73VXpZsB/t1OEhDpz/zEpmdQfbjDk=
git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=


@@ 22,6 24,8 @@ github.com/gdamore/tcell/v2 v2.0.0 h1:GRWG8aLfWAlekj9Q6W29bVvkHENc6hp79XOqG4AWDO
github.com/gdamore/tcell/v2 v2.0.0/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=


@@ 46,8 50,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLD
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=