From 84835018c95194c5a79ee9c30cee57a04de7be2e Mon Sep 17 00:00:00 2001 From: ayn2op Date: Wed, 7 Oct 2026 18:06:07 -0400 Subject: [PATCH] feat(config): generate docs/config.md from the config structs (#842) --- .github/workflows/ci.yml | 6 + README.md | 2 +- docs/config.md | 1037 ++++++++++++++++++++++++ internal/config/config.go | 157 +++- internal/config/config.toml | 370 --------- internal/config/config_test.go | 66 +- internal/config/gen.go | 110 +++ internal/config/keybinds.go | 257 +++--- internal/config/theme.go | 210 +++-- internal/config/ui.go | 245 +++--- internal/ui/chat/messageslist/model.go | 2 +- internal/ui/tree/tree.go | 2 +- 12 files changed, 1743 insertions(+), 721 deletions(-) create mode 100644 docs/config.md delete mode 100644 internal/config/config.toml create mode 100644 internal/config/gen.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7aa1ef..5669d66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/README.md b/README.md index 791c052..648b701 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..630df51 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,1037 @@ + + +# auto_focus + +Whether to focus the composer automatically when a channel is selected. +Set to false to preview channels without moving focus. + +Default: `true` + +# mouse + +Whether to enable the mouse. + +Default: `true` + +# editor + +The program opened by the `composer.open_editor` keybind. "default" uses `$EDITOR`. + +Default: `"default"` + +# status + +"default" (unknown), "online", "dnd", "idle", "invisible" or "offline". + +Default: `"default"` + +# hide_blocked_users + +Default: `true` + +# autocomplete_limit + +The maximum number of members in the mention autocomplete list. 0 disables it. + +Default: `20` + +# messages_limit + +The number of messages to fetch when a text-based channel is selected, from 1 to 100. + +Default: `50` + +# attachments + +## attachments.show_links + +Default: `true` + +## attachments.allowed_mime_types + +Attachment MIME types that can be opened or downloaded without confirmation. +Exact types and wildcards such as "image/*" are supported. + +Default: `["image/*", "video/*"]` + +## attachments.preview + +Whether to draw image attachments and embedded images below their messages. + +Default: `true` + +## attachments.protocol + +How previews are drawn: "auto", "kitty" or "halfblocks". + +Default: `"auto"` + +# markdown + +## markdown.enabled + +Whether to parse and render markdown in messages. + +Default: `true` + +## markdown.mask_spoilers + +Replace spoiler contents with a placeholder. + +Default: `false` + +## markdown.theme + +Theme for fenced code blocks. Available themes: https://xyproto.github.io/splash/docs + +Default: `"monokai"` + +# help + +## help.enabled + +Whether to show keybind help. + +Default: `true` + +## help.compact_modifiers + +Show compact key modifiers in help, e.g. "^x" instead of "ctrl+x". + +Default: `true` + +## help.padding + +[left, right] + +Default: `[1, 1]` + +## help.separator + +Default: `" • "` + +# picker + +## picker.width + +Default: `80` + +## picker.height + +Default: `25` + +# timestamps + +## timestamps.enabled + +Default: `true` + +## timestamps.format + +https://pkg.go.dev/time#Layout + +Default: `"3:04PM"` + +# date_separator + +## date_separator.enabled + +Default: `true` + +## date_separator.format + +https://pkg.go.dev/time#Layout + +Default: `"January 2, 2006"` + +## date_separator.character + +The fill character used on both sides of the date label. + +Default: `"─"` + +# notifications + +## notifications.enabled + +Default: `true` + +## notifications.when_unfocused + +Whether to also notify about messages in the open channel while the terminal window is unfocused. +Requires a terminal that reports focus. + +Default: `true` + +# typing_indicator + +## typing_indicator.send + +Whether to send typing status. + +Default: `true` + +## typing_indicator.receive + +Whether to receive typing status. + +Default: `true` + +# sidebar + +The guilds tree pane. + +## sidebar.visible + +Whether the pane is visible at start-up. It can still be toggled at runtime with its toggle keybind. + +Default: `true` + +## sidebar.width_percent + +Percentage (%) of the available width used by the pane. +Valid values are 1-99. Invalid values fall back to 20. + +Default: `20` + +## sidebar.markers + +### sidebar.markers.expanded + +Default: `"▾ "` + +### sidebar.markers.collapsed + +Default: `"▸ "` + +### sidebar.markers.leaf + +Default: `""` + +## sidebar.indents + +Indentation for different item types. + +### sidebar.indents.guild + +Default: `2` + +### sidebar.indents.category + +Default: `1` + +### sidebar.indents.channel + +Default: `2` + +### sidebar.indents.forum + +Default: `2` + +### sidebar.indents.group_dm + +Default: `1` + +### sidebar.indents.dm + +Default: `2` + +# members_tree + +The members tree pane, sized from the width right of the guilds tree. + +## members_tree.visible + +Whether the pane is visible at start-up. It can still be toggled at runtime with its toggle keybind. + +Default: `true` + +## members_tree.width_percent + +Percentage (%) of the available width used by the pane. +Valid values are 1-99. Invalid values fall back to 20. + +Default: `20` + +# composer + +## composer.max_height + +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. + +Default: `10` + +# ui + +Layout and behavior of the widgets. + +## ui.tabs + +The tabs of the login screen. + +### ui.tabs.alignment + +Where tabs that fit are placed: "left", "center" or "right". + +Default: `"center"` + +### ui.tabs.wrap + +Whether moving past the last tab selects the first, and past the first selects the last. + +Default: `false` + +### ui.tabs.separator + +Text between tabs. + +Default: `"│"` + +### ui.tabs.padding + +Text on either side of each tab: [left, right] + +Default: `[" ", " "]` + +### ui.tabs.arrows + +Arrows shown while tabs are hidden past an end: [start, end]. An empty string turns that arrow off. + +Default: `["◀", "▶"]` + +### ui.tabs.clickable_arrows + +Whether clicking an arrow selects the tab next to the active one. + +Default: `true` + +## ui.title + +### ui.title.alignment + +"left", "center" or "right". + +Default: `"left"` + +## ui.footer + +### ui.footer.alignment + +"left", "center" or "right". + +Default: `"left"` + +## ui.border + +### ui.border.enabled + +Default: `true` + +### ui.border.padding + +[top, bottom, left, right] + +Default: `[0, 0, 1, 1]` + +### ui.border.normal_set + +"hidden", "plain", "round", "thick" or "double". + +Default: `"round"` + +### ui.border.active_set + +The set used while the widget is focused. + +Default: `"round"` + +## ui.guilds_tree + +### ui.guilds_tree.graphics + +Whether to draw lines that give the tree its shape. + +Default: `true` + +### ui.guilds_tree.auto_expand_folders + +Default: `true` + +## ui.members_tree + +### ui.members_tree.graphics + +Whether to draw lines that give the tree its shape. + +Default: `true` + +## ui.scroll_bar + +### ui.scroll_bar.visibility + +"auto", "always" or "never". + +Default: `"auto"` + +### ui.scroll_bar.symbol_set + +"minimal", "box_drawing", "vertical" or "double_vertical". + +Default: `"box_drawing"` + +## ui.messages_list + +### ui.messages_list.reply_indicator + +Default: `">"` + +### ui.messages_list.forwarded_indicator + +Default: `"<"` + +## ui.mentions_list + +The list of mention suggestions above the composer. Its width and height are capped to the available space. + +### ui.mentions_list.min_width + +Minimum width. 0 makes the list as wide as possible. + +Default: `20` + +### ui.mentions_list.max_height + +Maximum height. 0 makes the list as tall as needed. + +Default: `0` + +# icons + +## icons.guild_category + +Default: `""` + +## icons.guild_text + +Default: `"#"` + +## icons.guild_voice + +Default: `"♪ "` + +## icons.guild_stage_voice + +Default: `"♪ "` + +## icons.guild_announcement_thread + +Default: `"a-"` + +## icons.guild_public_thread + +Default: `"› "` + +## icons.guild_private_thread + +Default: `"› "` + +## icons.guild_announcement + +Default: `"a-"` + +## icons.guild_forum + +Default: `"≡ "` + +## icons.guild_store + +Default: `"s-"` + +# keybinds + +Global shortcuts. A keybind is a key such as "ctrl+k", or a list of keys. + +## keybinds.toggle_guilds_tree + +Hide/show the guilds tree. + +Default: `"alt+g"` + +## keybinds.toggle_members_tree + +Hide/show the members tree. + +Default: `"alt+m"` + +## keybinds.toggle_channels_picker + +Default: `"ctrl+k"` + +## keybinds.toggle_help + +Hide/show keybind help. + +Default: `"alt+."` + +## keybinds.toggle_full_help + +Switch between compact and full keybind help. + +Default: `"ctrl+."` + +## keybinds.suspend + +Default: `"ctrl+z"` + +## keybinds.focus_guilds_tree + +Default: `"ctrl+g"` + +## keybinds.focus_members_tree + +Default: `"ctrl+y"` + +## keybinds.focus_messages_list + +Default: `"ctrl+t"` + +## keybinds.focus_composer + +Default: `"ctrl+i"` + +## keybinds.focus_previous + +Cycle focus between the widgets. + +Default: `"ctrl+h"` + +## keybinds.focus_next + +Default: `"ctrl+l"` + +## keybinds.picker + +### keybinds.picker.select_up + +Default: `"ctrl+p"` + +### keybinds.picker.select_down + +Default: `"ctrl+n"` + +### keybinds.picker.select_top + +Default: `"home"` + +### keybinds.picker.select_bottom + +Default: `"end"` + +### keybinds.picker.select + +Default: `"enter"` + +### keybinds.picker.cancel + +Default: `"esc"` + +## keybinds.guilds_tree + +Only while focusing on the guilds tree. + +### keybinds.guilds_tree.select_up + +Default: `"k"` + +### keybinds.guilds_tree.select_down + +Default: `"j"` + +### keybinds.guilds_tree.select_top + +Default: `"g"` + +### keybinds.guilds_tree.select_bottom + +Default: `"G"` + +### keybinds.guilds_tree.select_current + +Select the highlighted text-based channel, or expand or collapse the highlighted node. + +Default: `"enter"` + +### keybinds.guilds_tree.yank_id + +Default: `"i"` + +### keybinds.guilds_tree.collapse_all + +Default: `"_"` + +### keybinds.guilds_tree.collapse_parent_node + +Default: `"-"` + +### keybinds.guilds_tree.move_to_parent_node + +Default: `"p"` + +### keybinds.guilds_tree.select_previous_unread + +Highlight the previous or next unread guild or channel, wrapping around. + +Default: `"K"` + +### keybinds.guilds_tree.select_next_unread + +Default: `"J"` + +## keybinds.members_tree + +Only while focusing on the members tree. + +### keybinds.members_tree.select_up + +Default: `"k"` + +### keybinds.members_tree.select_down + +Default: `"j"` + +### keybinds.members_tree.select_top + +Default: `"g"` + +### keybinds.members_tree.select_bottom + +Default: `"G"` + +### keybinds.members_tree.select_current + +Select the highlighted text-based channel, or expand or collapse the highlighted node. + +Default: `"enter"` + +### keybinds.members_tree.yank_id + +Default: `"i"` + +### keybinds.members_tree.collapse_all + +Default: `"_"` + +### keybinds.members_tree.collapse_parent_node + +Default: `"-"` + +### keybinds.members_tree.move_to_parent_node + +Default: `"p"` + +## keybinds.messages_list + +Only while focusing on sent messages. + +### keybinds.messages_list.select_up + +Default: `"k"` + +### keybinds.messages_list.select_down + +Default: `"j"` + +### keybinds.messages_list.select_top + +Default: `"g"` + +### keybinds.messages_list.select_bottom + +Default: `"G"` + +### keybinds.messages_list.scroll_up + +Scroll the messages list without changing the selection. + +Default: `"K"` + +### keybinds.messages_list.scroll_down + +Default: `"J"` + +### keybinds.messages_list.scroll_top + +Default: `"home"` + +### keybinds.messages_list.scroll_bottom + +Default: `"end"` + +### keybinds.messages_list.select_reply + +Select the message that the selected message replies to. + +Default: `"s"` + +### keybinds.messages_list.reply + +Reply to the selected message. + +Default: `"R"` + +### keybinds.messages_list.reply_mention + +Reply (with mention) to the selected message. + +Default: `"r"` + +### keybinds.messages_list.cancel + +Default: `"esc"` + +### keybinds.messages_list.edit + +Default: `"e"` + +### keybinds.messages_list.delete + +Delete without confirmation. Unbound by default. + +Default: `""` + +### keybinds.messages_list.delete_confirm + +Default: `"d"` + +### keybinds.messages_list.open + +Open attachments with their associated application. MIME types outside the +allowlist require confirmation. Hyperlinks still use the browser. + +Default: `"o"` + +### keybinds.messages_list.open_in_browser + +Open attachments or hyperlinks in the browser. + +Default: `"O"` + +### keybinds.messages_list.download + +Download one of the selected message's attachments. + +Default: `"D"` + +### keybinds.messages_list.yank_content + +Yank (copy) the selected message's content/url/id. + +Default: `"y"` + +### keybinds.messages_list.yank_url + +Default: `"u"` + +### keybinds.messages_list.yank_id + +Default: `"i"` + +## keybinds.composer + +Only while typing a message. + +### keybinds.composer.edit_last + +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. + +Default: `"up"` + +### keybinds.composer.paste + +Paste from clipboard (supports both text and images). + +Default: `"ctrl+v"` + +### keybinds.composer.send + +Default: `"enter"` + +### keybinds.composer.newline + +Insert a new line at the cursor. + +Default: `"shift+enter"` + +### keybinds.composer.cancel + +Remove existing text or cancel reply. + +Default: `"esc"` + +### keybinds.composer.tab_complete + +Complete usernames when mentioning. + +Default: `"tab"` + +### keybinds.composer.toggle_reply_mention + +Toggle whether a reply mentions its author. + +Default: `"shift+tab"` + +### keybinds.composer.undo + +Default: `"ctrl+u"` + +### keybinds.composer.open_editor + +Default: `"ctrl+e"` + +### keybinds.composer.open_file_picker + +Default: `"ctrl+\\"` + +## keybinds.mentions_list + +Only while the mention suggestions are shown. + +### keybinds.mentions_list.select_up + +Default: `"ctrl+p"` + +### keybinds.mentions_list.select_down + +Default: `"ctrl+n"` + +### keybinds.mentions_list.select_top + +Default: `"home"` + +### keybinds.mentions_list.select_bottom + +Default: `"end"` + +## keybinds.logout + +Log out and remove the authentication token from keyring. +Requires re-login upon restart. + +Default: `"ctrl+d"` + +## keybinds.quit + +Default: `"ctrl+c"` + +# theme + +Styles of the widgets, each written as +`{ foreground = "", background = "", attributes = "" or ["", ""], underline = "", underline_color = "" }`. + +## theme.title + +### theme.title.normal_style + +Default: `{ attributes = "dim" }` + +### theme.title.active_style + +The style used while the widget is focused. + +Default: `{ foreground = "green", attributes = "bold" }` + +## theme.footer + +### theme.footer.normal_style + +Default: `{ attributes = "dim" }` + +### theme.footer.active_style + +The style used while the widget is focused. + +Default: `{ foreground = "green", attributes = "bold" }` + +## theme.border + +### theme.border.normal_style + +Default: `{ attributes = "dim" }` + +### theme.border.active_style + +The style used while the widget is focused. + +Default: `{ foreground = "green", attributes = "bold" }` + +## theme.guilds_tree + +The status styles are those of direct-message recipients. + +### theme.guilds_tree.graphics_color + +The color of the lines that give the tree its shape. + +Default: `"default"` + +### theme.guilds_tree.online_style + +Default: `{ foreground = "green" }` + +### theme.guilds_tree.idle_style + +Default: `{ foreground = "yellow" }` + +### theme.guilds_tree.dnd_style + +Default: `{ foreground = "red" }` + +### theme.guilds_tree.offline_style + +Default: `{ foreground = "gray" }` + +## theme.members_tree + +The status styles are those of the dot before each member. + +### theme.members_tree.graphics_color + +The color of the lines that give the tree its shape. + +Default: `"default"` + +### theme.members_tree.online_style + +Default: `{ foreground = "green" }` + +### theme.members_tree.idle_style + +Default: `{ foreground = "yellow" }` + +### theme.members_tree.dnd_style + +Default: `{ foreground = "red" }` + +### theme.members_tree.offline_style + +Default: `{ foreground = "gray" }` + +## theme.scroll_bar + +### theme.scroll_bar.track_style + +Default: `{ attributes = "dim" }` + +### theme.scroll_bar.thumb_style + +Default: `{}` + +## theme.messages_list + +### theme.messages_list.author_style + +Default: `{}` + +### theme.messages_list.mention_style + +Default: `{ foreground = "blue", attributes = "bold" }` + +### theme.messages_list.emoji_style + +Default: `{ foreground = "green" }` + +### theme.messages_list.url_style + +Default: `{ foreground = "blue" }` + +### theme.messages_list.attachment_style + +Default: `{ foreground = "yellow" }` + +### theme.messages_list.reaction_style + +Default: `{ attributes = "dim" }` + +### theme.messages_list.own_reaction_style + +Default: `{ attributes = "bold" }` + +### theme.messages_list.message_style + +Default: `{}` + +### theme.messages_list.selected_message_style + +Default: `{ attributes = "reverse" }` + +### theme.messages_list.embeds + +#### theme.messages_list.embeds.provider_style + +Default: `{ attributes = ["dim","italic"] }` + +#### theme.messages_list.embeds.author_style + +Default: `{ attributes = "italic" }` + +#### theme.messages_list.embeds.title_style + +Default: `{ foreground = "blue", attributes = "bold" }` + +#### theme.messages_list.embeds.description_style + +Default: `{ attributes = "dim" }` + +#### theme.messages_list.embeds.field_name_style + +Default: `{ attributes = "bold", underline = "solid" }` + +#### theme.messages_list.embeds.field_value_style + +Default: `{}` + +#### theme.messages_list.embeds.footer_style + +Default: `{ attributes = ["dim","italic"] }` + +#### theme.messages_list.embeds.url_style + +Default: `{ foreground = "blue", underline = "solid" }` + +## theme.mentions_list + +### theme.mentions_list.selected_style + +Default: `{ attributes = "reverse" }` + +## theme.dialog + +### theme.dialog.style + +Default: `{}` + +### theme.dialog.background_style + +The style of everything behind the dialog. + +Default: `{ attributes = "dim" }` + +## theme.help + +### theme.help.short_key_style + +Default: `{ attributes = "dim" }` + +### theme.help.short_desc_style + +Default: `{}` + +### theme.help.full_key_style + +Default: `{ attributes = "dim" }` + +### theme.help.full_desc_style + +Default: `{}` diff --git a/internal/config/config.go b/internal/config/config.go index 96a5105..8d993a6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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): diff --git a/internal/config/config.toml b/internal/config/config.toml deleted file mode 100644 index ddb8c04..0000000 --- a/internal/config/config.toml +++ /dev/null @@ -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 = {} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b480021..b2cf1a6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 diff --git a/internal/config/gen.go b/internal/config/gen.go new file mode 100644 index 0000000..16e64ae --- /dev/null +++ b/internal/config/gen.go @@ -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 = "\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) + } +} diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 45f0a95..5bf9d15 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -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"), } } diff --git a/internal/config/theme.go b/internal/config/theme.go index 6c66401..11b7eda 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -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}, + } +} diff --git a/internal/config/ui.go b/internal/config/ui.go index f8b5462..a366030 100644 --- a/internal/config/ui.go +++ b/internal/config/ui.go @@ -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}, + } +} diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index 50b16a5..b437a99 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -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) diff --git a/internal/ui/tree/tree.go b/internal/ui/tree/tree.go index 557ff89..8a3ab46 100644 --- a/internal/ui/tree/tree.go +++ b/internal/ui/tree/tree.go @@ -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