1package config
2
3import (
4 "math"
5 "slices"
6 "strings"
7
8 "github.com/bnema/dumber/internal/logging"
9)
10
11const (
12 sectionWorkspace = "workspace"
13 sectionSession = "session"
14)
15
16// LegacyConfigTransformer implements port.ConfigTransformer.
17// It transforms old-format action bindings (slices) to new ActionBinding format.
18type LegacyConfigTransformer struct {
19 defaults *Config
20}
21
22// NewLegacyConfigTransformer creates a new transformer with defaults for description lookup.
23func NewLegacyConfigTransformer() *LegacyConfigTransformer {
24 return &LegacyConfigTransformer{defaults: DefaultConfig()}
25}
26
27// legacyActionSlicePaths are the action maps that older configs wrote as plain
28// key lists. Vim Mode never had that format.
29var legacyActionSlicePaths = [][]string{
30 {"workspace", "prefix_mode", "actions"},
31 {"workspace", "pane_mode", "actions"},
32 {"workspace", "tab_mode", "actions"},
33 {"workspace", "resize_mode", "actions"},
34 {"workspace", "shortcuts", "actions"},
35 {"session", "session_mode", "actions"},
36}
37
38// actionNamePaths are all configurable action maps.
39var actionNamePaths = append(slices.Clone(legacyActionSlicePaths), []string{"workspace", "vim_mode", "actions"})
40
41// TransformLegacyActions canonicalizes action names and converts old-format
42// action bindings to the new format.
43//
44// Names: older configs spell actions with underscores (stack_pane); the
45// canonical spelling is hyphenated (stack-pane). Every action map is renamed to
46// the hyphen form so the rest of the system only ever sees one spelling. When a
47// map holds both spellings of an action, the hyphenated one wins.
48//
49// Bindings: this handles migration from:
50//
51// [workspace.pane_mode.actions]
52// "focus-left" = ["h", "arrowleft"]
53//
54// To:
55//
56// [workspace.pane_mode.actions.focus-left]
57// keys = ["h", "arrowleft"]
58// desc = "Focus pane to the left"
59func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
60 for _, path := range actionNamePaths {
61 if actions, ok := rawActionSection(rawConfig, path); ok {
62 canonicalizeActionNames(actions, strings.Join(path, "."))
63 }
64 }
65 for _, path := range legacyActionSlicePaths {
66 if actions, ok := rawActionSection(rawConfig, path); ok {
67 t.transformActionSection(actions, path)
68 }
69 }
70}
71
72// canonicalActionName returns the canonical (hyphenated) spelling of an action name.
73func canonicalActionName(name string) string {
74 return strings.ReplaceAll(name, "_", "-")
75}
76
77// canonicalizeActionNames renames underscore action names to their hyphenated
78// form in place. If both spellings exist, the hyphenated entry is kept.
79func canonicalizeActionNames(actions map[string]any, section string) {
80 var legacyNames []string
81 for name := range actions {
82 if canonicalActionName(name) != name {
83 legacyNames = append(legacyNames, name)
84 }
85 }
86 slices.Sort(legacyNames)
87
88 for _, name := range legacyNames {
89 canonical := canonicalActionName(name)
90 value := actions[name]
91 delete(actions, name)
92
93 log := logging.NewFromEnv()
94 if _, exists := actions[canonical]; exists {
95 log.Debug().
96 Str("section", section).
97 Str("action", name).
98 Str("canonical", canonical).
99 Msg("config: legacy action name ignored because the canonical spelling is also configured")
100 continue
101 }
102 actions[canonical] = value
103 log.Debug().
104 Str("section", section).
105 Str("action", name).
106 Str("canonical", canonical).
107 Msg("config: legacy action name renamed")
108 }
109}
110
111// rawActionSection returns the raw action map at path, if the path exists and
112// holds a table.
113func rawActionSection(rawConfig map[string]any, path []string) (map[string]any, bool) {
114 current := rawConfig
115 for _, key := range path[:len(path)-1] {
116 next, ok := current[key].(map[string]any)
117 if !ok {
118 return nil, false
119 }
120 current = next
121 }
122 actions, ok := current[path[len(path)-1]].(map[string]any)
123 return actions, ok
124}
125
126// TransformLegacyEngineConfig removes deprecated engine config keys.
127func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
128 adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
129 t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
130}
131
132func (t *LegacyConfigTransformer) TransformLegacyEngineConfigWithExplicitAdaptive(
133 rawConfig map[string]any,
134 hasAdaptive, adaptiveEnabled bool,
135) {
136 t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
137}
138
139func (*LegacyConfigTransformer) transformLegacyEngineConfig(rawConfig map[string]any, hasAdaptive, adaptiveEnabled bool) {
140 engine, ok := rawConfig["engine"].(map[string]any)
141 if !ok {
142 return
143 }
144
145 cef, ok := engine["cef"].(map[string]any)
146 if !ok {
147 return
148 }
149
150 delete(cef, "enable_context_menu_handler")
151 migrateLegacyCEFWindowlessFrameRateDefault(cef, hasAdaptive, adaptiveEnabled)
152 migrateLegacyCEFInputScrollDefaults(cef)
153}
154
155const (
156 legacyCEFScrollTouchpadMultiplierDefault = 0.35
157 configFloatTolerance = 0.000001
158)
159
160func migrateLegacyCEFInputScrollDefaults(cef map[string]any) {
161 input, ok := cef["input"].(map[string]any)
162 if !ok {
163 return
164 }
165
166 if legacyTouchpad, hasLegacyTouchpad := input["scroll_touchpad_multiplier"]; hasLegacyTouchpad {
167 if precise, hasPrecise := input["scroll_precise_multiplier"]; !hasPrecise {
168 input["scroll_precise_multiplier"] = migratedCEFPreciseScrollMultiplier(legacyTouchpad)
169 } else if shouldPreferLegacyTouchpadScrollMultiplier(legacyTouchpad, precise) {
170 input["scroll_precise_multiplier"] = legacyTouchpad
171 }
172 delete(input, "scroll_touchpad_multiplier")
173 }
174
175 if current, hasPrecise := input["scroll_precise_multiplier"]; hasPrecise {
176 input["scroll_precise_multiplier"] = migratedCEFPreciseScrollMultiplier(current)
177 }
178}
179
180func shouldPreferLegacyTouchpadScrollMultiplier(legacyTouchpad, precise any) bool {
181 legacyMultiplier, legacyOK := float64ConfigValue(legacyTouchpad)
182 preciseMultiplier, preciseOK := float64ConfigValue(precise)
183 if !legacyOK || !preciseOK {
184 return false
185 }
186 return !sameConfigFloat(legacyMultiplier, legacyCEFScrollTouchpadMultiplierDefault) &&
187 (sameConfigFloat(preciseMultiplier, defaultCEFScrollPreciseMultiplier) ||
188 sameConfigFloat(preciseMultiplier, legacyCEFScrollTouchpadMultiplierDefault))
189}
190
191func migratedCEFPreciseScrollMultiplier(value any) any {
192 multiplier, ok := float64ConfigValue(value)
193 if !ok || !sameConfigFloat(multiplier, legacyCEFScrollTouchpadMultiplierDefault) {
194 return value
195 }
196 return defaultCEFScrollPreciseMultiplier
197}
198
199func sameConfigFloat(a, b float64) bool {
200 return math.Abs(a-b) <= configFloatTolerance
201}
202
203func migrateLegacyCEFWindowlessFrameRateDefault(cef map[string]any, hasAdaptive, adaptiveEnabled bool) {
204 if hasAdaptive && !adaptiveEnabled {
205 return
206 }
207 frameRate, ok := int64ConfigValue(cef["windowless_frame_rate"])
208 if !ok || frameRate != defaultCEFWindowlessFrameRate {
209 return
210 }
211 cef["adaptive_windowless_frame_rate"] = true
212 cef["windowless_frame_rate"] = 0
213 if _, hasMax := cef["windowless_frame_rate_max"]; !hasMax {
214 cef["windowless_frame_rate_max"] = defaultCEFWindowlessFrameRateMax
215 }
216}
217
218func nestedConfigBoolValue(rawConfig map[string]any, path ...string) (bool, bool) {
219 current := rawConfig
220 for i, key := range path {
221 value, ok := current[key]
222 if !ok {
223 return false, false
224 }
225 if i == len(path)-1 {
226 parsed, parsedOK := value.(bool)
227 return parsed, parsedOK
228 }
229 next, ok := value.(map[string]any)
230 if !ok {
231 return false, false
232 }
233 current = next
234 }
235 return false, false
236}
237
238func float64ConfigValue(value any) (float64, bool) {
239 switch v := value.(type) {
240 case float32:
241 return float64(v), true
242 case float64:
243 return v, true
244 case int:
245 return float64(v), true
246 case int8:
247 return float64(v), true
248 case int16:
249 return float64(v), true
250 case int32:
251 return float64(v), true
252 case int64:
253 return float64(v), true
254 case uint:
255 return float64(v), true
256 case uint8:
257 return float64(v), true
258 case uint16:
259 return float64(v), true
260 case uint32:
261 return float64(v), true
262 case uint64:
263 return float64(v), true
264 default:
265 return 0, false
266 }
267}
268
269func int64ConfigValue(value any) (int64, bool) {
270 switch v := value.(type) {
271 case int:
272 return int64(v), true
273 case int8:
274 return int64(v), true
275 case int16:
276 return int64(v), true
277 case int32:
278 return int64(v), true
279 case int64:
280 return v, true
281 case uint:
282 return int64(v), true
283 case uint8:
284 return int64(v), true
285 case uint16:
286 return int64(v), true
287 case uint32:
288 return int64(v), true
289 case uint64:
290 if v > math.MaxInt64 {
291 return 0, false
292 }
293 return int64(v), true
294 default:
295 return 0, false
296 }
297}
298
299// transformActionSection converts old-format (key list) bindings of one action
300// map to the ActionBinding format.
301func (t *LegacyConfigTransformer) transformActionSection(actions map[string]any, path []string) {
302 for actionName, actionValue := range actions {
303 // If value is a slice (old format), convert to ActionBinding format
304 if slice, ok := actionValue.([]any); ok {
305 keys := make([]string, 0, len(slice))
306 for _, v := range slice {
307 if s, ok := v.(string); ok {
308 keys = append(keys, s)
309 }
310 }
311
312 actions[actionName] = map[string]any{
313 "keys": keys,
314 "desc": t.getDefaultDesc(path, actionName),
315 }
316 }
317 }
318}
319
320// TransformLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
321// workspace.browsing_contexts.* when the new section is absent. This allows
322// existing user configs with [workspace.popups] to be silently migrated.
323func (t *LegacyConfigTransformer) TransformLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
324 workspace, ok := rawConfig["workspace"].(map[string]any)
325 if !ok {
326 return
327 }
328
329 // Only transform if the new key does not already exist.
330 if _, hasNew := workspace["browsing_contexts"]; hasNew {
331 return
332 }
333
334 t.ForceLegacyPopupsToBrowsingContexts(rawConfig)
335}
336
337// ForceLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
338// workspace.browsing_contexts.* even when the new section already exists in the
339// raw map. This is used by the loader because Viper's AllSettings includes
340// defaults for the new section that do not indicate user intent.
341func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
342 workspace, ok := rawConfig["workspace"].(map[string]any)
343 if !ok {
344 return
345 }
346
347 oldPopups, ok := workspace["popups"]
348 if !ok {
349 return
350 }
351
352 // Move the popups map to browsing_contexts and delete the old key.
353 workspace["browsing_contexts"] = oldPopups
354 delete(workspace, "popups")
355}
356
357func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
358 switch {
359 case path[0] == sectionWorkspace && path[1] == "prefix_mode":
360 if b, ok := t.defaults.Workspace.PrefixMode.Actions[actionName]; ok {
361 return b.Desc
362 }
363 case path[0] == sectionWorkspace && path[1] == "pane_mode":
364 if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {
365 return b.Desc
366 }
367 case path[0] == sectionWorkspace && path[1] == "tab_mode":
368 if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok {
369 return b.Desc
370 }
371 case path[0] == sectionWorkspace && path[1] == "resize_mode":
372 if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok {
373 return b.Desc
374 }
375 case path[0] == sectionWorkspace && path[1] == "shortcuts":
376 if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok {
377 return b.Desc
378 }
379 case path[0] == sectionSession && path[1] == "session_mode":
380 if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok {
381 return b.Desc
382 }
383 }
384 return ""
385}