From 20ef1a7be2c8d9e3ac624c27e18e5a54645562fb Mon Sep 17 00:00:00 2001 From: Ayyan Date: Mon, 23 Feb 2026 16:03:11 -0500 Subject: [PATCH] feat: add help bar (#750) --- go.mod | 4 +- go.sum | 8 +- internal/app/app.go | 7 +- internal/config/config.go | 11 +- internal/config/config.toml | 118 +++++++------- internal/config/config_test.go | 8 +- internal/config/keybinds.go | 212 +++++++++++++++++++------ internal/config/theme.go | 8 + internal/ui/chat/channels_picker.go | 29 +++- internal/ui/chat/guilds_tree.go | 102 +++++++++++-- internal/ui/chat/keybinds.go | 71 +++++++++ internal/ui/chat/message_input.go | 69 +++++++-- internal/ui/chat/messages_list.go | 229 ++++++++++++++++++++-------- internal/ui/chat/view.go | 63 ++++++-- pkg/picker/keymap.go | 16 +- pkg/picker/picker.go | 15 +- 16 files changed, 751 insertions(+), 219 deletions(-) create mode 100644 internal/ui/chat/keybinds.go diff --git a/go.mod b/go.mod index 809e91f..7762333 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,9 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/chroma/v2 v2.23.1 github.com/andybalholm/brotli v1.2.0 - github.com/ayn2op/tview v0.0.0-20260217063213-aa3df80a3074 + github.com/ayn2op/tview v0.0.0-20260223065535-6a0c066d0bdd github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb - github.com/diamondburned/arikawa/v3 v3.6.1-0.20260221035217-15298f718931 + github.com/diamondburned/arikawa/v3 v3.6.1-0.20260221051847-b81b70d1a5cb github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d github.com/gdamore/tcell/v3 v3.1.2 github.com/gen2brain/beeep v0.11.2 diff --git a/go.sum b/go.sum index 7ca8c48..686376a 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/ayn2op/tview v0.0.0-20260217063213-aa3df80a3074 h1:16GcP8SZ7SnHvZEMu1EPxPjAlb/rwAgt556blNFdIiQ= -github.com/ayn2op/tview v0.0.0-20260217063213-aa3df80a3074/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0= +github.com/ayn2op/tview v0.0.0-20260223065535-6a0c066d0bdd h1:DdR19f4xNbd3XM9vxmC9LtF5XY1nv0rDMMzn0FSTwd0= +github.com/ayn2op/tview v0.0.0-20260223065535-6a0c066d0bdd/go.mod h1:lZ8RdOegQWBQafTOasGE7Ps1/Ymy4jmXoPt5vz2QsS0= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -25,8 +25,8 @@ github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI= github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc= github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb h1:6S+TKObz6+Io2c8IOkcbK4Sz7nj6RpEVU7TkvmsZZcw= github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb/go.mod h1:wf3nKtOnQqCp7kp9xB7hHnNlZ6m3NoiOxjrB9hFRq4Y= -github.com/diamondburned/arikawa/v3 v3.6.1-0.20260221035217-15298f718931 h1:7QLI6WfhnmvCzsU11+39i8IUoHyt7T7CtDjXInKLCIo= -github.com/diamondburned/arikawa/v3 v3.6.1-0.20260221035217-15298f718931/go.mod h1:TpV2GvCJIYSwAXUEAx4sutPcftyAbVidiFlG6l7K0go= +github.com/diamondburned/arikawa/v3 v3.6.1-0.20260221051847-b81b70d1a5cb h1:sBcyty++NvgR6SiAV5CuiTW3AKiAdtJ1NWE7jovZnUc= +github.com/diamondburned/arikawa/v3 v3.6.1-0.20260221051847-b81b70d1a5cb/go.mod h1:TpV2GvCJIYSwAXUEAx4sutPcftyAbVidiFlG6l7K0go= github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d h1:wS6HWl86RFItw38sUSdXlulj0VarV4G+9Rlv8j+p3oQ= github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d/go.mod h1:9PWnJlArEASrZQI89KQiBJBYOEDcsDyfhFcxRTL8eO4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= diff --git a/internal/app/app.go b/internal/app/app.go index 75442ec..3a1834a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,6 +12,7 @@ import ( "github.com/ayn2op/discordo/internal/ui/chat" "github.com/ayn2op/discordo/internal/ui/login" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/keybind" "github.com/gdamore/tcell/v3" ) @@ -100,11 +101,11 @@ func (a *App) quit() { } func (a *App) onInputCapture(event *tcell.EventKey) *tcell.EventKey { - switch event.Name() { - case a.cfg.Keybinds.Quit: + switch { + case keybind.Matches(event, a.cfg.Keybinds.Quit.Keybind): a.quit() return nil - case "Ctrl+C": + case keybind.Matches(event, keybind.NewKeybind(keybind.WithKeys("ctrl+c"))): // https://github.com/ayn2op/tview/blob/a64fc48d7654432f71922c8b908280cdb525805c/application.go#L153 return tcell.NewEventKey(tcell.KeyCtrlC, "", tcell.ModNone) } diff --git a/internal/config/config.go b/internal/config/config.go index 0616b68..0384fde 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,12 @@ type ( Theme string `toml:"theme"` } + HelpConfig struct { + CompactModifiers bool `toml:"compact_modifiers"` + Padding [2]int `toml:"padding"` + Separator string `toml:"separator"` + } + Config struct { AutoFocus bool `toml:"auto_focus"` Mouse bool `toml:"mouse"` @@ -82,6 +88,7 @@ type ( MessagesLimit uint8 `toml:"messages_limit"` Markdown MarkdownConfig `toml:"markdown"` + Help HelpConfig `toml:"help"` Picker PickerConfig `toml:"picker"` Timestamps Timestamps `toml:"timestamps"` DateSeparator DateSeparator `toml:"date_separator"` @@ -113,7 +120,9 @@ func DefaultPath() string { // Load reads the configuration file and parses it. func Load(path string) (*Config, error) { - var cfg Config + cfg := Config{ + Keybinds: defaultKeybinds(), + } if err := toml.Unmarshal(defaultCfg, &cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal default config: %w", err) } diff --git a/internal/config/config.toml b/internal/config/config.toml index 604dca6..9433729 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -26,6 +26,13 @@ enabled = true # Theme for fenced code blocks. Available themes: https://xyproto.github.io/splash/docs theme = "monokai" +[help] +# Show compact key modifiers in help, e.g. "^x" instead of "ctrl+x". +compact_modifiers = true +# [left, right] +padding = [1, 1] +separator = " • " + [picker] width = 60 height = 20 @@ -71,88 +78,89 @@ guild_store = "s-" # Global shortcuts # Esc: Reset message selection or close the channel selection popup. [keybinds] -focus_guilds_tree = "Ctrl+G" -focus_messages_list = "Ctrl+T" -focus_message_input = "Ctrl+I" +toggle_help = "ctrl+." +focus_guilds_tree = "ctrl+g" +focus_messages_list = "ctrl+t" +focus_message_input = "ctrl+i" # Cycle focus between the widgets. -focus_previous = "Ctrl+H" -focus_next = "Ctrl+L" +focus_previous = "ctrl+h" +focus_next = "ctrl+l" # Hide/show the guilds tree. -toggle_guilds_tree = "Ctrl+B" -quit = "Ctrl+C" +toggle_guilds_tree = "ctrl+b" +quit = "ctrl+c" # Log out and remove the authentication token from keyring. # Requires re-login upon restart. -logout = "Ctrl+D" +logout = "ctrl+d" [keybinds.picker] -toggle = "Ctrl+K" -cancel = "Esc" -up = "Ctrl+P" -down = "Ctrl+N" -top = "Home" -bottom = "End" -select = "Enter" +toggle = "ctrl+k" +cancel = "esc" +up = "ctrl+p" +down = "ctrl+n" +top = "home" +bottom = "end" +select = "enter" # Only while focusing on the guilds tree [keybinds.guilds_tree] -up = "Rune[k]" -down = "Rune[j]" -top = "Rune[g]" -bottom = "Rune[G]" +up = "k" +down = "j" +top = "g" +bottom = "G" # Select the currently highlighted text-based channel or expand a guild or channel. -select_current = "Enter" -yank_id = "Rune[i]" -collapse_parent_node = "Rune[-]" -move_to_parent_node = "Rune[p]" +select_current = "enter" +yank_id = "i" +collapse_parent_node = "-" +move_to_parent_node = "p" # Only while focusing on sent messages [keybinds.messages_list] -select_up = "Rune[k]" -select_down = "Rune[j]" -select_top = "Rune[g]" -select_bottom = "Rune[G]" +select_up = "k" +select_down = "j" +select_top = "g" +select_bottom = "G" # Scroll the messages list without changing the selection. -scroll_up = "Rune[K]" -scroll_down = "Rune[J]" -scroll_top = "Home" -scroll_bottom = "End" +scroll_up = "K" +scroll_down = "J" +scroll_top = "home" +scroll_bottom = "end" # Select the message reference (reply) of the selected channel. -select_reply = "Rune[s]" +select_reply = "s" # Reply to the selected message. -reply = "Rune[R]" +reply = "R" # Reply (with mention) to the selected message. -reply_mention = "Rune[r]" -cancel = "Esc" -edit = "Rune[e]" -delete = "Rune[D]" -delete_confirm = "Rune[d]" +reply_mention = "r" +cancel = "esc" +edit = "e" +delete = "D" +delete_confirm = "d" # Open the selected message's attachments or hyperlinks in the message # using the default browser application. -open = "Rune[o]" +open = "o" # Yank (copy) the selected message's content/url/id. -yank_content = "Rune[y]" -yank_url = "Rune[u]" -yank_id = "Rune[i]" +yank_content = "y" +yank_url = "u" +yank_id = "i" # Only while typing a message # Alt+Enter: Insert a new line to the current text. [keybinds.message_input] # paste from clipboard (supports both text and images) -paste = "Ctrl+V" -send = "Enter" +paste = "ctrl+v" +send = "enter" # Remove existing text or cancel reply. -cancel = "Esc" +cancel = "esc" # Complete usernames when mentioning -tab_complete = "Tab" +tab_complete = "tab" -open_editor = "Ctrl+E" -open_file_picker = "Ctrl+Rune[\\]" +open_editor = "ctrl+e" +open_file_picker = "ctrl+\\" [keybinds.mentions_list] -up = "Ctrl+P" -down = "Ctrl+N" -top = "Home" -bottom = "End" +up = "ctrl+p" +down = "ctrl+n" +top = "home" +bottom = "end" # style = { foreground = "", background = "", attributes = "" or [""] } [theme.title] @@ -213,3 +221,9 @@ max_height = 0 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 908ca8f..a9f136d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,7 @@ import ( "github.com/BurntSushi/toml" "github.com/ayn2op/discordo/internal/consts" + tviewkeybind "github.com/ayn2op/tview/keybind" "github.com/gdamore/tcell/v3" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -84,7 +85,12 @@ func TestLoad(t *testing.T) { } applyDefaults(&defCfg) - if diff := cmp.Diff(defCfg, *cfg, cmpopts.EquateComparable(tcell.Style{})); diff != "" { + if diff := cmp.Diff( + defCfg, + *cfg, + cmpopts.EquateComparable(tcell.Style{}), + cmpopts.IgnoreUnexported(tviewkeybind.Keybind{}), + ); diff != "" { t.Fatalf("got = -, want = +, diff=%s", diff) } }) diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 0a27806..44711c4 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -1,69 +1,104 @@ package config +import ( + "github.com/BurntSushi/toml" + "github.com/ayn2op/tview/keybind" +) + +type Keybind struct { + keybind.Keybind +} + +var _ toml.Unmarshaler = (*Keybind)(nil) + +func (k *Keybind) UnmarshalTOML(value any) error { + switch value := value.(type) { + case string: + k.SetKeys(value) + case []any: + keys := make([]string, 0, len(value)) + for _, key := range value { + if key, ok := key.(string); ok { + keys = append(keys, key) + } + } + k.SetKeys(keys...) + } + return nil +} + +func newKeybind(key, desc string) Keybind { + return Keybind{ + Keybind: keybind.NewKeybind( + keybind.WithKeys(key), + keybind.WithHelp(key, desc), + ), + } +} + type NavigationKeybinds struct { - Up string `toml:"up"` - Down string `toml:"down"` - Top string `toml:"top"` - Bottom string `toml:"bottom"` + Up Keybind `toml:"up"` + Down Keybind `toml:"down"` + Top Keybind `toml:"top"` + Bottom Keybind `toml:"bottom"` } type ScrollKeybinds struct { - ScrollUp string `toml:"scroll_up"` - ScrollDown string `toml:"scroll_down"` - ScrollTop string `toml:"scroll_top"` - ScrollBottom string `toml:"scroll_bottom"` + ScrollUp Keybind `toml:"scroll_up"` + ScrollDown Keybind `toml:"scroll_down"` + ScrollTop Keybind `toml:"scroll_top"` + ScrollBottom Keybind `toml:"scroll_bottom"` } type SelectionKeybinds struct { - SelectUp string `toml:"select_up"` - SelectDown string `toml:"select_down"` - SelectTop string `toml:"select_top"` - SelectBottom string `toml:"select_bottom"` + SelectUp Keybind `toml:"select_up"` + SelectDown Keybind `toml:"select_down"` + SelectTop Keybind `toml:"select_top"` + SelectBottom Keybind `toml:"select_bottom"` } type PickerKeybinds struct { NavigationKeybinds - Toggle string `toml:"toggle"` - Cancel string `toml:"cancel"` - Select string `toml:"select"` + Select Keybind `toml:"select"` + Cancel Keybind `toml:"cancel"` } type GuildsTreeKeybinds struct { NavigationKeybinds - SelectCurrent string `toml:"select_current"` - YankID string `toml:"yank_id"` + SelectCurrent Keybind `toml:"select_current"` + YankID Keybind `toml:"yank_id"` - CollapseParentNode string `toml:"collapse_parent_node"` - MoveToParentNode string `toml:"move_to_parent_node"` + CollapseParentNode Keybind `toml:"collapse_parent_node"` + MoveToParentNode Keybind `toml:"move_to_parent_node"` } type MessagesListKeybinds struct { SelectionKeybinds ScrollKeybinds - SelectReply string `toml:"select_reply"` - Reply string `toml:"reply"` - ReplyMention string `toml:"reply_mention"` + SelectReply Keybind `toml:"select_reply"` + Reply Keybind `toml:"reply"` + ReplyMention Keybind `toml:"reply_mention"` - Cancel string `toml:"cancel"` - Edit string `toml:"edit"` - Delete string `toml:"delete"` - DeleteConfirm string `toml:"delete_confirm"` - Open string `toml:"open"` + Cancel Keybind `toml:"cancel"` + Edit Keybind `toml:"edit"` + Delete Keybind `toml:"delete"` + DeleteConfirm Keybind `toml:"delete_confirm"` + Open Keybind `toml:"open"` - YankContent string `toml:"yank_content"` - YankURL string `toml:"yank_url"` - YankID string `toml:"yank_id"` + YankContent Keybind `toml:"yank_content"` + YankURL Keybind `toml:"yank_url"` + YankID Keybind `toml:"yank_id"` } type MessageInputKeybinds struct { - Paste string `toml:"paste"` - Send string `toml:"send"` - Cancel string `toml:"cancel"` - TabComplete string `toml:"tab_complete"` + Paste Keybind `toml:"paste"` + Send Keybind `toml:"send"` + Cancel Keybind `toml:"cancel"` + TabComplete Keybind `toml:"tab_complete"` - OpenEditor string `toml:"open_editor"` - OpenFilePicker string `toml:"open_file_picker"` + OpenEditor Keybind `toml:"open_editor"` + OpenFilePicker Keybind `toml:"open_file_picker"` } type MentionsListKeybinds struct { @@ -71,12 +106,16 @@ type MentionsListKeybinds struct { } type Keybinds struct { - FocusGuildsTree string `toml:"focus_guilds_tree"` - FocusMessagesList string `toml:"focus_messages_list"` - FocusMessageInput string `toml:"focus_message_input"` - FocusPrevious string `toml:"focus_previous"` - FocusNext string `toml:"focus_next"` - ToggleGuildsTree string `toml:"toggle_guilds_tree"` + ToggleGuildsTree Keybind `toml:"toggle_guilds_tree"` + ToggleChannelsPicker Keybind `toml:"toggle_channels_picker"` + ToggleHelp Keybind `toml:"toggle_help"` + + FocusGuildsTree Keybind `toml:"focus_guilds_tree"` + FocusMessagesList Keybind `toml:"focus_messages_list"` + FocusMessageInput Keybind `toml:"focus_message_input"` + + FocusPrevious Keybind `toml:"focus_previous"` + FocusNext Keybind `toml:"focus_next"` Picker PickerKeybinds `toml:"picker"` GuildsTree GuildsTreeKeybinds `toml:"guilds_tree"` @@ -84,6 +123,91 @@ type Keybinds struct { MessageInput MessageInputKeybinds `toml:"message_input"` MentionsList MentionsListKeybinds `toml:"mentions_list"` - Logout string `toml:"logout"` - Quit string `toml:"quit"` + Logout Keybind `toml:"logout"` + Quit Keybind `toml:"quit"` +} + +func defaultKeybinds() Keybinds { + return Keybinds{ + ToggleGuildsTree: newKeybind("ctrl+b", "toggle guilds"), + ToggleChannelsPicker: newKeybind("ctrl+k", "channels picker"), + ToggleHelp: newKeybind("ctrl+.", "help"), + + FocusGuildsTree: newKeybind("ctrl+g", "guilds"), + FocusMessagesList: newKeybind("ctrl+t", "messages"), + FocusMessageInput: newKeybind("ctrl+i", "input"), + + FocusPrevious: newKeybind("ctrl+h", "focus prev"), + FocusNext: newKeybind("ctrl+l", "focus next"), + + Logout: newKeybind("ctrl+d", "logout"), + Quit: newKeybind("ctrl+c", "quit"), + + Picker: PickerKeybinds{ + NavigationKeybinds: NavigationKeybinds{ + Up: newKeybind("ctrl+p", "up"), + Down: newKeybind("ctrl+n", "down"), + Top: newKeybind("home", "top"), + Bottom: newKeybind("end", "bottom"), + }, + Cancel: newKeybind("esc", "cancel"), + Select: newKeybind("enter", "sel"), + }, + GuildsTree: GuildsTreeKeybinds{ + NavigationKeybinds: NavigationKeybinds{ + Up: newKeybind("k", "up"), + Down: newKeybind("j", "down"), + Top: newKeybind("g", "top"), + Bottom: newKeybind("G", "bottom"), + }, + SelectCurrent: newKeybind("enter", "sel"), + YankID: newKeybind("i", "copy id"), + CollapseParentNode: newKeybind("-", "collapse"), + MoveToParentNode: newKeybind("p", "parent"), + }, + MessagesList: MessagesListKeybinds{ + SelectionKeybinds: SelectionKeybinds{ + SelectUp: newKeybind("k", "up"), + SelectDown: newKeybind("j", "down"), + SelectTop: newKeybind("g", "top"), + SelectBottom: newKeybind("G", "bottom"), + }, + ScrollKeybinds: ScrollKeybinds{ + ScrollUp: newKeybind("K", "scroll up"), + ScrollDown: newKeybind("J", "scroll down"), + ScrollTop: newKeybind("home", "scroll top"), + ScrollBottom: newKeybind("end", "scroll bottom"), + }, + SelectReply: newKeybind("s", "sel reply"), + Reply: newKeybind("R", "reply"), + ReplyMention: newKeybind("r", "@reply"), + Cancel: newKeybind("esc", "cancel"), + Edit: newKeybind("e", "edit"), + Delete: newKeybind("D", "force delete"), + DeleteConfirm: newKeybind( + "d", + "delete", + ), + Open: newKeybind("o", "open"), + YankContent: newKeybind("y", "copy text"), + YankURL: newKeybind("u", "copy url"), + YankID: newKeybind("i", "copy id"), + }, + MessageInput: MessageInputKeybinds{ + Paste: newKeybind("ctrl+v", "paste"), + Send: newKeybind("enter", "send"), + Cancel: newKeybind("esc", "cancel"), + TabComplete: newKeybind("tab", "complete"), + OpenEditor: newKeybind("ctrl+e", "editor"), + OpenFilePicker: newKeybind("ctrl+\\", "attach"), + }, + MentionsList: MentionsListKeybinds{ + NavigationKeybinds: NavigationKeybinds{ + Up: newKeybind("ctrl+p", "up"), + Down: newKeybind("ctrl+n", "down"), + Top: newKeybind("home", "top"), + Bottom: newKeybind("end", "bottom"), + }, + }, + } } diff --git a/internal/config/theme.go b/internal/config/theme.go index 6970673..8e215e7 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -147,6 +147,13 @@ func (vw *ScrollBarVisibilityWrapper) UnmarshalTOML(val any) error { return nil } +type HelpTheme 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"` +} + type ( ThemeStyle struct { NormalStyle StyleWrapper `toml:"normal_style"` @@ -217,6 +224,7 @@ type ( MessagesList MessagesListTheme `toml:"messages_list"` MentionsList MentionsListTheme `toml:"mentions_list"` Dialog DialogTheme `toml:"dialog"` + Help HelpTheme `toml:"help"` } ) diff --git a/internal/ui/chat/channels_picker.go b/internal/ui/chat/channels_picker.go index 7674930..ab38ef4 100644 --- a/internal/ui/chat/channels_picker.go +++ b/internal/ui/chat/channels_picker.go @@ -8,6 +8,8 @@ import ( "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/pkg/picker" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/help" + "github.com/ayn2op/tview/keybind" "github.com/diamondburned/arikawa/v3/discord" ) @@ -16,6 +18,8 @@ type channelsPicker struct { chatView *View } +var _ help.KeyMap = (*channelsPicker)(nil) + func newChannelsPicker(cfg *config.Config, chatView *View) *channelsPicker { cp := &channelsPicker{picker.New(), chatView} cp.Box = ui.ConfigureBox(tview.NewBox(), &cfg.Theme) @@ -36,12 +40,12 @@ func newChannelsPicker(cfg *config.Config, chatView *View) *channelsPicker { SetThumbStyle(cfg.Theme.ScrollBar.ThumbStyle.Style). SetGlyphSet(cfg.Theme.ScrollBar.GlyphSet.GlyphSet)) cp.SetKeyMap(&picker.KeyMap{ - Cancel: cfg.Keybinds.Picker.Cancel, - Up: cfg.Keybinds.Picker.Up, - Down: cfg.Keybinds.Picker.Down, - Top: cfg.Keybinds.Picker.Top, - Bottom: cfg.Keybinds.Picker.Bottom, - Select: cfg.Keybinds.Picker.Select, + Cancel: cfg.Keybinds.Picker.Cancel.Keybind, + Up: cfg.Keybinds.Picker.Up.Keybind, + Down: cfg.Keybinds.Picker.Down.Keybind, + Top: cfg.Keybinds.Picker.Top.Keybind, + Bottom: cfg.Keybinds.Picker.Bottom.Keybind, + Select: cfg.Keybinds.Picker.Select.Keybind, }) return cp } @@ -121,3 +125,16 @@ func (cp *channelsPicker) addChannel(guild *discord.Guild, channel discord.Chann name := b.String() cp.AddItem(picker.Item{Text: name, FilterText: name, Reference: channel.ID}) } + +func (cp *channelsPicker) ShortHelp() []keybind.Keybind { + cfg := cp.chatView.cfg.Keybinds.Picker + return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind} +} + +func (cp *channelsPicker) FullHelp() [][]keybind.Keybind { + cfg := cp.chatView.cfg.Keybinds.Picker + return [][]keybind.Keybind{ + {cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind}, + {cfg.Select.Keybind, cfg.Cancel.Keybind}, + } +} diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go index e3b45eb..34261d6 100644 --- a/internal/ui/chat/guilds_tree.go +++ b/internal/ui/chat/guilds_tree.go @@ -8,6 +8,8 @@ import ( "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/help" + "github.com/ayn2op/tview/keybind" "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/arikawa/v3/gateway" "github.com/diamondburned/ningen/v3" @@ -29,6 +31,8 @@ type guildsTree struct { dmRootNode *tview.TreeNode } +var _ help.KeyMap = (*guildsTree)(nil) + func newGuildsTree(cfg *config.Config, chatView *View) *guildsTree { gt := &guildsTree{ TreeView: tview.NewTreeView(), @@ -52,6 +56,86 @@ func newGuildsTree(cfg *config.Config, chatView *View) *guildsTree { return gt } +func (gt *guildsTree) ShortHelp() []keybind.Keybind { + cfg := gt.cfg.Keybinds.GuildsTree + selectCurrent := cfg.SelectCurrent.Keybind + collapseParent := cfg.CollapseParentNode.Keybind + selectHelp := selectCurrent.Help() + selectDesc := selectHelp.Desc + if node := gt.GetCurrentNode(); node != nil { + if len(node.GetChildren()) > 0 { + if node.IsExpanded() { + selectDesc = "collapse" + } else { + selectDesc = "expand" + } + } else { + switch node.GetReference().(type) { + case discord.GuildID, dmNode: + selectDesc = "expand" + } + } + } + selectCurrent.SetHelp(selectHelp.Key, selectDesc) + collapseHelp := collapseParent.Help() + collapseParent.SetHelp(collapseHelp.Key, "collapse parent") + + shortHelp := []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, selectCurrent} + if gt.canCollapseParent(gt.GetCurrentNode()) { + shortHelp = append(shortHelp, collapseParent) + } + return shortHelp +} + +func (gt *guildsTree) FullHelp() [][]keybind.Keybind { + cfg := gt.cfg.Keybinds.GuildsTree + selectCurrent := cfg.SelectCurrent.Keybind + collapseParent := cfg.CollapseParentNode.Keybind + selectHelp := selectCurrent.Help() + selectDesc := selectHelp.Desc + if node := gt.GetCurrentNode(); node != nil { + if len(node.GetChildren()) > 0 { + if node.IsExpanded() { + selectDesc = "collapse" + } else { + selectDesc = "expand" + } + } else { + switch node.GetReference().(type) { + case discord.GuildID, dmNode: + selectDesc = "expand" + } + } + } + selectCurrent.SetHelp(selectHelp.Key, selectDesc) + collapseHelp := collapseParent.Help() + collapseParent.SetHelp(collapseHelp.Key, "collapse parent") + + actions := []keybind.Keybind{selectCurrent, cfg.MoveToParentNode.Keybind} + if gt.canCollapseParent(gt.GetCurrentNode()) { + actions = append(actions, collapseParent) + } + + return [][]keybind.Keybind{ + {cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind}, + actions, + {cfg.YankID.Keybind}, + } +} + +func (gt *guildsTree) canCollapseParent(node *tview.TreeNode) bool { + if node == nil { + return false + } + path := gt.GetPath(node) + // Path layout is [root, ..., node]. A non-root parent means at least 3 nodes. + if len(path) < 3 { + return false + } + parent := path[len(path)-2] + return parent != nil && parent.GetLevel() != 0 +} + func (gt *guildsTree) resetNodeIndex() { // Keep allocated map capacity; READY can rebuild often during reconnects. clear(gt.guildNodeByID) @@ -284,28 +368,28 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) { } func (gt *guildsTree) onInputCapture(event *tcell.EventKey) *tcell.EventKey { - switch event.Name() { - case gt.cfg.Keybinds.GuildsTree.CollapseParentNode: + switch { + case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind): gt.collapseParentNode(gt.GetCurrentNode()) return nil - case gt.cfg.Keybinds.GuildsTree.MoveToParentNode: + case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.MoveToParentNode.Keybind): return tcell.NewEventKey(tcell.KeyRune, "K", tcell.ModNone) - case gt.cfg.Keybinds.GuildsTree.Up: + case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Up.Keybind): return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone) - case gt.cfg.Keybinds.GuildsTree.Down: + case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Down.Keybind): return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone) - case gt.cfg.Keybinds.GuildsTree.Top: + case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Top.Keybind): gt.Move(gt.GetRowCount() * -1) // return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone) - case gt.cfg.Keybinds.GuildsTree.Bottom: + case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.Bottom.Keybind): gt.Move(gt.GetRowCount()) // return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone) - case gt.cfg.Keybinds.GuildsTree.SelectCurrent: + case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind): return tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone) - case gt.cfg.Keybinds.GuildsTree.YankID: + case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.YankID.Keybind): gt.yankID() } diff --git a/internal/ui/chat/keybinds.go b/internal/ui/chat/keybinds.go new file mode 100644 index 0000000..719525d --- /dev/null +++ b/internal/ui/chat/keybinds.go @@ -0,0 +1,71 @@ +package chat + +import ( + "github.com/ayn2op/tview/help" + "github.com/ayn2op/tview/keybind" +) + +var _ help.KeyMap = (*View)(nil) + +func (v *View) ShortHelp() []keybind.Keybind { + short := make([]keybind.Keybind, 0, 16) + if active := v.activeKeyMap(); active != nil { + short = append(short, active.ShortHelp()...) + } + short = append(short, v.baseShortHelp()...) + return short +} + +func (v *View) FullHelp() [][]keybind.Keybind { + full := make([][]keybind.Keybind, 0, 8) + if active := v.activeKeyMap(); active != nil { + full = append(full, active.FullHelp()...) + } + full = append(full, v.baseFullHelp()...) + return full +} + +func (v *View) activeKeyMap() help.KeyMap { + if v.GetVisible(channelsPickerLayerName) { + return v.channelsPicker + } + + if v.app == nil { + return nil + } + + switch v.app.GetFocus() { + case v.guildsTree: + return v.guildsTree + case v.messagesList: + return v.messagesList + case v.messageInput: + return v.messageInput + default: + return nil + } +} + +func (v *View) baseShortHelp() []keybind.Keybind { + cfg := v.cfg.Keybinds + short := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind} + if !v.messageInput.GetDisabled() { + short = append(short, cfg.FocusMessageInput.Keybind) + } + short = append(short, cfg.ToggleGuildsTree.Keybind, cfg.ToggleChannelsPicker.Keybind, cfg.ToggleHelp.Keybind) + return short +} + +func (v *View) baseFullHelp() [][]keybind.Keybind { + cfg := v.cfg.Keybinds + focus := []keybind.Keybind{cfg.FocusGuildsTree.Keybind, cfg.FocusMessagesList.Keybind} + if !v.messageInput.GetDisabled() { + focus = append(focus, cfg.FocusMessageInput.Keybind) + } + return [][]keybind.Keybind{ + focus, + {cfg.FocusPrevious.Keybind, cfg.FocusNext.Keybind}, + {cfg.ToggleGuildsTree.Keybind, cfg.ToggleChannelsPicker.Keybind}, + {cfg.ToggleHelp.Keybind, cfg.Logout.Keybind, cfg.Quit.Keybind}, + } +} diff --git a/internal/ui/chat/message_input.go b/internal/ui/chat/message_input.go index facbaee..16e03b0 100644 --- a/internal/ui/chat/message_input.go +++ b/internal/ui/chat/message_input.go @@ -19,6 +19,8 @@ import ( "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/help" + "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/layers" "github.com/diamondburned/arikawa/v3/api" "github.com/diamondburned/arikawa/v3/discord" @@ -52,6 +54,8 @@ type messageInput struct { typingTimer *time.Timer } +var _ help.KeyMap = (*messageInput)(nil) + func newMessageInput(cfg *config.Config, chatView *View) *messageInput { mi := &messageInput{ TextArea: tview.NewTextArea(), @@ -90,12 +94,12 @@ func (mi *messageInput) stopTypingTimer() { } func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { - switch event.Name() { - case mi.cfg.Keybinds.MessageInput.Paste: + switch { + case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Paste.Keybind): mi.paste() return tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone) - case mi.cfg.Keybinds.MessageInput.Send: + case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Send.Keybind): if mi.chatView.GetVisible(mentionsListLayerName) { mi.tabComplete() return nil @@ -103,15 +107,15 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { mi.send() return nil - case mi.cfg.Keybinds.MessageInput.OpenEditor: + case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenEditor.Keybind): mi.stopTabCompletion() mi.editor() return nil - case mi.cfg.Keybinds.MessageInput.OpenFilePicker: + case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.OpenFilePicker.Keybind): mi.stopTabCompletion() mi.openFilePicker() return nil - case mi.cfg.Keybinds.MessageInput.Cancel: + case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.Cancel.Keybind): if mi.chatView.GetVisible(mentionsListLayerName) { mi.stopTabCompletion() } else { @@ -119,7 +123,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { } return nil - case mi.cfg.Keybinds.MessageInput.TabComplete: + case keybind.Matches(event, mi.cfg.Keybinds.MessageInput.TabComplete.Keybind): go mi.chatView.app.QueueUpdateDraw(func() { mi.tabComplete() }) return nil default: @@ -139,17 +143,17 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { if mi.cfg.AutocompleteLimit > 0 { if mi.chatView.GetVisible(mentionsListLayerName) { handler := mi.mentionsList.InputHandler() - switch event.Name() { - case mi.cfg.Keybinds.MentionsList.Up: + switch { + case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Up.Keybind): handler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil) return nil - case mi.cfg.Keybinds.MentionsList.Down: + case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Down.Keybind): handler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil) return nil - case mi.cfg.Keybinds.MentionsList.Top: + case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Top.Keybind): handler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil) return nil - case mi.cfg.Keybinds.MentionsList.Bottom: + case keybind.Matches(event, mi.cfg.Keybinds.MentionsList.Bottom.Keybind): handler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil) return nil } @@ -676,3 +680,44 @@ func (mi *messageInput) attach(name string, reader io.Reader) { } mi.SetFooter("Attached " + humanJoin(names)) } + +func (mi *messageInput) ShortHelp() []keybind.Keybind { + if mi.chatView.GetVisible(mentionsListLayerName) { + cfg := mi.cfg.Keybinds.MentionsList + icfg := mi.cfg.Keybinds.MessageInput + short := []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, icfg.Cancel.Keybind} + if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { + short = append(short, icfg.OpenFilePicker.Keybind) + } + return short + } + + cfg := mi.cfg.Keybinds.MessageInput + short := []keybind.Keybind{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind} + if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { + short = append(short, cfg.OpenFilePicker.Keybind) + } + return short +} + +func (mi *messageInput) FullHelp() [][]keybind.Keybind { + if mi.chatView.GetVisible(mentionsListLayerName) { + mcfg := mi.cfg.Keybinds.MentionsList + icfg := mi.cfg.Keybinds.MessageInput + return [][]keybind.Keybind{ + {mcfg.Up.Keybind, mcfg.Down.Keybind, mcfg.Top.Keybind, mcfg.Bottom.Keybind}, + {icfg.TabComplete.Keybind, icfg.Cancel.Keybind}, + } + } + + cfg := mi.cfg.Keybinds.MessageInput + openEditor := []keybind.Keybind{cfg.Paste.Keybind, cfg.OpenEditor.Keybind} + if selected := mi.chatView.SelectedChannel(); selected != nil && mi.chatView.state.HasPermissions(selected.ID, discord.PermissionAttachFiles) { + openEditor = append(openEditor, cfg.OpenFilePicker.Keybind) + } + + return [][]keybind.Keybind{ + {cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.TabComplete.Keybind}, + openEditor, + } +} diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index ba11dbb..7b9f4c0 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -22,6 +22,8 @@ import ( "github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/help" + "github.com/ayn2op/tview/keybind" "github.com/diamondburned/arikawa/v3/api" "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/arikawa/v3/gateway" @@ -58,6 +60,8 @@ type messagesList struct { } } +var _ help.KeyMap = (*messagesList)(nil) + type messagesListRowKind uint8 const ( @@ -523,52 +527,64 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) { } func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey { - switch event.Name() { - case ml.cfg.Keybinds.MessagesList.ScrollUp: + switch { + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollUp.Keybind): ml.ScrollUp() return nil - case ml.cfg.Keybinds.MessagesList.ScrollDown: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollDown.Keybind): ml.ScrollDown() return nil - case ml.cfg.Keybinds.MessagesList.ScrollTop: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollTop.Keybind): ml.ScrollToStart() return nil - case ml.cfg.Keybinds.MessagesList.ScrollBottom: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ScrollBottom.Keybind): ml.ScrollToEnd() return nil - case ml.cfg.Keybinds.MessagesList.Cancel: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind): ml.clearSelection() return nil - case ml.cfg.Keybinds.MessagesList.SelectUp, ml.cfg.Keybinds.MessagesList.SelectDown, ml.cfg.Keybinds.MessagesList.SelectTop, ml.cfg.Keybinds.MessagesList.SelectBottom, ml.cfg.Keybinds.MessagesList.SelectReply: - ml._select(event.Name()) + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind): + ml.selectUp() return nil - case ml.cfg.Keybinds.MessagesList.YankID: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind): + ml.selectDown() + return nil + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectTop.Keybind): + ml.selectTop() + return nil + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectBottom.Keybind): + ml.selectBottom() + return nil + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectReply.Keybind): + ml.selectReply() + return nil + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankID.Keybind): ml.yankID() return nil - case ml.cfg.Keybinds.MessagesList.YankContent: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankContent.Keybind): ml.yankContent() return nil - case ml.cfg.Keybinds.MessagesList.YankURL: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.YankURL.Keybind): ml.yankURL() return nil - case ml.cfg.Keybinds.MessagesList.Open: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Open.Keybind): ml.open() return nil - case ml.cfg.Keybinds.MessagesList.Reply: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Reply.Keybind): ml.reply(false) return nil - case ml.cfg.Keybinds.MessagesList.ReplyMention: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ReplyMention.Keybind): ml.reply(true) return nil - case ml.cfg.Keybinds.MessagesList.Edit: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Edit.Keybind): ml.edit() return nil - case ml.cfg.Keybinds.MessagesList.Delete: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Delete.Keybind): ml.delete() return nil - case ml.cfg.Keybinds.MessagesList.DeleteConfirm: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind): ml.confirmDelete() return nil } @@ -576,57 +592,81 @@ func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey { return event } -func (ml *messagesList) _select(name string) { +func (ml *messagesList) selectUp() { messages := ml.messages if len(messages) == 0 { return } cursor := ml.Cursor() - - switch name { - case ml.cfg.Keybinds.MessagesList.SelectUp: - switch { - case cursor == -1: - cursor = len(messages) - 1 - case cursor > 0: - cursor-- - case cursor == 0: - added := ml.prependOlderMessages() - if added == 0 { - return - } - cursor = added - 1 - } - case ml.cfg.Keybinds.MessagesList.SelectDown: - switch { - case cursor == -1: - cursor = len(messages) - 1 - case cursor < len(messages)-1: - cursor++ - } - case ml.cfg.Keybinds.MessagesList.SelectTop: - cursor = 0 - case ml.cfg.Keybinds.MessagesList.SelectBottom: + switch { + case cursor == -1: cursor = len(messages) - 1 - case ml.cfg.Keybinds.MessagesList.SelectReply: - if cursor == -1 || cursor >= len(messages) { + case cursor > 0: + cursor-- + case cursor == 0: + added := ml.prependOlderMessages() + if added == 0 { return } - - if ref := messages[cursor].ReferencedMessage; ref != nil { - refIdx := slices.IndexFunc(messages, func(m discord.Message) bool { - return m.ID == ref.ID - }) - if refIdx != -1 { - cursor = refIdx - } - } + cursor = added - 1 } ml.SetCursor(cursor) } +func (ml *messagesList) selectDown() { + messages := ml.messages + if len(messages) == 0 { + return + } + + cursor := ml.Cursor() + switch { + case cursor == -1: + cursor = len(messages) - 1 + case cursor < len(messages)-1: + cursor++ + } + + ml.SetCursor(cursor) +} + +func (ml *messagesList) selectTop() { + if len(ml.messages) == 0 { + return + } + ml.SetCursor(0) +} + +func (ml *messagesList) selectBottom() { + if len(ml.messages) == 0 { + return + } + ml.SetCursor(len(ml.messages) - 1) +} + +func (ml *messagesList) selectReply() { + messages := ml.messages + if len(messages) == 0 { + return + } + + cursor := ml.Cursor() + if cursor == -1 || cursor >= len(messages) { + return + } + + if ref := messages[cursor].ReferencedMessage; ref != nil { + refIdx := slices.IndexFunc(messages, func(m discord.Message) bool { + return m.ID == ref.ID + }) + if refIdx != -1 { + ml.SetCursor(refIdx) + } + } +} + func (ml *messagesList) prependOlderMessages() int { selectedChannel := ml.chatView.SelectedChannel() if selectedChannel == nil { @@ -815,16 +855,16 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord list.SetCursor(0) } list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Name() { - case ml.cfg.Keybinds.MessagesList.SelectUp: + switch { + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind): return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone) - case ml.cfg.Keybinds.MessagesList.SelectDown: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind): return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone) - case ml.cfg.Keybinds.MessagesList.SelectTop: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectTop.Keybind): return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone) - case ml.cfg.Keybinds.MessagesList.SelectBottom: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectBottom.Keybind): return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone) - case ml.cfg.Keybinds.MessagesList.Cancel: + case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind): closeList() return nil } @@ -1061,3 +1101,72 @@ func (ml *messagesList) waitForChunkEvent() uint { <-ml.fetchingMembers.done return ml.fetchingMembers.count } + +func (ml *messagesList) ShortHelp() []keybind.Keybind { + cfg := ml.cfg.Keybinds.MessagesList + help := []keybind.Keybind{ + cfg.SelectUp.Keybind, + cfg.SelectDown.Keybind, + cfg.Cancel.Keybind, + } + + if msg, err := ml.selectedMessage(); err == nil { + me, _ := ml.chatView.state.Cabinet.Me() + if msg.Author.ID != me.ID { + help = append(help, cfg.Reply.Keybind) + } + } + + return help +} + +func (ml *messagesList) FullHelp() [][]keybind.Keybind { + cfg := ml.cfg.Keybinds.MessagesList + + canSelectReply := false + canReply := false + canEdit := false + canDelete := false + canOpen := false + if msg, err := ml.selectedMessage(); err == nil { + canSelectReply = msg.ReferencedMessage != nil + canOpen = len(extractURLs(msg.Content)) != 0 || len(msg.Attachments) != 0 + + me, _ := ml.chatView.state.Cabinet.Me() + canReply = msg.Author.ID != me.ID + canEdit = msg.Author.ID == me.ID + canDelete = canEdit + if !canDelete { + selected := ml.chatView.SelectedChannel() + canDelete = selected != nil && ml.chatView.state.HasPermissions(selected.ID, discord.PermissionManageMessages) + } + } + + actions := make([]keybind.Keybind, 0, 4) + if canReply { + actions = append(actions, cfg.Reply.Keybind, cfg.ReplyMention.Keybind) + } + if canSelectReply { + actions = append(actions, cfg.SelectReply.Keybind) + } + actions = append(actions, cfg.Cancel.Keybind) + + manage := make([]keybind.Keybind, 0, 4) + if canEdit { + manage = append(manage, cfg.Edit.Keybind) + } + if canDelete { + manage = append(manage, cfg.DeleteConfirm.Keybind, cfg.Delete.Keybind) + } + if canOpen { + manage = append(manage, cfg.Open.Keybind) + } + + return [][]keybind.Keybind{ + {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind}, + {cfg.ScrollUp.Keybind, cfg.ScrollDown.Keybind, cfg.ScrollTop.Keybind, cfg.ScrollBottom.Keybind}, + actions, + manage, + {cfg.YankContent.Keybind, cfg.YankURL.Keybind, cfg.YankID.Keybind}, + } +} diff --git a/internal/ui/chat/view.go b/internal/ui/chat/view.go index 732235a..691355d 100644 --- a/internal/ui/chat/view.go +++ b/internal/ui/chat/view.go @@ -2,7 +2,6 @@ package chat import ( "fmt" - "github.com/ayn2op/tview/layers" "log/slog" "sync" "time" @@ -11,6 +10,9 @@ import ( "github.com/ayn2op/discordo/internal/keyring" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/help" + "github.com/ayn2op/tview/keybind" + "github.com/ayn2op/tview/layers" "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/ningen/v3" "github.com/diamondburned/ningen/v3/states/read" @@ -30,6 +32,7 @@ const ( type View struct { *layers.Layers + rootFlex *tview.Flex mainFlex *tview.Flex rightFlex *tview.Flex @@ -37,6 +40,7 @@ type View struct { messagesList *messagesList messageInput *messageInput channelsPicker *channelsPicker + help *help.Help selectedChannel *discord.Channel selectedChannelMu sync.RWMutex @@ -55,6 +59,7 @@ func NewView(app *tview.Application, cfg *config.Config, onLogout func()) *View v := &View{ Layers: layers.New(), + rootFlex: tview.NewFlex(), mainFlex: tview.NewFlex(), rightFlex: tview.NewFlex(), @@ -71,6 +76,20 @@ func NewView(app *tview.Application, cfg *config.Config, onLogout func()) *View v.channelsPicker = newChannelsPicker(cfg, v) v.channelsPicker.SetCancelFunc(v.closePicker) + v.help = help.New() + + styles := help.DefaultStyles() + styles.ShortKeyStyle = cfg.Theme.Help.ShortKeyStyle.Style + styles.ShortDescStyle = cfg.Theme.Help.ShortDescStyle.Style + styles.FullKeyStyle = cfg.Theme.Help.FullKeyStyle.Style + styles.FullDescStyle = cfg.Theme.Help.FullDescStyle.Style + v.help.SetStyles(styles) + + v.help.SetKeyMap(v) + v.help.SetCompactModifiers(cfg.Help.CompactModifiers) + v.help.SetShortSeparator(cfg.Help.Separator) + v.help.SetBorderPadding(0, 0, cfg.Help.Padding[0], cfg.Help.Padding[1]) + v.SetBackgroundLayerStyle(v.cfg.Theme.Dialog.BackgroundStyle.Style) v.SetInputCapture(v.onInputCapture) v.buildLayout() @@ -91,6 +110,7 @@ func (v *View) SetSelectedChannel(channel *discord.Channel) { func (v *View) buildLayout() { v.Clear() + v.rootFlex.Clear() v.rightFlex.Clear() v.mainFlex.Clear() @@ -103,7 +123,13 @@ func (v *View) buildLayout() { AddItem(v.guildsTree, 0, 1, true). AddItem(v.rightFlex, 0, 4, false) - v.AddLayer(v.mainFlex, layers.WithName(flexLayerName), layers.WithResize(true), layers.WithVisible(true)) + v.rootFlex. + SetDirection(tview.FlexRow). + AddItem(v.mainFlex, 0, 1, true). + AddItem(v.help, 1, 0, false) + + v.updateHelpHeight() + v.AddLayer(v.rootFlex, layers.WithName(flexLayerName), layers.WithResize(true), layers.WithVisible(true)) } func (v *View) togglePicker() { @@ -197,25 +223,29 @@ func (v *View) focusNext() { } func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey { - switch event.Name() { - case v.cfg.Keybinds.FocusGuildsTree: + switch { + case keybind.Matches(event, v.cfg.Keybinds.ToggleHelp.Keybind): + v.help.SetShowAll(!v.help.ShowAll()) + v.updateHelpHeight() + return nil + case keybind.Matches(event, v.cfg.Keybinds.FocusGuildsTree.Keybind): v.messageInput.removeMentionsList() v.focusGuildsTree() return nil - case v.cfg.Keybinds.FocusMessagesList: + case keybind.Matches(event, v.cfg.Keybinds.FocusMessagesList.Keybind): v.messageInput.removeMentionsList() v.app.SetFocus(v.messagesList) return nil - case v.cfg.Keybinds.FocusMessageInput: + case keybind.Matches(event, v.cfg.Keybinds.FocusMessageInput.Keybind): v.focusMessageInput() return nil - case v.cfg.Keybinds.FocusPrevious: + case keybind.Matches(event, v.cfg.Keybinds.FocusPrevious.Keybind): v.focusPrevious() return nil - case v.cfg.Keybinds.FocusNext: + case keybind.Matches(event, v.cfg.Keybinds.FocusNext.Keybind): v.focusNext() return nil - case v.cfg.Keybinds.Logout: + case keybind.Matches(event, v.cfg.Keybinds.Logout.Keybind): if v.onLogout != nil { v.onLogout() } @@ -226,10 +256,10 @@ func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey { } return nil - case v.cfg.Keybinds.ToggleGuildsTree: + case keybind.Matches(event, v.cfg.Keybinds.ToggleGuildsTree.Keybind): v.toggleGuildsTree() return nil - case v.cfg.Keybinds.Picker.Toggle: + case keybind.Matches(event, v.cfg.Keybinds.ToggleChannelsPicker.Keybind): v.togglePicker() return nil } @@ -237,6 +267,17 @@ func (v *View) onInputCapture(event *tcell.EventKey) *tcell.EventKey { return event } +func (v *View) updateHelpHeight() { + height := 1 + if v.help.ShowAll() { + height = len(v.help.FullHelpLines(v.FullHelp(), 0)) + if height < 1 { + height = 1 + } + } + v.rootFlex.ResizeItem(v.help, height, 0) +} + func (v *View) showConfirmModal(prompt string, buttons []string, onDone func(label string)) { previousFocus := v.app.GetFocus() diff --git a/pkg/picker/keymap.go b/pkg/picker/keymap.go index fe23a58..3c1db8a 100644 --- a/pkg/picker/keymap.go +++ b/pkg/picker/keymap.go @@ -1,11 +1,13 @@ package picker -type KeyMap struct { - Cancel string +import "github.com/ayn2op/tview/keybind" - Up string - Down string - Top string - Bottom string - Select string +type KeyMap struct { + Cancel keybind.Keybind + + Up keybind.Keybind + Down keybind.Keybind + Top keybind.Keybind + Bottom keybind.Keybind + Select keybind.Keybind } diff --git a/pkg/picker/picker.go b/pkg/picker/picker.go index b5eca81..4726b7e 100644 --- a/pkg/picker/picker.go +++ b/pkg/picker/picker.go @@ -2,6 +2,7 @@ package picker import ( "github.com/ayn2op/tview" + "github.com/ayn2op/tview/keybind" "github.com/gdamore/tcell/v3" "github.com/sahilm/fuzzy" ) @@ -154,23 +155,23 @@ func (p *Picker) onInputCapture(event *tcell.EventKey) *tcell.EventKey { } handler := p.list.InputHandler() - switch event.Name() { - case p.keyMap.Up: + switch { + case keybind.Matches(event, p.keyMap.Up): handler(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil) return nil - case p.keyMap.Down: + case keybind.Matches(event, p.keyMap.Down): handler(tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone), nil) return nil - case p.keyMap.Top: + case keybind.Matches(event, p.keyMap.Top): handler(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone), nil) return nil - case p.keyMap.Bottom: + case keybind.Matches(event, p.keyMap.Bottom): handler(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone), nil) - case p.keyMap.Select: + case keybind.Matches(event, p.keyMap.Select): p.onListSelected(p.list.Cursor()) return nil - case p.keyMap.Cancel: + case keybind.Matches(event, p.keyMap.Cancel): if p.onCancel != nil { p.onCancel() }