discordo/internal/config/config.go
2026-10-15 21:00:02 -04:00

332 lines
9.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"fmt"
"log/slog"
"mime"
"os"
"path"
"path/filepath"
"slices"
"sync"
"unicode/utf8"
"github.com/BurntSushi/toml"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/discordo/internal/consts"
)
const fileName = "config.toml"
type MIMETypes []string
func (types *MIMETypes) UnmarshalTOML(value any) error {
values, ok := value.([]any)
if !ok {
return errInvalidType
}
parsed := make(MIMETypes, 0, len(values))
for _, value := range values {
mediaType, ok := value.(string)
if !ok {
return errInvalidType
}
mediaType, _, err := mime.ParseMediaType(mediaType)
if err != nil {
return err
}
parsed = append(parsed, mediaType)
}
*types = parsed
return nil
}
func (types MIMETypes) Has(mediaType string) bool {
mediaType, _, err := mime.ParseMediaType(mediaType)
if err != nil {
return false
}
return slices.ContainsFunc(types, func(pattern string) bool {
matches, _ := path.Match(pattern, mediaType)
return matches
})
}
type (
Timestamps struct {
Enabled bool `toml:"enabled"`
// https://pkg.go.dev/time#Layout
Format string `toml:"format"`
}
DateSeparator struct {
Enabled bool `toml:"enabled"`
// https://pkg.go.dev/time#Layout
Format string `toml:"format"`
// The fill character used on both sides of the date label.
Character string `toml:"character"`
}
Notifications struct {
Enabled bool `toml:"enabled"`
// Whether to also notify about messages in the open channel while the terminal window is unfocused.
// Requires a terminal that reports focus.
WhenUnfocused bool `toml:"when_unfocused"`
}
TypingIndicator struct {
// Whether to send typing status.
Send bool `toml:"send"`
// Whether to receive typing status.
Receive bool `toml:"receive"`
}
Voice struct {
// How loud the microphone must be for what is said to be sent, in dB below the loudest it can be.
Sensitivity float64 `toml:"sensitivity"`
}
Icons struct {
GuildCategory string `toml:"guild_category"`
GuildText string `toml:"guild_text"`
GuildVoice string `toml:"guild_voice"`
GuildStageVoice string `toml:"guild_stage_voice"`
GuildAnnouncementThread string `toml:"guild_announcement_thread"`
GuildPublicThread string `toml:"guild_public_thread"`
GuildPrivateThread string `toml:"guild_private_thread"`
GuildAnnouncement string `toml:"guild_announcement"`
GuildForum string `toml:"guild_forum"`
GuildStore string `toml:"guild_store"`
// Shown after the members of a voice channel who are muted or deafened.
VoiceMuted string `toml:"voice_muted"`
VoiceDeafened string `toml:"voice_deafened"`
}
PickerConfig struct {
Width int `toml:"width"`
Height int `toml:"height"`
}
MarkdownConfig struct {
// Whether to parse and render markdown in messages.
Enabled bool `toml:"enabled"`
// Replace spoiler contents with a placeholder.
MaskSpoilers bool `toml:"mask_spoilers"`
// Theme for fenced code blocks. Available themes: https://xyproto.github.io/splash/docs
Theme string `toml:"theme"`
}
HelpConfig struct {
// Whether to show keybind help.
Enabled bool `toml:"enabled"`
// Show compact key modifiers in help, e.g. "^x" instead of "ctrl+x".
CompactModifiers bool `toml:"compact_modifiers"`
// [left, right]
Padding [2]int `toml:"padding"`
Separator string `toml:"separator"`
}
ComposerConfig struct {
// Maximum height (in newline-separated rows) the composer grows to before it starts scrolling internally.
// Set to 1 for a fixed single-line input. Values below 1 fall back to 10.
MaxHeight int `toml:"max_height"`
}
SidebarMarkersConfig struct {
Expanded string `toml:"expanded"`
Collapsed string `toml:"collapsed"`
Leaf string `toml:"leaf"`
}
SidebarIndentsConfig struct {
Guild int `toml:"guild"`
Category int `toml:"category"`
Channel int `toml:"channel"`
Forum int `toml:"forum"`
GroupDM int `toml:"group_dm"`
DM int `toml:"dm"`
}
// PaneConfig is a side pane that can be toggled at runtime.
PaneConfig struct {
// Whether the pane is visible at start-up. It can still be toggled at runtime with its toggle keybind.
Visible bool `toml:"visible"`
// Percentage (%) of the available width used by the pane.
// Valid values are 1-99. Invalid values fall back to 20.
WidthPercent int `toml:"width_percent"`
}
SidebarConfig struct {
PaneConfig
Markers SidebarMarkersConfig `toml:"markers"`
// Indentation for different item types.
Indents SidebarIndentsConfig `toml:"indents"`
}
Config struct {
// Whether to focus the composer automatically when a channel is selected.
// Set to false to preview channels without moving focus.
AutoFocus bool `toml:"auto_focus"`
// Whether to enable the mouse.
Mouse bool `toml:"mouse"`
// The program opened by the `composer.open_editor` keybind. "default" uses `$EDITOR`.
Editor string `toml:"editor"`
// "default" (unknown), "online", "dnd", "idle", "invisible" or "offline".
Status discord.Status `toml:"status"`
HideBlockedUsers bool `toml:"hide_blocked_users"`
// The maximum number of members in the mention autocomplete list. 0 disables it.
AutocompleteLimit uint8 `toml:"autocomplete_limit"`
// The number of messages to fetch when a text-based channel is selected, from 1 to 100.
MessagesLimit uint8 `toml:"messages_limit"`
Markdown MarkdownConfig `toml:"markdown"`
Help HelpConfig `toml:"help"`
Picker PickerConfig `toml:"picker"`
Timestamps Timestamps `toml:"timestamps"`
DateSeparator DateSeparator `toml:"date_separator"`
Notifications Notifications `toml:"notifications"`
TypingIndicator TypingIndicator `toml:"typing_indicator"`
// The guilds tree pane.
Sidebar SidebarConfig `toml:"sidebar"`
// The members tree pane, sized from the width right of the guilds tree.
MembersTree PaneConfig `toml:"members_tree"`
Composer ComposerConfig `toml:"composer"`
// Layout and behavior of the widgets.
UI UIConfig `toml:"ui"`
Voice Voice `toml:"voice"`
Icons Icons `toml:"icons"`
// Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys.
Keybinds Keybinds `toml:"keybinds"`
// Styles of the widgets, each written as
// `{ foreground = "", background = "", attributes = "" or ["", ""], underline = "", underline_color = "" }`.
Theme ThemeConfig `toml:"theme"`
}
)
//go:generate go run gen.go
// Default returns the default configuration.
func Default() Config {
pane := PaneConfig{Visible: true, WidthPercent: 20}
return Config{
AutoFocus: true,
Mouse: true,
Editor: "default",
Status: "default",
HideBlockedUsers: true,
AutocompleteLimit: 20,
MessagesLimit: 50,
Markdown: MarkdownConfig{Enabled: true, Theme: "monokai"},
Help: HelpConfig{Enabled: true, CompactModifiers: true, Padding: [2]int{1, 1}, Separator: " • "},
Picker: PickerConfig{Width: 80, Height: 25},
Timestamps: Timestamps{Enabled: true, Format: "3:04PM"},
DateSeparator: DateSeparator{Enabled: true, Format: "January 2, 2006", Character: "─"},
Notifications: Notifications{Enabled: true, WhenUnfocused: true},
TypingIndicator: TypingIndicator{Send: true, Receive: true},
Sidebar: SidebarConfig{
PaneConfig: pane,
Markers: SidebarMarkersConfig{Expanded: "▾ ", Collapsed: "▸ "},
Indents: SidebarIndentsConfig{Guild: 2, Category: 1, Channel: 2, Forum: 2, GroupDM: 1, DM: 2},
},
MembersTree: pane,
Composer: ComposerConfig{MaxHeight: 10},
UI: defaultUI(),
Voice: Voice{Sensitivity: -40},
Icons: Icons{
GuildText: "#",
GuildVoice: "♪ ",
GuildStageVoice: "♪ ",
GuildAnnouncementThread: "a-",
GuildPublicThread: "› ",
GuildPrivateThread: "› ",
GuildAnnouncement: "a-",
GuildForum: "≡ ",
GuildStore: "s-",
VoiceMuted: " (muted)",
VoiceDeafened: " (deafened)",
},
Keybinds: defaultKeybinds(),
Theme: defaultTheme(),
}
}
var dir = sync.OnceValue(func() string {
path, err := os.UserConfigDir()
if err != nil {
slog.Info("user config dir cannot be determined; falling back to the current dir", "err", err)
path = "."
}
return filepath.Join(path, consts.Name)
})
func DefaultPath() string {
return filepath.Join(dir(), fileName)
}
// Load reads the configuration file and parses it.
func Load(path string) (*Config, error) {
cfg := Default()
file, err := os.Open(path)
switch {
case os.IsNotExist(err):
slog.Info("config file does not exist, falling back to the default config", "path", path, "err", err)
case err != nil:
return nil, fmt.Errorf("failed to open config file: %w", err)
default:
defer file.Close() //nolint:errcheck
if _, err := toml.NewDecoder(file).Decode(&cfg); err != nil {
return nil, fmt.Errorf("failed to decode config: %w", err)
}
}
cfg.applyDefaults()
return &cfg, nil
}
func (cfg *Config) applyDefaults() {
if cfg.Editor == "default" {
cfg.Editor = os.Getenv("EDITOR")
}
if cfg.Status == "default" {
cfg.Status = discord.UnknownStatus
}
if cfg.Composer.MaxHeight <= 0 {
cfg.Composer.MaxHeight = 10
}
for _, pane := range []*PaneConfig{&cfg.Sidebar.PaneConfig, &cfg.MembersTree} {
if pane.WidthPercent <= 0 || pane.WidthPercent >= 100 {
pane.WidthPercent = 20
}
}
if cfg.DateSeparator.Format == "" {
cfg.DateSeparator.Format = "January 2, 2006"
}
if r, _ := utf8.DecodeRuneInString(cfg.DateSeparator.Character); r == utf8.RuneError {
cfg.DateSeparator.Character = "─"
} else {
cfg.DateSeparator.Character = string(r)
}
}