mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-01-28 08:25:18 +01:00
The contextual UI section regex pattern included "subject" as a valid context type, but the switch statement in parseUi never handled it. Any [ui:subject=...] sections would silently be parsed as folder contexts due to the missing case branch. Remove "subject" from the regex since the feature was never implemented. Update documentation to reflect that only account and folder contexts are supported. Signed-off-by: Robin Jarry <robin@jarry.cc> Reviewed-by: Bence Ferdinandy <bence@ferdinandy.com>
454 lines
16 KiB
Go
454 lines
16 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"path"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"text/template"
|
|
"time"
|
|
|
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
|
"git.sr.ht/~rockorager/vaxis"
|
|
"github.com/emersion/go-message/mail"
|
|
"github.com/go-ini/ini"
|
|
)
|
|
|
|
type UIConfig struct {
|
|
IndexColumns []*ColumnDef `ini:"index-columns" parse:"ParseIndexColumns" default:"flags:4,name<20%,subject,date>="`
|
|
ColumnSeparator string `ini:"column-separator" default:" "`
|
|
|
|
DirListLeft *template.Template `ini:"dirlist-left" default:"{{.Folder}}"`
|
|
DirListRight *template.Template `ini:"dirlist-right" default:"{{if .Unread}}{{humanReadable .Unread}}{{end}}"`
|
|
|
|
AutoMarkRead bool `ini:"auto-mark-read" default:"true"`
|
|
AutoMarkReadInSplit bool `ini:"auto-mark-read-split" default:"false"`
|
|
AutoMarkReadInSplitDelay time.Duration `ini:"auto-mark-read-split-delay" default:"3s"`
|
|
TimestampFormat string `ini:"timestamp-format" default:"2006 Jan 02"`
|
|
ThisDayTimeFormat string `ini:"this-day-time-format" default:"15:04"`
|
|
ThisWeekTimeFormat string `ini:"this-week-time-format" default:"Jan 02"`
|
|
ThisYearTimeFormat string `ini:"this-year-time-format" default:"Jan 02"`
|
|
MessageViewTimestampFormat string `ini:"message-view-timestamp-format" default:"2006 Jan 02, 15:04 GMT-0700"`
|
|
MessageViewThisDayTimeFormat string `ini:"message-view-this-day-time-format"`
|
|
MessageViewThisWeekTimeFormat string `ini:"message-view-this-week-time-format"`
|
|
MessageViewThisYearTimeFormat string `ini:"message-view-this-year-time-format"`
|
|
PinnedTabMarker string "ini:\"pinned-tab-marker\" default:\"`\""
|
|
SidebarWidth int `ini:"sidebar-width" default:"22"`
|
|
QuakeHeight int `ini:"quake-terminal-height" default:"20"`
|
|
MessageListSplit SplitParams `ini:"message-list-split" parse:"ParseSplit"`
|
|
EmptyMessage string `ini:"empty-message" default:"(no messages)"`
|
|
EmptyDirlist string `ini:"empty-dirlist" default:"(no folders)"`
|
|
EmptySubject string `ini:"empty-subject" default:"(no subject)"`
|
|
MouseEnabled bool `ini:"mouse-enabled"`
|
|
ThreadingEnabled bool `ini:"threading-enabled"`
|
|
ForceClientThreads bool `ini:"force-client-threads"`
|
|
ThreadingBySubject bool `ini:"threading-by-subject"`
|
|
ClientThreadsDelay time.Duration `ini:"client-threads-delay" default:"50ms"`
|
|
ThreadContext bool `ini:"show-thread-context"`
|
|
FuzzyComplete bool `ini:"fuzzy-complete"`
|
|
NewMessageBell bool `ini:"new-message-bell" default:"true"`
|
|
Spinner string `ini:"spinner" default:"[..] , [..] , [..] , [..] , [..], [..] , [..] , [..] "`
|
|
SpinnerDelimiter string `ini:"spinner-delimiter" default:","`
|
|
SpinnerInterval time.Duration `ini:"spinner-interval" default:"200ms"`
|
|
IconUnencrypted string `ini:"icon-unencrypted"`
|
|
IconEncrypted string `ini:"icon-encrypted" default:"[e]"`
|
|
IconSigned string `ini:"icon-signed" default:"[s]"`
|
|
IconSignedEncrypted string `ini:"icon-signed-encrypted"`
|
|
IconUnknown string `ini:"icon-unknown" default:"[s?]"`
|
|
IconInvalid string `ini:"icon-invalid" default:"[s!]"`
|
|
IconAttachment string `ini:"icon-attachment" default:"a"`
|
|
IconReplied string `ini:"icon-replied" default:"r"`
|
|
IconForwarded string `ini:"icon-forwarded" default:"f"`
|
|
IconNew string `ini:"icon-new" default:"N"`
|
|
IconOld string `ini:"icon-old" default:"O"`
|
|
IconDraft string `ini:"icon-draft" default:"d"`
|
|
IconFlagged string `ini:"icon-flagged" default:"!"`
|
|
IconMarked string `ini:"icon-marked" default:"*"`
|
|
IconDeleted string `ini:"icon-deleted" default:"X"`
|
|
DirListDelay time.Duration `ini:"dirlist-delay" default:"200ms"`
|
|
DirListTree bool `ini:"dirlist-tree"`
|
|
DirListCollapse int `ini:"dirlist-collapse"`
|
|
Sort []string `ini:"sort" delim:" "`
|
|
NextMessageOnDelete bool `ini:"next-message-on-delete" default:"true"`
|
|
CompletionDelay time.Duration `ini:"completion-delay" default:"250ms"`
|
|
CompletionMinChars int `ini:"completion-min-chars" default:"1" parse:"ParseCompletionMinChars"`
|
|
CompletionPopovers bool `ini:"completion-popovers" default:"true"`
|
|
MsglistScrollOffset int `ini:"msglist-scroll-offset" default:"0"`
|
|
DialogPosition string `ini:"dialog-position" default:"center" parse:"ParseDialogPosition"`
|
|
DialogWidth int `ini:"dialog-width" default:"50" parse:"ParseDialogDimensions"`
|
|
DialogHeight int `ini:"dialog-height" default:"50" parse:"ParseDialogDimensions"`
|
|
StyleSetDirs []string `ini:"stylesets-dirs" delim:":"`
|
|
StyleSetName string `ini:"styleset-name" default:"default"`
|
|
|
|
// customize border appearance
|
|
BorderCharVertical rune `ini:"border-char-vertical" default:"│" type:"rune"`
|
|
BorderCharHorizontal rune `ini:"border-char-horizontal" default:"─" type:"rune"`
|
|
|
|
SelectLast bool `ini:"select-last-message" default:"false"`
|
|
ReverseOrder bool `ini:"reverse-msglist-order"`
|
|
ReverseThreadOrder bool `ini:"reverse-thread-order"`
|
|
SortThreadSiblings bool `ini:"sort-thread-siblings"`
|
|
|
|
ThreadPrefixTip string `ini:"thread-prefix-tip" default:">"`
|
|
ThreadPrefixIndent string `ini:"thread-prefix-indent" default:" "`
|
|
ThreadPrefixStem string `ini:"thread-prefix-stem" default:"│"`
|
|
ThreadPrefixLimb string `ini:"thread-prefix-limb" default:""`
|
|
ThreadPrefixFolded string `ini:"thread-prefix-folded" default:"+"`
|
|
ThreadPrefixUnfolded string `ini:"thread-prefix-unfolded" default:""`
|
|
ThreadPrefixFirstChild string `ini:"thread-prefix-first-child" default:""`
|
|
ThreadPrefixHasSiblings string `ini:"thread-prefix-has-siblings" default:"├─"`
|
|
ThreadPrefixLone string `ini:"thread-prefix-lone" default:""`
|
|
ThreadPrefixOrphan string `ini:"thread-prefix-orphan" default:""`
|
|
ThreadPrefixLastSibling string `ini:"thread-prefix-last-sibling" default:"└─"`
|
|
ThreadPrefixDummy string `ini:"thread-prefix-dummy" default:"┬─"`
|
|
ThreadPrefixLastSiblingReverse string `ini:"thread-prefix-last-sibling-reverse" default:"┌─"`
|
|
ThreadPrefixFirstChildReverse string `ini:"thread-prefix-first-child-reverse" default:""`
|
|
ThreadPrefixOrphanReverse string `ini:"thread-prefix-orphan-reverse" default:""`
|
|
ThreadPrefixDummyReverse string `ini:"thread-prefix-dummy-reverse" default:"┴─"`
|
|
|
|
// Tab Templates
|
|
TabTitleAccount *template.Template `ini:"tab-title-account" default:"{{.Account}}"`
|
|
TabTitleComposer *template.Template `ini:"tab-title-composer" default:"{{if .To}}to:{{index (.To | shortmboxes) 0}} {{end}}{{.SubjectBase}}"`
|
|
TabTitleTerminal *template.Template `ini:"tab-title-terminal" default:"{{.Title}}"`
|
|
TabTitleViewer *template.Template `ini:"tab-title-viewer" default:"{{.Subject}}"`
|
|
|
|
// private
|
|
style atomic.Pointer[StyleSet]
|
|
contextualUis []*UiConfigContext
|
|
contextualCounts map[uiContextType]int
|
|
contextualCache map[uiContextKey]*UIConfig
|
|
}
|
|
|
|
type uiContextType int
|
|
|
|
const (
|
|
uiContextFolder uiContextType = iota
|
|
uiContextAccount
|
|
)
|
|
|
|
type UiConfigContext struct {
|
|
ContextType uiContextType
|
|
Regex *regexp.Regexp
|
|
UiConfig *UIConfig
|
|
Section ini.Section
|
|
}
|
|
|
|
type uiContextKey struct {
|
|
ctxType uiContextType
|
|
value string
|
|
}
|
|
|
|
var uiConfig atomic.Pointer[UIConfig]
|
|
|
|
func Ui() *UIConfig {
|
|
return uiConfig.Load()
|
|
}
|
|
|
|
var uiContextualSectionRe = regexp.MustCompile(`^ui:(account|folder)([~=])(.+)$`)
|
|
|
|
func parseUi(file *ini.File) (*UIConfig, error) {
|
|
conf := &UIConfig{
|
|
contextualCounts: make(map[uiContextType]int),
|
|
contextualCache: make(map[uiContextKey]*UIConfig),
|
|
}
|
|
if err := conf.parse(file.Section("ui")); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, section := range file.Sections() {
|
|
var err error
|
|
groups := uiContextualSectionRe.FindStringSubmatch(section.Name())
|
|
if groups == nil {
|
|
continue
|
|
}
|
|
ctx, separator, value := groups[1], groups[2], groups[3]
|
|
|
|
uiSubConfig := UIConfig{}
|
|
if err = uiSubConfig.parse(section); err != nil {
|
|
return nil, err
|
|
}
|
|
contextualUi := UiConfigContext{
|
|
UiConfig: &uiSubConfig,
|
|
Section: *section,
|
|
}
|
|
|
|
switch ctx {
|
|
case "account":
|
|
contextualUi.ContextType = uiContextAccount
|
|
case "folder":
|
|
contextualUi.ContextType = uiContextFolder
|
|
}
|
|
if separator == "=" {
|
|
value = "^" + regexp.QuoteMeta(value) + "$"
|
|
}
|
|
contextualUi.Regex, err = regexp.Compile(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conf.contextualUis = append(conf.contextualUis, &contextualUi)
|
|
conf.contextualCounts[contextualUi.ContextType]++
|
|
}
|
|
|
|
// append default paths to styleset-dirs
|
|
for _, dir := range SearchDirs {
|
|
conf.StyleSetDirs = append(
|
|
conf.StyleSetDirs, path.Join(dir, "stylesets"),
|
|
)
|
|
}
|
|
|
|
if err := conf.LoadStyle(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
log.Debugf("aerc.conf: [ui] %#v", conf)
|
|
|
|
return conf, nil
|
|
}
|
|
|
|
func (config *UIConfig) parse(section *ini.Section) error {
|
|
if err := MapToStruct(section, config, section.Name() == "ui"); err != nil {
|
|
return err
|
|
}
|
|
|
|
if config.MessageViewTimestampFormat == "" {
|
|
config.MessageViewTimestampFormat = config.TimestampFormat
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (*UIConfig) ParseIndexColumns(section *ini.Section, key *ini.Key) ([]*ColumnDef, error) {
|
|
if !section.HasKey("column-date") {
|
|
_, _ = section.NewKey("column-date", `{{.DateAutoFormat .Date.Local}}`)
|
|
}
|
|
if !section.HasKey("column-name") {
|
|
_, _ = section.NewKey("column-name", `{{index (.From | names) 0}}`)
|
|
}
|
|
if !section.HasKey("column-flags") {
|
|
_, _ = section.NewKey("column-flags", `{{.Flags | join ""}}`)
|
|
}
|
|
if !section.HasKey("column-subject") {
|
|
_, _ = section.NewKey("column-subject", `{{.ThreadPrefix}}{{.Subject}}`)
|
|
}
|
|
return ParseColumnDefs(key, section)
|
|
}
|
|
|
|
type SplitDirection int
|
|
|
|
const (
|
|
SPLIT_NONE SplitDirection = iota
|
|
SPLIT_HORIZONTAL
|
|
SPLIT_VERTICAL
|
|
)
|
|
|
|
type SplitParams struct {
|
|
Direction SplitDirection
|
|
Size int
|
|
}
|
|
|
|
func (*UIConfig) ParseSplit(section *ini.Section, key *ini.Key) (p SplitParams, err error) {
|
|
re := regexp.MustCompile(`^\s*(v(?:ert(?:ical)?)?|h(?:oriz(?:ontal)?)?)?\s+(\d+)\s*$`)
|
|
match := re.FindStringSubmatch(key.String())
|
|
if len(match) != 3 {
|
|
err = fmt.Errorf("bad option value")
|
|
return
|
|
}
|
|
p.Direction = SPLIT_HORIZONTAL
|
|
switch match[1] {
|
|
case "v", "vert", "vertical":
|
|
p.Direction = SPLIT_VERTICAL
|
|
case "h", "horiz", "horizontal":
|
|
p.Direction = SPLIT_HORIZONTAL
|
|
}
|
|
size, e := strconv.ParseUint(match[2], 10, 32)
|
|
if e != nil {
|
|
err = e
|
|
return
|
|
}
|
|
p.Size = int(size)
|
|
return
|
|
}
|
|
|
|
func (*UIConfig) ParseDialogPosition(section *ini.Section, key *ini.Key) (string, error) {
|
|
match, _ := regexp.MatchString(`^\s*(top|center|bottom)\s*$`, key.String())
|
|
if !(match) {
|
|
return "", fmt.Errorf("bad option value")
|
|
}
|
|
return key.String(), nil
|
|
}
|
|
|
|
const (
|
|
DIALOG_MIN_PROPORTION = 10
|
|
DIALOG_MAX_PROPORTION = 100
|
|
)
|
|
|
|
func (*UIConfig) ParseDialogDimensions(section *ini.Section, key *ini.Key) (int, error) {
|
|
value, err := key.Int()
|
|
if value < DIALOG_MIN_PROPORTION || value > DIALOG_MAX_PROPORTION || err != nil {
|
|
return 0, fmt.Errorf("value out of range")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
const MANUAL_COMPLETE = math.MaxInt
|
|
|
|
func (*UIConfig) ParseCompletionMinChars(section *ini.Section, key *ini.Key) (int, error) {
|
|
if key.String() == "manual" {
|
|
return MANUAL_COMPLETE, nil
|
|
}
|
|
return key.Int()
|
|
}
|
|
|
|
func (ui *UIConfig) ClearCache() {
|
|
for k := range ui.contextualCache {
|
|
delete(ui.contextualCache, k)
|
|
}
|
|
}
|
|
|
|
func (ui *UIConfig) LoadStyle() error {
|
|
if err := ui.loadStyleSet(ui.StyleSetDirs); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, contextualUi := range ui.contextualUis {
|
|
if contextualUi.UiConfig.StyleSetName == "" &&
|
|
len(contextualUi.UiConfig.StyleSetDirs) == 0 {
|
|
continue // no need to do anything if nothing is overridden
|
|
}
|
|
// fill in the missing part from the base
|
|
if contextualUi.UiConfig.StyleSetName == "" {
|
|
contextualUi.UiConfig.StyleSetName = ui.StyleSetName
|
|
} else if len(contextualUi.UiConfig.StyleSetDirs) == 0 {
|
|
contextualUi.UiConfig.StyleSetDirs = ui.StyleSetDirs
|
|
}
|
|
// since at least one of them has changed, load the styleset
|
|
if err := contextualUi.UiConfig.loadStyleSet(
|
|
contextualUi.UiConfig.StyleSetDirs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ui *UIConfig) loadStyleSet(styleSetDirs []string) error {
|
|
style := NewStyleSet()
|
|
err := style.LoadStyleSet(ui.StyleSetName, styleSetDirs)
|
|
if err != nil {
|
|
if len(style.paths) == 0 {
|
|
style.paths = append(style.paths, ui.StyleSetName)
|
|
}
|
|
return fmt.Errorf("%v: %w", style.paths, err)
|
|
}
|
|
|
|
ui.style.Store(style)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (base *UIConfig) mergeContextual(
|
|
contextType uiContextType, s string,
|
|
) *UIConfig {
|
|
for _, contextualUi := range base.contextualUis {
|
|
if contextualUi.ContextType != contextType {
|
|
continue
|
|
}
|
|
if !contextualUi.Regex.Match([]byte(s)) {
|
|
continue
|
|
}
|
|
|
|
ui := new(UIConfig)
|
|
|
|
// only copy public fields
|
|
baseVal := reflect.ValueOf(base).Elem()
|
|
uiVal := reflect.ValueOf(ui).Elem()
|
|
for i := range baseVal.NumField() {
|
|
field := baseVal.Type().Field(i)
|
|
if field.IsExported() {
|
|
uiVal.Field(i).Set(baseVal.Field(i))
|
|
}
|
|
}
|
|
|
|
err := ui.parse(&contextualUi.Section)
|
|
if err != nil {
|
|
log.Warnf("merge ui failed: %v", err)
|
|
}
|
|
ui.contextualCache = make(map[uiContextKey]*UIConfig)
|
|
ui.contextualCounts = base.contextualCounts
|
|
ui.contextualUis = base.contextualUis
|
|
if contextualUi.UiConfig.StyleSetName != "" {
|
|
ui.style.Store(contextualUi.UiConfig.style.Load())
|
|
} else {
|
|
ui.style.Store(base.style.Load())
|
|
}
|
|
return ui
|
|
}
|
|
return base
|
|
}
|
|
|
|
func (uiConfig *UIConfig) GetUserStyle(name string) vaxis.Style {
|
|
return uiConfig.style.Load().UserStyle(name)
|
|
}
|
|
|
|
func (uiConfig *UIConfig) GetStyle(so StyleObject) vaxis.Style {
|
|
return uiConfig.style.Load().Get(so, nil)
|
|
}
|
|
|
|
func (uiConfig *UIConfig) GetStyleSelected(so StyleObject) vaxis.Style {
|
|
return uiConfig.style.Load().Selected(so, nil)
|
|
}
|
|
|
|
func (uiConfig *UIConfig) GetComposedStyle(base StyleObject,
|
|
styles []StyleObject,
|
|
) vaxis.Style {
|
|
return uiConfig.style.Load().Compose(base, styles, nil)
|
|
}
|
|
|
|
func (uiConfig *UIConfig) GetComposedStyleSelected(
|
|
base StyleObject, styles []StyleObject,
|
|
) vaxis.Style {
|
|
return uiConfig.style.Load().ComposeSelected(base, styles, nil)
|
|
}
|
|
|
|
func (uiConfig *UIConfig) MsgComposedStyle(
|
|
base StyleObject, styles []StyleObject, h *mail.Header,
|
|
) vaxis.Style {
|
|
return uiConfig.style.Load().Compose(base, styles, h)
|
|
}
|
|
|
|
func (uiConfig *UIConfig) MsgComposedStyleSelected(
|
|
base StyleObject, styles []StyleObject, h *mail.Header,
|
|
) vaxis.Style {
|
|
return uiConfig.style.Load().ComposeSelected(base, styles, h)
|
|
}
|
|
|
|
func (uiConfig *UIConfig) StyleSetPath() string {
|
|
return strings.Join(uiConfig.style.Load().paths, ",")
|
|
}
|
|
|
|
func (base *UIConfig) contextual(ctxType uiContextType, value string) *UIConfig {
|
|
if base.contextualCounts[ctxType] == 0 {
|
|
// shortcut if no contextual ui for that type
|
|
return base
|
|
}
|
|
key := uiContextKey{ctxType: ctxType, value: value}
|
|
c, found := base.contextualCache[key]
|
|
if !found {
|
|
c = base.mergeContextual(ctxType, value)
|
|
base.contextualCache[key] = c
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (base *UIConfig) ForAccount(account string) *UIConfig {
|
|
return base.contextual(uiContextAccount, account)
|
|
}
|
|
|
|
func (base *UIConfig) ForFolder(folder string) *UIConfig {
|
|
return base.contextual(uiContextFolder, folder)
|
|
}
|