feat(config): generate docs/config.md from the config structs (#842)

This commit is contained in:
ayn2op 2026-10-07 18:06:07 -04:00 • committed by GitHub
parent 534c4e3c64
commit 84835018c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1743 additions and 721 deletions

View file

@ -26,6 +26,12 @@ jobs:
with:
go-version-file: go.mod
- name: Check that docs/config.md is up to date
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
go generate ./internal/config
git diff --exit-code docs/config.md
- name: Test
if: ${{ matrix.os != 'windows-11-arm' }}
run: |

View file

@ -72,7 +72,7 @@ The configuration file allows you to configure and customize the behavior, keybi
- Darwin: `$HOME/Library/Application Support/discordo/config.toml`
- Windows: `%AppData%/discordo/config.toml`
Discordo uses the default configuration if a configuration file is not found in the aforementioned path; however, the default configuration file is not written to the path. [The default configuration can be found here](./internal/config/config.toml).
Discordo uses the default configuration if a configuration file is not found in the aforementioned path; however, the default configuration file is not written to the path. [The configuration reference can be found here](./docs/config.md).
## License

1037
docs/config.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
package config
import (
_ "embed"
"fmt"
"log/slog"
"mime"
@ -58,30 +57,41 @@ func (types MIMETypes) Has(mediaType string) bool {
type (
Timestamps struct {
Enabled bool `toml:"enabled"`
Format string `toml:"format"`
Enabled bool `toml:"enabled"`
// https://pkg.go.dev/time#Layout
Format string `toml:"format"`
}
AttachmentsConfig struct {
ShowLinks bool `toml:"show_links"`
ShowLinks bool `toml:"show_links"`
// Attachment MIME types that can be opened or downloaded without confirmation.
// Exact types and wildcards such as "image/*" are supported.
AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"`
Preview bool `toml:"preview"`
Protocol string `toml:"protocol"`
// Whether to draw image attachments and embedded images below their messages.
Preview bool `toml:"preview"`
// How previews are drawn: "auto", "kitty" or "halfblocks".
Protocol string `toml:"protocol"`
}
DateSeparator struct {
Enabled bool `toml:"enabled"`
Format string `toml:"format"`
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"`
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 {
Send bool `toml:"send"`
// Whether to send typing status.
Send bool `toml:"send"`
// Whether to receive typing status.
Receive bool `toml:"receive"`
}
@ -106,21 +116,27 @@ type (
}
MarkdownConfig struct {
Enabled bool `toml:"enabled"`
MaskSpoilers bool `toml:"mask_spoilers"`
Theme string `toml:"theme"`
// 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 {
Enabled bool `toml:"enabled"`
CompactModifiers bool `toml:"compact_modifiers"`
Padding [2]int `toml:"padding"`
Separator string `toml:"separator"`
// 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 {
// MaxHeight caps how tall (in newline-separated rows) the input grows before it starts scrolling internally.
// Must be >= 1; values <= 0 fall back to the default.
// 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"`
}
@ -141,29 +157,37 @@ type (
// PaneConfig is a side pane that can be toggled at runtime.
PaneConfig struct {
// Visible controls whether the pane is shown by default at start-up.
// Whether the pane is visible at start-up. It can still be toggled at runtime with its toggle keybind.
Visible bool `toml:"visible"`
// WidthPercent is the percentage of the total window width that the pane occupies.
// 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 {
AutoFocus bool `toml:"auto_focus"`
Mouse bool `toml:"mouse"`
Editor string `toml:"editor"`
// 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"`
// Use 0 to disable
// The maximum number of members in the mention autocomplete list. 0 disables it.
AutocompleteLimit uint8 `toml:"autocomplete_limit"`
MessagesLimit uint8 `toml:"messages_limit"`
// The number of messages to fetch when a text-based channel is selected, from 1 to 100.
MessagesLimit uint8 `toml:"messages_limit"`
Attachments AttachmentsConfig `toml:"attachments"`
@ -174,20 +198,81 @@ type (
DateSeparator DateSeparator `toml:"date_separator"`
Notifications Notifications `toml:"notifications"`
TypingIndicator TypingIndicator `toml:"typing_indicator"`
Sidebar SidebarConfig `toml:"sidebar"`
MembersTree PaneConfig `toml:"members_tree"`
Composer ComposerConfig `toml:"composer"`
// 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"`
Icons Icons `toml:"icons"`
// Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys.
Keybinds Keybinds `toml:"keybinds"`
Theme Theme `toml:"theme"`
// Styles of the widgets, each written as
// `{ foreground = "", background = "", attributes = "" or ["", ""], underline = "", underline_color = "" }`.
Theme ThemeConfig `toml:"theme"`
}
)
//go:embed config.toml
var defaultCfg []byte
//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,
Attachments: AttachmentsConfig{
ShowLinks: true,
AllowedMIMETypes: MIMETypes{"image/*", "video/*"},
Preview: true,
Protocol: "auto",
},
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(),
Icons: Icons{
GuildText: "#",
GuildVoice: "♪ ",
GuildStageVoice: "♪ ",
GuildAnnouncementThread: "a-",
GuildPublicThread: "› ",
GuildPrivateThread: "› ",
GuildAnnouncement: "a-",
GuildForum: "≡ ",
GuildStore: "s-",
},
Keybinds: defaultKeybinds(),
Theme: defaultTheme(),
}
}
var dir = sync.OnceValue(func() string {
path, err := os.UserConfigDir()
@ -204,13 +289,7 @@ func DefaultPath() string {
// Load reads the configuration file and parses it.
func Load(path string) (*Config, error) {
cfg := Config{
Keybinds: defaultKeybinds(),
}
if err := toml.Unmarshal(defaultCfg, &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal default config: %w", err)
}
cfg := Default()
file, err := os.Open(path)
switch {
case os.IsNotExist(err):

View file

@ -1,370 +0,0 @@
# Whether to focus the composer automatically when a channel is selected.
# Set to false to preview channels without moving focus.
auto_focus = true
# Whether to enable mouse or not.
mouse = true
# The program to open when the `composer.open_editor` keybind is pressed. Set the value to `"default"` to use `$EDITOR`.
editor = "default"
# "default" (unknown), "online", "dnd", "idle", "invisible", "offline"
status = "default"
hide_blocked_users = true
# Max members to be in the mention autocomplete suggestions list
# Note: Use autocomplete_limit = 0 to disable.
autocomplete_limit = 20
# The number of messages to fetch when a text-based channel is selected from guilds tree. The minimum and maximum value is 1 and 100, respectively.
messages_limit = 50
[attachments]
show_links = true
# Attachment MIME types that can be opened or downloaded without confirmation.
# Exact types and wildcards such as "image/*" are supported.
allowed_mime_types = ["image/*", "video/*"]
# Whether to draw image attachments and embedded images below their messages.
preview = true
# How previews are drawn: "auto", "kitty" or "halfblocks".
protocol = "auto"
[composer]
# Maximum height (in newline-separated rows) the composer grows to before it starts scrolling internally.
# Set to 1 for a fixed single-line input.
max_height = 10
[markdown]
# Whether to parse and render markdown in messages or not.
enabled = true
# Replace spoiler contents with a placeholder.
mask_spoilers = false
# Theme for fenced code blocks. Available themes: https://xyproto.github.io/splash/docs
theme = "monokai"
[help]
# Whether to show keybind help.
enabled = true
# Show compact key modifiers in help, e.g. "^x" instead of "ctrl+x".
compact_modifiers = true
# [left, right]
padding = [1, 1]
separator = " • "
[picker]
width = 80
height = 25
[ui.tabs]
# Where tabs that fit are placed: "left", "center" or "right".
alignment = "center"
# Whether moving past the last tab selects the first, and past the first selects the last.
wrap = false
# Text between tabs.
separator = "│"
# Text on either side of each tab: [left, right]
padding = [" ", " "]
# Arrows shown while tabs are hidden past an end: [start, end]. An empty string turns that arrow off.
arrows = ["◀", "▶"]
# Whether clicking an arrow selects the tab next to the active one.
clickable_arrows = true
[ui.title]
# "left", "center" or "right".
alignment = "left"
[ui.footer]
alignment = "left"
[ui.border]
enabled = true
padding = [0, 0, 1, 1] # [top, bottom, left, right]
# "hidden", "plain", "round", "thick" or "double".
normal_set = "round"
active_set = "round"
[ui.guilds_tree]
auto_expand_folders = true
# Give tree-like shape
graphics = true
[ui.members_tree]
# Give tree-like shape
graphics = true
[ui.scroll_bar]
visibility = "auto"
# "minimal", "box_drawing", "vertical" or "double_vertical"
symbol_set = "box_drawing"
[ui.messages_list]
reply_indicator = ">"
forwarded_indicator = "<"
[ui.mentions_list]
# Note: width and height are capped to the available space
# Minimum width
# 0 = make the list as wide as possible
min_width = 20
# Maximum height
# 0 = make the list as tall as needed
max_height = 0
[timestamps]
enabled = true
# https://pkg.go.dev/time#Layout
format = "3:04PM"
[date_separator]
enabled = true
# https://pkg.go.dev/time#Layout
format = "January 2, 2006"
# The fill character used on both sides of the date label.
character = "─"
[notifications]
enabled = true
# Whether to also notify about messages in the open channel while the terminal window is unfocused. Requires a terminal that reports focus.
when_unfocused = true
[typing_indicator]
# Whether to send typing status or not.
send = true
# Whether to receive typing status or not.
receive = true
[sidebar]
# Whether the guilds tree sidebar is visible by default at start-up.
# It can still be toggled at runtime with the `toggle_guilds_tree` keybind.
visible = true
# Percentage (%) of the available width used by the guilds tree sidebar.
# Valid values are 1-99. Invalid values fall back to 20.
width_percent = 20
[sidebar.markers]
expanded = "▾ "
collapsed = "▸ "
leaf = ""
# Indentation for different item types.
[sidebar.indents]
dm = 2
group_dm = 1
guild = 2
category = 1
channel = 2
forum = 2
[members_tree]
# Whether the members tree is visible by default at start-up.
# It can still be toggled at runtime with the `toggle_members_tree` keybind.
visible = true
# Percentage (%) of the width right of the guilds tree used by the members tree.
# Valid values are 1-99. Invalid values fall back to 20.
width_percent = 20
[icons]
guild_category = ""
guild_text = "#"
guild_voice = "♪ "
guild_stage_voice = "♪ "
guild_announcement_thread = "a-"
guild_public_thread = "› "
guild_private_thread = "› "
guild_announcement = "a-"
guild_forum = "≡ "
guild_store = "s-"
# Global shortcuts
# Esc: Reset message selection or close the channel selection popup.
[keybinds]
# Hide/show the guilds tree.
toggle_guilds_tree = "alt+g"
# Hide/show the members tree.
toggle_members_tree = "alt+m"
toggle_channels_picker = "ctrl+k"
# Hide/show keybind help.
toggle_help = "alt+."
# Switch between compact and full keybind help.
toggle_full_help = "ctrl+."
focus_guilds_tree = "ctrl+g"
focus_members_tree = "ctrl+y"
focus_messages_list = "ctrl+t"
focus_composer = "ctrl+i"
# Cycle focus between the widgets.
focus_previous = "ctrl+h"
focus_next = "ctrl+l"
# Log out and remove the authentication token from keyring.
# Requires re-login upon restart.
logout = "ctrl+d"
suspend = "ctrl+z"
quit = "ctrl+c"
[keybinds.picker]
cancel = "esc"
select_up = "ctrl+p"
select_down = "ctrl+n"
select_top = "home"
select_bottom = "end"
select = "enter"
# Only while focusing on the guilds tree
[keybinds.guilds_tree]
select_up = "k"
select_down = "j"
select_top = "g"
select_bottom = "G"
# Select the currently highlighted text-based channel or expand a guild or channel.
select_current = "enter"
# Highlight the previous or next unread guild or channel, wrapping around.
select_previous_unread = "K"
select_next_unread = "J"
yank_id = "i"
collapse_all = "_"
collapse_parent_node = "-"
move_to_parent_node = "p"
# Only while focusing on the members tree
[keybinds.members_tree]
select_up = "k"
select_down = "j"
select_top = "g"
select_bottom = "G"
# Expand or collapse the highlighted group.
select_current = "enter"
yank_id = "i"
collapse_all = "_"
collapse_parent_node = "-"
move_to_parent_node = "p"
# Only while focusing on sent messages
[keybinds.messages_list]
select_up = "k"
select_down = "j"
select_top = "g"
select_bottom = "G"
# Scroll the messages list without changing the selection.
scroll_up = "K"
scroll_down = "J"
scroll_top = "home"
scroll_bottom = "end"
# Select the message reference (reply) of the selected channel.
select_reply = "s"
# Reply to the selected message.
reply = "R"
# Reply (with mention) to the selected message.
reply_mention = "r"
cancel = "esc"
edit = "e"
# Delete without confirmation.
# delete = "D"
delete_confirm = "d"
# Open attachments with their associated application. MIME types outside the
# allowlist require confirmation. Hyperlinks still use the browser.
open = "o"
# Open attachments or hyperlinks in the browser.
open_in_browser = "O"
# Download one of the selected message's attachments.
download = "D"
# Yank (copy) the selected message's content/url/id.
yank_content = "y"
yank_url = "u"
yank_id = "i"
# Only while typing a message
[keybinds.composer]
# Edit your newest editable message loaded in the current channel, with no age limit.
# Only when the composer is empty, with no reply, attachments, or edit in progress.
edit_last = "up"
# Paste from clipboard (supports both text and images)
paste = "ctrl+v"
send = "enter"
# Insert a new line at the cursor.
newline = "shift+enter"
# Remove existing text or cancel reply.
cancel = "esc"
# Complete usernames when mentioning
tab_complete = "tab"
# Toggle whether a reply mentions its author.
toggle_reply_mention = "shift+tab"
undo = "ctrl+u"
open_editor = "ctrl+e"
open_file_picker = "ctrl+\\"
[keybinds.mentions_list]
select_up = "ctrl+p"
select_down = "ctrl+n"
select_top = "home"
select_bottom = "end"
# style = { foreground = "", background = "", attributes = "" or ["",""], underline = "", underline_color = "" }
[theme.title]
normal_style = { attributes = "dim" }
active_style = { foreground = "green", attributes = "bold" }
[theme.footer]
normal_style = { attributes = "dim" }
active_style = { foreground = "green", attributes = "bold" }
[theme.border]
normal_style = { attributes = "dim" }
active_style = { foreground = "green", attributes = "bold" }
[theme.guilds_tree]
graphics_color = "default"
# Direct-message recipient presence styles.
online_style = { foreground = "green" }
idle_style = { foreground = "yellow" }
dnd_style = { foreground = "red" }
offline_style = { foreground = "gray" }
[theme.members_tree]
graphics_color = "default"
# Styles of the status dot before each member.
online_style = { foreground = "green" }
idle_style = { foreground = "yellow" }
dnd_style = { foreground = "red" }
offline_style = { foreground = "gray" }
[theme.scroll_bar]
track_style = { attributes = "dim" }
thumb_style = {}
[theme.messages_list]
mention_style = { foreground = "blue", attributes = "bold" }
emoji_style = { foreground = "green" }
url_style = { foreground = "blue" }
attachment_style = { foreground = "yellow" }
reaction_style = { attributes = "dim" }
own_reaction_style = { attributes = "bold" }
message_style = {}
selected_message_style = { attributes = "reverse" }
[theme.messages_list.embeds]
provider_style = { attributes = ["dim", "italic"] }
author_style = { attributes = "italic" }
title_style = { foreground = "blue", attributes = "bold" }
description_style = { attributes = "dim" }
field_name_style = { attributes = ["bold", "underline"] }
field_value_style = {}
footer_style = { attributes = ["dim", "italic"] }
url_style = { foreground = "blue", underline = "solid" }
[theme.mentions_list]
selected_style = { attributes = "reverse" }
[theme.dialog]
style = {}
# Background style: everything else behind the dialog
background_style = { attributes = "dim" }
[theme.help]
short_key_style = { attributes = "dim" }
short_desc_style = {}
full_key_style = { attributes = "dim" }
full_desc_style = {}

View file

@ -7,6 +7,7 @@ import (
"testing"
"github.com/BurntSushi/toml"
"github.com/ayn2op/discordo/internal/consts"
)
@ -27,15 +28,6 @@ func TestDefaultPath(t *testing.T) {
}
func TestLoad(t *testing.T) {
t.Run("invalid default config returns error", func(t *testing.T) {
orig := defaultCfg
defaultCfg = []byte("invalid =")
t.Cleanup(func() { defaultCfg = orig })
if _, err := Load("does-not-matter.toml"); err == nil {
t.Fatal(err)
}
})
t.Run("invalid config returns error", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.toml")
if err := os.WriteFile(path, []byte("invalid ="), os.ModePerm); err != nil {
@ -77,41 +69,6 @@ func TestLoad(t *testing.T) {
}
})
// Keys live in config.toml, descriptions in defaultKeybinds(); fail if either is missing.
t.Run("default keybinds are fully populated", func(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "missing.toml"))
if err != nil {
t.Fatal(err)
}
var walk func(path string, v reflect.Value)
walk = func(path string, v reflect.Value) {
rt := v.Type()
for i := range rt.NumField() {
fv, name := v.Field(i), path+rt.Field(i).Name
if kb, ok := fv.Interface().(Keybind); ok {
if len(kb.Keys()) == 0 {
if rt.Field(i).Tag.Get("toml") != "delete" {
t.Errorf("%s: no keys (missing from config.toml?)", name)
}
continue
}
if kb.Help().Desc == "" {
t.Errorf("%s: no help description (missing from defaultKeybinds?)", name)
}
if kb.Help().Key == "" {
t.Errorf("%s: help key not populated from config.toml", name)
}
continue
}
if fv.Kind() == reflect.Struct {
walk(name+".", fv)
}
}
}
walk("Keybinds.", reflect.ValueOf(cfg.Keybinds))
})
t.Run("open with bad path returns error (!= ErrNotExist)", func(t *testing.T) {
if _, err := Load("bad\x00path"); err == nil {
t.Fatal("expected error")
@ -125,10 +82,7 @@ func TestLoad(t *testing.T) {
t.Fatal(err)
}
defCfg := Config{Keybinds: defaultKeybinds()}
if err := toml.Unmarshal(defaultCfg, &defCfg); err != nil {
t.Fatal(err)
}
defCfg := Default()
defCfg.applyDefaults()
if !reflect.DeepEqual(defCfg, *cfg) {
@ -137,6 +91,22 @@ func TestLoad(t *testing.T) {
})
}
// The generated docs show defaults as they are marshaled, so those must parse back to the same defaults.
func TestDefaultRoundTrip(t *testing.T) {
data, err := toml.Marshal(Default())
if err != nil {
t.Fatal(err)
}
// Help descriptions are not part of the TOML.
got := Config{Keybinds: defaultKeybinds()}
if err := toml.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
if want := Default(); !reflect.DeepEqual(got, want) {
t.Fatalf("got = %+v, want = %+v", got, want)
}
}
func TestMIMETypesHas(t *testing.T) {
tests := []struct {
name string

110
internal/config/gen.go Normal file
View file

@ -0,0 +1,110 @@
//go:build ignore
// Command gen writes docs/config.md from the config structs: a heading per table and option, named by its toml key, with its doc comment and default value.
package main
import (
"bytes"
"encoding"
"fmt"
"go/ast"
"go/parser"
"go/token"
"log"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/BurntSushi/toml"
"github.com/ayn2op/discordo/internal/config"
)
const header = "<!-- GENERATED BY go generate; DO NOT EDIT. -->\n\n"
// docs maps "Type.Field" to the doc comment of that struct field.
var docs = map[string]string{}
type field struct {
key, doc string
value reflect.Value
}
// fields returns the fields of the struct v, with those of embedded structs in place.
func fields(v reflect.Value) []field {
var fs []field
t := v.Type()
for i := range t.NumField() {
f := t.Field(i)
if f.Anonymous {
fs = append(fs, fields(v.Field(i))...)
continue
}
fs = append(fs, field{f.Tag.Get("toml"), docs[t.Name()+"."+f.Name], v.Field(i)})
}
return fs
}
// isTable reports whether v is written as a table rather than as a value.
func isTable(v reflect.Value) bool {
switch v.Interface().(type) {
case toml.Marshaler, encoding.TextMarshaler:
return false
}
return v.Kind() == reflect.Struct
}
// write writes a section for each option of the table v at path: its doc comment, then its default value or, for a table, its own options.
func write(w *bytes.Buffer, path string, v reflect.Value) {
if path != "" {
path += "."
}
for _, f := range fields(v) {
name := path + f.key
fmt.Fprintf(w, "%s %s\n\n", strings.Repeat("#", strings.Count(name, ".")+1), name)
if f.doc != "" {
fmt.Fprintf(w, "%s\n", f.doc)
}
if isTable(f.value) {
write(w, name, f.value)
continue
}
// The encoder only writes whole documents, so the value is what follows the key.
var line bytes.Buffer
if err := toml.NewEncoder(&line).Encode(map[string]any{f.key: f.value.Interface()}); err != nil {
log.Fatal(err)
}
_, value, _ := strings.Cut(line.String(), " = ")
fmt.Fprintf(w, "Default: `%s`\n\n", strings.TrimSpace(value))
}
}
func main() {
names, _ := filepath.Glob("*.go")
for _, name := range names {
file, err := parser.ParseFile(token.NewFileSet(), name, nil, parser.ParseComments)
if err != nil {
log.Fatal(err)
}
ast.Inspect(file, func(n ast.Node) bool {
spec, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
if s, ok := spec.Type.(*ast.StructType); ok {
for _, f := range s.Fields.List {
for _, name := range f.Names {
docs[spec.Name.Name+"."+name.Name] = f.Doc.Text()
}
}
}
return false
})
}
w := bytes.NewBufferString(header)
write(w, "", reflect.ValueOf(config.Default()))
if err := os.WriteFile("../../docs/config.md", append(bytes.TrimSpace(w.Bytes()), '\n'), 0o644); err != nil {
log.Fatal(err)
}
}

View file

@ -1,6 +1,9 @@
package config
import (
"encoding/json"
"strings"
"github.com/BurntSushi/toml"
"github.com/ayn2op/tview/keybind"
)
@ -35,14 +38,23 @@ func (k *Keybind) UnmarshalTOML(value any) error {
return nil
}
// desc builds a Keybind with only a help description; keys come from config.toml.
func desc(s string) Keybind {
return Keybind{
Keybind: keybind.New().WithHelp("", s),
// MarshalTOML writes the keys as UnmarshalTOML reads them: one key as a string, an unbound keybind as an empty one, and several keys as a list.
func (k Keybind) MarshalTOML() ([]byte, error) {
if keys := k.Keys(); len(keys) > 1 {
return json.Marshal(keys)
}
return json.Marshal(strings.Join(k.Keys(), ""))
}
// key builds a Keybind bound to key, or unbound if key is empty, with desc as its help description.
func key(key, desc string) Keybind {
k := Keybind{keybind.New().WithHelp("", desc)}
k.UnmarshalTOML(key)
return k
}
type ScrollKeybinds struct {
// Scroll the messages list without changing the selection.
ScrollUp Keybind `toml:"scroll_up"`
ScrollDown Keybind `toml:"scroll_down"`
ScrollTop Keybind `toml:"scroll_top"`
@ -64,6 +76,7 @@ type PickerKeybinds struct {
type TreeKeybinds struct {
SelectionKeybinds
// Select the highlighted text-based channel, or expand or collapse the highlighted node.
SelectCurrent Keybind `toml:"select_current"`
YankID Keybind `toml:"yank_id"`
@ -74,6 +87,7 @@ type TreeKeybinds struct {
type GuildsTreeKeybinds struct {
TreeKeybinds
// Highlight the previous or next unread guild or channel, wrapping around.
SelectPreviousUnread Keybind `toml:"select_previous_unread"`
SelectNextUnread Keybind `toml:"select_next_unread"`
}
@ -82,32 +96,48 @@ type MessagesListKeybinds struct {
SelectionKeybinds
ScrollKeybinds
SelectReply Keybind `toml:"select_reply"`
Reply Keybind `toml:"reply"`
// Select the message that the selected message replies to.
SelectReply Keybind `toml:"select_reply"`
// Reply to the selected message.
Reply Keybind `toml:"reply"`
// Reply (with mention) to the selected message.
ReplyMention Keybind `toml:"reply_mention"`
Cancel Keybind `toml:"cancel"`
Edit Keybind `toml:"edit"`
// Delete without confirmation. Unbound by default.
Delete Keybind `toml:"delete"`
DeleteConfirm Keybind `toml:"delete_confirm"`
Open Keybind `toml:"open"`
// Open attachments with their associated application. MIME types outside the
// allowlist require confirmation. Hyperlinks still use the browser.
Open Keybind `toml:"open"`
// Open attachments or hyperlinks in the browser.
OpenInBrowser Keybind `toml:"open_in_browser"`
Download Keybind `toml:"download"`
// Download one of the selected message's attachments.
Download Keybind `toml:"download"`
// Yank (copy) the selected message's content/url/id.
YankContent Keybind `toml:"yank_content"`
YankURL Keybind `toml:"yank_url"`
YankID Keybind `toml:"yank_id"`
}
type ComposerKeybinds struct {
EditLast Keybind `toml:"edit_last"`
Paste Keybind `toml:"paste"`
Send Keybind `toml:"send"`
Newline Keybind `toml:"newline"`
Cancel Keybind `toml:"cancel"`
TabComplete Keybind `toml:"tab_complete"`
// Edit your newest editable message loaded in the current channel, with no age limit.
// Only when the composer is empty, with no reply, attachments, or edit in progress.
EditLast Keybind `toml:"edit_last"`
// Paste from clipboard (supports both text and images).
Paste Keybind `toml:"paste"`
Send Keybind `toml:"send"`
// Insert a new line at the cursor.
Newline Keybind `toml:"newline"`
// Remove existing text or cancel reply.
Cancel Keybind `toml:"cancel"`
// Complete usernames when mentioning.
TabComplete Keybind `toml:"tab_complete"`
// Toggle whether a reply mentions its author.
ToggleReplyMention Keybind `toml:"toggle_reply_mention"`
Undo Keybind `toml:"undo"`
@ -120,130 +150,135 @@ type MentionsListKeybinds struct {
}
type Keybinds struct {
ToggleGuildsTree Keybind `toml:"toggle_guilds_tree"`
// Hide/show the guilds tree.
ToggleGuildsTree Keybind `toml:"toggle_guilds_tree"`
// Hide/show the members tree.
ToggleMembersTree Keybind `toml:"toggle_members_tree"`
ToggleChannelsPicker Keybind `toml:"toggle_channels_picker"`
ToggleHelp Keybind `toml:"toggle_help"`
ToggleFullHelp Keybind `toml:"toggle_full_help"`
Suspend Keybind `toml:"suspend"`
// Hide/show keybind help.
ToggleHelp Keybind `toml:"toggle_help"`
// Switch between compact and full keybind help.
ToggleFullHelp Keybind `toml:"toggle_full_help"`
Suspend Keybind `toml:"suspend"`
FocusGuildsTree Keybind `toml:"focus_guilds_tree"`
FocusMembersTree Keybind `toml:"focus_members_tree"`
FocusMessagesList Keybind `toml:"focus_messages_list"`
FocusComposer Keybind `toml:"focus_composer"`
// Cycle focus between the widgets.
FocusPrevious Keybind `toml:"focus_previous"`
FocusNext Keybind `toml:"focus_next"`
Picker PickerKeybinds `toml:"picker"`
GuildsTree GuildsTreeKeybinds `toml:"guilds_tree"`
MembersTree TreeKeybinds `toml:"members_tree"`
Picker PickerKeybinds `toml:"picker"`
// Only while focusing on the guilds tree.
GuildsTree GuildsTreeKeybinds `toml:"guilds_tree"`
// Only while focusing on the members tree.
MembersTree TreeKeybinds `toml:"members_tree"`
// Only while focusing on sent messages.
MessagesList MessagesListKeybinds `toml:"messages_list"`
Composer ComposerKeybinds `toml:"composer"`
// Only while typing a message.
Composer ComposerKeybinds `toml:"composer"`
// Only while the mention suggestions are shown.
MentionsList MentionsListKeybinds `toml:"mentions_list"`
// Log out and remove the authentication token from keyring.
// Requires re-login upon restart.
Logout Keybind `toml:"logout"`
Quit Keybind `toml:"quit"`
}
func defaultSelectionKeybinds() SelectionKeybinds {
func selectionKeybinds(up, down, top, bottom string) SelectionKeybinds {
return SelectionKeybinds{
SelectUp: desc("up"),
SelectDown: desc("down"),
SelectTop: desc("top"),
SelectBottom: desc("btm"),
}
}
func defaultPickerKeybinds() PickerKeybinds {
return PickerKeybinds{
SelectionKeybinds: defaultSelectionKeybinds(),
Cancel: desc("cancel"),
Select: desc("sel"),
}
}
func defaultTreeKeybinds() TreeKeybinds {
return TreeKeybinds{
SelectionKeybinds: defaultSelectionKeybinds(),
SelectCurrent: desc("select"),
YankID: desc("copy id"),
CollapseAll: desc("collapse all"),
CollapseParentNode: desc("collapse parent"),
MoveToParentNode: desc("parent"),
}
}
func defaultMessagesListKeybinds() MessagesListKeybinds {
return MessagesListKeybinds{
SelectionKeybinds: defaultSelectionKeybinds(),
ScrollUp: desc("scr up"),
ScrollDown: desc("scr down"),
ScrollTop: desc("scr top"),
ScrollBottom: desc("scr btm"),
SelectReply: desc("sel reply"),
Reply: desc("reply"),
ReplyMention: desc("@reply"),
Cancel: desc("cancel"),
Edit: desc("edit"),
Delete: desc("force delete"),
DeleteConfirm: desc("delete"),
Open: desc("open"),
OpenInBrowser: desc("open browser"),
Download: desc("download"),
YankContent: desc("copy text"),
YankURL: desc("copy url"),
YankID: desc("copy id"),
}
}
func defaultComposerKeybinds() ComposerKeybinds {
return ComposerKeybinds{
EditLast: desc("edit last"),
Paste: desc("paste"),
Send: desc("send"),
Newline: desc("nl"),
Cancel: desc("cancel"),
TabComplete: desc("complete"),
ToggleReplyMention: desc("toggle @reply"),
Undo: desc("undo"),
OpenEditor: desc("editor"),
OpenFilePicker: desc("attach"),
}
}
func defaultMentionsListKeybinds() MentionsListKeybinds {
return MentionsListKeybinds{
SelectionKeybinds: defaultSelectionKeybinds(),
SelectUp: key(up, "up"),
SelectDown: key(down, "down"),
SelectTop: key(top, "top"),
SelectBottom: key(bottom, "btm"),
}
}
func defaultKeybinds() Keybinds {
vi := selectionKeybinds("k", "j", "g", "G")
emacs := selectionKeybinds("ctrl+p", "ctrl+n", "home", "end")
tree := TreeKeybinds{
SelectionKeybinds: vi,
SelectCurrent: key("enter", "select"),
YankID: key("i", "copy id"),
CollapseAll: key("_", "collapse all"),
CollapseParentNode: key("-", "collapse parent"),
MoveToParentNode: key("p", "parent"),
}
return Keybinds{
ToggleGuildsTree: desc("toggle guilds"),
ToggleMembersTree: desc("toggle members"),
ToggleChannelsPicker: desc("channels picker"),
ToggleHelp: desc("toggle help"),
ToggleFullHelp: desc("full help"),
Suspend: desc("suspend"),
ToggleGuildsTree: key("alt+g", "toggle guilds"),
ToggleMembersTree: key("alt+m", "toggle members"),
ToggleChannelsPicker: key("ctrl+k", "channels picker"),
ToggleHelp: key("alt+.", "toggle help"),
ToggleFullHelp: key("ctrl+.", "full help"),
Suspend: key("ctrl+z", "suspend"),
FocusGuildsTree: desc("guilds"),
FocusMembersTree: desc("members"),
FocusMessagesList: desc("messages"),
FocusComposer: desc("composer"),
FocusGuildsTree: key("ctrl+g", "guilds"),
FocusMembersTree: key("ctrl+y", "members"),
FocusMessagesList: key("ctrl+t", "messages"),
FocusComposer: key("ctrl+i", "composer"),
FocusPrevious: desc("focus prev"),
FocusNext: desc("focus next"),
FocusPrevious: key("ctrl+h", "focus prev"),
FocusNext: key("ctrl+l", "focus next"),
Logout: desc("logout"),
Quit: desc("quit"),
Picker: PickerKeybinds{
SelectionKeybinds: emacs,
Select: key("enter", "sel"),
Cancel: key("esc", "cancel"),
},
GuildsTree: GuildsTreeKeybinds{
TreeKeybinds: tree,
SelectPreviousUnread: key("K", "prev unread"),
SelectNextUnread: key("J", "next unread"),
},
MembersTree: tree,
MessagesList: MessagesListKeybinds{
SelectionKeybinds: vi,
ScrollKeybinds: ScrollKeybinds{
ScrollUp: key("K", "scr up"),
ScrollDown: key("J", "scr down"),
ScrollTop: key("home", "scr top"),
ScrollBottom: key("end", "scr btm"),
},
Picker: defaultPickerKeybinds(),
GuildsTree: GuildsTreeKeybinds{TreeKeybinds: defaultTreeKeybinds(), SelectPreviousUnread: desc("prev unread"), SelectNextUnread: desc("next unread")},
MembersTree: defaultTreeKeybinds(),
MessagesList: defaultMessagesListKeybinds(),
Composer: defaultComposerKeybinds(),
MentionsList: defaultMentionsListKeybinds(),
SelectReply: key("s", "sel reply"),
Reply: key("R", "reply"),
ReplyMention: key("r", "@reply"),
Cancel: key("esc", "cancel"),
Edit: key("e", "edit"),
Delete: key("", "force delete"),
DeleteConfirm: key("d", "delete"),
Open: key("o", "open"),
OpenInBrowser: key("O", "open browser"),
Download: key("D", "download"),
YankContent: key("y", "copy text"),
YankURL: key("u", "copy url"),
YankID: key("i", "copy id"),
},
Composer: ComposerKeybinds{
EditLast: key("up", "edit last"),
Paste: key("ctrl+v", "paste"),
Send: key("enter", "send"),
Newline: key("shift+enter", "nl"),
Cancel: key("esc", "cancel"),
TabComplete: key("tab", "complete"),
ToggleReplyMention: key("shift+tab", "toggle @reply"),
Undo: key("ctrl+u", "undo"),
OpenEditor: key("ctrl+e", "editor"),
OpenFilePicker: key("ctrl+\\", "attach"),
},
MentionsList: MentionsListKeybinds{SelectionKeybinds: emacs},
Logout: key("ctrl+d", "logout"),
Quit: key("ctrl+c", "quit"),
}
}

View file

@ -1,8 +1,10 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/tview"
@ -11,6 +13,25 @@ import (
var errInvalidType = errors.New("invalid type")
var (
attributes = enum[tcell.AttrMask]{
{"bold", tcell.AttrBold},
{"dim", tcell.AttrDim},
{"italic", tcell.AttrItalic},
{"blink", tcell.AttrBlink},
{"reverse", tcell.AttrReverse},
{"strikethrough", tcell.AttrStrikeThrough},
}
underlines = enum[tcell.UnderlineStyle]{
{"", tcell.UnderlineStyleNone},
{"solid", tcell.UnderlineStyleSolid},
{"double", tcell.UnderlineStyleDouble},
{"curly", tcell.UnderlineStyleCurly},
{"dotted", tcell.UnderlineStyleDotted},
{"dashed", tcell.UnderlineStyleDashed},
}
)
type StyleWrapper struct{ tview.Style }
func (sw *StyleWrapper) UnmarshalTOML(v any) error {
@ -45,22 +66,11 @@ func (sw *StyleWrapper) UnmarshalTOML(v any) error {
}
case "underline":
if s, ok := val.(string); ok {
switch s {
case "":
sw.Style = sw.Underline(tcell.UnderlineStyleNone)
case "solid":
sw.Style = sw.Underline(tcell.UnderlineStyleSolid)
case "double":
sw.Style = sw.Underline(tcell.UnderlineStyleDouble)
case "curly":
sw.Style = sw.Underline(tcell.UnderlineStyleCurly)
case "dotted":
sw.Style = sw.Underline(tcell.UnderlineStyleDotted)
case "dashed":
sw.Style = sw.Underline(tcell.UnderlineStyleDashed)
default:
return fmt.Errorf("unknown value: %q", s)
underline, err := underlines.parse(s)
if err != nil {
return err
}
sw.Style = sw.Underline(underline)
}
case "underline_color":
if s, ok := val.(string); ok {
@ -73,38 +83,83 @@ func (sw *StyleWrapper) UnmarshalTOML(v any) error {
}
func (sw *StyleWrapper) parseAttr(s string) {
switch s {
case "underline":
if s == "underline" {
sw.Style = sw.Underline(true)
case "bold":
sw.Style = sw.Bold(true)
case "blink":
sw.Style = sw.Blink(true)
case "reverse":
sw.Style = sw.Reverse(true)
case "dim":
sw.Style = sw.Dim(true)
case "italic":
sw.Style = sw.Italic(true)
case "strikethrough":
sw.Style = sw.StrikeThrough(true)
return
}
// An unknown attribute parses to no attribute.
attr, _ := attributes.parse(s)
sw.Style = sw.Attributes(sw.GetAttributes() | attr)
}
// MarshalTOML writes the style as the inline table UnmarshalTOML reads.
func (sw StyleWrapper) MarshalTOML() ([]byte, error) {
var fields []string
add := func(key string, value any) {
b, _ := json.Marshal(value)
fields = append(fields, key+" = "+string(b))
}
if c := sw.GetForeground(); c != tcell.ColorDefault {
add("foreground", colorName(c))
}
if c := sw.GetBackground(); c != tcell.ColorDefault {
add("background", colorName(c))
}
var attrs []string
for _, a := range attributes {
if sw.GetAttributes()&a.value != 0 {
attrs = append(attrs, a.name)
}
}
switch len(attrs) {
case 0:
case 1:
add("attributes", attrs[0])
default:
add("attributes", attrs)
}
if u := sw.GetUnderlineStyle(); u != tcell.UnderlineStyleNone {
name, _ := underlines.text(u)
add("underline", string(name))
}
if c := sw.GetUnderlineColor(); c != tcell.ColorDefault {
add("underline_color", colorName(c))
}
if fields == nil {
return []byte("{}"), nil
}
return []byte("{ " + strings.Join(fields, ", ") + " }"), nil
}
// colorName returns the first of the names of c, or its hex code if it has none.
func colorName(c tcell.Color) string {
var names []string
for name, color := range tcell.ColorNames {
if color == c {
names = append(names, name)
}
}
if names == nil {
return c.CSS()
}
return slices.Min(names)
}
type (
HelpTheme struct {
HelpThemeConfig struct {
ShortKeyStyle StyleWrapper `toml:"short_key_style"`
ShortDescStyle StyleWrapper `toml:"short_desc_style"`
FullKeyStyle StyleWrapper `toml:"full_key_style"`
FullDescStyle StyleWrapper `toml:"full_desc_style"`
}
ThemeStyle struct {
StyleThemeConfig struct {
NormalStyle StyleWrapper `toml:"normal_style"`
// The style used while the widget is focused.
ActiveStyle StyleWrapper `toml:"active_style"`
}
MessagesListTheme struct {
MessagesListThemeConfig struct {
AuthorStyle StyleWrapper `toml:"author_style"`
MentionStyle StyleWrapper `toml:"mention_style"`
EmojiStyle StyleWrapper `toml:"emoji_style"`
@ -116,10 +171,10 @@ type (
MessageStyle StyleWrapper `toml:"message_style"`
SelectedMessageStyle StyleWrapper `toml:"selected_message_style"`
Embeds MessagesListEmbedsTheme `toml:"embeds"`
Embeds MessagesListEmbedsThemeConfig `toml:"embeds"`
}
MessagesListEmbedsTheme struct {
MessagesListEmbedsThemeConfig struct {
ProviderStyle StyleWrapper `toml:"provider_style"`
AuthorStyle StyleWrapper `toml:"author_style"`
TitleStyle StyleWrapper `toml:"title_style"`
@ -130,35 +185,39 @@ type (
URLStyle StyleWrapper `toml:"url_style"`
}
MentionsListTheme struct {
MentionsListThemeConfig struct {
SelectedStyle StyleWrapper `toml:"selected_style"`
}
DialogTheme struct {
Style StyleWrapper `toml:"style"`
DialogThemeConfig struct {
Style StyleWrapper `toml:"style"`
// The style of everything behind the dialog.
BackgroundStyle StyleWrapper `toml:"background_style"`
}
ScrollBarTheme struct {
ScrollBarThemeConfig struct {
TrackStyle StyleWrapper `toml:"track_style"`
ThumbStyle StyleWrapper `toml:"thumb_style"`
}
Theme struct {
Title ThemeStyle `toml:"title"`
Footer ThemeStyle `toml:"footer"`
Border ThemeStyle `toml:"border"`
GuildsTree CommonTreeTheme `toml:"guilds_tree"`
MembersTree CommonTreeTheme `toml:"members_tree"`
ScrollBar ScrollBarTheme `toml:"scroll_bar"`
MessagesList MessagesListTheme `toml:"messages_list"`
MentionsList MentionsListTheme `toml:"mentions_list"`
Dialog DialogTheme `toml:"dialog"`
Help HelpTheme `toml:"help"`
ThemeConfig struct {
Title StyleThemeConfig `toml:"title"`
Footer StyleThemeConfig `toml:"footer"`
Border StyleThemeConfig `toml:"border"`
// The status styles are those of direct-message recipients.
GuildsTree TreeThemeConfig `toml:"guilds_tree"`
// The status styles are those of the dot before each member.
MembersTree TreeThemeConfig `toml:"members_tree"`
ScrollBar ScrollBarThemeConfig `toml:"scroll_bar"`
MessagesList MessagesListThemeConfig `toml:"messages_list"`
MentionsList MentionsListThemeConfig `toml:"mentions_list"`
Dialog DialogThemeConfig `toml:"dialog"`
Help HelpThemeConfig `toml:"help"`
}
)
type CommonTreeTheme struct {
type TreeThemeConfig struct {
// The color of the lines that give the tree its shape.
GraphicsColor string `toml:"graphics_color"`
OnlineStyle StyleWrapper `toml:"online_style"`
@ -168,7 +227,7 @@ type CommonTreeTheme struct {
}
// StatusStyle returns the style for a user with status.
func (ctt CommonTreeTheme) StatusStyle(status discord.Status) tview.Style {
func (ctt TreeThemeConfig) StatusStyle(status discord.Status) tview.Style {
switch status {
case discord.OnlineStatus:
return ctt.OnlineStyle.Style
@ -180,3 +239,50 @@ func (ctt CommonTreeTheme) StatusStyle(status discord.Status) tview.Style {
return ctt.OfflineStyle.Style
}
}
func defaultTheme() ThemeConfig {
var (
plain = tcell.StyleDefault
dim = StyleWrapper{plain.Dim(true)}
bold = plain.Bold(true)
italic = plain.Italic(true)
blue = plain.Foreground(tcell.ColorBlue)
focus = StyleThemeConfig{dim, StyleWrapper{bold.Foreground(tcell.ColorGreen)}}
tree = TreeThemeConfig{
GraphicsColor: "default",
OnlineStyle: StyleWrapper{plain.Foreground(tcell.ColorGreen)},
IdleStyle: StyleWrapper{plain.Foreground(tcell.ColorYellow)},
DNDStyle: StyleWrapper{plain.Foreground(tcell.ColorRed)},
OfflineStyle: StyleWrapper{plain.Foreground(tcell.ColorGray)},
}
)
return ThemeConfig{
Title: focus,
Footer: focus,
Border: focus,
GuildsTree: tree,
MembersTree: tree,
ScrollBar: ScrollBarThemeConfig{TrackStyle: dim},
MessagesList: MessagesListThemeConfig{
MentionStyle: StyleWrapper{blue.Bold(true)},
EmojiStyle: StyleWrapper{plain.Foreground(tcell.ColorGreen)},
URLStyle: StyleWrapper{blue},
AttachmentStyle: StyleWrapper{plain.Foreground(tcell.ColorYellow)},
ReactionStyle: dim,
OwnReactionStyle: StyleWrapper{bold},
SelectedMessageStyle: StyleWrapper{plain.Reverse(true)},
Embeds: MessagesListEmbedsThemeConfig{
ProviderStyle: StyleWrapper{italic.Dim(true)},
AuthorStyle: StyleWrapper{italic},
TitleStyle: StyleWrapper{blue.Bold(true)},
DescriptionStyle: dim,
FieldNameStyle: StyleWrapper{bold.Underline(true)},
FooterStyle: StyleWrapper{italic.Dim(true)},
URLStyle: StyleWrapper{blue.Underline(true)},
},
},
MentionsList: MentionsListThemeConfig{SelectedStyle: StyleWrapper{plain.Reverse(true)}},
Dialog: DialogThemeConfig{BackgroundStyle: dim},
Help: HelpThemeConfig{ShortKeyStyle: dim, FullKeyStyle: dim},
}
}

View file

@ -8,146 +8,195 @@ import (
"github.com/ayn2op/tview/scrollbar"
)
// enum pairs the names an option accepts with their values; a value is written back as the first of its names.
type enum[T comparable] []struct {
name string
value T
}
func (e enum[T]) parse(name string) (T, error) {
for _, p := range e {
if p.name == name {
return p.value, nil
}
}
var zero T
return zero, fmt.Errorf("unknown value: %q", name)
}
func (e enum[T]) text(value T) ([]byte, error) {
for _, p := range e {
if p.value == value {
return []byte(p.name), nil
}
}
return nil, fmt.Errorf("unknown value: %v", value)
}
var (
boxDrawing = scrollbar.SymbolSet{Track: "│", Thumb: "█", Begin: "▲", End: "▼"}
alignments = enum[tview.Alignment]{
{"left", tview.AlignmentLeft},
{"center", tview.AlignmentCenter},
{"right", tview.AlignmentRight},
}
borderSets = enum[tview.BorderSet]{
{"hidden", tview.BorderSetHidden()},
{"plain", tview.BorderSetPlain()},
{"round", tview.BorderSetRound()},
{"thick", tview.BorderSetThick()},
{"double", tview.BorderSetDouble()},
}
symbolSets = enum[scrollbar.SymbolSet]{
{"minimal", scrollbar.SymbolSet{Thumb: "█", Begin: "▲", End: "▼"}},
{"box_drawing", boxDrawing},
{"boxdrawing", boxDrawing},
{"box", boxDrawing},
{"vertical", scrollbar.SymbolSetVertical()},
{"double_vertical", scrollbar.SymbolSetDoubleVertical()},
}
scrollBarVisibilities = enum[list.ScrollBarVisibility]{
{"auto", list.ScrollBarVisibilityAutomatic},
{"automatic", list.ScrollBarVisibilityAutomatic},
{"always", list.ScrollBarVisibilityAlways},
{"never", list.ScrollBarVisibilityNever},
{"hidden", list.ScrollBarVisibilityNever},
{"off", list.ScrollBarVisibilityNever},
}
)
type AlignmentWrapper struct{ tview.Alignment }
func (aw *AlignmentWrapper) UnmarshalTOML(v any) error {
s, ok := v.(string)
if !ok {
return errInvalidType
}
switch s {
case "left":
aw.Alignment = tview.AlignmentLeft
case "center":
aw.Alignment = tview.AlignmentCenter
case "right":
aw.Alignment = tview.AlignmentRight
default:
return fmt.Errorf("unknown value: %q", s)
}
return nil
func (aw *AlignmentWrapper) UnmarshalText(text []byte) (err error) {
aw.Alignment, err = alignments.parse(string(text))
return err
}
func (aw AlignmentWrapper) MarshalText() ([]byte, error) { return alignments.text(aw.Alignment) }
type BorderSetWrapper struct{ tview.BorderSet }
func (bw *BorderSetWrapper) UnmarshalTOML(val any) error {
s, ok := val.(string)
if !ok {
return errInvalidType
}
switch s {
case "hidden":
bw.BorderSet = tview.BorderSetHidden()
case "plain":
bw.BorderSet = tview.BorderSetPlain()
case "round":
bw.BorderSet = tview.BorderSetRound()
case "thick":
bw.BorderSet = tview.BorderSetThick()
case "double":
bw.BorderSet = tview.BorderSetDouble()
default:
return fmt.Errorf("unknown value: %q", s)
}
return nil
func (bw *BorderSetWrapper) UnmarshalText(text []byte) (err error) {
bw.BorderSet, err = borderSets.parse(string(text))
return err
}
func (bw BorderSetWrapper) MarshalText() ([]byte, error) { return borderSets.text(bw.BorderSet) }
type SymbolSetWrapper struct{ scrollbar.SymbolSet }
func (sw *SymbolSetWrapper) UnmarshalTOML(val any) error {
s, ok := val.(string)
if !ok {
return errInvalidType
}
switch s {
case "minimal":
sw.SymbolSet = scrollbar.SymbolSet{Thumb: "█", Begin: "▲", End: "▼"}
case "box_drawing", "boxdrawing", "box":
sw.SymbolSet = scrollbar.SymbolSet{Track: "│", Thumb: "█", Begin: "▲", End: "▼"}
case "vertical":
sw.SymbolSet = scrollbar.SymbolSetVertical()
case "double_vertical":
sw.SymbolSet = scrollbar.SymbolSetDoubleVertical()
default:
return fmt.Errorf("unknown value: %q", s)
}
return nil
func (sw *SymbolSetWrapper) UnmarshalText(text []byte) (err error) {
sw.SymbolSet, err = symbolSets.parse(string(text))
return err
}
func (sw SymbolSetWrapper) MarshalText() ([]byte, error) { return symbolSets.text(sw.SymbolSet) }
type ScrollBarVisibilityWrapper struct{ list.ScrollBarVisibility }
func (vw *ScrollBarVisibilityWrapper) UnmarshalTOML(val any) error {
s, ok := val.(string)
if !ok {
return errInvalidType
}
switch s {
case "automatic", "auto":
vw.ScrollBarVisibility = list.ScrollBarVisibilityAutomatic
case "always":
vw.ScrollBarVisibility = list.ScrollBarVisibilityAlways
case "never", "hidden", "off":
vw.ScrollBarVisibility = list.ScrollBarVisibilityNever
default:
return fmt.Errorf("unknown value: %q", s)
}
return nil
func (vw *ScrollBarVisibilityWrapper) UnmarshalText(text []byte) (err error) {
vw.ScrollBarVisibility, err = scrollBarVisibilities.parse(string(text))
return err
}
func (vw ScrollBarVisibilityWrapper) MarshalText() ([]byte, error) {
return scrollBarVisibilities.text(vw.ScrollBarVisibility)
}
type (
UIConfig struct {
Tabs TabsConfig `toml:"tabs"`
Title TitleConfig `toml:"title"`
Footer TitleConfig `toml:"footer"`
Border BorderConfig `toml:"border"`
GuildsTree GuildsTreeConfig `toml:"guilds_tree"`
MembersTree TreeConfig `toml:"members_tree"`
ScrollBar ScrollBarConfig `toml:"scroll_bar"`
MessagesList MessagesListConfig `toml:"messages_list"`
MentionsList MentionsListConfig `toml:"mentions_list"`
// The tabs of the login screen.
Tabs TabsUIConfig `toml:"tabs"`
Title TitleUIConfig `toml:"title"`
Footer TitleUIConfig `toml:"footer"`
Border BorderUIConfig `toml:"border"`
GuildsTree GuildsTreeUIConfig `toml:"guilds_tree"`
MembersTree TreeUIConfig `toml:"members_tree"`
ScrollBar ScrollBarUIConfig `toml:"scroll_bar"`
MessagesList MessagesListUIConfig `toml:"messages_list"`
// The list of mention suggestions above the composer. Its width and height are capped to the available space.
MentionsList MentionsListUIConfig `toml:"mentions_list"`
}
TabsConfig struct {
Alignment AlignmentWrapper `toml:"alignment"`
Wrap bool `toml:"wrap"`
Separator string `toml:"separator"`
Padding [2]string `toml:"padding"`
Arrows [2]string `toml:"arrows"`
ClickableArrows bool `toml:"clickable_arrows"`
TabsUIConfig struct {
// Where tabs that fit are placed: "left", "center" or "right".
Alignment AlignmentWrapper `toml:"alignment"`
// Whether moving past the last tab selects the first, and past the first selects the last.
Wrap bool `toml:"wrap"`
// Text between tabs.
Separator string `toml:"separator"`
// Text on either side of each tab: [left, right]
Padding [2]string `toml:"padding"`
// Arrows shown while tabs are hidden past an end: [start, end]. An empty string turns that arrow off.
Arrows [2]string `toml:"arrows"`
// Whether clicking an arrow selects the tab next to the active one.
ClickableArrows bool `toml:"clickable_arrows"`
}
TitleConfig struct {
TitleUIConfig struct {
// "left", "center" or "right".
Alignment AlignmentWrapper `toml:"alignment"`
}
BorderConfig struct {
Enabled bool `toml:"enabled"`
Padding [4]int `toml:"padding"`
BorderUIConfig struct {
Enabled bool `toml:"enabled"`
// [top, bottom, left, right]
Padding [4]int `toml:"padding"`
// "hidden", "plain", "round", "thick" or "double".
NormalSet BorderSetWrapper `toml:"normal_set"`
// The set used while the widget is focused.
ActiveSet BorderSetWrapper `toml:"active_set"`
}
TreeConfig struct {
TreeUIConfig struct {
// Whether to draw lines that give the tree its shape.
Graphics bool `toml:"graphics"`
}
GuildsTreeConfig struct {
TreeConfig
GuildsTreeUIConfig struct {
TreeUIConfig
AutoExpandFolders bool `toml:"auto_expand_folders"`
}
ScrollBarConfig struct {
ScrollBarUIConfig struct {
// "auto", "always" or "never".
Visibility ScrollBarVisibilityWrapper `toml:"visibility"`
SymbolSet SymbolSetWrapper `toml:"symbol_set"`
// "minimal", "box_drawing", "vertical" or "double_vertical".
SymbolSet SymbolSetWrapper `toml:"symbol_set"`
}
MessagesListConfig struct {
MessagesListUIConfig struct {
ReplyIndicator string `toml:"reply_indicator"`
ForwardedIndicator string `toml:"forwarded_indicator"`
}
MentionsListConfig struct {
MinWidth uint `toml:"min_width"`
MentionsListUIConfig struct {
// Minimum width. 0 makes the list as wide as possible.
MinWidth uint `toml:"min_width"`
// Maximum height. 0 makes the list as tall as needed.
MaxHeight uint `toml:"max_height"`
}
)
func defaultUI() UIConfig {
left := TitleUIConfig{AlignmentWrapper{tview.AlignmentLeft}}
round := BorderSetWrapper{tview.BorderSetRound()}
return UIConfig{
Tabs: TabsUIConfig{
Alignment: AlignmentWrapper{tview.AlignmentCenter},
Separator: "│",
Padding: [2]string{" ", " "},
Arrows: [2]string{"◀", "▶"},
ClickableArrows: true,
},
Title: left,
Footer: left,
Border: BorderUIConfig{Enabled: true, Padding: [4]int{0, 0, 1, 1}, NormalSet: round, ActiveSet: round},
GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true},
MembersTree: TreeUIConfig{Graphics: true},
ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}},
MessagesList: MessagesListUIConfig{ReplyIndicator: ">", ForwardedIndicator: "<"},
MentionsList: MentionsListUIConfig{MinWidth: 20},
}
}

View file

@ -670,7 +670,7 @@ const (
embedLineURL
)
func embedLineStyles(baseStyle tview.Style, theme config.MessagesListEmbedsTheme) [8]tview.Style {
func embedLineStyles(baseStyle tview.Style, theme config.MessagesListEmbedsThemeConfig) [8]tview.Style {
styles := [8]tview.Style{}
styles[embedLineProvider] = tview.MergeStyle(baseStyle, theme.ProviderStyle.Style)
styles[embedLineAuthor] = tview.MergeStyle(baseStyle, theme.AuthorStyle.Style)

View file

@ -12,7 +12,7 @@ import (
)
// New returns a boxed tree of the nodes under root, drawn with theme and graphics and moved with kbs, that turns changes and selected nodes into the messages onChange and onSelect return.
func New(root *tree.Node, selectionState tree.SelectionState, cfg *config.Config, theme config.CommonTreeTheme, graphics bool, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg, onSelect func(*tree.Node) tview.Msg) box.Widget {
func New(root *tree.Node, selectionState tree.SelectionState, cfg *config.Config, theme config.TreeThemeConfig, graphics bool, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg, onSelect func(*tree.Node) tview.Msg) box.Widget {
set := cfg.UI.Border.NormalSet.BorderSet
if focused {
set = cfg.UI.Border.ActiveSet.BorderSet