#!/usr/bin/env python3 """Fold noctalia GUI overrides back into the declarative dotfiles config. This script folds `noctalia config export merged` back into the per-aspect *.toml files so the repo alone reproduces the current effective config: * export values win (added if missing, updated if changed), * repo-only keys and empty-table declarations are never deleted, * f32 serialization noise (0.74999998696148396 vs 0.75) is ignored, * floats are written back clean (0.8400000000000001 -> 0.84), * comments (tombi directives) are preserved across the rewrite. Files are parsed with `tomli` and rewritten with `tomli_w`, then touched files are re-formatted with `tombi` to match repo style. Note that `tomli_w` renders every non-empty array on multiple lines and `tombi` preserves that layout, so arrays in a folded file are reflowed from inline to multiline the first time that file is touched. After a successful fold the GUI override file is moved to /settings.bak.d/.toml (the 3 most recent are kept) and the running instance is reloaded. Usage: sync-noctalia.py # preview the fold; change nothing sync-noctalia.py --apply # apply, backup settings.toml, reload sync-noctalia.py --export-file # read the export from F instead of noctalia sync-noctalia.py --config-dir # target a different config dir sync-noctalia.py --state-dir # state dir holding settings.toml """ import argparse import math import os import re import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path from subprocess import CalledProcessError import tomli import tomli_w REPO = Path(__file__).resolve().parent.parent DEFAULT_CONFIG_DIR = REPO / "modules" / "desktop" / "wm" / "niri" / "noctalia" DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "noctalia" KEEP_BACKUPS = 3 FLOAT_TOL = 1e-4 FLOAT_PRECISION = 6 def parse_export(export_file: Path | None) -> dict: if export_file: return tomli.loads(export_file.read_text()) try: proc = subprocess.run( ["noctalia", "config", "export", "merged"], capture_output=True, text=True, check=True, ) except FileNotFoundError: sys.exit("error: `noctalia` not found on PATH") except CalledProcessError as err: sys.exit(f"error: `noctalia config export merged` failed:\n{err.stderr}") return tomli.loads(proc.stdout) def values_differ(a, b) -> bool: """True if two values differ as config values (float-tolerant, recursive).""" if isinstance(a, (int, float)) and isinstance(b, (int, float)): return not math.isclose(float(a), float(b), rel_tol=0.0, abs_tol=FLOAT_TOL) if isinstance(a, list) and isinstance(b, list): return len(a) != len(b) or any(values_differ(x, y) for x, y in zip(a, b)) if isinstance(a, dict) and isinstance(b, dict): return set(a) != set(b) or any(values_differ(a[k], b[k]) for k in a) return a != b def collect_changes(repo_sub, exp_sub, prefix, changes): """Recursively diff export subtree against repo subtree, appending (path, old_value, new_value) tuples. New subtrees carry old_value=None and a dict new_value.""" for k, v in exp_sub.items(): path = prefix + [k] if isinstance(v, dict): cur = repo_sub.get(k) if isinstance(cur, dict): collect_changes(cur, v, path, changes) else: changes.append((path, cur, v)) else: cur = repo_sub.get(k) if cur is None or values_differ(cur, v): changes.append((path, cur, v)) def clean_value(v): """Round floats to FLOAT_PRECISION places, recursively. tomli_w serialises floats with str(), which would keep f32 noise like 0.74999998696148396 written by a float slider; rounding keeps written values clean without losing precision we care about. """ if isinstance(v, float): return round(v, FLOAT_PRECISION) if isinstance(v, list): return [clean_value(x) for x in v] if isinstance(v, dict): return {k: clean_value(x) for k, x in v.items()} return v # Preview rendering (display only; file I/O goes through tomli_w) def _dotted(segments) -> str: """Render segments as a TOML dotted path, quoting non-bare segments.""" out = [] for s in segments: if not s or not all(c.isalnum() or c in "_-" for c in s): out.append('"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"') else: out.append(s) return ".".join(out) def _clean_float(v: float) -> str: s = f"{v:.6f}".rstrip("0").rstrip(".") return s + ".0" if "." not in s else s def serialize_value(v, indent: str = "") -> str: if isinstance(v, bool): return "true" if v else "false" if isinstance(v, int): return str(v) if isinstance(v, float): return _clean_float(v) if isinstance(v, str): return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"' if isinstance(v, list): return "[" + ", ".join(serialize_value(e, indent) for e in v) + "]" if isinstance(v, dict): inner = ", ".join( f"{_dotted([k])} = {serialize_value(x, '')}" for k, x in v.items() ) return "{ " + inner + " }" raise TypeError(f"unsupported TOML value: {v!r}") def serialize_subtree(path, subtree) -> str: """Serialize a dict as a TOML section block: headers followed by keys.""" out = [] def walk(p, d): out.append(f"[{_dotted(p)}]\n") for k, v in d.items(): if not isinstance(v, dict): out.append(f"{k} = {serialize_value(v)}\n") for k, v in d.items(): if isinstance(v, dict): walk(p + [k], v) walk(path, subtree) return "".join(out) # Comment preservation def extract_comment_blocks(text: str) -> list[tuple[list[str], str | None]]: """Contiguous comment lines, each paired with the table header that follows them (or None for a trailing run). tomli_w drops comments when rewriting a file, so they are re-anchored from the original text.""" lines = text.splitlines() runs = [] i = 0 while i < len(lines): if lines[i].lstrip().startswith("#"): j = i while j < len(lines) and lines[j].lstrip().startswith("#"): j += 1 runs.append((i, j)) i = j else: i += 1 blocks = [] for start, end in runs: anchor = None for k in range(end, len(lines)): t = lines[k].strip() if t.startswith("[") and t.endswith("]"): anchor = t break blocks.append((lines[start:end], anchor)) return blocks def restore_comments(original_text: str, rendered_text: str) -> str: """Re-insert the original file's comments into tomli_w's output, before the table header each comment preceded (any stragglers go at EOF).""" blocks = extract_comment_blocks(original_text) if not blocks: return rendered_text pending = list(blocks) out = [] for line in rendered_text.splitlines(): stripped = line.strip() if stripped.startswith("[") and stripped.endswith("]"): for block, anchor in list(pending): if anchor == stripped: out.extend(block) pending.remove((block, anchor)) out.append(line) for block, _anchor in pending: out.extend(block) return "\n".join(out) + "\n" # Dict-based edits def apply_path(data: dict, path, value, *, as_table: bool, old): """Set ``path`` to ``value`` in ``data``, creating intermediate tables. Leaf values are assigned in place (a new key appends at the end of its table). Sub-tables are inserted at their sorted position among sibling sub-tables, matching the repo's alphabetical `[section]` order; a scalar being replaced by a table is dropped first. """ node = data for seg in path[:-1]: nxt = node.get(seg) if not isinstance(nxt, dict): nxt = {} node[seg] = nxt node = nxt key = path[-1] if as_table: if old is not None: node.pop(key, None) items = list(node.items()) pos = len(items) for i, (k, v) in enumerate(items): if isinstance(v, dict) and k > key: pos = i break items.insert(pos, (key, value)) node.clear() node.update(items) else: node[key] = value # Verification and state handling def completeness_diffs(export, repo_after) -> list: """Leaf paths where repo_after still differs from the export.""" diffs = [] def walk(e, r, prefix): for k, v in e.items(): p = prefix + [k] if isinstance(v, dict): if not isinstance(r.get(k), dict): diffs.append((p, None, v)) else: walk(v, r[k], p) else: cur = r.get(k) if cur is None or values_differ(cur, v): diffs.append((p, cur, v)) walk(export, repo_after, []) return diffs def run_validate(config_dir: Path) -> bool: print(f" validate: noctalia config validate {config_dir}") proc = subprocess.run( ["noctalia", "config", "validate", str(config_dir)], capture_output=True, text=True, check=False, ) out = proc.stdout + proc.stderr for line in out.splitlines(): print(f" {line}") if "WARN" in out: print( " note: confirm none of the warnings above were newly introduced by this fold" ) return proc.returncode == 0 def run_tombi(files) -> None: for f in files: proc = subprocess.run( ["tombi", "format", str(f)], capture_output=True, text=True, check=False ) if proc.returncode != 0: print(f" tombi format {f.name}: FAILED\n{proc.stdout}{proc.stderr}") def backup_settings(state_dir: Path, keep: int = KEEP_BACKUPS): src = state_dir / "settings.toml" if not src.exists(): return None bak = state_dir / "settings.bak.d" bak.mkdir(parents=True, exist_ok=True) ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") dest = bak / f"{ts}.toml" n = 1 while dest.exists(): dest = bak / f"{ts}-{n}.toml" n += 1 shutil.move(str(src), str(dest)) # Prune oldest backups, counting only timestamp-shaped files we own. backups = sorted( f for f in bak.glob("*.toml") if re.fullmatch(r"\d{8}-\d{6}(?:-\d+)?", f.stem) ) for old in backups[:-keep]: old.unlink() return dest def reload_instance(): proc = subprocess.run( ["noctalia", "msg", "config-reload"], capture_output=True, text=True, timeout=15, check=False, ) if proc.returncode == 0: print(" reload: noctalia msg config-reload") else: print(" reload: noctalia not running or reload failed") def main(): ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) ap.add_argument( "--apply", action="store_true", help="apply the fold (default: preview only)" ) ap.add_argument( "--config-dir", type=Path, default=DEFAULT_CONFIG_DIR, help=argparse.SUPPRESS ) ap.add_argument( "--state-dir", type=Path, default=DEFAULT_STATE_DIR, help=argparse.SUPPRESS ) ap.add_argument("--export-file", type=Path, default=None, help=argparse.SUPPRESS) args = ap.parse_args() config_dir = args.config_dir.resolve() state_dir = args.state_dir if not config_dir.is_dir(): sys.exit(f"error: config dir not found: {config_dir}") if not args.export_file: live = Path(os.path.realpath(Path.home() / ".config" / "noctalia")) if live != config_dir: print( f"warning: ~/.config/noctalia is not linked to {config_dir} (found {live});" ) print(" the live export may describe a different config") export = parse_export(args.export_file) # Parse per-aspect files; map each top-level root to its owning file. files = sorted(config_dir.glob("*.toml")) texts = {} parsed = {} for f in files: text = f.read_text() texts[f] = text parsed[f] = tomli.loads(text) root_map = {} for f, d in parsed.items(): for root in d: root_map.setdefault(root, f) # Collect every change the export implies relative to the repo. changes = [] new_files = {} for root, subtree in export.items(): if not isinstance(subtree, dict): continue # do not fold top-level scalars tgt = root_map.get(root) if tgt is None: new_files[root] = subtree continue cur = parsed[tgt].get(root) collect_changes(cur if isinstance(cur, dict) else {}, subtree, [root], changes) sets_by_file, subs_by_file = {}, {} for path, old, val in changes: f = root_map[path[0]] if isinstance(val, dict): # new subtree subs_by_file.setdefault(f, []).append((path, old, val)) else: sets_by_file.setdefault(f, []).append((path, old, val)) if not changes and not new_files: print("No changes to fold. Repo config already matches the export.") return # Preview touched = set(sets_by_file) | set(subs_by_file) for f in sorted(touched, key=str): rel = os.path.relpath(f, REPO) print(f"\n{rel}") for path, old, val in sorted( sets_by_file.get(f, []), key=lambda c: _dotted(c[0]) ): key = _dotted(path) if old is None: print(f" + {key} = {serialize_value(val)}") else: print(f" ~ {key}: {serialize_value(old)} -> {serialize_value(val)}") for path, old, subtree in subs_by_file.get(f, []): print(f" + [{_dotted(path)}]") for line in serialize_subtree(path, subtree).splitlines(): if line.strip(): print(f" {line}") for root in new_files: print( f"\n + new section root: {root} (new file {root.replace('_', '-')}.toml)" ) if not args.apply: total = len(changes) + len(new_files) file_count = len(touched) + len(new_files) print( f"\n{total} change{'s' if total != 1 else ''} in {file_count} file(s)." ) print("Run with --apply to fold into the repo.") return # Apply for f in sorted(touched, key=str): data = parsed[f] for path, old, val in sets_by_file.get(f, []): apply_path(data, path, clean_value(val), as_table=False, old=old) for path, old, subtree in subs_by_file.get(f, []): apply_path(data, path, clean_value(subtree), as_table=True, old=old) f.write_text(restore_comments(texts[f], tomli_w.dumps(data))) new_paths = [] for root, subtree in new_files.items(): p = config_dir / (root.replace("_", "-") + ".toml") p.write_text(tomli_w.dumps(clean_value({root: subtree}))) new_paths.append(p) files.append(p) print(" formatting changed files with tombi...") run_tombi(sorted(touched, key=str) + new_paths) # Completeness: repo-as-loaded must now equal the export (within tolerance). repo_after = {} for f in files: repo_after.update(tomli.loads(f.read_text())) diffs = completeness_diffs(export, repo_after) if diffs: print("error: repo does not fully reproduce the export after applying:") for p, old, val in diffs[:20]: print(f" {_dotted(p)}: {serialize_value(old)} != {serialize_value(val)}") sys.exit(1) if not run_validate(config_dir): sys.exit("error: validation failed after applying changes") bak = backup_settings(state_dir) if bak: print(f" backed up {state_dir}/settings.toml -> {bak}") reload_instance() else: # Should not reach this branch, since a fold implies GUI overrides exist. print("warning: settings.toml does not exist. Nothing backuped") total = len(changes) + len(new_files) print(f"\nDone. {total} change{'s' if total != 1 else ''} folded.") if __name__ == "__main__": main()