1package config
2
3import "math"
4
5const (
6 sectionWorkspace = "workspace"
7 sectionSession = "session"
8)
9
10// LegacyConfigTransformer implements port.ConfigTransformer.
11// It transforms old-format action bindings (slices) to new ActionBinding format.
12type LegacyConfigTransformer struct {
13 defaults *Config
14}
15
16// NewLegacyConfigTransformer creates a new transformer with defaults for description lookup.
17func NewLegacyConfigTransformer() *LegacyConfigTransformer {
18 return &LegacyConfigTransformer{defaults: DefaultConfig()}
19}
20
21// TransformLegacyActions converts old-format action bindings to new format.
22// This handles migration from:
23//
24// [workspace.pane_mode.actions]
25// "focus-left" = ["h", "arrowleft"]
26//
27// To:
28//
29// [workspace.pane_mode.actions.focus-left]
30// keys = ["h", "arrowleft"]
31// desc = "Focus pane to the left"
32func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) {
33 actionPaths := [][]string{
34 {"workspace", "pane_mode", "actions"},
35 {"workspace", "tab_mode", "actions"},
36 {"workspace", "resize_mode", "actions"},
37 {"workspace", "shortcuts", "actions"},
38 {"session", "session_mode", "actions"},
39 }
40
41 for _, path := range actionPaths {
42 t.transformActionSection(rawConfig, path)
43 }
44}
45
46// TransformLegacyEngineConfig removes deprecated engine config keys.
47func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
48 adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
49 t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
50}
51
52func (t *LegacyConfigTransformer) TransformLegacyEngineConfigWithExplicitAdaptive(
53 rawConfig map[string]any,
54 hasAdaptive, adaptiveEnabled bool,
55) {
56 t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
57}
58
59func (*LegacyConfigTransformer) transformLegacyEngineConfig(rawConfig map[string]any, hasAdaptive, adaptiveEnabled bool) {
60 engine, ok := rawConfig["engine"].(map[string]any)
61 if !ok {
62 return
63 }
64
65 cef, ok := engine["cef"].(map[string]any)
66 if !ok {
67 return
68 }
69
70 delete(cef, "enable_context_menu_handler")
71 migrateLegacyCEFWindowlessFrameRateDefault(cef, hasAdaptive, adaptiveEnabled)
72 migrateLegacyCEFInputScrollDefaults(cef)
73}
74
75const (
76 legacyCEFScrollTouchpadMultiplierDefault = 0.35
77 configFloatTolerance = 0.000001
78)
79
80func migrateLegacyCEFInputScrollDefaults(cef map[string]any) {
81 input, ok := cef["input"].(map[string]any)
82 if !ok {
83 return
84 }
85
86 if legacyTouchpad, hasLegacyTouchpad := input["scroll_touchpad_multiplier"]; hasLegacyTouchpad {
87 if precise, hasPrecise := input["scroll_precise_multiplier"]; !hasPrecise {
88 input["scroll_precise_multiplier"] = migratedCEFPreciseScrollMultiplier(legacyTouchpad)
89 } else if shouldPreferLegacyTouchpadScrollMultiplier(legacyTouchpad, precise) {
90 input["scroll_precise_multiplier"] = legacyTouchpad
91 }
92 delete(input, "scroll_touchpad_multiplier")
93 }
94
95 if current, hasPrecise := input["scroll_precise_multiplier"]; hasPrecise {
96 input["scroll_precise_multiplier"] = migratedCEFPreciseScrollMultiplier(current)
97 }
98}
99
100func shouldPreferLegacyTouchpadScrollMultiplier(legacyTouchpad, precise any) bool {
101 legacyMultiplier, legacyOK := float64ConfigValue(legacyTouchpad)
102 preciseMultiplier, preciseOK := float64ConfigValue(precise)
103 if !legacyOK || !preciseOK {
104 return false
105 }
106 return !sameConfigFloat(legacyMultiplier, legacyCEFScrollTouchpadMultiplierDefault) &&
107 (sameConfigFloat(preciseMultiplier, defaultCEFScrollPreciseMultiplier) ||
108 sameConfigFloat(preciseMultiplier, legacyCEFScrollTouchpadMultiplierDefault))
109}
110
111func migratedCEFPreciseScrollMultiplier(value any) any {
112 multiplier, ok := float64ConfigValue(value)
113 if !ok || !sameConfigFloat(multiplier, legacyCEFScrollTouchpadMultiplierDefault) {
114 return value
115 }
116 return defaultCEFScrollPreciseMultiplier
117}
118
119func sameConfigFloat(a, b float64) bool {
120 return math.Abs(a-b) <= configFloatTolerance
121}
122
123func migrateLegacyCEFWindowlessFrameRateDefault(cef map[string]any, hasAdaptive, adaptiveEnabled bool) {
124 if hasAdaptive && !adaptiveEnabled {
125 return
126 }
127 frameRate, ok := int64ConfigValue(cef["windowless_frame_rate"])
128 if !ok || frameRate != defaultCEFWindowlessFrameRate {
129 return
130 }
131 cef["adaptive_windowless_frame_rate"] = true
132 cef["windowless_frame_rate"] = 0
133 if _, hasMax := cef["windowless_frame_rate_max"]; !hasMax {
134 cef["windowless_frame_rate_max"] = defaultCEFWindowlessFrameRateMax
135 }
136}
137
138func nestedConfigBoolValue(rawConfig map[string]any, path ...string) (bool, bool) {
139 current := rawConfig
140 for i, key := range path {
141 value, ok := current[key]
142 if !ok {
143 return false, false
144 }
145 if i == len(path)-1 {
146 parsed, parsedOK := value.(bool)
147 return parsed, parsedOK
148 }
149 next, ok := value.(map[string]any)
150 if !ok {
151 return false, false
152 }
153 current = next
154 }
155 return false, false
156}
157
158func float64ConfigValue(value any) (float64, bool) {
159 switch v := value.(type) {
160 case float32:
161 return float64(v), true
162 case float64:
163 return v, true
164 case int:
165 return float64(v), true
166 case int8:
167 return float64(v), true
168 case int16:
169 return float64(v), true
170 case int32:
171 return float64(v), true
172 case int64:
173 return float64(v), true
174 case uint:
175 return float64(v), true
176 case uint8:
177 return float64(v), true
178 case uint16:
179 return float64(v), true
180 case uint32:
181 return float64(v), true
182 case uint64:
183 return float64(v), true
184 default:
185 return 0, false
186 }
187}
188
189func int64ConfigValue(value any) (int64, bool) {
190 switch v := value.(type) {
191 case int:
192 return int64(v), true
193 case int8:
194 return int64(v), true
195 case int16:
196 return int64(v), true
197 case int32:
198 return int64(v), true
199 case int64:
200 return v, true
201 case uint:
202 return int64(v), true
203 case uint8:
204 return int64(v), true
205 case uint16:
206 return int64(v), true
207 case uint32:
208 return int64(v), true
209 case uint64:
210 if v > math.MaxInt64 {
211 return 0, false
212 }
213 return int64(v), true
214 default:
215 return 0, false
216 }
217}
218
219func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
220 // Navigate to the parent of the actions section
221 current := rawConfig
222 for _, key := range path[:len(path)-1] {
223 next, ok := current[key].(map[string]any)
224 if !ok {
225 return // Path doesn't exist
226 }
227 current = next
228 }
229
230 actionsKey := path[len(path)-1]
231 actionsRaw, ok := current[actionsKey]
232 if !ok {
233 return
234 }
235
236 actions, ok := actionsRaw.(map[string]any)
237 if !ok {
238 return
239 }
240
241 // Check each action and transform if needed
242 for actionName, actionValue := range actions {
243 // If value is a slice (old format), convert to ActionBinding format
244 if slice, ok := actionValue.([]any); ok {
245 keys := make([]string, 0, len(slice))
246 for _, v := range slice {
247 if s, ok := v.(string); ok {
248 keys = append(keys, s)
249 }
250 }
251
252 actions[actionName] = map[string]any{
253 "keys": keys,
254 "desc": t.getDefaultDesc(path, actionName),
255 }
256 }
257 }
258}
259
260// TransformLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
261// workspace.browsing_contexts.* when the new section is absent. This allows
262// existing user configs with [workspace.popups] to be silently migrated.
263func (t *LegacyConfigTransformer) TransformLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
264 workspace, ok := rawConfig["workspace"].(map[string]any)
265 if !ok {
266 return
267 }
268
269 // Only transform if the new key does not already exist.
270 if _, hasNew := workspace["browsing_contexts"]; hasNew {
271 return
272 }
273
274 t.ForceLegacyPopupsToBrowsingContexts(rawConfig)
275}
276
277// ForceLegacyPopupsToBrowsingContexts maps old workspace.popups.* keys to
278// workspace.browsing_contexts.* even when the new section already exists in the
279// raw map. This is used by the loader because Viper's AllSettings includes
280// defaults for the new section that do not indicate user intent.
281func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig map[string]any) {
282 workspace, ok := rawConfig["workspace"].(map[string]any)
283 if !ok {
284 return
285 }
286
287 oldPopups, ok := workspace["popups"]
288 if !ok {
289 return
290 }
291
292 // Move the popups map to browsing_contexts and delete the old key.
293 workspace["browsing_contexts"] = oldPopups
294 delete(workspace, "popups")
295}
296
297func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string {
298 switch {
299 case path[0] == sectionWorkspace && path[1] == "pane_mode":
300 if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok {
301 return b.Desc
302 }
303 case path[0] == sectionWorkspace && path[1] == "tab_mode":
304 if b, ok := t.defaults.Workspace.TabMode.Actions[actionName]; ok {
305 return b.Desc
306 }
307 case path[0] == sectionWorkspace && path[1] == "resize_mode":
308 if b, ok := t.defaults.Workspace.ResizeMode.Actions[actionName]; ok {
309 return b.Desc
310 }
311 case path[0] == sectionWorkspace && path[1] == "shortcuts":
312 if b, ok := t.defaults.Workspace.Shortcuts.Actions[actionName]; ok {
313 return b.Desc
314 }
315 case path[0] == sectionSession && path[1] == "session_mode":
316 if b, ok := t.defaults.Session.SessionMode.Actions[actionName]; ok {
317 return b.Desc
318 }
319 }
320 return ""
321}