浏览代码

feat: add help bar (#750)

Ayyan 2 月之前
父节点
当前提交
20ef1a7be2

+ 2 - 2
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

+ 4 - 4
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=

+ 4 - 3
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)
 	}

+ 10 - 1
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)
 	}

+ 66 - 52
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 = {}

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

+ 168 - 44
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"),
+			},
+		},
+	}
 }

+ 8 - 0
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"`
 	}
 )
 

+ 23 - 6
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},
+	}
+}

+ 93 - 9
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()
 	}
 

+ 71 - 0
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},
+	}
+}

+ 57 - 12
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,
+	}
+}

+ 168 - 59
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 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 ml.cfg.Keybinds.MessagesList.YankID:
+	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
 		}
+		cursor = added - 1
+	}
 
-		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
-			}
-		}
+	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},
+	}
+}

+ 52 - 11
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()
 

+ 8 - 6
pkg/picker/keymap.go

@@ -1,11 +1,13 @@
 package picker
 
+import "github.com/ayn2op/tview/keybind"
+
 type KeyMap struct {
-	Cancel string
+	Cancel keybind.Keybind
 
-	Up     string
-	Down   string
-	Top    string
-	Bottom string
-	Select string
+	Up     keybind.Keybind
+	Down   keybind.Keybind
+	Top    keybind.Keybind
+	Bottom keybind.Keybind
+	Select keybind.Keybind
 }

+ 8 - 7
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()
 		}