mirror of
https://git.sr.ht/~rjarry/aerc
synced 2026-04-28 20:45:14 +02:00
On wide screens, it is common to have aerc running in a wide window. In that case, the message viewer and the composer aligned to the left leave the user looking at the side of the window for long periods of time, which can be tiresome if the window sits on a side of the screen. Introduce an option to allow the message viewer and the composer to be centered on the window by setting a fixed width for them and padding both sides with empty space. Changelog-added: The editor and the message viewer can now be centered on screen using a fixed width. Signed-off-by: inwit <inwit@sindominio.net> Tested-by: Antonin Godard <antonin@godard.cc> Acked-by: Robin Jarry <robin@jarry.cc>
455 lines
16 KiB
Go
455 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"`
|
|
CenteredLayoutWidth int `ini:"centered-layout-width" default:"0"`
|
|
|
|
// 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)
|
|
}
|