1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2026-01-28 08:25:18 +01:00
aerc/config/ui.go
Robin Jarry 09090d9675 config: remove dead code for subject-based UI context
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>
2026-01-07 11:10:49 +01:00

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