Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/tui/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
109 changes: 108 additions & 1 deletion internal/tui/keys.go
Comment thread
shatrughantwt marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
}
6 changes: 5 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
10 changes: 6 additions & 4 deletions internal/tui/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Comment thread
shatrughantwt marked this conversation as resolved.
Outdated
}

// custom_theme.toml
Expand Down Expand Up @@ -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
}
Expand Down