diff --git a/packages/homeassistant/ha-bambulab/default.nix b/packages/homeassistant/ha-bambulab/default.nix index 22173e9..4a534c6 100644 --- a/packages/homeassistant/ha-bambulab/default.nix +++ b/packages/homeassistant/ha-bambulab/default.nix @@ -7,13 +7,13 @@ buildHomeAssistantComponent rec { owner = "greghesp"; domain = "bambu_lab"; - version = "v2.2.19"; + version = "v2.2.20"; src = fetchFromGitHub { owner = owner; repo = "ha-bambulab"; rev = version; - hash = "sha256-BRTbo9v9a4iCkrgVfyFzZXZS4ogDr+Kkx9qz8bhAaDc="; + hash = "sha256-lKKfPWWcri2OUM9nkdY2iltvIaoFhnUP4HGBGDUnEww="; }; nativeBuildInputs = with python3Packages; [ diff --git a/packages/homeassistant/ha-bedjet/default.nix b/packages/homeassistant/ha-bedjet/default.nix index f190532..6adb45a 100644 --- a/packages/homeassistant/ha-bedjet/default.nix +++ b/packages/homeassistant/ha-bedjet/default.nix @@ -7,13 +7,13 @@ buildHomeAssistantComponent rec { owner = "natekspencer"; domain = "bedjet"; - version = "1.2.3"; + version = "2.0.0"; src = fetchFromGitHub { owner = owner; repo = "ha-bedjet"; rev = version; - hash = "sha256-Zuidx6YrjqDzgtOTW380Rfzi1zHqJ07IrgBYztfM2II="; + hash = "sha256-Vs35OvIfSxvwiK6HenZWKOi7V8xz/RMWgplYxhQtvxU="; }; nativeBuildInputs = with python3Packages; [ diff --git a/packages/homeassistant/ha-wyzeapi/default.nix b/packages/homeassistant/ha-wyzeapi/default.nix index 694994e..c53d5b7 100644 --- a/packages/homeassistant/ha-wyzeapi/default.nix +++ b/packages/homeassistant/ha-wyzeapi/default.nix @@ -8,13 +8,13 @@ buildHomeAssistantComponent rec { owner = "SecKatie"; domain = "wyzeapi"; - version = "0.1.35"; + version = "0.1.36"; src = fetchFromGitHub { owner = owner; repo = "ha-wyzeapi"; rev = version; - hash = "sha256-J9VFNImri0xF8RfND1bZl12CreKA023eHsXFNVt1YNQ="; + hash = "sha256-4i5Ne3LYV7DXn6F6e5MCVZhIdDYR7fe3tT2GeSmYb/k="; }; nativeBuildInputs = with pkgs.${namespace}; [ diff --git a/packages/python/python-roborock/default.nix b/packages/python/python-roborock/default.nix index b399e34..1553161 100644 --- a/packages/python/python-roborock/default.nix +++ b/packages/python/python-roborock/default.nix @@ -8,7 +8,7 @@ python3Packages.buildPythonPackage rec { pname = "python-roborock"; - version = "4.4.0"; + version = "4.12.0"; pyproject = true; disabled = python3Packages.pythonOlder "3.11"; @@ -17,7 +17,7 @@ python3Packages.buildPythonPackage rec { owner = "humbertogontijo"; repo = "python-roborock"; tag = "v${version}"; - hash = "sha256-zswKFRde6N8tciRiXzEnQKArzrbtrKaM+78s2N846S8="; + hash = "sha256-H47NKOGKUCJs9LolVcTg6R8W6Fuq+YWBgrwJUB08JVA="; }; pythonRelaxDeps = [ diff --git a/scripts/version_tui.py b/scripts/version_tui.py index 2a11998..8dc4c46 100755 --- a/scripts/version_tui.py +++ b/scripts/version_tui.py @@ -48,7 +48,7 @@ import urllib.request import urllib.error from urllib.parse import urlparse from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union ROOT = Path(__file__).resolve().parents[1] PKGS_DIR = ROOT / "packages" @@ -241,55 +241,512 @@ def gh_release_tags_api(owner: str, repo: str, token: Optional[str]) -> List[str # ------------------------------ Data scanning ------------------------------ -def find_packages() -> List[Tuple[str, Path]]: +def find_packages() -> List[Tuple[str, Path, bool, bool]]: results = [] + # Find regular packages with version.json for p in PKGS_DIR.rglob("version.json"): # name is directory name under packages (e.g., raspberrypi/linux-rpi => raspberrypi/linux-rpi) rel = p.relative_to(PKGS_DIR).parent - results.append((str(rel), p)) + results.append((str(rel), p, False, False)) # (name, path, is_python, is_homeassistant) + + # Find Python packages with default.nix + python_dir = PKGS_DIR / "python" + if python_dir.exists(): + for pkg_dir in python_dir.iterdir(): + if pkg_dir.is_dir(): + nix_file = pkg_dir / "default.nix" + if nix_file.exists(): + # name is python/package-name + rel = pkg_dir.relative_to(PKGS_DIR) + results.append((str(rel), nix_file, True, False)) # (name, path, is_python, is_homeassistant) + + # Find Home Assistant components with default.nix + homeassistant_dir = PKGS_DIR / "homeassistant" + if homeassistant_dir.exists(): + for pkg_dir in homeassistant_dir.iterdir(): + if pkg_dir.is_dir(): + nix_file = pkg_dir / "default.nix" + if nix_file.exists(): + # name is homeassistant/component-name + rel = pkg_dir.relative_to(PKGS_DIR) + results.append((str(rel), nix_file, False, True)) # (name, path, is_python, is_homeassistant) + results.sort() return results +def parse_python_package(path: Path) -> Dict[str, Any]: + """Parse a Python package's default.nix file to extract version and source information.""" + with path.open("r", encoding="utf-8") as f: + content = f.read() + + # Extract version + version_match = re.search(r'version\s*=\s*"([^"]+)"', content) + version = version_match.group(1) if version_match else "" + + # Extract pname (package name) + pname_match = re.search(r'pname\s*=\s*"([^"]+)"', content) + pname = pname_match.group(1) if pname_match else "" + + # Check for fetchFromGitHub pattern + fetch_github_match = re.search(r'src\s*=\s*fetchFromGitHub\s*\{([^}]+)\}', content, re.DOTALL) + + # Check for fetchPypi pattern + fetch_pypi_match = re.search(r'src\s*=\s*.*fetchPypi\s*\{([^}]+)\}', content, re.DOTALL) + + # Create a structure similar to version.json for compatibility + result = { + "variables": {}, + "sources": {} + } + + # Only add non-empty values to variables + if version: + result["variables"]["version"] = version + + # Determine source name - use pname, repo name, or derive from path + source_name = "" + if pname: + source_name = pname.lower() + else: + # Use directory name as source name + source_name = path.parent.name.lower() + + # Handle fetchFromGitHub pattern + if fetch_github_match: + fetch_block = fetch_github_match.group(1) + + # Extract GitHub info from the fetchFromGitHub block + owner_match = re.search(r'owner\s*=\s*"([^"]+)"', fetch_block) + repo_match = re.search(r'repo\s*=\s*"([^"]+)"', fetch_block) + rev_match = re.search(r'rev\s*=\s*"([^"]+)"', fetch_block) + hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', fetch_block) + + owner = owner_match.group(1) if owner_match else "" + repo = repo_match.group(1) if repo_match else "" + rev = rev_match.group(1) if rev_match else "" + hash_value = hash_match.group(2) if hash_match else "" + + # Create source entry + result["sources"][source_name] = { + "fetcher": "github", + "owner": owner, + "repo": repo, + "hash": hash_value + } + + # Handle rev field which might contain a tag or version reference + if rev: + # Check if it's a tag reference (starts with v) + if rev.startswith("v"): + result["sources"][source_name]["tag"] = rev + # Check if it contains ${version} variable + elif "${version}" in rev: + result["sources"][source_name]["tag"] = rev + # Check if it's "master" or a specific branch + elif rev in ["master", "main"]: + result["sources"][source_name]["rev"] = rev + # Otherwise treat as a regular revision + else: + result["sources"][source_name]["rev"] = rev + # Handle fetchPypi pattern + elif fetch_pypi_match: + fetch_block = fetch_pypi_match.group(1) + + # Extract PyPI info + hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', fetch_block) + hash_value = hash_match.group(2) if hash_match else "" + + # Look for GitHub info in meta section + homepage_match = re.search(r'homepage\s*=\s*"https://github.com/([^/]+)/([^"]+)"', content) + + if homepage_match: + owner = homepage_match.group(1) + repo = homepage_match.group(2) + + # Create source entry with GitHub info + result["sources"][source_name] = { + "fetcher": "github", + "owner": owner, + "repo": repo, + "hash": hash_value, + "pypi": True # Mark as PyPI source + } + + # Add version as tag if available + if version: + result["sources"][source_name]["tag"] = f"v{version}" + else: + # Create PyPI source entry + result["sources"][source_name] = { + "fetcher": "pypi", + "pname": pname, + "version": version, + "hash": hash_value + } + else: + # Try to extract standalone GitHub info if present + owner_match = re.search(r'owner\s*=\s*"([^"]+)"', content) + repo_match = re.search(r'repo\s*=\s*"([^"]+)"', content) + rev_match = re.search(r'rev\s*=\s*"([^"]+)"', content) + tag_match = re.search(r'tag\s*=\s*"([^"]+)"', content) + hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', content) + + owner = owner_match.group(1) if owner_match else "" + repo = repo_match.group(1) if repo_match else "" + rev = rev_match.group(1) if rev_match else "" + tag = tag_match.group(1) if tag_match else "" + hash_value = hash_match.group(2) if hash_match else "" + + # Try to extract URL if GitHub info is not present + url_match = re.search(r'url\s*=\s*"([^"]+)"', content) + url = url_match.group(1) if url_match else "" + + # Check for GitHub homepage in meta section + homepage_match = re.search(r'homepage\s*=\s*"https://github.com/([^/]+)/([^"]+)"', content) + if homepage_match and not (owner and repo): + owner = homepage_match.group(1) + repo = homepage_match.group(2) + + # Handle GitHub sources + if owner and repo: + result["sources"][source_name] = { + "fetcher": "github", + "owner": owner, + "repo": repo, + "hash": hash_value + } + + # Handle tag + if tag: + result["sources"][source_name]["tag"] = tag + # Handle rev + elif rev: + result["sources"][source_name]["rev"] = rev + # Handle URL sources + elif url: + result["sources"][source_name] = { + "fetcher": "url", + "url": url, + "hash": hash_value + } + # Fallback for packages with no clear source info + else: + # Create a minimal source entry so the package shows up in the UI + result["sources"][source_name] = { + "fetcher": "unknown", + "hash": hash_value + } + + return result + +def update_python_package(path: Path, source_name: str, updates: Dict[str, Any]) -> bool: + """Update a Python package's default.nix file with new version and/or hash.""" + with path.open("r", encoding="utf-8") as f: + content = f.read() + + modified = False + + # Update version if provided + if "version" in updates: + new_version = updates["version"] + content, version_count = re.subn( + r'(version\s*=\s*)"([^"]+)"', + f'\\1"{new_version}"', + content + ) + if version_count > 0: + modified = True + + # Update hash if provided + if "hash" in updates: + new_hash = updates["hash"] + # Match both sha256 and hash attributes + content, hash_count = re.subn( + r'(sha256|hash)\s*=\s*"([^"]+)"', + f'\\1 = "{new_hash}"', + content + ) + if hash_count > 0: + modified = True + + # Update tag if provided + if "tag" in updates: + new_tag = updates["tag"] + content, tag_count = re.subn( + r'(tag\s*=\s*)"([^"]+)"', + f'\\1"{new_tag}"', + content + ) + if tag_count > 0: + modified = True + + # Update rev if provided + if "rev" in updates: + new_rev = updates["rev"] + content, rev_count = re.subn( + r'(rev\s*=\s*)"([^"]+)"', + f'\\1"{new_rev}"', + content + ) + if rev_count > 0: + modified = True + + if modified: + with path.open("w", encoding="utf-8") as f: + f.write(content) + + return modified + +def parse_homeassistant_component(path: Path) -> Dict[str, Any]: + """Parse a Home Assistant component's default.nix file to extract version and source information.""" + with path.open("r", encoding="utf-8") as f: + content = f.read() + + # Extract domain, version, and owner + domain_match = re.search(r'domain\s*=\s*"([^"]+)"', content) + version_match = re.search(r'version\s*=\s*"([^"]+)"', content) + owner_match = re.search(r'owner\s*=\s*"([^"]+)"', content) + + domain = domain_match.group(1) if domain_match else "" + version = version_match.group(1) if version_match else "" + owner = owner_match.group(1) if owner_match else "" + + # Extract GitHub repo info + repo_match = re.search(r'repo\s*=\s*"([^"]+)"', content) + rev_match = re.search(r'rev\s*=\s*"([^"]+)"', content) + tag_match = re.search(r'tag\s*=\s*"([^"]+)"', content) + hash_match = re.search(r'(sha256|hash)\s*=\s*"([^"]+)"', content) + + repo = repo_match.group(1) if repo_match else "" + rev = rev_match.group(1) if rev_match else "" + tag = tag_match.group(1) if tag_match else "" + hash_value = hash_match.group(2) if hash_match else "" + + # Create a structure similar to version.json for compatibility + result = { + "variables": {}, + "sources": {} + } + + # Only add non-empty values to variables + if version: + result["variables"]["version"] = version + if domain: + result["variables"]["domain"] = domain + + # Determine source name - use domain or directory name + source_name = domain if domain else path.parent.name.lower() + + # Handle GitHub sources + if owner: + repo_name = repo if repo else source_name + result["sources"][source_name] = { + "fetcher": "github", + "owner": owner, + "repo": repo_name + } + + # Only add non-empty values + if hash_value: + result["sources"][source_name]["hash"] = hash_value + + # Handle tag or rev + if tag: + result["sources"][source_name]["tag"] = tag + elif rev: + result["sources"][source_name]["rev"] = rev + elif version: # If no tag or rev specified, but version exists, use version as tag + result["sources"][source_name]["tag"] = version + else: + # Fallback for components with no clear source info + result["sources"][source_name] = { + "fetcher": "unknown" + } + if hash_value: + result["sources"][source_name]["hash"] = hash_value + + return result + +def update_homeassistant_component(path: Path, source_name: str, updates: Dict[str, Any]) -> bool: + """Update a Home Assistant component's default.nix file with new version and/or hash.""" + with path.open("r", encoding="utf-8") as f: + content = f.read() + + modified = False + + # Update version if provided + if "version" in updates: + new_version = updates["version"] + content, version_count = re.subn( + r'(version\s*=\s*)"([^"]+)"', + f'\\1"{new_version}"', + content + ) + if version_count > 0: + modified = True + + # Update hash if provided + if "hash" in updates: + new_hash = updates["hash"] + # Match both sha256 and hash attributes in src = fetchFromGitHub { ... } + content, hash_count = re.subn( + r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(sha256|hash)\s*=\s*"([^"]+)"([^}]*\})', + f'\\1\\2 = "{new_hash}"\\4', + content + ) + if hash_count > 0: + modified = True + + # Update tag if provided + if "tag" in updates: + new_tag = updates["tag"] + content, tag_count = re.subn( + r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(tag|rev)\s*=\s*"([^"]+)"([^}]*\})', + f'\\1\\2 = "{new_tag}"\\4', + content + ) + if tag_count == 0: # If no tag/rev found, try to add it + content, tag_count = re.subn( + r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(hash\s*=\s*"[^"]+")([^}]*\})', + f'\\1\\2;\n tag = "{new_tag}"\\3', + content + ) + if tag_count > 0: + modified = True + + # Update rev if provided + if "rev" in updates: + new_rev = updates["rev"] + content, rev_count = re.subn( + r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(rev|tag)\s*=\s*"([^"]+)"([^}]*\})', + f'\\1\\2 = "{new_rev}"\\4', + content + ) + if rev_count == 0: # If no rev/tag found, try to add it + content, rev_count = re.subn( + r'(src\s*=\s*fetchFromGitHub\s*\{[^}]*)(hash\s*=\s*"[^"]+")([^}]*\})', + f'\\1\\2;\n rev = "{new_rev}"\\3', + content + ) + if rev_count > 0: + modified = True + + if modified: + with path.open("w", encoding="utf-8") as f: + f.write(content) + + return modified + # ------------------------------ TUI helpers ------------------------------ +# Define color pairs +COLOR_NORMAL = 1 +COLOR_HIGHLIGHT = 2 +COLOR_HEADER = 3 +COLOR_STATUS = 4 +COLOR_ERROR = 5 +COLOR_SUCCESS = 6 +COLOR_BORDER = 7 +COLOR_TITLE = 8 + +def init_colors(): + """Initialize color pairs for the TUI.""" + curses.start_color() + curses.use_default_colors() + + # Define color pairs + curses.init_pair(COLOR_NORMAL, curses.COLOR_WHITE, -1) + curses.init_pair(COLOR_HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(COLOR_HEADER, curses.COLOR_CYAN, -1) + curses.init_pair(COLOR_STATUS, curses.COLOR_YELLOW, -1) + curses.init_pair(COLOR_ERROR, curses.COLOR_RED, -1) + curses.init_pair(COLOR_SUCCESS, curses.COLOR_GREEN, -1) + curses.init_pair(COLOR_BORDER, curses.COLOR_BLUE, -1) + curses.init_pair(COLOR_TITLE, curses.COLOR_MAGENTA, -1) + +def draw_border(win, y, x, h, w): + """Draw a border around a region of the window.""" + # Draw corners + win.addch(y, x, curses.ACS_ULCORNER, curses.color_pair(COLOR_BORDER)) + win.addch(y, x + w - 1, curses.ACS_URCORNER, curses.color_pair(COLOR_BORDER)) + win.addch(y + h - 1, x, curses.ACS_LLCORNER, curses.color_pair(COLOR_BORDER)) + + # Draw bottom-right corner safely + try: + win.addch(y + h - 1, x + w - 1, curses.ACS_LRCORNER, curses.color_pair(COLOR_BORDER)) + except curses.error: + # This is expected when trying to write to the bottom-right corner + pass + + # Draw horizontal lines + for i in range(1, w - 1): + win.addch(y, x + i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) + win.addch(y + h - 1, x + i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) + + # Draw vertical lines + for i in range(1, h - 1): + win.addch(y + i, x, curses.ACS_VLINE, curses.color_pair(COLOR_BORDER)) + win.addch(y + i, x + w - 1, curses.ACS_VLINE, curses.color_pair(COLOR_BORDER)) + class ScreenBase: def __init__(self, stdscr): self.stdscr = stdscr self.status = "" + self.status_type = "normal" # "normal", "error", "success" def draw_status(self, height, width): if self.status: - self.stdscr.addstr(height-1, 0, self.status[:max(0, width-1)]) + color = COLOR_STATUS + if self.status_type == "error": + color = COLOR_ERROR + elif self.status_type == "success": + color = COLOR_SUCCESS + self.stdscr.addstr(height-1, 0, self.status[:max(0, width-1)], curses.color_pair(color)) else: - self.stdscr.addstr(height-1, 0, "q: quit, Backspace: back, Enter: select") + self.stdscr.addstr(height-1, 0, "q: quit, Backspace: back, Enter: select", curses.color_pair(COLOR_STATUS)) - def set_status(self, text: str): + def set_status(self, text: str, status_type="normal"): self.status = text + self.status_type = status_type def run(self): raise NotImplementedError def prompt_input(stdscr, prompt: str) -> Optional[str]: curses.echo() - stdscr.addstr(prompt) + stdscr.addstr(prompt, curses.color_pair(COLOR_HEADER)) stdscr.clrtoeol() s = stdscr.getstr().decode("utf-8") curses.noecho() return s -def show_popup(stdscr, lines: List[str]): +def show_popup(stdscr, lines: List[str], title: str = ""): h, w = stdscr.getmaxyx() box_h = min(len(lines)+4, h-2) - box_w = min(max(len(l) for l in lines)+4, w-2) + box_w = min(max(max(len(l) for l in lines), len(title))+6, w-2) top = (h - box_h)//2 left = (w - box_w)//2 win = curses.newwin(box_h, box_w, top, left) - win.box() + + # Draw fancy border + draw_border(win, 0, 0, box_h, box_w) + + # Add title if provided + if title: + title_x = (box_w - len(title)) // 2 + win.addstr(0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE)) + + # Add content for i, line in enumerate(lines, start=1): if i >= box_h-1: break - win.addstr(i, 2, line[:box_w-3]) - win.addstr(box_h-2, 2, "Press any key") + win.addstr(i, 2, line[:box_w-4], curses.color_pair(COLOR_NORMAL)) + + # Add footer + footer = "Press any key to continue" + footer_x = (box_w - len(footer)) // 2 + win.addstr(box_h-1, footer_x, footer, curses.color_pair(COLOR_STATUS)) + win.refresh() win.getch() @@ -300,6 +757,8 @@ class PackagesScreen(ScreenBase): super().__init__(stdscr) self.packages = find_packages() self.idx = 0 + self.filter_mode = "all" # "all", "regular", "python" + self.scroll_offset = 0 # Add scroll offset to handle long lists def run(self): while True: @@ -311,22 +770,92 @@ class PackagesScreen(ScreenBase): right_x = left_w + 1 right_w = max(0, w - right_x) + # Draw borders for left and right panes + draw_border(self.stdscr, 0, 0, h-1, left_w) + if right_w >= 20: + draw_border(self.stdscr, 0, right_x, h-1, right_w) + # Left pane: package list - self.stdscr.addstr(0, 0, "Packages (version.json)"[:max(0, left_w-1)]) + title = "Packages" + if self.filter_mode == "regular": + title = "Packages (version.json)" + elif self.filter_mode == "python": + title = "Python Packages" + else: + title = "All Packages [f to filter]" + + # Center the title in the left pane + title_x = (left_w - len(title)) // 2 + self.stdscr.addstr(0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD) + + # Filter packages based on mode + filtered_packages = self.packages + if self.filter_mode == "regular": + filtered_packages = [p for p in self.packages if not p[2]] # Not Python packages + elif self.filter_mode == "python": + filtered_packages = [p for p in self.packages if p[2]] # Only Python packages + + # Implement scrolling for long lists max_rows = h - 3 - for i, (name, _path) in enumerate(self.packages[:max_rows], start=0): - sel = ">" if i == self.idx else " " - self.stdscr.addstr(1 + i, 0, f"{sel} {name}"[:max(0, left_w-1)]) + total_packages = len(filtered_packages) + + # Adjust scroll offset if needed + if self.idx >= self.scroll_offset + max_rows: + self.scroll_offset = self.idx - max_rows + 1 + elif self.idx < self.scroll_offset: + self.scroll_offset = self.idx + + # Display visible packages with scroll offset + visible_packages = filtered_packages[self.scroll_offset:self.scroll_offset + max_rows] + + # Show scroll indicators if needed + if self.scroll_offset > 0: + self.stdscr.addstr(1, left_w - 3, "↑", curses.color_pair(COLOR_STATUS)) + if self.scroll_offset + max_rows < total_packages: + self.stdscr.addstr(min(1 + len(visible_packages), h - 2), left_w - 3, "↓", curses.color_pair(COLOR_STATUS)) + + for i, (name, _path, is_python, is_homeassistant) in enumerate(visible_packages, start=0): + # Use consistent display style for all packages + pkg_type = "" # Remove the [Py] prefix for consistent display + + # Highlight the selected item + if i + self.scroll_offset == self.idx: + attr = curses.color_pair(COLOR_HIGHLIGHT) + sel = "►" # Use a fancier selector + else: + attr = curses.color_pair(COLOR_NORMAL) + sel = " " + + # Add a small icon for Python packages or Home Assistant components + if is_python: + pkg_type = "🐍 " # Python icon + elif is_homeassistant: + pkg_type = "🏠 " # Home Assistant icon + + self.stdscr.addstr(1 + i, 2, f"{sel} {pkg_type}{name}"[:max(0, left_w-5)], attr) # Right pane: preview of selected package (non-interactive summary) - if right_w >= 20 and self.packages: + if right_w >= 20 and filtered_packages: try: - name, path = self.packages[self.idx] - self.stdscr.addstr(0, right_x, f"{name}"[:max(0, right_w-1)]) - self.stdscr.addstr(1, right_x, f"{path}"[:max(0, right_w-1)]) - self.stdscr.addstr(2, right_x, "Sources:"[:max(0, right_w-1)]) + name, path, is_python, is_homeassistant = filtered_packages[self.idx] + + # Center the package name in the right pane header + title_x = right_x + (right_w - len(name)) // 2 + self.stdscr.addstr(0, title_x, f" {name} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD) + + # Path with a nice label + self.stdscr.addstr(1, right_x + 2, "Path:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr(1, right_x + 8, f"{path}"[:max(0, right_w-10)], curses.color_pair(COLOR_NORMAL)) + + # Sources header + self.stdscr.addstr(2, right_x + 2, "Sources:", curses.color_pair(COLOR_HEADER)) - spec = load_json(path) + if is_python: + spec = parse_python_package(path) + elif is_homeassistant: + spec = parse_homeassistant_component(path) + else: + spec = load_json(path) merged_vars, merged_srcs, _ = merged_view(spec, None) snames = sorted(list(merged_srcs.keys())) max_src_rows = max(0, h - 6) @@ -376,14 +905,33 @@ class PackagesScreen(ScreenBase): ref_short = (display_ref[:max_ref] + ("..." if len(display_ref) > max_ref else "")) else: ref_short = display_ref - self.stdscr.addstr(3 + i2, right_x, f"{sname:<20} {fetcher:<7} {ref_short}"[:max(0, right_w-1)]) + + # Color-code the fetcher type + fetcher_color = COLOR_NORMAL + if fetcher == "github": + fetcher_color = COLOR_SUCCESS + elif fetcher == "url": + fetcher_color = COLOR_STATUS + elif fetcher == "git": + fetcher_color = COLOR_HEADER + + # Display source name + self.stdscr.addstr(3 + i2, right_x + 2, f"{sname:<18}", curses.color_pair(COLOR_NORMAL)) + + # Display fetcher with color + self.stdscr.addstr(3 + i2, right_x + 21, f"{fetcher:<7}", curses.color_pair(fetcher_color)) + + # Display reference + self.stdscr.addstr(3 + i2, right_x + 29, f"{ref_short}"[:max(0, right_w-31)], curses.color_pair(COLOR_NORMAL)) # Hint line for workflow hint = "Enter: open details | k/j: move | q: quit" if h >= 5: - self.stdscr.addstr(h - 5, right_x, hint[:max(0, right_w-1)]) + hint_x = right_x + (right_w - len(hint)) // 2 + self.stdscr.addstr(h - 5, hint_x, hint[:max(0, right_w-1)], curses.color_pair(COLOR_STATUS)) except Exception as e: - self.stdscr.addstr(2, right_x, f"Failed to load: {e}"[:max(0, right_w-1)]) + self.stdscr.addstr(2, right_x + 2, "Error:", curses.color_pair(COLOR_ERROR)) + self.stdscr.addstr(2, right_x + 9, f"{e}"[:max(0, right_w-11)], curses.color_pair(COLOR_ERROR)) self.draw_status(h, w) self.stdscr.refresh() @@ -394,16 +942,45 @@ class PackagesScreen(ScreenBase): self.idx = max(0, self.idx-1) elif ch in (curses.KEY_DOWN, ord('j')): self.idx = min(len(self.packages)-1, self.idx+1) + elif ch == curses.KEY_PPAGE: # Page Up + self.idx = max(0, self.idx - (h - 4)) + elif ch == curses.KEY_NPAGE: # Page Down + self.idx = min(len(self.packages)-1, self.idx + (h - 4)) + elif ch == ord('g'): # Go to top + self.idx = 0 + elif ch == ord('G'): # Go to bottom + self.idx = len(self.packages)-1 + elif ch == ord('f'): + # Cycle through filter modes + if self.filter_mode == "all": + self.filter_mode = "regular" + elif self.filter_mode == "regular": + self.filter_mode = "python" + else: + self.filter_mode = "all" + self.idx = 0 # Reset selection when changing filters elif ch in (curses.KEY_ENTER, 10, 13): - if not self.packages: + filtered_packages = self.packages + if self.filter_mode == "regular": + filtered_packages = [p for p in self.packages if not p[2]] + elif self.filter_mode == "python": + filtered_packages = [p for p in self.packages if p[2]] + + if not filtered_packages: continue - name, path = self.packages[self.idx] + + name, path, is_python, is_homeassistant = filtered_packages[self.idx] try: - spec = load_json(path) + if is_python: + spec = parse_python_package(path) + elif is_homeassistant: + spec = parse_homeassistant_component(path) + else: + spec = load_json(path) except Exception as e: self.set_status(f"Failed to load {path}: {e}") continue - screen = PackageDetailScreen(self.stdscr, name, path, spec) + screen = PackageDetailScreen(self.stdscr, name, path, spec, is_python, is_homeassistant) ret = screen.run() if ret == "reload": # re-scan on save @@ -413,11 +990,13 @@ class PackagesScreen(ScreenBase): pass class PackageDetailScreen(ScreenBase): - def __init__(self, stdscr, pkg_name: str, path: Path, spec: Json): + def __init__(self, stdscr, pkg_name: str, path: Path, spec: Json, is_python: bool = False, is_homeassistant: bool = False): super().__init__(stdscr) self.pkg_name = pkg_name self.path = path self.spec = spec + self.is_python = is_python + self.is_homeassistant = is_homeassistant self.variants = [""] + sorted(list(self.spec.get("variants", {}).keys())) self.vidx = 0 self.gh_token = os.environ.get("GITHUB_TOKEN") @@ -677,23 +1256,117 @@ class PackageDetailScreen(ScreenBase): del comp["tag"] def save(self): - save_json(self.path, self.spec) + if self.is_python: + # For Python packages, update the default.nix file + for name in self.snames: + source = self.merged_srcs[name] + updates = {} + + # Get version from variables + if "version" in self.merged_vars: + updates["version"] = self.merged_vars["version"] + + # Get hash from source + if "hash" in source: + updates["hash"] = source["hash"] + + # Get tag from source + if "tag" in source: + updates["tag"] = source["tag"] + + # Get rev from source + if "rev" in source: + updates["rev"] = source["rev"] + + if updates: + update_python_package(self.path, name, updates) + return True + elif self.is_homeassistant: + # For Home Assistant components, update the default.nix file + for name in self.snames: + source = self.merged_srcs[name] + updates = {} + + # Get version from variables + if "version" in self.merged_vars: + updates["version"] = self.merged_vars["version"] + + # Get hash from source + if "hash" in source: + updates["hash"] = source["hash"] + + # Get tag from source + if "tag" in source: + updates["tag"] = source["tag"] + + # Get rev from source + if "rev" in source: + updates["rev"] = source["rev"] + + if updates: + update_homeassistant_component(self.path, name, updates) + return True + else: + # For regular packages, save to version.json + save_json(self.path, self.spec) + return True def run(self): while True: self.stdscr.clear() h, w = self.stdscr.getmaxyx() + + # Draw main border around the entire screen + draw_border(self.stdscr, 0, 0, h-1, w) + + # Title with package name and path title = f"{self.pkg_name} [{self.path}]" - self.stdscr.addstr(0, 0, title[:w-1]) - # Variant line - vline = "Variants: " + " | ".join( - [f"[{v}]" if i == self.vidx else v for i, v in enumerate(self.variants)] - ) - self.stdscr.addstr(1, 0, vline[:w-1]) - # Sources header - self.stdscr.addstr(2, 0, "Sources:") + if self.is_python: + title += " [Python Package]" + + # Center the title + title_x = (w - len(title)) // 2 + self.stdscr.addstr(0, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD) + + # Variant line with highlighting for selected variant + if not self.is_python: + vline_parts = [] + for i, v in enumerate(self.variants): + if i == self.vidx: + vline_parts.append(f"[{v}]") + else: + vline_parts.append(v) + + vline = "Variants: " + " | ".join(vline_parts) + self.stdscr.addstr(1, 2, "Variants:", curses.color_pair(COLOR_HEADER)) + + # Display each variant with appropriate highlighting + x_pos = 12 # Position after "Variants: " + for i, v in enumerate(self.variants): + if i > 0: + self.stdscr.addstr(1, x_pos, " | ", curses.color_pair(COLOR_NORMAL)) + x_pos += 3 + + if i == self.vidx: + self.stdscr.addstr(1, x_pos, f"[{v}]", curses.color_pair(COLOR_HIGHLIGHT)) + x_pos += len(f"[{v}]") + else: + self.stdscr.addstr(1, x_pos, v, curses.color_pair(COLOR_NORMAL)) + x_pos += len(v) + else: + # For Python packages, show version instead of variants + version = self.merged_vars.get("version", "") + self.stdscr.addstr(1, 2, "Version:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr(1, 11, version, curses.color_pair(COLOR_SUCCESS)) + + # Sources header with decoration + self.stdscr.addstr(2, 2, "Sources:", curses.color_pair(COLOR_HEADER) | curses.A_BOLD) + + # Draw a separator line under the header + for i in range(1, w-1): + self.stdscr.addch(3, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) # List sources - for i, name in enumerate(self.snames[:h-8], start=0): + for i, name in enumerate(self.snames[:h-10], start=0): comp = self.merged_srcs[name] fetcher = comp.get("fetcher", "none") # Render refs so variables resolve; compress long forms for display @@ -735,9 +1408,40 @@ class PackageDetailScreen(ScreenBase): else: display_ref = "" ref_short = display_ref if not isinstance(display_ref, str) else (display_ref[:60] + ("..." if len(display_ref) > 60 else "")) - sel = ">" if i == self.sidx else " " - self.stdscr.addstr(3+i, 0, f"{sel} {name:<20} {fetcher:<7} ref={ref_short}"[:w-1]) + + # Determine colors and styles based on selection and fetcher type + if i == self.sidx: + # Selected item + attr = curses.color_pair(COLOR_HIGHLIGHT) + sel = "►" # Use a fancier selector + else: + # Non-selected item + attr = curses.color_pair(COLOR_NORMAL) + sel = " " + + # Determine fetcher color + fetcher_color = COLOR_NORMAL + if fetcher == "github": + fetcher_color = COLOR_SUCCESS + elif fetcher == "url": + fetcher_color = COLOR_STATUS + elif fetcher == "git": + fetcher_color = COLOR_HEADER + + # Display source name with selection indicator + self.stdscr.addstr(4+i, 2, f"{sel} {name:<20}", attr) + + # Display fetcher with appropriate color + self.stdscr.addstr(4+i, 24, fetcher, curses.color_pair(fetcher_color)) + + # Display reference + self.stdscr.addstr(4+i, 32, f"ref={ref_short}"[:w-34], curses.color_pair(COLOR_NORMAL)) + # Draw a separator line before the latest candidates section + y_latest = h - 8 + for i in range(1, w-1): + self.stdscr.addch(y_latest, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) + # Latest candidates section for selected component (auto-fetched) if self.snames: _sel_name = self.snames[self.sidx] @@ -746,28 +1450,64 @@ class PackageDetailScreen(ScreenBase): # Preload candidates lazily for selected item if _fetcher in ("github", "git", "url") and _sel_name not in self.candidates: self.fetch_candidates_for(_sel_name) - y_latest = h - 6 - if y_latest >= 3: - self.stdscr.addstr(y_latest, 0, "Latest:"[:w-1]) - if _fetcher in ("github", "git"): - _cand = self.candidates.get(_sel_name, {}) - _line = f" release={_cand.get('release') or '-'} tag={_cand.get('tag') or '-'} commit={( (_cand.get('commit') or '')[:12] ) or '-'}" - self.stdscr.addstr(y_latest+1, 0, _line[:w-1]) - elif _fetcher == "url": - _cand_u = self.url_candidates.get(_sel_name, {}) or {} - _tag = _cand_u.get("tag") or (self.candidates.get(_sel_name, {}).get("release") or "-") - _line = f" tag={_tag} base={_cand_u.get('base') or '-'} release={_cand_u.get('release') or '-'}" - self.stdscr.addstr(y_latest+1, 0, _line[:w-1]) - else: - if self.pkg_name == "linux-cachyos" and _sel_name == "linux": - _suffix = self.cachyos_suffix() - _latest = self.fetch_cachyos_linux_latest(_suffix) - self.stdscr.addstr(y_latest+1, 0, f" linux from PKGBUILD: {_latest or '-'}"[:w-1]) + + # Latest header with decoration + self.stdscr.addstr(y_latest+1, 2, "Latest Versions:", curses.color_pair(COLOR_HEADER) | curses.A_BOLD) + + if _fetcher in ("github", "git"): + _cand = self.candidates.get(_sel_name, {}) + + # Display each candidate with appropriate color + if _cand.get('release'): + self.stdscr.addstr(y_latest+2, 4, "Release:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr(y_latest+2, 13, _cand.get('release'), curses.color_pair(COLOR_SUCCESS)) + + if _cand.get('tag'): + self.stdscr.addstr(y_latest+2, 30, "Tag:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr(y_latest+2, 35, _cand.get('tag'), curses.color_pair(COLOR_SUCCESS)) + + if _cand.get('commit'): + self.stdscr.addstr(y_latest+3, 4, "Commit:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr(y_latest+3, 12, (_cand.get('commit') or '')[:12], curses.color_pair(COLOR_NORMAL)) + + elif _fetcher == "url": + _cand_u = self.url_candidates.get(_sel_name, {}) or {} + _tag = _cand_u.get("tag") or (self.candidates.get(_sel_name, {}).get("release") or "-") + + if _tag != "-": + self.stdscr.addstr(y_latest+2, 4, "Tag:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr(y_latest+2, 9, _tag, curses.color_pair(COLOR_SUCCESS)) + + if _cand_u.get('base'): + self.stdscr.addstr(y_latest+2, 30, "Base:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr(y_latest+2, 36, _cand_u.get('base'), curses.color_pair(COLOR_NORMAL)) + + if _cand_u.get('release'): + self.stdscr.addstr(y_latest+3, 4, "Release:", curses.color_pair(COLOR_HEADER)) + self.stdscr.addstr(y_latest+3, 13, _cand_u.get('release'), curses.color_pair(COLOR_NORMAL)) + + else: + if self.pkg_name == "linux-cachyos" and _sel_name == "linux": + _suffix = self.cachyos_suffix() + _latest = self.fetch_cachyos_linux_latest(_suffix) + self.stdscr.addstr(y_latest+2, 4, "Linux from PKGBUILD:", curses.color_pair(COLOR_HEADER)) + if _latest: + self.stdscr.addstr(y_latest+2, 24, _latest, curses.color_pair(COLOR_SUCCESS)) else: - self.stdscr.addstr(y_latest+1, 0, " -"[:w-1]) + self.stdscr.addstr(y_latest+2, 24, "-", curses.color_pair(COLOR_NORMAL)) + else: + self.stdscr.addstr(y_latest+2, 4, "No candidates available", curses.color_pair(COLOR_NORMAL)) - # Footer instructions - self.stdscr.addstr(h-4, 0, "Enter: component actions | r: refresh candidates | h: prefetch hash | e: edit field | s: save | Backspace: back | q: quit") + # Draw a separator line before the footer + for i in range(1, w-1): + self.stdscr.addch(h-5, i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) + + # Footer instructions with better formatting + footer = "Enter: component actions | r: refresh | h: hash | e: edit | s: save | Backspace: back | q: quit" + footer_x = (w - len(footer)) // 2 + self.stdscr.addstr(h-4, footer_x, footer, curses.color_pair(COLOR_STATUS)) + + # Draw status at the bottom self.draw_status(h, w) self.stdscr.refresh() @@ -1011,20 +1751,56 @@ def select_menu(stdscr, title: str, options: List[str], header: Optional[List[st while True: stdscr.clear() h, w = stdscr.getmaxyx() - stdscr.addstr(0, 0, title[:w-1]) - y = 1 + + # Calculate menu dimensions + menu_width = min(w - 4, max(40, max(len(title) + 4, max(len(opt) + 4 for opt in options)))) + menu_height = min(h - 4, len(options) + (len(header) if header else 0) + 4) + + # Calculate position for centered menu + start_x = (w - menu_width) // 2 + start_y = (h - menu_height) // 2 + + # Draw border around menu + draw_border(stdscr, start_y, start_x, menu_height, menu_width) + + # Draw title + title_x = start_x + (menu_width - len(title)) // 2 + stdscr.addstr(start_y, title_x, f" {title} ", curses.color_pair(COLOR_TITLE) | curses.A_BOLD) + + # Draw header if provided + y = start_y + 1 if header: for line in header: - if y >= h-2: + if y >= start_y + menu_height - 2: break - stdscr.addstr(y, 0, str(line)[:w-1]) + stdscr.addstr(y, start_x + 2, str(line)[:menu_width-4], curses.color_pair(COLOR_HEADER)) y += 1 - start_y = y + 1 - max_opts = max(0, h - start_y - 1) - for i, opt in enumerate(options[:max_opts], start=0): - sel = ">" if i == idx else " " - stdscr.addstr(start_y + i, 0, f"{sel} {opt}"[:w-1]) - stdscr.addstr(h-1, 0, "Enter: select | Backspace: cancel") + + # Add separator line after header + for i in range(1, menu_width - 1): + stdscr.addch(y, start_x + i, curses.ACS_HLINE, curses.color_pair(COLOR_BORDER)) + y += 1 + + # Draw options + options_start_y = y + visible_options = min(len(options), start_y + menu_height - options_start_y - 1) + + for i, opt in enumerate(options[:visible_options], start=0): + # Highlight selected option + if i == idx: + attr = curses.color_pair(COLOR_HIGHLIGHT) + sel = "►" # Use a fancier selector + else: + attr = curses.color_pair(COLOR_NORMAL) + sel = " " + + stdscr.addstr(options_start_y + i, start_x + 2, f"{sel} {opt}"[:menu_width-4], attr) + + # Draw footer + footer = "Enter: select | Backspace: cancel" + footer_x = start_x + (menu_width - len(footer)) // 2 + stdscr.addstr(start_y + menu_height - 1, footer_x, footer, curses.color_pair(COLOR_STATUS)) + stdscr.refresh() ch = stdscr.getch() if ch in (curses.KEY_UP, ord('k')): @@ -1039,9 +1815,45 @@ def select_menu(stdscr, title: str, options: List[str], header: Optional[List[st # ------------------------------ main ------------------------------ def main(stdscr): - curses.curs_set(0) - stdscr.nodelay(False) + curses.curs_set(0) # Hide cursor + stdscr.nodelay(False) # Blocking input + + # Initialize colors + if curses.has_colors(): + init_colors() + try: + # Display welcome screen + h, w = stdscr.getmaxyx() + welcome_lines = [ + "╔═══════════════════════════════════════════╗", + "║ ║", + "║ NixOS Package Version Manager ║", + "║ ║", + "║ Browse and update package versions with ║", + "║ this interactive TUI. Navigate using ║", + "║ arrow keys and Enter to select. ║", + "║ ║", + "╚═══════════════════════════════════════════╝", + "", + "Loading packages..." + ] + + # Center the welcome message + start_y = (h - len(welcome_lines)) // 2 + for i, line in enumerate(welcome_lines): + if start_y + i < h: + start_x = (w - len(line)) // 2 + if "NixOS Package Version Manager" in line: + stdscr.addstr(start_y + i, start_x, line, curses.color_pair(COLOR_TITLE) | curses.A_BOLD) + elif "Loading packages..." in line: + stdscr.addstr(start_y + i, start_x, line, curses.color_pair(COLOR_STATUS)) + else: + stdscr.addstr(start_y + i, start_x, line, curses.color_pair(COLOR_NORMAL)) + + stdscr.refresh() + + # Start the main screen after a short delay screen = PackagesScreen(stdscr) screen.run() except Exception: