From 47b9bb3403cfbd647cface8ae01e2dbf6e4061bf Mon Sep 17 00:00:00 2001 From: shatrughan mishra Date: Wed, 25 Feb 2026 20:29:54 +0530 Subject: [PATCH 1/2] feat: add configurable keybindings support Signed-off-by: shatrughan mishra --- internal/tui/config.go | 2 +- internal/tui/keys.go | 109 ++++++++++++++++++++++++++++++++++++- internal/tui/model.go | 6 +- internal/tui/model_test.go | 51 +++++++++++++++++ internal/tui/theme.go | 10 ++-- 5 files changed, 171 insertions(+), 7 deletions(-) diff --git a/internal/tui/config.go b/internal/tui/config.go index 2be2cc3..6e273da 100644 --- a/internal/tui/config.go +++ b/internal/tui/config.go @@ -36,7 +36,7 @@ func initializeConfig() error { if _, err := os.Stat(ConfigFilePath); err != nil { if os.IsNotExist(err) { - defaultConfig := fmt.Sprintf("Theme = %q\n", DefaultThemeName) + defaultConfig := fmt.Sprintf("theme = %q\n\n[keybindings]\n", DefaultThemeName) if writeErr := os.WriteFile(ConfigFilePath, []byte(defaultConfig), 0644); writeErr != nil { return fmt.Errorf("failed to create default config file: %w", writeErr) } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 2fac375..92909a8 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -1,6 +1,11 @@ package tui -import "github.com/charmbracelet/bubbles/key" +import ( + "log" + "strings" + + "github.com/charmbracelet/bubbles/key" +) // KeyMap defines the keybindings for the application. type KeyMap struct { @@ -267,3 +272,105 @@ func DefaultKeyMap() KeyMap { ), } } + +// KeyMapFromConfig returns keybindings with user overrides applied on top of defaults. +func KeyMapFromConfig(overrides map[string]string) KeyMap { + resolved := DefaultKeyMap() + for action, configured := range overrides { + keys, ok := parseConfiguredKeys(configured) + if !ok { + log.Printf("invalid keybinding for %q: empty value, using default", action) + continue + } + + switch action { + case "quit": + resolved.Quit = overrideBinding(resolved.Quit, keys) + case "escape": + resolved.Escape = overrideBinding(resolved.Escape, keys) + case "toggle_help": + resolved.ToggleHelp = overrideBinding(resolved.ToggleHelp, keys) + case "switch_theme": + resolved.SwitchTheme = overrideBinding(resolved.SwitchTheme, keys) + case "focus_next": + resolved.FocusNext = overrideBinding(resolved.FocusNext, keys) + case "focus_prev": + resolved.FocusPrev = overrideBinding(resolved.FocusPrev, keys) + case "focus_main": + resolved.FocusZero = overrideBinding(resolved.FocusZero, keys) + case "focus_status": + resolved.FocusOne = overrideBinding(resolved.FocusOne, keys) + case "focus_files": + resolved.FocusTwo = overrideBinding(resolved.FocusTwo, keys) + case "focus_branches": + resolved.FocusThree = overrideBinding(resolved.FocusThree, keys) + case "focus_commits": + resolved.FocusFour = overrideBinding(resolved.FocusFour, keys) + case "focus_stash": + resolved.FocusFive = overrideBinding(resolved.FocusFive, keys) + case "focus_command_log": + resolved.FocusSix = overrideBinding(resolved.FocusSix, keys) + case "up": + resolved.Up = overrideBinding(resolved.Up, keys) + case "down": + resolved.Down = overrideBinding(resolved.Down, keys) + case "stage_item": + resolved.StageItem = overrideBinding(resolved.StageItem, keys) + case "stage_all": + resolved.StageAll = overrideBinding(resolved.StageAll, keys) + case "discard": + resolved.Discard = overrideBinding(resolved.Discard, keys) + case "stash": + resolved.Stash = overrideBinding(resolved.Stash, keys) + case "stash_all": + resolved.StashAll = overrideBinding(resolved.StashAll, keys) + case "commit": + resolved.Commit = overrideBinding(resolved.Commit, keys) + case "checkout", "open": + resolved.Checkout = overrideBinding(resolved.Checkout, keys) + case "new_branch": + resolved.NewBranch = overrideBinding(resolved.NewBranch, keys) + case "delete_branch": + resolved.DeleteBranch = overrideBinding(resolved.DeleteBranch, keys) + case "rename_branch": + resolved.RenameBranch = overrideBinding(resolved.RenameBranch, keys) + case "amend_commit": + resolved.AmendCommit = overrideBinding(resolved.AmendCommit, keys) + case "revert": + resolved.Revert = overrideBinding(resolved.Revert, keys) + case "reset_to_commit": + resolved.ResetToCommit = overrideBinding(resolved.ResetToCommit, keys) + case "stash_apply": + resolved.StashApply = overrideBinding(resolved.StashApply, keys) + case "stash_pop": + resolved.StashPop = overrideBinding(resolved.StashPop, keys) + case "stash_drop": + resolved.StashDrop = overrideBinding(resolved.StashDrop, keys) + default: + log.Printf("unknown keybinding action %q, ignoring", action) + } + } + + return resolved +} + +func overrideBinding(current key.Binding, keys []string) key.Binding { + desc := current.Help().Desc + helpKey := strings.Join(keys, "/") + return key.NewBinding( + key.WithKeys(keys...), + key.WithHelp(helpKey, desc), + ) +} + +func parseConfiguredKeys(configured string) ([]string, bool) { + parts := strings.Split(configured, ",") + keys := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + keys = append(keys, trimmed) + } + } + return keys, len(keys) > 0 +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 5f176f5..efdb8e4 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -54,7 +54,11 @@ type Model struct { // initialModel creates the initial state of the application. func initialModel() Model { themeNames := ThemeNames() //built-in themes load - cfg, _ := load_config() + cfg, err := load_config() + if err != nil { + cfg = &appConfig{Theme: DefaultThemeName} + } + keys = KeyMapFromConfig(cfg.Keybindings) var selectedThemeName string if t, ok := Themes[cfg.Theme]; ok { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index b6cd63c..d53da47 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -181,6 +181,45 @@ func TestModel_contextualHelp(t *testing.T) { }) } +func TestKeyMapFromConfig_OverridesAndFallback(t *testing.T) { + defaults := DefaultKeyMap() + resolved := KeyMapFromConfig(map[string]string{ + "quit": "x", + }) + + if got := resolved.Quit.Keys(); len(got) != 1 || got[0] != "x" { + t.Fatalf("expected quit key to be overridden to x, got %v", got) + } + + if got, want := resolved.Up.Keys(), defaults.Up.Keys(); !sameKeys(got, want) { + t.Fatalf("expected unspecified keybinding to fallback to default, got %v want %v", got, want) + } +} + +func TestKeyMapFromConfig_MultiKeyValue(t *testing.T) { + resolved := KeyMapFromConfig(map[string]string{ + "quit": "x,ctrl+c", + }) + + got := resolved.Quit.Keys() + if len(got) != 2 || got[0] != "x" || got[1] != "ctrl+c" { + t.Fatalf("expected parsed multi-key binding, got %v", got) + } +} + +func TestKeyMapFromConfig_InvalidValueUsesDefault(t *testing.T) { + defaults := DefaultKeyMap() + resolved := KeyMapFromConfig(map[string]string{ + "quit": " ", + }) + + got := resolved.Quit.Keys() + want := defaults.Quit.Keys() + if !sameKeys(got, want) { + t.Fatalf("expected invalid override to keep default, got %v want %v", got, want) + } +} + func TestModel_HelpToggle(t *testing.T) { m := initialModel() t.Run("toggles help on", func(t *testing.T) { @@ -408,3 +447,15 @@ func assertKeyBindingsEqual(t *testing.T, got, want []key.Binding) { t.Errorf("\n\tgot \t%v\n\twant \t%v", got, want) } } + +func sameKeys(got []string, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go index b8564b3..020ed36 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -152,8 +152,10 @@ type TreeStyle struct { } // config.toml -type themeConfig struct { - Theme string `toml:"theme"` +type appConfig struct { + Theme string `toml:"theme"` + Keybindings map[string]string `toml:"keybindings"` + } // custom_theme.toml @@ -244,10 +246,10 @@ func ThemeNames() []string { return names } -func load_config() (*themeConfig, error) { +func load_config() (*appConfig, error) { cfgPath := ConfigFilePath - var cfg themeConfig + var cfg appConfig if _, err := toml.DecodeFile(cfgPath, &cfg); err != nil { return nil, err } From 8234893b180e4888121e5c247b0955c99998cd7f Mon Sep 17 00:00:00 2001 From: shatrughan mishra Date: Thu, 26 Feb 2026 19:08:41 +0530 Subject: [PATCH 2/2] refactor: relocate appconfig and simplify keybind handling Signed-off-by: shatrughan mishra --- internal/tui/config.go | 19 ++ internal/tui/keys.go | 501 +++++++++++++------------------------ internal/tui/model.go | 14 +- internal/tui/model_test.go | 40 +-- internal/tui/theme.go | 18 -- internal/tui/update.go | 77 +++--- internal/tui/view.go | 4 +- 7 files changed, 256 insertions(+), 417 deletions(-) diff --git a/internal/tui/config.go b/internal/tui/config.go index 6e273da..6058356 100644 --- a/internal/tui/config.go +++ b/internal/tui/config.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/BurntSushi/toml" ) var ( @@ -14,6 +16,23 @@ var ( ConfigThemesDirPath string ) +// config.toml +type appConfig struct { + Theme string `toml:"theme"` + Keybindings map[string]string `toml:"keybindings"` +} + +func load_config() (*appConfig, error) { + cfgPath := ConfigFilePath + + var cfg appConfig + if _, err := toml.DecodeFile(cfgPath, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + func initializeConfig() error { homeDir, err := os.UserHomeDir() if err != nil { diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 92909a8..9119a58 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -1,376 +1,229 @@ package tui import ( - "log" "strings" "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" ) -// KeyMap defines the keybindings for the application. -type KeyMap struct { - // miscellaneous keybindings - Quit key.Binding - Escape key.Binding - ToggleHelp key.Binding +// KeyMap stores keybindings by action name. +type KeyMap map[string]string - // keybindings for changing theme - SwitchTheme key.Binding +// HelpSection is a struct to hold a title and keybindings for a help section. +type HelpSection struct { + Title string + Bindings []key.Binding +} - // keybindings for navigation - FocusNext key.Binding - FocusPrev key.Binding - FocusZero key.Binding - FocusOne key.Binding - FocusTwo key.Binding - FocusThree key.Binding - FocusFour key.Binding - FocusFive key.Binding - FocusSix key.Binding - Up key.Binding - Down key.Binding +var keybindingDescriptions = map[string]string{ + "quit": "quit", + "escape": "cancel", + "toggle_help": "toggle help", + "switch_theme": "switch theme", + "focus_next": "Focus Next Window", + "focus_prev": "Focus Previous Window", + "focus_main": "Focus Main Window", + "focus_status": "Focus Status Window", + "focus_files": "Focus Files Window", + "focus_branches": "Focus Branches Window", + "focus_commits": "Focus Commits Window", + "focus_stash": "Focus Stash Window", + "focus_command_log": "Focus Command log Window", + "up": "up", + "down": "down", + "stage_item": "Stage Item", + "stage_all": "Stage All", + "discard": "Discard", + "stash": "Stash", + "stash_all": "Stash all", + "commit": "Commit", + "checkout": "Checkout", + "new_branch": "New Branch", + "delete_branch": "Delete", + "rename_branch": "Rename", + "amend_commit": "Amend", + "revert": "Revert", + "reset_to_commit": "Reset to Commit", + "stash_apply": "Apply", + "stash_pop": "Pop", + "stash_drop": "Drop", +} - // Keybindings for FilesPanel - StageItem key.Binding - StageAll key.Binding - Discard key.Binding - Stash key.Binding - StashAll key.Binding - Commit key.Binding +func keySpec(keys ...string) string { + return strings.Join(keys, ",") +} - // Keybindings for BranchesPanel - Checkout key.Binding - NewBranch key.Binding - DeleteBranch key.Binding - RenameBranch key.Binding +// DefaultKeybindings returns default keybindings for each action. +func DefaultKeybindings() map[string]string { + return map[string]string{ + "quit": keySpec("q", "ctrl+c"), + "escape": keySpec("esc"), + "toggle_help": keySpec("?"), + "switch_theme": keySpec("ctrl+t"), + "focus_next": keySpec("tab"), + "focus_prev": keySpec("shift+tab"), + "focus_main": keySpec("0"), + "focus_status": keySpec("1"), + "focus_files": keySpec("2"), + "focus_branches": keySpec("3"), + "focus_commits": keySpec("4"), + "focus_stash": keySpec("5"), + "focus_command_log": keySpec("6"), + "up": keySpec("k", "up"), + "down": keySpec("j", "down"), + "stage_item": keySpec("a"), + "stage_all": keySpec("space"), + "discard": keySpec("d"), + "stash": keySpec("s"), + "stash_all": keySpec("S"), + "commit": keySpec("c"), + "checkout": keySpec("enter"), + "new_branch": keySpec("n"), + "delete_branch": keySpec("d"), + "rename_branch": keySpec("r"), + "amend_commit": keySpec("A"), + "revert": keySpec("v"), + "reset_to_commit": keySpec("R"), + "stash_apply": keySpec("a"), + "stash_pop": keySpec("p"), + "stash_drop": keySpec("d"), + } +} - // Keybindings for CommitsPanel - AmendCommit key.Binding - Revert key.Binding - ResetToCommit key.Binding +// DefaultKeyMap returns default keybindings. +func DefaultKeyMap() KeyMap { + defaults := DefaultKeybindings() + result := make(KeyMap, len(defaults)) + for k, v := range defaults { + result[k] = v + } + return result +} - // Keybindings for StashPanel - StashApply key.Binding - StashPop key.Binding - StashDrop key.Binding +// MergeKeybindings merges user overrides into defaults and ignores empty override values. +func MergeKeybindings(defaults, overrides map[string]string) map[string]string { + result := make(map[string]string, len(defaults)+len(overrides)) + for k, v := range defaults { + result[k] = v + } + for k, v := range overrides { + if strings.TrimSpace(v) != "" { + result[k] = v + } + } + return result } -// HelpSection is a struct to hold a title and keybindings for a help section. -type HelpSection struct { - Title string - Bindings []key.Binding +// KeyMapFromConfig returns keybindings with user overrides applied on top of defaults. +func KeyMapFromConfig(overrides map[string]string) KeyMap { + merged := MergeKeybindings(DefaultKeybindings(), overrides) + result := make(KeyMap, len(merged)) + for k, v := range merged { + result[k] = v + } + if alias, ok := result["open"]; ok && strings.TrimSpace(result["checkout"]) == "" { + result["checkout"] = alias + } + return result +} + +func parseConfiguredKeys(configured string) ([]string, bool) { + parts := strings.Split(configured, ",") + keys := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + keys = append(keys, trimmed) + } + } + return keys, len(keys) > 0 +} + +func helpLabel(keys []string) string { + return strings.Join(keys, "/") +} + +func (k KeyMap) binding(action string) key.Binding { + spec := strings.TrimSpace(k[action]) + if spec == "" { + spec = DefaultKeybindings()[action] + } + resolvedKeys, ok := parseConfiguredKeys(spec) + if !ok { + resolvedKeys, _ = parseConfiguredKeys(DefaultKeybindings()[action]) + } + desc := keybindingDescriptions[action] + return key.NewBinding( + key.WithKeys(resolvedKeys...), + key.WithHelp(helpLabel(resolvedKeys), desc), + ) +} + +// Matches reports whether the message matches the configured key spec. +func Matches(msg tea.KeyMsg, spec string) bool { + resolvedKeys, ok := parseConfiguredKeys(spec) + if !ok { + return false + } + return key.Matches(msg, key.NewBinding(key.WithKeys(resolvedKeys...))) +} + +func (k KeyMap) bindings(actions ...string) []key.Binding { + result := make([]key.Binding, 0, len(actions)) + for _, action := range actions { + result = append(result, k.binding(action)) + } + return result } // FullHelp returns a structured slice of HelpSection, which is used to build // the full help view. func (k KeyMap) FullHelp() []HelpSection { return []HelpSection{ - { - Title: "Navigation", - Bindings: []key.Binding{ - k.FocusNext, k.FocusPrev, k.FocusZero, k.FocusOne, - k.FocusTwo, k.FocusThree, k.FocusFour, k.FocusFive, - k.FocusSix, k.Up, k.Down, - }, - }, - { - Title: "Files", - Bindings: []key.Binding{ - k.Commit, k.Stash, k.StashAll, k.StageItem, - k.StageAll, k.Discard, - }, - }, - { - Title: "Branches", - Bindings: []key.Binding{k.Checkout, k.NewBranch, k.DeleteBranch, k.RenameBranch}, - }, - { - Title: "Commits", - Bindings: []key.Binding{k.AmendCommit, k.Revert, k.ResetToCommit}, - }, - { - Title: "Stash", - Bindings: []key.Binding{k.StashApply, k.StashPop, k.StashDrop}, - }, - { - Title: "Misc", - Bindings: []key.Binding{k.SwitchTheme, k.ToggleHelp, k.Escape, k.Quit}, - }, + {Title: "Navigation", Bindings: k.bindings( + "focus_next", "focus_prev", "focus_main", "focus_status", + "focus_files", "focus_branches", "focus_commits", "focus_stash", + "focus_command_log", "up", "down", + )}, + {Title: "Files", Bindings: k.bindings("commit", "stash", "stash_all", "stage_item", "stage_all", "discard")}, + {Title: "Branches", Bindings: k.bindings("checkout", "new_branch", "delete_branch", "rename_branch")}, + {Title: "Commits", Bindings: k.bindings("amend_commit", "revert", "reset_to_commit")}, + {Title: "Stash", Bindings: k.bindings("stash_apply", "stash_pop", "stash_drop")}, + {Title: "Misc", Bindings: k.bindings("switch_theme", "toggle_help", "escape", "quit")}, } } // ShortHelp returns a slice of key.Binding containing help for default keybindings. func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.ToggleHelp, k.Escape, k.Quit} + return k.bindings("toggle_help", "escape", "quit") } // HelpViewHelp returns a slice of key.Binding containing help for keybindings related to Help View. func (k KeyMap) HelpViewHelp() []key.Binding { - return []key.Binding{k.ToggleHelp, k.Escape, k.Quit} + return k.ShortHelp() } // FilesPanelHelp returns a slice of key.Binding containing help for keybindings related to Files Panel. func (k KeyMap) FilesPanelHelp() []key.Binding { - help := []key.Binding{k.Commit, k.Stash, k.Discard, k.StageItem} + help := k.bindings("commit", "stash", "discard", "stage_item") return append(help, k.ShortHelp()...) } // BranchesPanelHelp returns a slice of key.Binding for the Branches Panel help bar. func (k KeyMap) BranchesPanelHelp() []key.Binding { - help := []key.Binding{k.Checkout, k.NewBranch, k.DeleteBranch} + help := k.bindings("checkout", "new_branch", "delete_branch") return append(help, k.ShortHelp()...) } // CommitsPanelHelp returns a slice of key.Binding for the Commits Panel help bar. func (k KeyMap) CommitsPanelHelp() []key.Binding { - help := []key.Binding{k.AmendCommit, k.Revert, k.ResetToCommit} + help := k.bindings("amend_commit", "revert", "reset_to_commit") return append(help, k.ShortHelp()...) } // StashPanelHelp returns a slice of key.Binding for the Stash Panel help bar. func (k KeyMap) StashPanelHelp() []key.Binding { - help := []key.Binding{k.StashApply, k.StashPop, k.StashDrop} + help := k.bindings("stash_apply", "stash_pop", "stash_drop") return append(help, k.ShortHelp()...) } - -// DefaultKeyMap returns a set of default keybindings. -func DefaultKeyMap() KeyMap { - return KeyMap{ - // misc - Quit: key.NewBinding( - key.WithKeys("q", "ctrl+c"), - key.WithHelp("q", "quit"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("", "cancel"), - ), - ToggleHelp: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), - ), - - // theme - SwitchTheme: key.NewBinding( - key.WithKeys("ctrl+t"), - key.WithHelp("", "switch theme"), - ), - - // navigation - FocusNext: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "Focus Next Window"), - ), - FocusPrev: key.NewBinding( - key.WithKeys("shift+tab"), - key.WithHelp("", "Focus Previous Window"), - ), - FocusZero: key.NewBinding( - key.WithKeys("0"), - key.WithHelp("0", "Focus Main Window"), - ), - FocusOne: key.NewBinding( - key.WithKeys("1"), - key.WithHelp("1", "Focus Status Window"), - ), - FocusTwo: key.NewBinding( - key.WithKeys("2"), - key.WithHelp("2", "Focus Files Window"), - ), - FocusThree: key.NewBinding( - key.WithKeys("3"), - key.WithHelp("3", "Focus Branches Window"), - ), - FocusFour: key.NewBinding( - key.WithKeys("4"), - key.WithHelp("4", "Focus Commits Window"), - ), - FocusFive: key.NewBinding( - key.WithKeys("5"), - key.WithHelp("5", "Focus Stash Window"), - ), - FocusSix: key.NewBinding( - key.WithKeys("6"), - key.WithHelp("6", "Focus Command log Window"), - ), - Up: key.NewBinding( - key.WithKeys("k", "up"), - key.WithHelp("k/↑", "up"), - ), - Down: key.NewBinding( - key.WithKeys("j", "down"), - key.WithHelp("j/↓", "down"), - ), - - // FilesPanel - StageItem: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "Stage Item"), - ), - StageAll: key.NewBinding( - key.WithKeys("space"), - key.WithHelp("", "Stage All"), - ), - Discard: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "Discard"), - ), - Stash: key.NewBinding( - key.WithKeys("s"), - key.WithHelp("s", "Stash"), - ), - StashAll: key.NewBinding( - key.WithKeys("S"), - key.WithHelp("S", "Stash all"), - ), - Commit: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "Commit"), - ), - - Checkout: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "Checkout"), - ), - NewBranch: key.NewBinding( - key.WithKeys("n"), - key.WithHelp("n", "New Branch"), - ), - DeleteBranch: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "Delete"), - ), - RenameBranch: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "Rename"), - ), - - AmendCommit: key.NewBinding( - key.WithKeys("A"), - key.WithHelp("A", "Amend"), - ), - Revert: key.NewBinding( - key.WithKeys("v"), - key.WithHelp("v", "Revert"), - ), - ResetToCommit: key.NewBinding( - key.WithKeys("R"), - key.WithHelp("R", "Reset to Commit"), - ), - - StashApply: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "Apply"), - ), - StashPop: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "Pop"), - ), - StashDrop: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "Drop"), - ), - } -} - -// KeyMapFromConfig returns keybindings with user overrides applied on top of defaults. -func KeyMapFromConfig(overrides map[string]string) KeyMap { - resolved := DefaultKeyMap() - for action, configured := range overrides { - keys, ok := parseConfiguredKeys(configured) - if !ok { - log.Printf("invalid keybinding for %q: empty value, using default", action) - continue - } - - switch action { - case "quit": - resolved.Quit = overrideBinding(resolved.Quit, keys) - case "escape": - resolved.Escape = overrideBinding(resolved.Escape, keys) - case "toggle_help": - resolved.ToggleHelp = overrideBinding(resolved.ToggleHelp, keys) - case "switch_theme": - resolved.SwitchTheme = overrideBinding(resolved.SwitchTheme, keys) - case "focus_next": - resolved.FocusNext = overrideBinding(resolved.FocusNext, keys) - case "focus_prev": - resolved.FocusPrev = overrideBinding(resolved.FocusPrev, keys) - case "focus_main": - resolved.FocusZero = overrideBinding(resolved.FocusZero, keys) - case "focus_status": - resolved.FocusOne = overrideBinding(resolved.FocusOne, keys) - case "focus_files": - resolved.FocusTwo = overrideBinding(resolved.FocusTwo, keys) - case "focus_branches": - resolved.FocusThree = overrideBinding(resolved.FocusThree, keys) - case "focus_commits": - resolved.FocusFour = overrideBinding(resolved.FocusFour, keys) - case "focus_stash": - resolved.FocusFive = overrideBinding(resolved.FocusFive, keys) - case "focus_command_log": - resolved.FocusSix = overrideBinding(resolved.FocusSix, keys) - case "up": - resolved.Up = overrideBinding(resolved.Up, keys) - case "down": - resolved.Down = overrideBinding(resolved.Down, keys) - case "stage_item": - resolved.StageItem = overrideBinding(resolved.StageItem, keys) - case "stage_all": - resolved.StageAll = overrideBinding(resolved.StageAll, keys) - case "discard": - resolved.Discard = overrideBinding(resolved.Discard, keys) - case "stash": - resolved.Stash = overrideBinding(resolved.Stash, keys) - case "stash_all": - resolved.StashAll = overrideBinding(resolved.StashAll, keys) - case "commit": - resolved.Commit = overrideBinding(resolved.Commit, keys) - case "checkout", "open": - resolved.Checkout = overrideBinding(resolved.Checkout, keys) - case "new_branch": - resolved.NewBranch = overrideBinding(resolved.NewBranch, keys) - case "delete_branch": - resolved.DeleteBranch = overrideBinding(resolved.DeleteBranch, keys) - case "rename_branch": - resolved.RenameBranch = overrideBinding(resolved.RenameBranch, keys) - case "amend_commit": - resolved.AmendCommit = overrideBinding(resolved.AmendCommit, keys) - case "revert": - resolved.Revert = overrideBinding(resolved.Revert, keys) - case "reset_to_commit": - resolved.ResetToCommit = overrideBinding(resolved.ResetToCommit, keys) - case "stash_apply": - resolved.StashApply = overrideBinding(resolved.StashApply, keys) - case "stash_pop": - resolved.StashPop = overrideBinding(resolved.StashPop, keys) - case "stash_drop": - resolved.StashDrop = overrideBinding(resolved.StashDrop, keys) - default: - log.Printf("unknown keybinding action %q, ignoring", action) - } - } - - return resolved -} - -func overrideBinding(current key.Binding, keys []string) key.Binding { - desc := current.Help().Desc - helpKey := strings.Join(keys, "/") - return key.NewBinding( - key.WithKeys(keys...), - key.WithHelp(helpKey, desc), - ) -} - -func parseConfiguredKeys(configured string) ([]string, bool) { - parts := strings.Split(configured, ",") - keys := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - keys = append(keys, trimmed) - } - } - return keys, len(keys) > 0 -} diff --git a/internal/tui/model.go b/internal/tui/model.go index efdb8e4..43cd736 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -49,6 +49,7 @@ type Model struct { confirmCallback func(bool) tea.Cmd // New fields for command history CommandHistory []string + keymap KeyMap } // initialModel creates the initial state of the application. @@ -58,7 +59,7 @@ func initialModel() Model { if err != nil { cfg = &appConfig{Theme: DefaultThemeName} } - keys = KeyMapFromConfig(cfg.Keybindings) + keymap := KeyMapFromConfig(cfg.Keybindings) var selectedThemeName string if t, ok := Themes[cfg.Theme]; ok { @@ -119,6 +120,7 @@ func initialModel() Model { textInput: ti, descriptionInput: ta, CommandHistory: []string{}, + keymap: keymap, } } @@ -155,14 +157,14 @@ func (m *Model) nextTheme() { func (m *Model) panelShortHelp() []key.Binding { switch m.focusedPanel { case FilesPanel: - return keys.FilesPanelHelp() + return m.keymap.FilesPanelHelp() case BranchesPanel: - return keys.BranchesPanelHelp() + return m.keymap.BranchesPanelHelp() case CommitsPanel: - return keys.CommitsPanelHelp() + return m.keymap.CommitsPanelHelp() case StashPanel: - return keys.StashPanelHelp() + return m.keymap.StashPanelHelp() default: - return keys.ShortHelp() + return m.keymap.ShortHelp() } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index d53da47..2de516f 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -173,26 +173,25 @@ func TestModel_KeyFocus(t *testing.T) { func TestModel_contextualHelp(t *testing.T) { m := initialModel() - keys = DefaultKeyMap() t.Run("Files Panel Help", func(t *testing.T) { m.focusedPanel = FilesPanel gotKeys := m.panelShortHelp() - assertKeyBindingsEqual(t, gotKeys, keys.FilesPanelHelp()) + assertKeyBindingsEqual(t, gotKeys, m.keymap.FilesPanelHelp()) }) } func TestKeyMapFromConfig_OverridesAndFallback(t *testing.T) { - defaults := DefaultKeyMap() + defaults := DefaultKeybindings() resolved := KeyMapFromConfig(map[string]string{ "quit": "x", }) - if got := resolved.Quit.Keys(); len(got) != 1 || got[0] != "x" { - t.Fatalf("expected quit key to be overridden to x, got %v", got) + if got := resolved["quit"]; got != "x" { + t.Fatalf("expected quit key to be overridden to x, got %q", got) } - if got, want := resolved.Up.Keys(), defaults.Up.Keys(); !sameKeys(got, want) { - t.Fatalf("expected unspecified keybinding to fallback to default, got %v want %v", got, want) + if got, want := resolved["up"], defaults["up"]; got != want { + t.Fatalf("expected unspecified keybinding to fallback to default, got %q want %q", got, want) } } @@ -201,22 +200,21 @@ func TestKeyMapFromConfig_MultiKeyValue(t *testing.T) { "quit": "x,ctrl+c", }) - got := resolved.Quit.Keys() - if len(got) != 2 || got[0] != "x" || got[1] != "ctrl+c" { - t.Fatalf("expected parsed multi-key binding, got %v", got) + if got := resolved["quit"]; got != "x,ctrl+c" { + t.Fatalf("expected parsed multi-key binding, got %q", got) } } func TestKeyMapFromConfig_InvalidValueUsesDefault(t *testing.T) { - defaults := DefaultKeyMap() + defaults := DefaultKeybindings() resolved := KeyMapFromConfig(map[string]string{ "quit": " ", }) - got := resolved.Quit.Keys() - want := defaults.Quit.Keys() - if !sameKeys(got, want) { - t.Fatalf("expected invalid override to keep default, got %v want %v", got, want) + got := resolved["quit"] + want := defaults["quit"] + if got != want { + t.Fatalf("expected invalid override to keep default, got %q want %q", got, want) } } @@ -447,15 +445,3 @@ func assertKeyBindingsEqual(t *testing.T, got, want []key.Binding) { t.Errorf("\n\tgot \t%v\n\twant \t%v", got, want) } } - -func sameKeys(got []string, want []string) bool { - if len(got) != len(want) { - return false - } - for i := range got { - if got[i] != want[i] { - return false - } - } - return true -} diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 020ed36..b1fff93 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -151,13 +151,6 @@ type TreeStyle struct { Connector, ConnectorLast, Prefix, PrefixLast string } -// config.toml -type appConfig struct { - Theme string `toml:"theme"` - Keybindings map[string]string `toml:"keybindings"` - -} - // custom_theme.toml type ThemeFile struct { Fg string `toml:"fg"` @@ -246,17 +239,6 @@ func ThemeNames() []string { return names } -func load_config() (*appConfig, error) { - cfgPath := ConfigFilePath - - var cfg appConfig - if _, err := toml.DecodeFile(cfgPath, &cfg); err != nil { - return nil, err - } - - return &cfg, nil -} - func load_custom_theme(name string) (*Palette, error) { themePath := filepath.Join(ConfigThemesDirPath, name+".toml") if _, err := os.Stat(themePath); os.IsNotExist(err) { diff --git a/internal/tui/update.go b/internal/tui/update.go index 287456f..3dbfe06 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -5,15 +5,12 @@ import ( "log" "strings" - "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/gitxtui/gitx/internal/git" zone "github.com/lrstanley/bubblezone" ) -var keys = DefaultKeyMap() - // commandExecutedMsg is sent after a git command has been run successfully. type commandExecutedMsg struct { cmdStr string @@ -194,10 +191,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: if m.showHelp { switch { - case key.Matches(msg, keys.ToggleHelp): + case Matches(msg, m.keymap["toggle_help"]): m.toggleHelp() return m, nil - case key.Matches(msg, keys.Escape): + case Matches(msg, m.keymap["escape"]): m.toggleHelp() return m, nil default: @@ -208,23 +205,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch { - case key.Matches(msg, keys.Quit): + case Matches(msg, m.keymap["quit"]): return m, tea.Quit - case key.Matches(msg, keys.Escape): + case Matches(msg, m.keymap["escape"]): return m, nil - case key.Matches(msg, keys.ToggleHelp): + case Matches(msg, m.keymap["toggle_help"]): m.toggleHelp() - case key.Matches(msg, keys.SwitchTheme): + case Matches(msg, m.keymap["switch_theme"]): m.nextTheme() - case key.Matches(msg, keys.FocusNext), key.Matches(msg, keys.FocusPrev), - key.Matches(msg, keys.FocusZero), key.Matches(msg, keys.FocusOne), - key.Matches(msg, keys.FocusTwo), key.Matches(msg, keys.FocusThree), - key.Matches(msg, keys.FocusFour), key.Matches(msg, keys.FocusFive), - key.Matches(msg, keys.FocusSix): + case Matches(msg, m.keymap["focus_next"]), Matches(msg, m.keymap["focus_prev"]), + Matches(msg, m.keymap["focus_main"]), Matches(msg, m.keymap["focus_status"]), + Matches(msg, m.keymap["focus_files"]), Matches(msg, m.keymap["focus_branches"]), + Matches(msg, m.keymap["focus_commits"]), Matches(msg, m.keymap["focus_stash"]), + Matches(msg, m.keymap["focus_command_log"]): m.handleFocusKeys(msg) } @@ -596,7 +593,7 @@ func (m *Model) handleCursorMovement(msg tea.KeyMsg) (bool, tea.Cmd) { p := &m.panels[m.focusedPanel] itemSelected := false switch { - case key.Matches(msg, keys.Up): + case Matches(msg, m.keymap["up"]): if p.cursor > 0 { p.cursor-- if p.cursor < p.viewport.YOffset { @@ -604,7 +601,7 @@ func (m *Model) handleCursorMovement(msg tea.KeyMsg) (bool, tea.Cmd) { } itemSelected = true } - case key.Matches(msg, keys.Down): + case Matches(msg, m.keymap["down"]): if p.cursor < len(p.lines)-1 { p.cursor++ if p.cursor >= p.viewport.YOffset+p.viewport.Height { @@ -637,7 +634,7 @@ func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { filePath := parts[3] switch { - case key.Matches(msg, keys.Commit): + case Matches(msg, m.keymap["commit"]): m.mode = modeCommit m.textInput.SetValue("") m.descriptionInput.SetValue("") @@ -656,7 +653,7 @@ func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { } } - case key.Matches(msg, keys.StageItem): + case Matches(msg, m.keymap["stage_item"]): return func() tea.Msg { var cmdStr string var err error @@ -671,7 +668,7 @@ func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { return commandExecutedMsg{cmdStr} } - case key.Matches(msg, keys.StageAll): + case Matches(msg, m.keymap["stage_all"]): return func() tea.Msg { _, cmdStr, err := m.git.AddFiles([]string{"."}) if err != nil { @@ -680,7 +677,7 @@ func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { return commandExecutedMsg{cmdStr} } - case key.Matches(msg, keys.Discard): + case Matches(msg, m.keymap["discard"]): m.mode = modeConfirm m.confirmMessage = fmt.Sprintf("Discard changes to %s?", filePath) m.confirmCallback = func(confirmed bool) tea.Cmd { @@ -700,7 +697,7 @@ func (m *Model) handleFilesPanelKeys(msg tea.KeyMsg) tea.Cmd { } } - case key.Matches(msg, keys.StashAll): + case Matches(msg, m.keymap["stash_all"]): return func() tea.Msg { _, cmdStr, err := m.git.StashAll() if err != nil { @@ -728,7 +725,7 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { branchName := strings.TrimSpace(strings.TrimPrefix(parts[1], "(*) → ")) switch { - case key.Matches(msg, keys.Checkout): + case Matches(msg, m.keymap["checkout"]): return func() tea.Msg { _, cmdStr, err := m.git.Checkout(branchName) if err != nil { @@ -737,7 +734,7 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { return commandExecutedMsg{cmdStr} } - case key.Matches(msg, keys.NewBranch): + case Matches(msg, m.keymap["new_branch"]): m.mode = modeInput m.promptTitle = "New Branch Name" m.textInput.SetValue("") @@ -765,7 +762,7 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { ) } - case key.Matches(msg, keys.DeleteBranch): + case Matches(msg, m.keymap["delete_branch"]): m.mode = modeConfirm m.confirmMessage = fmt.Sprintf("Delete branch %s?", branchName) m.confirmCallback = func(confirmed bool) tea.Cmd { @@ -782,7 +779,7 @@ func (m *Model) handleBranchesPanelKeys(msg tea.KeyMsg) tea.Cmd { } } - case key.Matches(msg, keys.RenameBranch): + case Matches(msg, m.keymap["rename_branch"]): m.mode = modeInput m.promptTitle = "New Branch Name" m.textInput.SetValue(branchName) @@ -820,7 +817,7 @@ func (m *Model) handleCommitsPanelKeys(msg tea.KeyMsg) tea.Cmd { sha := parts[1] switch { - case key.Matches(msg, keys.AmendCommit): + case Matches(msg, m.keymap["amend_commit"]): m.mode = modeCommit m.textInput.SetValue("") m.descriptionInput.SetValue("") @@ -839,7 +836,7 @@ func (m *Model) handleCommitsPanelKeys(msg tea.KeyMsg) tea.Cmd { } } - case key.Matches(msg, keys.Revert): + case Matches(msg, m.keymap["revert"]): m.mode = modeConfirm m.confirmMessage = fmt.Sprintf("Revert commit %s?", sha) m.confirmCallback = func(confirmed bool) tea.Cmd { @@ -856,7 +853,7 @@ func (m *Model) handleCommitsPanelKeys(msg tea.KeyMsg) tea.Cmd { } } - case key.Matches(msg, keys.ResetToCommit): + case Matches(msg, m.keymap["reset_to_commit"]): m.mode = modeConfirm m.confirmMessage = fmt.Sprintf("Hard reset to commit %s? This will discard all changes!", sha) m.confirmCallback = func(confirmed bool) tea.Cmd { @@ -892,7 +889,7 @@ func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { stashID := parts[0] switch { - case key.Matches(msg, keys.StashApply): + case Matches(msg, m.keymap["stash_apply"]): return func() tea.Msg { _, cmdStr, err := m.git.Stash(git.StashOptions{Apply: true, StashID: stashID}) if err != nil { @@ -901,7 +898,7 @@ func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { return commandExecutedMsg{cmdStr} } - case key.Matches(msg, keys.StashPop): + case Matches(msg, m.keymap["stash_pop"]): return func() tea.Msg { _, cmdStr, err := m.git.Stash(git.StashOptions{Pop: true, StashID: stashID}) if err != nil { @@ -910,7 +907,7 @@ func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { return commandExecutedMsg{cmdStr} } - case key.Matches(msg, keys.StashDrop): + case Matches(msg, m.keymap["stash_drop"]): m.mode = modeConfirm m.confirmMessage = fmt.Sprintf("Drop stash %s?", stashID) m.confirmCallback = func(confirmed bool) tea.Cmd { @@ -937,23 +934,23 @@ func (m *Model) handleStashPanelKeys(msg tea.KeyMsg) tea.Cmd { // handleFocusKeys changes the focused panel based on keyboard shortcuts. func (m *Model) handleFocusKeys(msg tea.KeyMsg) { switch { - case key.Matches(msg, keys.FocusNext): + case Matches(msg, m.keymap["focus_next"]): m.nextPanel() - case key.Matches(msg, keys.FocusPrev): + case Matches(msg, m.keymap["focus_prev"]): m.prevPanel() - case key.Matches(msg, keys.FocusZero): + case Matches(msg, m.keymap["focus_main"]): m.focusedPanel = MainPanel - case key.Matches(msg, keys.FocusOne): + case Matches(msg, m.keymap["focus_status"]): m.focusedPanel = StatusPanel - case key.Matches(msg, keys.FocusTwo): + case Matches(msg, m.keymap["focus_files"]): m.focusedPanel = FilesPanel - case key.Matches(msg, keys.FocusThree): + case Matches(msg, m.keymap["focus_branches"]): m.focusedPanel = BranchesPanel - case key.Matches(msg, keys.FocusFour): + case Matches(msg, m.keymap["focus_commits"]): m.focusedPanel = CommitsPanel - case key.Matches(msg, keys.FocusFive): + case Matches(msg, m.keymap["focus_stash"]): m.focusedPanel = StashPanel - case key.Matches(msg, keys.FocusSix): + case Matches(msg, m.keymap["focus_command_log"]): m.focusedPanel = SecondaryPanel } } diff --git a/internal/tui/view.go b/internal/tui/view.go index ba36302..752ea14 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -232,7 +232,7 @@ func (m Model) renderHelpBar() string { if !m.showHelp { helpBindings = m.panelShortHelp() } else { - helpBindings = keys.ShortHelp() + helpBindings = m.keymap.ShortHelp() } shortHelp := m.help.ShortHelpView(helpBindings) helpButton := m.theme.HelpButton.Render(" help:? ") @@ -290,7 +290,7 @@ func renderBox(title string, titleStyle lipgloss.Style, borderStyle BorderStyle, // generateHelpContent builds the formatted help string from the application's keymap. func (m Model) generateHelpContent() string { - helpSections := keys.FullHelp() + helpSections := m.keymap.FullHelp() var renderedSections []string for _, section := range helpSections { title := m.theme.HelpTitle.