Files
euri-mklive/euri_mklive/tui.py
T
c-ludenberg d3bc5ba321 feat: DE/WM selection with cross-distro package mappings
- 12 DE/WMs: sway, i3, Hyprland, GNOME, KDE, XFCE, Cinnamon, MATE, LXQt, bspwm, openbox, Headless
- 5 distro families: void, arch, debian, alpine, fedora
- Correct package names per distro (researched from archinstall + distro repos)
- Shows availability table with package counts
- Warns if DE/WM unavailable on selected distro
2026-08-27 20:46:21 +02:00

540 lines
20 KiB
Python

"""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 = {
"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_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:
console.print(f" Init system: [cyan]{inits[0]}[/] (only option)\n")
return inits[0]
console.print("[bold]Step 2: Init system[/]\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:
console.print(f" Libc: [cyan]{libcs[0]}[/] (only option)\n")
return libcs[0]
console.print("[bold]Step 3: Libc[/]\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."""
console.print("[bold]Step 4: Architecture[/]\n")
choice = questionary.select(
"Pick 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]Step 5: 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)
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)
# let user deselect individual packages
if deduped:
console.print(f"\n [dim]{len(deduped)} packages from groups. Deselect any:[/]")
deselected = questionary.checkbox(
"Uncheck packages to remove (space=remove, enter=done):",
choices=deduped,
).ask()
if deselected is None:
sys.exit(0)
deselected_set = set(deselected)
deduped = [p for p in deduped if p not in deselected_set]
# 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 and p not in seen:
seen.add(p)
deduped.append(p)
console.print(f" [green]{len(deduped)} packages selected[/]\n")
return deduped
def configure_iso() -> dict:
"""Configure ISO metadata."""
console.print("[bold]Step 6: ISO metadata[/]\n")
name = questionary.text("ISO name:", default="Euri Linux").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="euri").ask()
if hostname is None:
sys.exit(0)
label = questionary.text("ISO label:", default="EURI").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) -> tuple[str, list[str]]:
"""Pick a DE or window manager — returns (name, distro-specific packages)."""
console.print("[bold]Step 5b: 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")
# 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")
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", "")
de_table.add_row(str(i), name, server, f"{info['desc']} ({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:
"""Full interactive TUI flow. Returns a config dict."""
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)
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