"""Interactive TUI — guided ISO builder with rich + questionary.""" from __future__ import annotations import os import shutil import subprocess import sys from pathlib import Path import questionary from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text from rich.columns import Columns from .distros import REGISTRY console = Console() DISTRO_INFO = { "horizon": {"desc": "Euri Linux Horizon — void+dinit+musl, Euri branding", "libc": ["musl"], "inits": ["dinit"]}, "void": {"desc": "Void Linux — musl/glibc, runit/dinit, independent", "libc": ["musl", "glibc"], "inits": ["dinit", "runit"]}, "debian": {"desc": "Debian — stable, apt, systemd", "libc": ["glibc"], "inits": ["systemd"]}, "ubuntu": {"desc": "Ubuntu — Debian-based, apt, systemd", "libc": ["glibc"], "inits": ["systemd"]}, "arch": {"desc": "Arch Linux — rolling release, pacman", "libc": ["glibc"], "inits": ["dinit", "runit", "s6", "openrc", "systemd"]}, "artix": {"desc": "Artix Linux — Arch without systemd", "libc": ["glibc"], "inits": ["dinit", "runit", "s6", "openrc"]}, "alpine": {"desc": "Alpine Linux — minimal, musl, apk", "libc": ["musl"], "inits": ["openrc", "s6"]}, "fedora": {"desc": "Fedora — cutting edge, dnf/rpm", "libc": ["glibc"], "inits": ["systemd"]}, "rocky": {"desc": "Rocky Linux — RHEL rebuild, dnf/rpm", "libc": ["glibc"], "inits": ["systemd"]}, "almalinux": {"desc": "AlmaLinux — RHEL rebuild, dnf/rpm", "libc": ["glibc"], "inits": ["systemd"]}, } COMMON_PACKAGES = { "Base": ["base-system", "sudo", "bash"], "Networking": ["NetworkManager", "dhcpcd", "openssh", "iw", "wpa_supplicant"], "Development": ["gcc", "make", "git", "python3", "cmake"], "Fonts": ["font-noto", "font-dejavu"], "Audio": ["pipewire", "wireplumber", "alsa-utils"], "Firmware": ["linux-firmware", "linux-firmware-network", "linux-firmware-amd"], } # Cross-distro DE/WM package mapping # Keys: distro families (void, arch, debian, alpine, fedora) DE_WM_PACKAGES = { "Sway": { "desc": "Wayland tiling WM — minimal, keyboard-driven", "server": "wayland", "void": ["sway", "foot", "waybar", "swaybg", "swaylock", "swayidle", "xorg-server-xwayland", "wmenu", "grim", "slurp", "wl-clipboard", "dinit"], "arch": ["sway", "foot", "waybar", "swaybg", "swaylock", "swayidle", "xorg-xwayland", "wmenu", "grim", "slurp", "wl-clipboard"], "debian": ["sway", "foot", "waybar", "swaybg", "swaylock", "swayidle", "xwayland", "wmenu", "grim", "slurp", "wl-clipboard"], "alpine": ["sway", "foot", "waybar", "swaybg", "swaylockd", "swayidle", "xwayland", "wmenu", "grim", "wl-clipboard", "brightnessctl"], "fedora": ["sway", "foot", "waybar", "swaybg", "swaylock", "swayidle", "xorg-x11-server-Xwayland", "wofi", "grim", "slurp", "wl-clipboard"], }, "i3": { "desc": "X11 tiling WM — keyboard-driven, highly configurable", "server": "x11", "void": ["i3", "i3status", "dmenu", "rofi", "dunst", "xorg", "lightdm"], "arch": ["i3-wm", "i3status", "i3blocks", "dmenu", "rofi", "dunst", "xorg-server", "lightdm", "lightdm-gtk-greeter"], "debian": ["i3", "i3status", "dmenu", "rofi", "dunst", "xorg", "lightdm"], "alpine": ["i3wm", "i3status", "dmenu", "rofi", "xorg-server", "lightdm"], "fedora": ["i3", "i3status", "dmenu", "rofi", "dunst", "xorg-x11-server-Xorg", "lightdm"], }, "Hyprland": { "desc": "Wayland dynamic tiling — animations, eye candy", "server": "wayland", "void": [], # not in official repos "arch": ["hyprland", "kitty", "waybar", "wofi", "dunst", "xdg-desktop-portal-hyprland", "hypridle", "hyprlock", "qt5-wayland", "qt6-wayland", "polkit"], "debian": ["hyprland", "kitty", "waybar", "wofi", "dunst", "xdg-desktop-portal-hyprland", "xwayland", "polkit"], "alpine": [], # not in official repos "fedora": ["hyprland", "kitty", "waybar", "wofi", "dunst", "xdg-desktop-portal-hyprland", "hypridle", "hyprlock", "polkit"], }, "GNOME": { "desc": "Full desktop — polished, Wayland by default", "server": "wayland", "void": ["gnome", "gnome-terminal", "gdm", "gnome-tweaks", "xorg"], "arch": ["gnome", "gnome-extra", "gdm", "gnome-terminal", "xdg-desktop-portal-gnome"], "debian": ["gnome", "gdm3", "gnome-terminal", "xdg-desktop-portal-gnome", "xorg"], "alpine": ["gnome", "gdm", "gnome-terminal", "xorg-server"], "fedora": ["gnome", "gdm", "gnome-terminal", "xdg-desktop-portal-gnome", "gnome-tweaks"], }, "KDE Plasma": { "desc": "Full desktop — customizable, feature-rich", "server": "wayland", "void": ["plasma-desktop", "konsole", "sddm", "dolphin", "kate", "xorg"], "arch": ["plasma-meta", "konsole", "sddm", "dolphin", "kate", "xdg-desktop-portal-kde", "xorg-server"], "debian": ["kde-plasma-desktop", "konsole", "sddm", "dolphin", "kate", "xdg-desktop-portal-kde", "xorg"], "alpine": ["plasma-desktop-meta", "konsole", "sddm", "oxygen", "xorg-server"], "fedora": ["plasma-desktop", "konsole", "sddm", "dolphin", "kate", "xdg-desktop-portal-kde", "qt6-wayland"], }, "XFCE": { "desc": "Lightweight desktop — traditional, fast", "server": "x11", "void": ["xfce4", "xfce4-terminal", "lightdm", "gvfs", "xorg"], "arch": ["xfce4", "xfce4-goodies", "xfce4-terminal", "lightdm", "lightdm-gtk-greeter", "xorg-server"], "debian": ["xfce4", "xfce4-goodies", "xfce4-terminal", "lightdm", "xorg", "gvfs"], "alpine": ["xfce4", "xfce4-terminal", "lightdm", "xorg-server", "elogind"], "fedora": ["xfce4-session", "xfce4-terminal", "lightdm", "xorg-x11-server-Xorg"], }, "Cinnamon": { "desc": "Modern traditional desktop — Windows-like", "server": "x11", "void": ["cinnamon", "gnome-terminal", "lightdm", "nemo", "xorg"], "arch": ["cinnamon", "gnome-terminal", "lightdm", "lightdm-gtk-greeter", "nemo", "xorg-server"], "debian": ["cinnamon", "gnome-terminal", "lightdm", "nemo", "xorg"], "alpine": [], # limited support "fedora": ["cinnamon", "gnome-terminal", "lightdm", "nemo", "xorg-x11-server-Xorg"], }, "MATE": { "desc": "Traditional desktop — GNOME 2 fork, lightweight", "server": "x11", "void": ["mate", "mate-terminal", "lightdm", "caja", "xorg"], "arch": ["mate", "mate-extra", "mate-terminal", "lightdm", "lightdm-gtk-greeter", "caja", "xorg-server"], "debian": ["mate-desktop-environment", "mate-terminal", "lightdm", "caja", "xorg"], "alpine": ["mate-desktop-environment", "mate-terminal", "lightdm", "xorg-server"], "fedora": ["mate-desktop", "mate-terminal", "lightdm", "caja", "xorg-x11-server-Xorg"], }, "LXQt": { "desc": "Ultra-lightweight desktop — Qt-based", "server": "x11", "void": ["lxqt", "qterminal", "sddm", "openbox", "xorg"], "arch": ["lxqt", "qterminal", "sddm", "openbox", "xorg-server"], "debian": ["lxqt", "qterminal", "sddm", "openbox", "xorg"], "alpine": ["lxqt-desktop", "qterminal", "sddm", "openbox", "xorg-server"], "fedora": ["lxqt", "qterminal", "sddm", "openbox", "xorg-x11-server-Xorg"], }, "bspwm": { "desc": "X11 tiling WM — binary space partitioning", "server": "x11", "void": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "picom", "dunst", "xorg"], "arch": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "picom", "dunst", "xorg-server"], "debian": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "picom", "dunst", "xorg"], "alpine": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "xorg-server"], "fedora": ["bspwm", "sxhkd", "alacritty", "polybar", "dmenu", "rofi", "picom", "dunst", "xorg-x11-server-Xorg"], }, "openbox": { "desc": "X11 stacking WM — minimalist, scriptable", "server": "x11", "void": ["openbox", "alacritty", "obconf", "tint2", "rofi", "picom", "dunst", "xorg"], "arch": ["openbox", "alacritty", "obconf", "tint2", "rofi", "picom", "dunst", "xorg-server"], "debian": ["openbox", "alacritty", "obconf", "tint2", "rofi", "picom", "dunst", "xorg"], "alpine": ["openbox", "alacritty", "obconf", "tint2", "rofi", "xorg-server"], "fedora": ["openbox", "alacritty", "obconf", "tint2", "rofi", "picom", "dunst", "xorg-x11-server-Xorg"], }, "Headless (no DE)": { "desc": "Server / minimal — no graphical environment", "server": "none", "void": [], "arch": [], "debian": [], "alpine": [], "fedora": [], }, } def _check_fakeroot() -> bool: """Check if fakeroot is available.""" return shutil.which("fakeroot") is not None def _check_root() -> bool: """Check if running as root.""" return os.geteuid() == 0 def _disk_free(path: str = "/") -> int: """Return free disk space in bytes.""" st = os.statvfs(path) return st.f_bavail * st.f_frsize def _format_size(b: int) -> str: for unit in ("B", "KB", "MB", "GB"): if b < 1024: return f"{b:.0f} {unit}" b /= 1024 return f"{b:.1f} TB" def banner() -> None: """Show the app banner.""" console.print() console.print(Panel( "[bold cyan] ________ ______ ____[/]\n" " [bold cyan]/ ____/ / / / __ \\/ _/[/]\n" " [bold cyan]/ __/ / / / / /_/ // /[/]\n" " [bold cyan]/ /___/ /_/ / _, _// /[/]\n" "[bold cyan]/_____/\\____/_/ |_/___/[/]\n" "[bold white] universal iso builder[/]\n" "[dim] any distro, from any distro[/]", border_style="cyan", padding=(0, 2), )) console.print() def select_setup_mode() -> str: """Pick recommended or custom mode.""" console.print(Panel( "[bold]How would you like to configure your ISO?[/]\n\n" " [cyan]Recommended[/] — Euri Linux Horizon preset\n" " (void + dinit + musl, pick your desktop)\n\n" " [white]Custom[/] — choose every detail yourself\n" " (any distro, init, libc, packages, etc.)", border_style="cyan", padding=(0, 1), )) console.print() choice = questionary.select( "Setup mode:", choices=[ "Recommended — Euri Linux Horizon", "Custom — full control over everything", ], ).ask() if choice is None: sys.exit(0) console.print() return "recommended" if "Recommended" in choice else "custom" def run_recommended() -> dict: """Euri Linux Horizon preset — void + dinit + musl, full branding.""" banner() mode = select_mode() distro = "horizon" init = "dinit" libc = "musl" arch = "x86_64" console.print(Panel( "[bold cyan]Euri Linux Horizon[/]\n\n" " Distro: [cyan]void[/] (independent, fast, musl)\n" " Init: [cyan]dinit[/] (modern service manager)\n" " Libc: [cyan]musl[/] (small, secure)\n" " Arch: [cyan]x86_64[/]\n\n" " [dim]Pick your desktop environment or window manager.[/]", border_style="cyan", padding=(0, 1), )) console.print() # DE/WM selection with explanations de_name, de_pkgs = select_de_wm("void", explain=True) # auto-pick package groups auto_groups = ["Base", "Networking", "Audio", "Firmware"] pkgs = [] for g in auto_groups: pkgs.extend(COMMON_PACKAGES[g]) console.print(f" [dim]Auto-selected groups:[/] {', '.join(auto_groups)}") # custom packages console.print() custom = questionary.text( "Additional packages (comma-separated, or empty):", default="", ).ask() if custom and custom.strip(): for p in custom.split(","): p = p.strip() if p: pkgs.append(p) # merge seen = set() merged = [] for p in pkgs + de_pkgs: if p not in seen: seen.add(p) merged.append(p) iso_version = "1.0" cfg = { "distro": distro, "arch": arch, "init": init, "libc": libc, "hostname": "euri", "packages": merged, "de_wm": de_name, "services": [], "users": [], "iso": { "name": "Euri Linux Horizon", "version": iso_version, "label": f"HORIZON_{iso_version}", }, "_mode": mode, } if not confirm_build(cfg): sys.exit(0) return cfg def run_custom() -> dict: """Full control flow — choose everything.""" banner() mode = select_mode() distro = select_distro() init = select_init(distro) libc = select_libc(distro) arch = select_arch() pkgs = select_packages() de_name, de_pkgs = select_de_wm(distro, explain=False) iso = configure_iso() # merge packages: base groups + DE/WM seen = set() merged = [] for p in pkgs + de_pkgs: if p not in seen: seen.add(p) merged.append(p) cfg = { "distro": distro, "arch": arch, "init": init, "libc": libc, "hostname": iso["hostname"], "packages": merged, "de_wm": de_name, "services": [], "users": [], "iso": { "name": iso["name"], "version": iso["version"], "label": iso["label"], }, "_mode": mode, } if not confirm_build(cfg): sys.exit(0) return cfg def select_mode() -> str: """Select root or fakeroot mode.""" root = _check_root() fake = _check_fakeroot() console.print("[bold]Build mode[/]") console.print(f" Root detected: {'[green]yes[/]' if root else '[red]no[/]'}") console.print(f" Fakeroot: {'[green]available[/]' if fake else '[red]not found[/]'}") console.print() if root: console.print(" [green]>> Running as root — full performance[/]") return "root" if not fake: console.print("[bold red]Error:[/] not root and fakeroot not installed.") console.print(" Install fakeroot: [cyan]sudo pacman -S fakeroot[/] / [cyan]sudo emerge sys-apps/fakeroot[/]") sys.exit(1) console.print("[bold yellow]!! Non-root mode: performance degraded, some post-install scripts may fail.[/]") console.print() choice = questionary.select( "Select mode:", choices=[ "fakeroot -- no root needed, degraded perf", "root -- enter sudo password to continue as root", ], default="fakeroot -- no root needed, degraded perf", ).ask() if choice is None: sys.exit(0) if choice.startswith("root"): console.print("\n[bold]Entering root mode...[/]") ret = subprocess.run(["sudo", "true"], capture_output=True) if ret.returncode != 0: console.print("[red]sudo authentication failed[/]") sys.exit(1) console.print("[green]Authenticated.[/]\n") return "root" return "fakeroot" def select_distro() -> str: """Pick a distro.""" console.print("[bold]Step 1: Distribution[/]\n") table = Table(show_header=True, border_style="dim", pad_edge=False) table.add_column("#", style="cyan", width=3) table.add_column("Distro", style="bold") table.add_column("Description") for i, (name, info) in enumerate(sorted(DISTRO_INFO.items()), 1): table.add_row(str(i), name, info["desc"]) console.print(table) console.print() names = sorted(DISTRO_INFO.keys()) choice = questionary.select( "Pick a distro:", choices=names, ).ask() if choice is None: sys.exit(0) console.print() return choice def select_init(distro: str) -> str: """Pick init system.""" inits = DISTRO_INFO[distro]["inits"] if len(inits) == 1: return inits[0] console.print("[bold]Init system[/]\n") console.print(" [dim]Init = how services start. systemd is most common.[/]\n") choice = questionary.select( "Pick init system:", choices=inits, ).ask() if choice is None: sys.exit(0) console.print() return choice def select_libc(distro: str) -> str: """Pick libc.""" libcs = DISTRO_INFO[distro]["libc"] if len(libcs) == 1: return libcs[0] console.print("[bold]Libc[/]\n") console.print(" [dim]Libc = C library. glibc = wide compatibility, musl = smaller/stricter.[/]\n") choice = questionary.select( "Pick libc:", choices=libcs, ).ask() if choice is None: sys.exit(0) console.print() return choice def select_arch() -> str: """Pick architecture.""" choice = questionary.select( "Architecture:", choices=["x86_64", "aarch64"], default="x86_64", ).ask() if choice is None: sys.exit(0) console.print() return choice def select_packages() -> list[str]: """Pick packages interactively.""" console.print("[bold]Packages[/]\n") selected: list[str] = [] # show available groups grp_table = Table(show_header=True, border_style="dim", pad_edge=False) grp_table.add_column("Group", style="cyan") grp_table.add_column("Packages") for name, pkgs in COMMON_PACKAGES.items(): grp_table.add_row(name, ", ".join(pkgs)) console.print(grp_table) console.print() # preset groups — use select per group to avoid toggle-all color bug groups = [] for gname in COMMON_PACKAGES: pick = questionary.confirm(f" Include {gname}?", default=False).ask() if pick is None: sys.exit(0) if pick: groups.append(gname) console.print(f" [green]+ {gname}[/] ({len(COMMON_PACKAGES[gname])} packages)") console.print() for group_name in groups: selected.extend(COMMON_PACKAGES[group_name]) # dedupe after groups seen = set() deduped = [] for p in selected: if p not in seen: seen.add(p) deduped.append(p) # custom packages custom = questionary.text( "Additional packages (comma-separated, or empty):", default="", ).ask() if custom and custom.strip(): for p in custom.split(","): p = p.strip() if p and p not in seen: seen.add(p) deduped.append(p) console.print(f" [green]{len(deduped)} packages total[/]\n") return deduped def configure_iso() -> dict: """Configure ISO metadata.""" console.print("[bold]ISO metadata[/]\n") console.print(" [dim]These identify your ISO image. Leave empty to skip.[/]\n") name = questionary.text("ISO name:", default="").ask() if name is None: sys.exit(0) version = questionary.text("Version:", default="1.0").ask() if version is None: sys.exit(0) hostname = questionary.text("Hostname:", default="live").ask() if hostname is None: sys.exit(0) label = questionary.text("ISO label:", default="").ask() if label is None: sys.exit(0) console.print() return {"name": name, "version": version, "hostname": hostname, "label": label} def confirm_build(cfg: dict) -> bool: """Show summary and ask for confirmation.""" console.print("[bold]Build summary[/]\n") table = Table(show_header=False, border_style="dim", pad_edge=False) table.add_column("Key", style="cyan", width=14) table.add_column("Value") table.add_row("Distro", cfg["distro"]) table.add_row("Arch", cfg["arch"]) table.add_row("Init", cfg["init"]) table.add_row("Libc", cfg["libc"]) table.add_row("DE/WM", cfg.get("de_wm", "none")) table.add_row("Packages", str(len(cfg.get("packages", [])))) table.add_row("ISO name", cfg["iso"]["name"]) table.add_row("Version", cfg["iso"]["version"]) table.add_row("Hostname", cfg["hostname"]) table.add_row("Mode", cfg["_mode"]) console.print(table) console.print() # disk space check free = _disk_free("/tmp") est_rootfs = 800 * 1024 * 1024 # ~800MB for base-system if free < est_rootfs: console.print(f"[bold yellow]Warning:[/] only {_format_size(free)} free in /tmp, need ~{_format_size(est_rootfs)}") if not questionary.confirm("Continue anyway?", default=False).ask(): sys.exit(0) else: console.print(f" [dim]Disk free: {_format_size(free)}[/]") console.print() return questionary.confirm("Start build?", default=True).ask() or sys.exit(0) def select_de_wm(distro: str, explain: bool = False) -> tuple[str, list[str]]: """Pick a DE or window manager — returns (name, distro-specific packages). If explain=True, show longer descriptions for new users. If explain=False, show compact table for power users. """ console.print("[bold]Desktop Environment / Window Manager[/]\n") # map distro to family distro_family = { "void": "void", "arch": "arch", "artix": "arch", "debian": "debian", "ubuntu": "debian", "alpine": "alpine", "fedora": "fedora", "rocky": "fedora", "almalinux": "fedora", }.get(distro, "arch") # explanation panel for new users if explain: console.print(Panel( "[bold]What is a DE/WM?[/]\n\n" " A [bold]Desktop Environment (DE)[/] is a full graphical workspace —\n" " taskbar, file manager, settings, apps. (GNOME, KDE, XFCE)\n\n" " A [bold]Window Manager (WM)[/] is just window management —\n" " lighter, keyboard-driven, you build your own workflow.\n" " (Sway, i3, Hyprland, bspwm, openbox)\n\n" " [dim]Both work fine. DE = ready to use. WM = more control.[/]", border_style="dim", padding=(0, 1), )) console.print() # show table de_table = Table(show_header=True, border_style="dim", pad_edge=False) de_table.add_column("#", style="cyan", width=3) de_table.add_column("DE/WM", style="bold") de_table.add_column("Type") de_table.add_column("Description" if explain else "Info") for i, (name, info) in enumerate(DE_WM_PACKAGES.items(), 1): pkgs = info.get(distro_family, []) status = f"[green]{len(pkgs)} pkgs[/]" if pkgs else "[red]unavailable[/]" server = info.get("server", "") if explain: de_table.add_row(str(i), name, server, f"{info['desc']} ({status})") else: short = info["desc"].split("—")[0].strip() if "—" in info["desc"] else info["desc"][:30] de_table.add_row(str(i), name, server, f"{short} ({status})") console.print(de_table) console.print() names = list(DE_WM_PACKAGES.keys()) choice = questionary.select( "Pick a DE/WM:", choices=names, ).ask() if choice is None: sys.exit(0) pkgs = DE_WM_PACKAGES.get(choice, {}).get(distro_family, []) if not pkgs: console.print(f" [yellow]Warning:[/] {choice} is not available in {distro} repos.") console.print(f" [dim]Continuing without DE/WM packages.[/]\n") else: console.print(f" [green]{len(pkgs)} packages[/] for {choice} on {distro}\n") return choice, pkgs def build_progress(cfg: dict, func, *args, **kwargs): """Run a function with a rich progress spinner.""" from rich.progress import Progress, SpinnerColumn, TextColumn with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: task = progress.add_task(f"Building {cfg['distro']}...", total=None) try: result = func(*args, **kwargs) progress.update(task, description="[green]Done![/]") return result except Exception as e: progress.update(task, description=f"[red]Failed: {e}[/]") raise def run_interactive() -> dict: """Interactive TUI flow — routes to recommended or custom.""" banner() mode = select_setup_mode() if mode == "recommended": return run_recommended() return run_custom()