"""Dependency scanning, summary and per-dependency install screens.""" import re import threading import time import tkinter as tk from tkinter import ttk, messagebox, filedialog from knoe import config as inst_config from knoe import screen as ui class DependenciesScreenMixin: """Dependency scanning, summary and per-dependency install screens.""" def _render_deps_summary_page(self): self._render_title("Dependencies", y=40) y = 100 # Draw each dependency row: status dot (blank initially), name, info row_gap = 36 self.dep_status_items = {} # Store canvas IDs to update later self.dep_install_buttons = {} # Store button window items for dep in self.dependencies: indent = 0 if dep.get("parent"): indent = 24 left = 56 + indent text_x = left + 28 # blank box (outline only initially) dot = ui.canvas_rectangle( self, left, y, left + 18, y + 18, outline="#6e6e73", width=2 ) self._canvas_items.append(dot) # name self._canvas_items.append( ui.canvas_text( self, text_x, y - 2, dep["name"], fill="black", font=("SF Pro Text", 12, "bold"), ) ) # info placeholder info = ui.canvas_text( self, text_x + 180, y, "Checking...", fill="#6e6e73", font=("SF Pro Text", 11), ) self._canvas_items.append(info) self.dep_status_items[dep["id"]] = { "dot": dot, "info": info, "y": y, "left": left, } y += row_gap # Message line msg_y = y + 30 self.deps_msg_item = ui.canvas_text( self, 48, msg_y, "Scanning system...", fill="#6e6e73", font=("SF Pro Text", 11), ) self._canvas_items.append(self.deps_msg_item) # Start async check threading.Thread(target=self._run_delayed_deps_check, daemon=True).start() def _render_dependency_page(self, dep): self._render_title(dep["name"], y=40) self._render_paragraph(dep["description"], y=88) # Status ok, location, version = self.get_dep_info(dep) y = 180 left = 56 if ok: self._canvas_items.append( ui.canvas_oval( self, left, y, left + 18, y + 18, fill="#34c759", outline="" ) ) self._canvas_items.append( ui.canvas_text( self, left + 32, y - 2, "Installed", fill="black", font=("SF Pro Text", 12, "bold"), ) ) if location: self._canvas_items.append( ui.canvas_text( self, left + 32, y + 32, f"Location: {location}", fill="#6e6e73", font=("SF Pro Text", 11), ) ) if version: ver = self.normalize_version(version) self._canvas_items.append( ui.canvas_text( self, left + 32, y + 56, f"Version: {ver}", fill="#6e6e73", font=("SF Pro Text", 11), ) ) else: # Check if we should auto-install install_cmd = dep.get("install_cmd") if install_cmd and self._installing_dep_id != dep["id"]: # Launch directly into installation self._canvas_items.append( ui.canvas_oval( self, left, y, left + 18, y + 18, fill="#ffd60a", outline="" ) ) self._canvas_items.append( ui.canvas_text( self, left + 32, y - 2, "Installing...", fill="black", font=("SF Pro Text", 12, "bold"), ) ) # Trigger console-based install for brew/pip if "brew install" in install_cmd or "pip install" in install_cmd: self.root.after(500, lambda: self._install_dep_in_console(dep)) else: self.root.after( 500, lambda: self.open_terminal_with_command(install_cmd) ) elif self._installing_dep_id == dep["id"]: self._canvas_items.append( ui.canvas_oval( self, left, y, left + 18, y + 18, fill="#ffd60a", outline="" ) ) self._canvas_items.append( ui.canvas_text( self, left + 32, y - 2, "Installing...", fill="black", font=("SF Pro Text", 12, "bold"), ) ) else: self._canvas_items.append( ui.canvas_oval( self, left, y, left + 18, y + 18, fill="#ff9f0a", outline="" ) ) self._canvas_items.append( ui.canvas_text( self, left + 32, y - 2, "Not installed", fill="black", font=("SF Pro Text", 12, "bold"), ) ) # Click to install link (if available) if install_cmd: link_y = y + 40 link_text = ui.render_link( self, left + 32, link_y, "Click to install" ) self._canvas_items.append(link_text) self.bg_canvas.config(cursor="hand2") def _on_click(event): ex, ey = event.x, event.y bbox = self.bg_canvas.bbox(link_text) if ( bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3] ): self.open_terminal_with_command(install_cmd) self.bg_canvas.bind("", _on_click) def _install_dep_in_console(self, dep): """Run dependency installation in the embedded console.""" if self._installing_dep_id == dep["id"]: return try: self._action_flags[f"dependencies.{dep['id']}.install"] = True except Exception: pass install_cmd = dep.get("install_cmd") if not install_cmd: return self._installing_dep_id = dep["id"] # Prepare log file logs_dir = self._resolve_env_dir("PROLE_LOGS", "logs") try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass ts = time.strftime("%Y%m%d-%H%M%S") log_path = logs_dir / f"install-{dep['id']}-{ts}.log" # Show console self._ensure_console_overlay(radio_bottom_y=160) self._console_text.configure(state="normal") self._console_text.delete("1.0", tk.END) self._console_text.insert("end", f"Starting installation of {dep['name']}...\n") self._console_text.insert("end", f"Command: {install_cmd}\n\n") self._console_text.configure(state="disabled") def on_done(rc): self._installing_dep_id = None if rc == 0: self._append_console(f"\nSuccessfully installed {dep['name']}.\n") # Refresh status and re-render page self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}")) else: self._append_console(f"\nInstallation failed with exit code {rc}.\n") # Enable next button so user can retry or proceed if they fixed it manually try: self.next_button.configure(state="normal", text="Next") except Exception: pass self._run_in_console(install_cmd, str(log_path), on_complete=on_done) # ---------------- Initialize Screen Handlers ---------------- def _run_delayed_deps_check(self): results = {} any_missing = False for dep in self.dependencies: ok, location, version = self.get_dep_info(dep) results[dep["id"]] = (ok, location, version) # Update UI for this item def update_item(did=dep["id"], ok=ok, version=version): items = self.dep_status_items.get(did) if not items: return # Replace box with colored dot/check self.bg_canvas.delete(items["dot"]) left = items.get("left", 56) y = items["y"] if ok: items["dot"] = ui.canvas_oval( self, left, y, left + 18, y + 18, fill="#34c759", outline="" ) # Add a small white checkmark inside the green dot self.bg_canvas.create_line( left + 5, y + 9, left + 8, y + 12, fill="white", width=2, tags=f"page_item_{self.pages[self.page_index][0]}", ) self.bg_canvas.create_line( left + 8, y + 12, left + 13, y + 6, fill="white", width=2, tags=f"page_item_{self.pages[self.page_index][0]}", ) else: items["dot"] = ui.canvas_oval( self, left, y, left + 18, y + 18, fill="#ff3b30", outline="" ) # Add "Click to install" button btn = tk.Button( self.bg_canvas, text="Install", command=lambda d=did: self.show_page(f"dep_{d}"), bg="#F5F5DC", fg="black", activebackground="#E5E5D5", highlightbackground="#F5F5DC", highlightthickness=0, relief="flat", font=("SF Pro Text", 10), padx=8, pady=2, ) btn_window = self.bg_canvas.create_window( left + 350, y, window=btn, anchor="nw" ) self._overlay_widgets.append(btn) self._canvas_items.append(btn_window) self.dep_install_buttons[did] = btn_window info_text = ( self.normalize_version(version) if ok and version else ("Not installed" if not ok else "") ) self.bg_canvas.itemconfig(items["info"], text=info_text) self.safe_after(update_item) time.sleep(0.1) # small delay to show it checking one by one def final_update(): missing = [ d["name"] for d in self.dependencies if not results.get(d["id"], (False, None, None))[0] ] if missing: msg = f"Preparing to install ... {', '.join(missing)}" else: msg = "All dependencies installed." try: if ( self.bg_canvas.winfo_exists() and self.deps_msg_item in self.bg_canvas.find_all() ): self.bg_canvas.itemconfig(self.deps_msg_item, text=msg) except (tk.TclError, RuntimeError): pass self.update_footer() self.safe_after(final_update) def _create_page_dependencies_summary(self): f = self._page_container() ttk.Label(f, text="Dependencies", style="Title.TLabel").pack( anchor="w", padx=24, pady=(24, 6) ) self.deps_container = ttk.Frame(f) self.deps_container.pack(fill="both", expand=True, padx=16, pady=8) self.dep_status = {} for dep in self.dependencies: row = ttk.Frame(self.deps_container) row.pack(fill="x", pady=6) indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0) indicator.pack(side="left", padx=8) name = ttk.Label(row, text=dep["name"], style="Body.TLabel") name.pack(side="left") info = ttk.Label(row, text="", style="Dim.TLabel") info.pack(side="left", padx=10) self.dep_status[dep["id"]] = {"canvas": indicator, "info": info, "dep": dep} # Verify all checkbox (controls navigation behavior) chk = ttk.Checkbutton( f, text="Verify all dependencies", variable=self.verify_mode, command=self.update_footer, ) chk.pack(anchor="w", padx=24, pady=(4, 0)) # Message label must be created before refresh to avoid AttributeError self.deps_msg = ttk.Label(f, text="", style="Dim.TLabel") self.deps_msg.pack(anchor="w", padx=24, pady=(8, 8)) # Now we can safely populate the UI. Start async scan to avoid startup delay self.deps_msg.configure(text="Checking dependencies...") self.start_dependency_scan() # self._register_page('deps_summary', f) def _create_page_dependency(self, dep): f = self._page_container() ttk.Label(f, text=dep["name"], style="Title.TLabel").pack( anchor="w", padx=24, pady=(24, 6) ) ttk.Label( f, text=dep["description"], style="Body.TLabel", wraplength=800, justify="left", ).pack(anchor="w", padx=24) status_var = tk.StringVar(value="Checking...") loc_var = tk.StringVar(value="") ver_var = tk.StringVar(value="") info_frame = ttk.Frame(f) info_frame.pack(fill="x", padx=24, pady=12) # Pretty status row with an icon status_row = ttk.Frame(info_frame) status_row.pack(anchor="w", fill="x") status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0) status_icon.pack(side="left", padx=(0, 8), pady=(2, 0)) ttk.Label(status_row, textvariable=status_var, style="Body.TLabel").pack( side="left" ) # Detail rows (hidden when not installed) loc_label = ttk.Label(info_frame, textvariable=loc_var, style="Dim.TLabel") ver_label = ttk.Label(info_frame, textvariable=ver_var, style="Dim.TLabel") loc_label.pack(anchor="w") ver_label.pack(anchor="w") link = ttk.Label( f, text="Click to install", foreground="#0a84ff", cursor="hand2", style="Body.TLabel", ) link.pack(anchor="w", padx=24, pady=(12, 0)) def _draw_status_icon(canvas, status): ui.canvas_clear(canvas) if status == "installed": ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill="#34c759", outline="") ui.canvas_line_on(canvas, 4, 9, 8, 13, fill="white", width=2) ui.canvas_line_on(canvas, 8, 13, 15, 5, fill="white", width=2) elif status == "missing": # Friendly warning dot with exclamation ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill="#ff9f0a", outline="") ui.canvas_line_on(canvas, 9, 5, 9, 11, fill="white", width=2) ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill="white", outline="white") def check_then_update(): ok, location, version = self.get_dep_info(dep) if ok: status_var.set("Installed") _draw_status_icon(status_icon, "installed") loc_var.set(f'Location: {location or ""}') norm_ver = self.normalize_version(version) if version else None ver_var.set(f'Version: {norm_ver or ""}') # Ensure details visible try: loc_label.pack_configure() ver_label.pack_configure() link.pack_forget() except Exception: pass else: status_var.set("Not installed") _draw_status_icon(status_icon, "missing") # Hide undefined details instead of showing dashes try: loc_label.pack_forget() ver_label.pack_forget() except Exception: pass try: # Keep install link visible if we have a command if dep.get("install_cmd") and not link.winfo_ismapped(): link.pack(anchor="w", padx=24, pady=(12, 0)) except Exception: pass self.root.after(50, check_then_update) def do_install(event=None): self.open_terminal_with_command(dep.get("install_cmd")) if dep.get("install_cmd"): link.bind("", do_install) else: try: link.pack_forget() except Exception: pass # self._register_page(f'dep_{dep["id"]}', f) def _dep_navigation_sequence(self, force_all: bool = False): """Return a list of dependency page ids to traverse next. - If force_all is True or verify_mode is True: include all dep pages in defined order. - Else: include only missing dependency pages based on current checks. """ if force_all or self.verify_mode.get(): res = [f'dep_{d["id"]}' for d in self.dependencies] print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}") return res seq = [] for d in self.dependencies: ok, _, _ = self.get_dep_info(d) if not ok: seq.append(f'dep_{d["id"]}') print(f"[DEBUG] _dep_navigation_sequence: {seq}") return seq def start_dependency_scan(self): """Scan dependencies in a background thread to avoid blocking UI startup.""" if self.validation_running: return self.validation_running = True # Hold incremental results so we can update the status line and icons progressively self._dep_scan_results = {} def worker(): for dep in self.dependencies: try: ok, location, version = self.get_dep_info(dep) except Exception: ok, location, version = False, None, None did = dep["id"] # Save and apply incrementally self._dep_scan_results[did] = (ok, location, version) self.safe_after( lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental( d, o, l, v ) ) try: time.sleep(0.02) except Exception: pass # After all are processed, finalize pass to unify any remaining labels self.safe_after( lambda: self._apply_dependency_scan(dict(self._dep_scan_results)) ) t = threading.Thread(target=worker, daemon=True) self.validation_thread = t t.start() def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version): """Update the dependencies page row and message as each check completes.""" if not hasattr(self, "dep_status"): return slot = self.dep_status.get(dep_id) if not slot: return # Update dot icon and info text for this row self._draw_status(slot["canvas"], "success" if ok else "error") if ok: norm_ver = self.normalize_version(version) if version else "" slot["info"].configure(text=(norm_ver or "")) else: slot["info"].configure(text="Not installed") # Update the message line with current missing list if hasattr(self, "_dep_scan_results"): missing = [ self.dep_status[d]["dep"]["name"] for d, res in self._dep_scan_results.items() if not res[0] and d in self.dep_status ] if hasattr(self, "deps_msg") and self.deps_msg is not None: if missing: self.deps_msg.configure( text=f"Preparing to install ... {', '.join(missing)}" ) else: self.deps_msg.configure(text="All dependencies installed.") # Footer may need to react if verify mode is enabled self.update_footer() # Update prole.cfg data try: status_str = f"installed (version {version})" if ok else "not installed" self.prole_cfg_data["Dependencies"][dep_id] = status_str self._save_prole_cfg() except Exception: pass def _apply_dependency_scan(self, results: dict): """Apply scan results to the summary UI and footer labels.""" self.validation_running = False any_missing = False for did, slot in self.dep_status.items(): ok, location, version = results.get(did, (False, None, None)) self._draw_status(slot["canvas"], "success" if ok else "error") if ok: norm_ver = self.normalize_version(version) if version else "" slot["info"].configure(text=(norm_ver or "")) else: any_missing = True slot["info"].configure(text="Not installed") has_msg = hasattr(self, "deps_msg") and self.deps_msg is not None if any_missing: missing = [ slot["dep"]["name"] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0] ] if missing and has_msg: self.deps_msg.configure( text=f"Preparing to install ... {', '.join(missing)}" ) else: if has_msg: self.deps_msg.configure(text="All dependencies installed.") self.update_footer() def refresh_dependencies_ui(self): """Deprecated synchronous refresh retained for compatibility. Prefer start_dependency_scan -> _apply_dependency_scan. """ self.start_dependency_scan() def all_dependencies_installed(self): for dep in self.dependencies: ok, _, _ = self.get_dep_info(dep) if not ok: print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING") return False print("[DEBUG] all_dependencies_installed: YES (all OK)") return True def get_dep_info(self, dep): """Delegate dependency probing to knoe.config.get_dep_info.""" return inst_config.get_dep_info(dep) def normalize_version(self, text: str) -> str: """Normalize versions via knoe.config.normalize_version.""" return inst_config.normalize_version(text)