hyprconf2lua 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,931 @@
1
+ from __future__ import annotations
2
+ import re
3
+ from typing import Dict, List, Optional
4
+
5
+ from hyprconf2lua.ast import *
6
+ from hyprconf2lua.mappings import *
7
+
8
+
9
+ KNOWN_SECTIONS = {
10
+ "general", "decoration", "input", "animations", "gestures",
11
+ "misc", "binds", "cursor", "debug", "dwindle", "master",
12
+ "group", "render", "xwayland", "opengl", "ecosystem",
13
+ "experimental", "layout", "scrolling", "quirks", "blur",
14
+ "shadow", "touchpad", "tablet", "windowing", "startup",
15
+ "autostart", "window", "monitor", "workspace", "layer",
16
+ }
17
+
18
+
19
+ class Codegen:
20
+ def __init__(self):
21
+ self.lines: List[str] = []
22
+ self.indent_level = 0
23
+ self.variables: Dict[str, str] = {}
24
+ self.has_exec = False
25
+ self.has_animation = False
26
+ self.has_bezier = False
27
+ self.has_bind = False
28
+ self.main_mod_var: Optional[str] = None
29
+ self.pending_execs: List[str] = []
30
+ self.pending_exec_ones: List[str] = []
31
+ self.pending_exec_shutdowns: List[str] = []
32
+ self.flag_count = 0
33
+ self.translated_count = 0
34
+ self.passthrough_count = 0
35
+
36
+ def indent(self):
37
+ self.indent_level += 1
38
+
39
+ def dedent(self):
40
+ self.indent_level = max(0, self.indent_level - 1)
41
+
42
+ def emit(self, line: str = ""):
43
+ if line:
44
+ self.lines.append(" " * self.indent_level + line)
45
+ else:
46
+ self.lines.append("")
47
+
48
+ def emit_block(self, lines: List[str]):
49
+ for line in lines:
50
+ self.emit(line)
51
+
52
+ def generate(self, config: ConfigFile) -> str:
53
+ self.lines = []
54
+ self.emit("-- Generated by hyprconf2lua v1.2.0")
55
+ self.emit("-- https://github.com/yourusername/hyprconf2lua")
56
+ self.emit("-- Manual review may be needed for complex directives")
57
+ self.emit("")
58
+ self.emit('---@module \'hl\'')
59
+ self.emit("")
60
+
61
+ for stmt in config.body:
62
+ self.visit(stmt)
63
+ self.emit()
64
+
65
+ self.emit_pending_execs()
66
+ self.finalize_vars()
67
+
68
+ return "\n".join(self.lines)
69
+
70
+ def visit(self, stmt: BlockStmt):
71
+ if isinstance(stmt, Comment):
72
+ self.emit("--" + stmt.text[1:])
73
+ elif isinstance(stmt, VariableDef):
74
+ self.visit_variable(stmt)
75
+ elif isinstance(stmt, Directive):
76
+ self.visit_directive(stmt)
77
+ elif isinstance(stmt, Section):
78
+ self.visit_section(stmt)
79
+ elif isinstance(stmt, BindDirective):
80
+ self.visit_bind(stmt)
81
+ elif isinstance(stmt, MonitorDirective):
82
+ self.visit_monitor(stmt)
83
+ elif isinstance(stmt, WindowRule):
84
+ self.visit_windowrule(stmt)
85
+ elif isinstance(stmt, WindowRuleBlock):
86
+ self.visit_windowrule_block(stmt)
87
+ elif isinstance(stmt, ExecDirective):
88
+ self.visit_exec(stmt)
89
+ elif isinstance(stmt, AnimationDirective):
90
+ self.visit_animation(stmt)
91
+ elif isinstance(stmt, BezierDirective):
92
+ self.visit_bezier(stmt)
93
+ elif isinstance(stmt, EnvDirective):
94
+ self.visit_env(stmt)
95
+ elif isinstance(stmt, SourceDirective):
96
+ self.visit_source(stmt)
97
+ elif isinstance(stmt, DeviceSection):
98
+ self.visit_device(stmt)
99
+ elif isinstance(stmt, GestureDirective):
100
+ self.visit_gesture(stmt)
101
+ elif isinstance(stmt, WorkspaceDirective):
102
+ self.visit_workspace(stmt)
103
+ elif isinstance(stmt, SubmapDef):
104
+ self.visit_submap(stmt)
105
+ elif isinstance(stmt, LayerRuleDirective):
106
+ self.visit_layerrule(stmt)
107
+ elif isinstance(stmt, LayerRuleBlock):
108
+ self.visit_layerrule_block(stmt)
109
+ else:
110
+ self.flag_count += 1
111
+ self.emit(f"-- TODO: manual review (unknown statement type: {type(stmt).__name__})")
112
+
113
+ def resolve_val(self, val: str) -> str:
114
+ def _repl(m: re.Match) -> str:
115
+ var_name = m.group(1)
116
+ if var_name in self.variables:
117
+ return self.variables[var_name]
118
+ return "local_var_" + var_name
119
+ return re.sub(r'\$(\w+)', _repl, val)
120
+
121
+ def needs_quotes(self, val: str) -> bool:
122
+ if not val:
123
+ return True
124
+ if val.startswith('"') and val.endswith('"'):
125
+ return False
126
+ if re.match(r'^-?\d+(\.\d+)?$', val):
127
+ return False
128
+ if val in ("true", "false", "nil"):
129
+ return False
130
+ if val in self.variables or val in ("mainMod", "terminal", "fileManager", "menu"):
131
+ return False
132
+ if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', val) and val in self.variables:
133
+ return False
134
+ return True
135
+
136
+ def quote(self, val: str) -> str:
137
+ if self.needs_quotes(val):
138
+ escaped = val.replace("\\", "\\\\").replace('"', '\\"')
139
+ return f'"{escaped}"'
140
+ return val
141
+
142
+ def to_lua_val(self, val: str):
143
+ val = self.resolve_val(val)
144
+ if val.startswith("local_var_"):
145
+ return val[len("local_var_"):]
146
+ if val.lower() in ("true", "on", "yes"):
147
+ return "true"
148
+ if val.lower() in ("false", "off", "no"):
149
+ return "false"
150
+ try:
151
+ int(val)
152
+ return val
153
+ except ValueError:
154
+ pass
155
+ try:
156
+ float(val)
157
+ return val
158
+ except ValueError:
159
+ pass
160
+ return self.quote(val)
161
+
162
+ def visit_variable(self, stmt: VariableDef):
163
+ val = self.resolve_val(stmt.value)
164
+ self.variables[stmt.name] = val
165
+ self.translated_count += 1
166
+ if stmt.name.upper() == "MAINMOD" or stmt.name == "mainMod":
167
+ self.main_mod_var = stmt.name
168
+ self.emit(f'local {stmt.name} = "{val}"')
169
+ else:
170
+ if self.needs_quotes(val):
171
+ self.emit(f'local {stmt.name} = {self.quote(val)}')
172
+ else:
173
+ self.emit(f'local {stmt.name} = {val}')
174
+
175
+ def finalize_vars(self):
176
+ if self.main_mod_var is None:
177
+ for name, val in self.variables.items():
178
+ if val == "SUPER" or val == "ALT" or val == "CTRL" or val == "SHIFT":
179
+ self.main_mod_var = name
180
+ break
181
+
182
+ def emit_pending_execs(self):
183
+ if self.pending_exec_ones:
184
+ self.emit("-- Autostart")
185
+ self.emit('hl.on("hyprland.start", function()')
186
+ self.indent()
187
+ for cmd in self.pending_exec_ones:
188
+ resolved = self.resolve_val(cmd)
189
+ if resolved.startswith("$") and resolved[1:] in self.variables:
190
+ self.emit(f"hl.exec_cmd({resolved[1:]})")
191
+ else:
192
+ self.emit(f"hl.exec_cmd({self.quote(resolved)})")
193
+ self.dedent()
194
+ self.emit("end)")
195
+ self.emit("")
196
+
197
+ if self.pending_execs:
198
+ self.emit("-- Exec (run every reload)")
199
+ self.emit('hl.on("config.reloaded", function()')
200
+ self.indent()
201
+ for cmd in self.pending_execs:
202
+ resolved = self.resolve_val(cmd)
203
+ if resolved.startswith("$") and resolved[1:] in self.variables:
204
+ self.emit(f"hl.exec_cmd({resolved[1:]})")
205
+ else:
206
+ self.emit(f"hl.exec_cmd({self.quote(resolved)})")
207
+ self.dedent()
208
+ self.emit("end)")
209
+ self.emit("")
210
+
211
+ if self.pending_exec_shutdowns:
212
+ self.emit("-- Shutdown")
213
+ self.emit('hl.on("hyprland.shutdown", function()')
214
+ self.indent()
215
+ for cmd in self.pending_exec_shutdowns:
216
+ resolved = self.resolve_val(cmd)
217
+ if resolved.startswith("$") and resolved[1:] in self.variables:
218
+ self.emit(f"hl.exec_cmd({resolved[1:]})")
219
+ else:
220
+ self.emit(f"hl.exec_cmd({self.quote(resolved)})")
221
+ self.dedent()
222
+ self.emit("end)")
223
+ self.emit("")
224
+
225
+ def emit_section_config(self, section_name: str, directives: List[BlockStmt]):
226
+ if not directives:
227
+ return
228
+
229
+ self.emit(f"hl.config({{")
230
+ self.indent()
231
+ self.emit(f"{section_name} = {{")
232
+ self.indent()
233
+
234
+ for d in directives:
235
+ if isinstance(d, Comment):
236
+ self.emit(f"--{d.text[1:]}")
237
+ elif isinstance(d, Section):
238
+ sub_name = d.name
239
+ self.emit(f"{sub_name} = {{")
240
+ self.indent()
241
+ for sd in d.body:
242
+ if isinstance(sd, Comment):
243
+ self.emit(f"--{sd.text[1:]}")
244
+ elif isinstance(sd, Directive):
245
+ self.translated_count += 1
246
+ key = sd.key
247
+ if len(sd.value) == 1:
248
+ val = self.to_lua_val(sd.value[0])
249
+ self.emit(f"{key} = {val},")
250
+ else:
251
+ vals = [self.to_lua_val(v) for v in sd.value]
252
+ self.emit(f"{key} = {{ {', '.join(vals)} }},")
253
+ elif isinstance(sd, Section):
254
+ self.passthrough_count += 1
255
+ self.emit(f"-- Nested subsection {sd.name}:")
256
+ for ssd in sd.body:
257
+ if isinstance(ssd, Directive):
258
+ k = ssd.key
259
+ vv = self.to_lua_val(ssd.value[0]) if ssd.value else "true"
260
+ self.emit(f"{k} = {vv},")
261
+ self.dedent()
262
+ self.emit("},")
263
+ elif isinstance(d, Directive):
264
+ self.translated_count += 1
265
+ key = d.key
266
+ if len(d.value) == 1:
267
+ val = self.to_lua_val(d.value[0])
268
+ self.emit(f"{key} = {val},")
269
+ else:
270
+ vals = [self.to_lua_val(v) for v in d.value]
271
+ self.emit(f"{key} = {{ {', '.join(vals)} }},")
272
+
273
+ self.dedent()
274
+ self.emit("},")
275
+ self.dedent()
276
+ self.emit("})")
277
+
278
+ def flush_subsection(self, name: str, directives: List[Directive]):
279
+ if not directives:
280
+ return
281
+ self.emit(f"{name} = {{")
282
+ self.indent()
283
+ for d in directives:
284
+ key = d.key
285
+ if len(d.value) == 1:
286
+ val = self.to_lua_val(d.value[0])
287
+ self.emit(f"{key} = {val},")
288
+ else:
289
+ vals = [self.to_lua_val(v) for v in d.value]
290
+ self.emit(f"{key} = {{ {', '.join(vals)} }},")
291
+ self.dedent()
292
+ self.emit("},")
293
+
294
+ def visit_section(self, stmt: Section):
295
+ if stmt.name == "plugin":
296
+ self.translated_count += 1
297
+ has_plugins = False
298
+ for child in stmt.body:
299
+ if isinstance(child, Section):
300
+ has_plugins = True
301
+ plugin_name = child.name
302
+ self.emit(f"hl.plugin({self.quote(plugin_name)}, function()")
303
+ self.indent()
304
+ for sd in child.body:
305
+ if isinstance(sd, Comment):
306
+ self.emit(f"--{sd.text[1:]}")
307
+ elif isinstance(sd, Directive):
308
+ k = sd.key
309
+ if len(sd.value) == 1:
310
+ v = self.to_lua_val(sd.value[0])
311
+ self.emit(f"{k} = {v},")
312
+ else:
313
+ vals = [self.to_lua_val(v) for v in sd.value]
314
+ self.emit(f"{k} = {{ {', '.join(vals)} }},")
315
+ self.dedent()
316
+ self.emit("end)")
317
+ elif isinstance(child, Directive):
318
+ has_plugins = True
319
+ name = child.key
320
+ vals = ", ".join(self.quote(self.resolve_val(v)) for v in child.value)
321
+ self.emit(f"hl.plugin({self.quote(name)}, {{{vals}}})")
322
+ if not has_plugins:
323
+ self.emit("-- TODO: manual review (plugin config)")
324
+ return
325
+ if stmt.name in KNOWN_SECTIONS:
326
+ self.emit_section_config(stmt.name, stmt.body)
327
+ else:
328
+ self.translated_count += 1
329
+ self.emit(f"hl.config({{")
330
+ self.indent()
331
+ self.emit(f"{stmt.name} = {{")
332
+ self.indent()
333
+ for child in stmt.body:
334
+ if isinstance(child, Comment):
335
+ self.emit(f"--{child.text[1:]}")
336
+ elif isinstance(child, Directive):
337
+ k = child.key
338
+ if len(child.value) == 1:
339
+ v = self.to_lua_val(child.value[0])
340
+ self.emit(f"{k} = {v},")
341
+ else:
342
+ vals = [self.to_lua_val(v) for v in child.value]
343
+ self.emit(f"{k} = {{ {', '.join(vals)} }},")
344
+ self.dedent()
345
+ self.emit("},")
346
+ self.dedent()
347
+ self.emit("})")
348
+ self.emit(f"-- NOTE: Section '{stmt.name}' may be a plugin or custom section; verify the output")
349
+
350
+ def visit_directive(self, stmt: Directive):
351
+ if stmt.key in ("submap", "submap_reset"):
352
+ return
353
+ if stmt.key == "unbind":
354
+ self.translated_count += 1
355
+ mods_str = stmt.value[0].strip() if stmt.value else ""
356
+ key_str = stmt.value[1].strip() if len(stmt.value) > 1 else ""
357
+ mods = [m.strip() for m in mods_str.replace(",", " ").split()] if mods_str else []
358
+ combo_expr = self.build_unbind_combo(mods, key_str)
359
+ self.emit(f"hl.unbind({combo_expr})")
360
+ return
361
+
362
+ self.passthrough_count += 1
363
+ vals = ", ".join(self.quote(self.resolve_val(v)) for v in stmt.value)
364
+ self.emit(f"-- TODO: manual review: {stmt.key} = {vals}")
365
+
366
+ def build_unbind_combo(self, mods: List[str], key: str) -> str:
367
+ if not mods:
368
+ return self.quote(key) if key else '""'
369
+ parts = [self.quote(m) for m in mods]
370
+ all_str = " + ".join(p.strip('"') for p in parts)
371
+ return self.quote(all_str) + ' .. " + " .. ' + (self.quote(key) if key else '""')
372
+
373
+ def visit_bind(self, stmt: BindDirective):
374
+ self.translated_count += 1
375
+ self.has_bind = True
376
+
377
+ combo_expr = self.build_combo_expr(stmt.mods, stmt.key)
378
+
379
+ dispatcher = stmt.dispatcher
380
+ params = stmt.params
381
+
382
+ if stmt.key.startswith("mouse:") and not params:
383
+ if dispatcher == "movewindow":
384
+ dsp_code = "hl.dsp.window.drag()"
385
+ elif dispatcher == "resizewindow":
386
+ dsp_code = "hl.dsp.window.resize()"
387
+ else:
388
+ dsp_code = self.build_dispatcher(dispatcher, params)
389
+ else:
390
+ dsp_code = self.build_dispatcher(dispatcher, params)
391
+ if dsp_code is None and params:
392
+ first_param = params[0]
393
+ rest = params[1:]
394
+ dsp_code = self.build_dispatcher(first_param, rest)
395
+ if dsp_code is not None:
396
+ dispatcher = first_param
397
+ params = rest
398
+
399
+ if dsp_code is None:
400
+ self.flag_count += 1
401
+ mods_str = " + ".join(stmt.mods) if stmt.mods else ""
402
+ self.emit(f'-- TODO: manual review (unknown dispatcher: {stmt.dispatcher})')
403
+ pstr = ", ".join(self.quote(self.resolve_val(p)) for p in stmt.params)
404
+ self.emit(f'-- hl.bind("{mods_str} + {stmt.key}", hl.dsp.{stmt.dispatcher}({pstr}))')
405
+ return
406
+
407
+ opt_parts = []
408
+ if stmt.flags:
409
+ for f in stmt.flags:
410
+ if f in BIND_FLAGS_TO_OPTIONS:
411
+ opt_key, opt_val = BIND_FLAGS_TO_OPTIONS[f]
412
+ opt_parts.append(f"{opt_key} = {opt_val}")
413
+
414
+ if opt_parts:
415
+ opts = ", { " + ", ".join(opt_parts) + " }"
416
+ else:
417
+ opts = ""
418
+
419
+ self.emit(f"hl.bind({combo_expr}, {dsp_code}{opts})")
420
+
421
+ def build_combo_expr(self, mods: List[str], key: str) -> str:
422
+ parts = []
423
+
424
+ is_key_var = key.startswith("$")
425
+ key_resolved = self.resolve_val(key) if is_key_var else None
426
+ key_str = key_resolved if is_key_var else self.quote(key)
427
+
428
+ for m in mods:
429
+ is_var = m.startswith("$")
430
+ if is_var:
431
+ var_name = m[1:]
432
+ if var_name in self.variables:
433
+ parts.append(var_name)
434
+ else:
435
+ parts.append(self.quote(m))
436
+ else:
437
+ parts.append(self.quote(m))
438
+
439
+ if not parts:
440
+ return key_str
441
+
442
+ has_var = any(not p.startswith('"') for p in parts)
443
+
444
+ if not has_var:
445
+ all_str = " + ".join(p.strip('"') for p in parts)
446
+ return self.quote(all_str) + ' .. " + " .. ' + key_str
447
+
448
+ segments = []
449
+ current_str = None
450
+ for p in parts:
451
+ if p.startswith('"'):
452
+ lit = p.strip('"')
453
+ if current_str is None:
454
+ current_str = lit
455
+ else:
456
+ current_str += " + " + lit
457
+ else:
458
+ if current_str is not None:
459
+ segments.append(self.quote(current_str))
460
+ current_str = None
461
+ segments.append(p)
462
+ if current_str is not None:
463
+ segments.append(self.quote(current_str))
464
+
465
+ separator = ' .. " + " .. '
466
+ result = separator.join(segments) + separator + key_str
467
+ return result
468
+
469
+ def build_dispatcher(self, dispatcher: str, params: List[str]) -> Optional[str]:
470
+ if dispatcher in DISPATCHER_MAP:
471
+ func, needs_args = DISPATCHER_MAP[dispatcher]
472
+
473
+ if dispatcher == "movetoworkspacesilent":
474
+ args = self.build_dispatcher_args(params, needs_args)
475
+ return f'{func}({args}, {{ follow = false }})'
476
+
477
+ if dispatcher == "movefocus":
478
+ dir_map = {"l": "left", "r": "right", "u": "up", "d": "down"}
479
+ if params and params[0] in dir_map:
480
+ return f'{func}({{ direction = "{dir_map[params[0]]}" }})'
481
+ return f'{func}({{ direction = {self.quote(params[0]) if params else "nil"} }})'
482
+
483
+ if dispatcher == "cyclenext":
484
+ if params and params[0].lower() == "prev":
485
+ return f'{func}({{ next = false }})'
486
+ return f'{func}()'
487
+
488
+ if dispatcher == "movewindow":
489
+ if params and params[0].startswith("into_group:"):
490
+ return f'{func}({{ into_group = {self.quote(params[0][len("into_group:"):])} }})'
491
+ if params and params[0] == "out_of_group":
492
+ return f'{func}({{ out_of_group = true }})'
493
+ return self.build_dispatcher_args(params, needs_args, func)
494
+
495
+ if dispatcher == "swapwindow":
496
+ if params and params[0] in ("l", "r", "u", "d"):
497
+ dir_map = {"l": "left", "r": "right", "u": "up", "d": "down"}
498
+ return f'{func}({{ direction = "{dir_map[params[0]]}" }})'
499
+ if params and params[0].startswith("class:"):
500
+ return f'{func}({{ target = {self.quote(params[0])} }})'
501
+ return self.build_dispatcher_args(params, needs_args, func)
502
+
503
+ if dispatcher == "changegroupactive":
504
+ if params and params[0].lower() == "f":
505
+ return f'{func}()'
506
+ return f'{func}({{ forward = false }})'
507
+
508
+ if dispatcher == "movegroupwindow":
509
+ if params and params[0].lower() == "b":
510
+ return f'{func}({{ forward = false }})'
511
+ return f'{func}({{ forward = true }})'
512
+
513
+ if dispatcher == "workspace":
514
+ return self.build_workspace_dispatcher(func, params)
515
+
516
+ if dispatcher == "focuswindow":
517
+ return self.build_focus_dispatcher(func, params)
518
+
519
+ if dispatcher == "focusurgentorlast":
520
+ return f'{func}({{ urgent = true }})'
521
+
522
+ if dispatcher == "focuscurrentorlast":
523
+ return f'{func}({{ last = true }})'
524
+
525
+ if dispatcher == "focusonemonitor":
526
+ return f'{func}({{ on_monitor = true }})'
527
+
528
+ if dispatcher == "mouse":
529
+ mouse_actions = {"272": "drag", "273": "resize"}
530
+ if params and params[0] in mouse_actions:
531
+ return f'hl.dsp.window.{mouse_actions[params[0]]}()'
532
+ return None
533
+
534
+ if dispatcher in ("exec", "execr"):
535
+ resolved = self.resolve_val(params[0]) if params else ""
536
+ return f'{func}({self.quote(resolved)})'
537
+
538
+ args = self.build_dispatcher_args(params, needs_args)
539
+ return f'{func}({args})' if needs_args else f'{func}()'
540
+
541
+ if dispatcher == "mouse":
542
+ mouse_actions = {"272": "drag", "273": "resize"}
543
+ if params and params[0] in mouse_actions:
544
+ return f'hl.dsp.window.{mouse_actions[params[0]]}()'
545
+ return None
546
+
547
+ if dispatcher in ("moveintogroup",):
548
+ if params:
549
+ dir_map = {"l": "left", "r": "right", "u": "up", "d": "down"}
550
+ d = dir_map.get(params[0], params[0])
551
+ return f'hl.dsp.window.move({{ into_group = "{d}" }})'
552
+ return None
553
+
554
+ elif dispatcher in ("moveoutofgroup",):
555
+ return f'hl.dsp.window.move({{ out_of_group = true }})'
556
+
557
+ return None
558
+
559
+ def build_workspace_dispatcher(self, func: str, params: List[str]) -> str:
560
+ if not params:
561
+ return f'{func}({{ workspace = "e+1" }})'
562
+ p = params[0]
563
+ if p.isdigit() or (p.startswith("-") and p[1:].isdigit()):
564
+ return f'{func}({{ workspace = {p} }})'
565
+ if p.startswith("special"):
566
+ if ":" in p:
567
+ return f'{func}({{ workspace = {self.quote(p)} }})'
568
+ return f'{func}({{ workspace = "special" }})'
569
+ if p in ("e+1", "e-1", "m+1", "m-1", "r", "empty", "previous"):
570
+ short_map = {"previous": "previous", "empty": "empty", "r": "r"}
571
+ return f'{func}({{ workspace = {self.quote(short_map.get(p, p))} }})'
572
+ if p.startswith("name:"):
573
+ return f'{func}({{ workspace = {self.quote(p[len("name:"):])} }})'
574
+ return f'{func}({{ workspace = {self.quote(p)} }})'
575
+
576
+ def build_focus_dispatcher(self, func: str, params: List[str]) -> str:
577
+ if not params:
578
+ return f'{func}({{ window = "" }})'
579
+ p = params[0]
580
+ if p.startswith("class:"):
581
+ return f'{func}({{ window = {self.quote(p)} }})'
582
+ if p.startswith("title:"):
583
+ return f'{func}({{ window = {self.quote(p)} }})'
584
+ if p.startswith("address:"):
585
+ return f'{func}({{ window = {self.quote(p)} }})'
586
+ return f'{func}({{ window = {self.quote(p)} }})'
587
+
588
+ def build_dispatcher_args(self, params: List[str], needs_args: bool,
589
+ func: Optional[str] = None) -> str:
590
+ if not params:
591
+ return "" if not needs_args else "nil"
592
+
593
+ if len(params) == 1:
594
+ p = params[0]
595
+ if func and func == "hl.dsp.window.move":
596
+ if p.startswith("special"):
597
+ return f'{{ workspace = {self.quote(p)} }}'
598
+ if p.isdigit():
599
+ return f'{{ workspace = {p} }}'
600
+ return f'{{ direction = {self.quote(p)} }}'
601
+ return self.quote(self.resolve_val(p))
602
+
603
+ parts = []
604
+ for p in params:
605
+ parts.append(self.quote(self.resolve_val(p)))
606
+ return ", ".join(parts)
607
+
608
+ def visit_monitor(self, stmt: MonitorDirective):
609
+ self.translated_count += 1
610
+ self.emit("hl.monitor({")
611
+ self.indent()
612
+ self.emit(f'output = {self.quote(stmt.name)},')
613
+ self.emit(f'mode = {self.quote(stmt.mode)},')
614
+ self.emit(f'position = {self.quote(stmt.position)},')
615
+ self.emit(f'scale = {self.quote(stmt.scale)},')
616
+ for key, val in stmt.extra.items():
617
+ lua_val = self.to_lua_val(val)
618
+ self.emit(f'{key} = {lua_val},')
619
+ self.dedent()
620
+ self.emit("})")
621
+
622
+ def visit_windowrule(self, stmt: WindowRule):
623
+ self.translated_count += 1
624
+ rule = stmt.rule
625
+ match_params = stmt.match_params
626
+
627
+ match = {}
628
+ effects = {}
629
+
630
+ for mp in match_params:
631
+ colon_idx = mp.find(":")
632
+ if colon_idx > 0:
633
+ prefix = mp[:colon_idx].strip()
634
+ value = mp[colon_idx + 1:].strip()
635
+ match_key = prefix
636
+ if value.lower() == "true":
637
+ match[match_key] = "true"
638
+ elif value.lower() == "false":
639
+ match[match_key] = "false"
640
+ else:
641
+ match[match_key] = self.quote(value)
642
+
643
+ if not match and not stmt.is_v2:
644
+ if match_params:
645
+ class_val = match_params[0]
646
+ match["class"] = self.quote(class_val)
647
+
648
+ self.emit("hl.window_rule({")
649
+ self.indent()
650
+
651
+ name_str = re.sub(r'[^a-zA-Z0-9_-]', '_', rule[:20].lower())
652
+ self.emit(f'name = "{name_str}",')
653
+
654
+ self.emit("match = {")
655
+ self.indent()
656
+ for k, v in match.items():
657
+ self.emit(f"{k} = {v},")
658
+ self.dedent()
659
+ self.emit("},")
660
+
661
+ rule_lower = rule.lower().strip()
662
+ applied = False
663
+
664
+ if rule_lower in WINDOW_RULE_MAP:
665
+ k, v = WINDOW_RULE_MAP[rule_lower]
666
+ self.emit(f"{k} = {v},")
667
+ applied = True
668
+ else:
669
+ parts = rule.split(None, 1)
670
+ if parts and parts[0] in WINDOW_RULE_PARAM_MAP:
671
+ k, needs_val = WINDOW_RULE_PARAM_MAP[parts[0]]
672
+ if needs_val and len(parts) > 1:
673
+ val = self.to_lua_val(parts[1])
674
+ self.emit(f"{k} = {val},")
675
+ elif needs_val:
676
+ self.emit(f"{k} = true,")
677
+ else:
678
+ self.emit(f"{k} = true,")
679
+ applied = True
680
+
681
+ if not applied:
682
+ self.flag_count += 1
683
+ self.emit(f'-- TODO: review rule: {self.quote(rule)}')
684
+
685
+ self.dedent()
686
+ self.emit("})")
687
+
688
+ def visit_windowrule_block(self, stmt: WindowRuleBlock):
689
+ self.translated_count += 1
690
+ name_str = stmt.name if stmt.name else re.sub(r'[^a-zA-Z0-9_-]', '_', f'rule_{stmt.line}')
691
+
692
+ self.emit("hl.window_rule({")
693
+ self.indent()
694
+ self.emit(f'name = "{name_str}",')
695
+
696
+ if stmt.match:
697
+ self.emit("match = {")
698
+ self.indent()
699
+ for k, v in stmt.match.items():
700
+ self.emit(f"{k} = {self.quote(v)},")
701
+ self.dedent()
702
+ self.emit("},")
703
+
704
+ for key, vals in stmt.effects.items():
705
+ if len(vals) == 1:
706
+ parts = vals[0].split()
707
+ if len(parts) == 1:
708
+ self.emit(f"{key} = {self.to_lua_val(parts[0])},")
709
+ else:
710
+ lua_parts = [self.to_lua_val(p) for p in parts]
711
+ self.emit(f"{key} = {{ {', '.join(lua_parts)} }},")
712
+ else:
713
+ lua_vals = [self.to_lua_val(v) for v in vals]
714
+ self.emit(f"{key} = {{ {', '.join(lua_vals)} }},")
715
+
716
+ self.dedent()
717
+ self.emit("})")
718
+
719
+ def visit_exec(self, stmt: ExecDirective):
720
+ self.translated_count += 1
721
+ resolved = self.resolve_val(stmt.command)
722
+ if stmt.kind in ("exec-once", "execr-once"):
723
+ self.pending_exec_ones.append(stmt.command)
724
+ elif stmt.kind == "exec-shutdown":
725
+ self.pending_exec_shutdowns.append(stmt.command)
726
+ else:
727
+ self.pending_execs.append(stmt.command)
728
+
729
+ def visit_animation(self, stmt: AnimationDirective):
730
+ self.translated_count += 1
731
+ name = stmt.name
732
+ style = stmt.style
733
+ speed = stmt.speed
734
+ curve = stmt.curve
735
+
736
+ try:
737
+ float(speed)
738
+ speed_lit = speed
739
+ except ValueError:
740
+ speed_lit = self.quote(speed)
741
+
742
+ style_extra = ""
743
+ if style and style != "default":
744
+ if " " in style:
745
+ style_name, *style_args = style.split()
746
+ style_extra = f', style = {self.quote(style)}'
747
+ else:
748
+ style_extra = f', style = {self.quote(style)}'
749
+
750
+ if curve and curve != "default":
751
+ if re.match(r'^\d+(\.\d+)?$', curve):
752
+ return self.emit(f"-- TODO: manual review (numeric curve ref in animation: {stmt.name})")
753
+
754
+ if curve.startswith("spring"):
755
+ spring_parts = curve.split()
756
+ if len(spring_parts) >= 4:
757
+ self.passthrough_count += 1
758
+ self.emit(f'hl.animation({{ leaf = "{name}", enabled = true, speed = {speed_lit}, spring = {self.quote(curve)}{style_extra} }})')
759
+ else:
760
+ self.emit(f'hl.animation({{ leaf = "{name}", enabled = true, speed = {speed_lit}, spring = {self.quote(curve)}{style_extra} }})')
761
+ else:
762
+ self.emit(f'hl.animation({{ leaf = "{name}", enabled = true, speed = {speed_lit}, bezier = {self.quote(curve)}{style_extra} }})')
763
+ else:
764
+ self.emit(f'hl.animation({{ leaf = "{name}", enabled = true, speed = {speed_lit}{style_extra} }})')
765
+
766
+ def visit_bezier(self, stmt: BezierDirective):
767
+ self.translated_count += 1
768
+ self.emit(f'hl.curve({self.quote(stmt.name)}, {{')
769
+ self.indent()
770
+ self.emit(f'type = "bezier",')
771
+ self.emit(f'points = {{ {{ {stmt.p1x}, {stmt.p1y} }}, {{ {stmt.p2x}, {stmt.p2y} }} }},')
772
+ self.dedent()
773
+ self.emit("})")
774
+
775
+ def visit_env(self, stmt: EnvDirective):
776
+ self.translated_count += 1
777
+ resolved_name = self.resolve_val(stmt.name)
778
+ resolved_val = self.resolve_val(stmt.value)
779
+ name_q = self.quote(resolved_name)
780
+ val_q = self.quote(resolved_val)
781
+ self.emit(f"hl.env({name_q}, {val_q})")
782
+
783
+ def visit_source(self, stmt: SourceDirective):
784
+ self.passthrough_count += 1
785
+ path = stmt.path
786
+
787
+ lua_path = path.replace(".conf", "")
788
+ lua_path = lua_path.replace("~", "os.getenv(\"HOME\")")
789
+ lua_path = lua_path.replace("/", ".")
790
+
791
+ if path.endswith("/*.conf"):
792
+ self.emit(f'-- source = {path}')
793
+ self.emit(f"-- NOTE: glob sources need manual handling")
794
+ self.emit(f"-- Directory contents must be required individually")
795
+ return
796
+
797
+ self.emit(f'-- source = {path} -> requires manual conversion')
798
+ home_relative = path.replace("~", "").replace(os_path := "", ".hypr/")
799
+
800
+ import os as _os
801
+ just_name = _os.path.splitext(_os.path.basename(path))[0]
802
+ self.emit(f'-- local {just_name} = require("{just_name}")')
803
+ self.emit(f'-- TODO: convert {path} to .lua and use require()')
804
+
805
+ def visit_device(self, stmt: DeviceSection):
806
+ self.translated_count += 1
807
+ self.emit(f"hl.device({{")
808
+ self.indent()
809
+ self.emit(f'name = {self.quote(stmt.name)},')
810
+ for d in stmt.body:
811
+ if isinstance(d, Directive):
812
+ key = d.key
813
+ val = self.to_lua_val(d.value[0]) if d.value else "true"
814
+ self.emit(f"{key} = {val},")
815
+ self.dedent()
816
+ self.emit("})")
817
+
818
+ def visit_gesture(self, stmt: GestureDirective):
819
+ self.translated_count += 1
820
+ props = {}
821
+ for d in stmt.body:
822
+ if isinstance(d, Directive):
823
+ props[d.key] = d.value[0] if d.value else "true"
824
+
825
+ self.emit("hl.gesture({")
826
+ self.indent()
827
+ for k, v in props.items():
828
+ lua_v = self.to_lua_val(v)
829
+ lua_k = k.replace("_", " ")
830
+ self.emit(f"[{self.quote(lua_k)}] = {lua_v},")
831
+ self.dedent()
832
+ self.emit("})")
833
+
834
+ def visit_submap(self, stmt: SubmapDef):
835
+ self.translated_count += 1
836
+ self.emit(f'hl.define_submap({self.quote(stmt.name)}, function()')
837
+ self.indent()
838
+ for s in stmt.body:
839
+ if isinstance(s, Comment):
840
+ self.emit("--" + s.text[1:])
841
+ elif isinstance(s, BindDirective):
842
+ self.visit_bind(s)
843
+ elif isinstance(s, Directive):
844
+ if s.key == "submap_reset":
845
+ continue
846
+ self.emit(f'-- submap: {s.key} = {", ".join(s.value)}')
847
+ self.dedent()
848
+ self.emit("end)")
849
+ self.emit("")
850
+
851
+ def visit_workspace(self, stmt: WorkspaceDirective):
852
+ self.translated_count += 1
853
+ self.emit("hl.workspace_rule({")
854
+ self.indent()
855
+ self.emit(f'workspace = {self.quote(stmt.name)},')
856
+ for param in stmt.params:
857
+ colon_idx = param.find(":")
858
+ if colon_idx > 0:
859
+ key = param[:colon_idx].strip()
860
+ val = param[colon_idx + 1:].strip()
861
+ lua_key = WORKSPACE_RULE_MAP.get(key, key)
862
+ lua_val = self.to_lua_val(val)
863
+ self.emit(f"{lua_key} = {lua_val},")
864
+ else:
865
+ self.emit(f'-- {param} (unrecognized workspace param)')
866
+ self.dedent()
867
+ self.emit("})")
868
+
869
+ def visit_layerrule(self, stmt: LayerRuleDirective):
870
+ self.translated_count += 1
871
+ rule_lower = stmt.rule.lower()
872
+ ns = stmt.namespace
873
+
874
+ self.emit("hl.layer_rule({")
875
+ self.indent()
876
+ self.emit("match = {")
877
+ self.indent()
878
+ self.emit(f'namespace = {self.quote(ns)},')
879
+ self.dedent()
880
+ self.emit("},")
881
+
882
+ if rule_lower in LAYER_RULE_MAP:
883
+ k, v = LAYER_RULE_MAP[rule_lower]
884
+ if v == "true":
885
+ self.emit(f"{k} = {v},")
886
+ else:
887
+ self.emit(f"{k} = {self.to_lua_val(v)},")
888
+ else:
889
+ parts = rule_lower.split(None, 1)
890
+ if parts:
891
+ self.emit(f"{parts[0]} = {self.quote(parts[1]) if len(parts) > 1 else 'true'},")
892
+
893
+ self.dedent()
894
+ self.emit("})")
895
+
896
+ def visit_layerrule_block(self, stmt: LayerRuleBlock):
897
+ self.translated_count += 1
898
+ name_str = stmt.name if stmt.name else f'layerrule_{stmt.line}'
899
+
900
+ self.emit("hl.layer_rule({")
901
+ self.indent()
902
+
903
+ if stmt.match:
904
+ self.emit("match = {")
905
+ self.indent()
906
+ for k, v in stmt.match.items():
907
+ self.emit(f"{k} = {self.quote(v)},")
908
+ self.dedent()
909
+ self.emit("},")
910
+
911
+ for key, vals in stmt.effects.items():
912
+ if len(vals) == 1:
913
+ parts = vals[0].split()
914
+ if len(parts) == 1:
915
+ self.emit(f"{key} = {self.to_lua_val(parts[0])},")
916
+ else:
917
+ lua_parts = [self.to_lua_val(p) for p in parts]
918
+ self.emit(f"{key} = {{ {', '.join(lua_parts)} }},")
919
+ else:
920
+ lua_vals = [self.to_lua_val(v) for v in vals]
921
+ self.emit(f"{key} = {{ {', '.join(lua_vals)} }},")
922
+
923
+ self.dedent()
924
+ self.emit("})")
925
+
926
+ def get_report(self) -> dict:
927
+ return {
928
+ "translated": self.translated_count,
929
+ "passthrough": self.passthrough_count,
930
+ "flagged": self.flag_count,
931
+ }