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