1package config
2
3import (
4 "fmt"
5 "slices"
6 "sort"
7
8 "github.com/bnema/dumber/internal/logging"
9)
10
11// actionMapSpec describes one configurable action map. It is the single source
12// of truth for the list of action maps shared by load-time completion and the
13// config migrator.
14type actionMapSpec struct {
15 // key is the dotted config path of the action map.
16 key string
17 // actions returns a pointer to the action map inside a Config.
18 actions func(*Config) *map[string]ActionBinding
19 // canonKey canonicalizes a key sequence the way the validator does for
20 // duplicate detection. nil means single key chords (see canonicalizeChordKey).
21 canonKey func(string) (string, error)
22}
23
24func actionMapSpecs() []actionMapSpec {
25 return []actionMapSpec{
26 {key: "workspace.shortcuts.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.Shortcuts.Actions }},
27 {key: "workspace.prefix_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PrefixMode.Actions }},
28 {key: "workspace.pane_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.PaneMode.Actions }},
29 {key: "workspace.tab_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.TabMode.Actions }},
30 {
31 key: "workspace.vim_mode.actions",
32 actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.VimMode.Actions },
33 canonKey: canonicalizeVimModeSequenceKey,
34 },
35 {key: "workspace.resize_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Workspace.ResizeMode.Actions }},
36 {key: "session.session_mode.actions", actions: func(c *Config) *map[string]ActionBinding { return &c.Session.SessionMode.Actions }},
37 }
38}
39
40// skippedDefaultAction is a default action not added because a user binding wins.
41type skippedDefaultAction struct {
42 name string
43 sequence string
44 by string
45}
46
47// missingDefaultActions returns the sorted names of default actions that the
48// user did not configure and whose key sequences are not already used by a user
49// action. userKeys maps each user action name to its keys; an action with no
50// keys (disabled) is still present, so it is never re-added. Action names are
51// expected in canonical (hyphenated) form: LegacyConfigTransformer renames
52// underscore spellings before this runs.
53func (s actionMapSpec) missingDefaultActions(
54 defaults map[string]ActionBinding,
55 userKeys map[string][]string,
56) (missing []string, skipped []skippedDefaultAction) {
57 canon := s.canonKey
58 if canon == nil {
59 canon = canonicalizeChordKey
60 }
61 canonOrRaw := func(key string) string {
62 if c, err := canon(key); err == nil {
63 return c
64 }
65 return key
66 }
67
68 used := make(map[string]string)
69 userNames := make([]string, 0, len(userKeys))
70 for name := range userKeys {
71 userNames = append(userNames, name)
72 }
73 sort.Strings(userNames)
74 for _, name := range userNames {
75 for _, key := range userKeys[name] {
76 used[canonOrRaw(key)] = name
77 }
78 }
79
80 defaultNames := make([]string, 0, len(defaults))
81 for name := range defaults {
82 defaultNames = append(defaultNames, name)
83 }
84 sort.Strings(defaultNames)
85
86 for _, name := range defaultNames {
87 if _, ok := userKeys[name]; ok {
88 continue
89 }
90 conflict := false
91 for _, key := range defaults[name].Keys {
92 sequence := canonOrRaw(key)
93 if by, ok := used[sequence]; ok {
94 skipped = append(skipped, skippedDefaultAction{name: name, sequence: sequence, by: by})
95 conflict = true
96 break
97 }
98 }
99 if !conflict {
100 missing = append(missing, name)
101 }
102 }
103 return missing, skipped
104}
105
106// completeDefaultActions fills every action map of cfg with the default
107// actions the user did not configure. User actions always win: a default is
108// skipped when one of its key sequences is already used by a user action in the
109// same map. Maps are replaced, never mutated, so copies of cfg sharing them are
110// unaffected. The config file is not touched.
111func completeDefaultActions(cfg *Config) {
112 if cfg == nil {
113 return
114 }
115 defaults := DefaultConfig()
116 var skippedAll []string
117 for _, spec := range actionMapSpecs() {
118 userActions := *spec.actions(cfg)
119 userKeys := make(map[string][]string, len(userActions))
120 for name, binding := range userActions {
121 userKeys[name] = binding.Keys
122 }
123
124 defaultActions := *spec.actions(defaults)
125 missing, skipped := spec.missingDefaultActions(defaultActions, userKeys)
126
127 merged := make(map[string]ActionBinding, len(userActions)+len(missing))
128 for name, binding := range userActions {
129 merged[name] = binding
130 }
131 for _, name := range missing {
132 binding := defaultActions[name]
133 binding.Keys = slices.Clone(binding.Keys)
134 merged[name] = binding
135 }
136 *spec.actions(cfg) = merged
137
138 for _, s := range skipped {
139 skippedAll = append(skippedAll, fmt.Sprintf("%s.%s (key '%s' used by %s)", spec.key, s.name, s.sequence, s.by))
140 }
141 }
142 if len(skippedAll) > 0 {
143 log := logging.NewFromEnv()
144 log.Debug().Strs("skipped", skippedAll).Msg("config: default actions not added because user bindings take precedence")
145 }
146}