1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2026-04-28 20:45:14 +02:00
aerc/config/ui.go
inwit 741f93d991 ui: allow centering the message viewer and composer
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>
2026-02-10 11:11:33 +01:00

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