"""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 installer import config as inst_config from installer 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 installer.config.get_dep_info.""" return inst_config.get_dep_info(dep) def normalize_version(self, text: str) -> str: """Normalize versions via installer.config.normalize_version.""" return inst_config.normalize_version(text)