""" Shared UI helpers for the Prole installer screens. These routines are intentionally lightweight wrappers around the existing Tk Canvas used by the installer, to reduce clutter in install.py. """ from __future__ import annotations import tkinter as tk import platform def render_title(app, text: str, y: int = 40): """Render a section title on the main background canvas.""" if getattr(app, "bg_canvas", None) is None: return item = app.bg_canvas.create_text( 48, y, anchor="nw", text=text, fill="black", font=("SF Pro Text", 18, "bold") ) app._canvas_items.append(item) def render_paragraph(app, text: str, y: int, wrap: int = 800): """Render a paragraph on the main background canvas.""" if getattr(app, "bg_canvas", None) is None: return item = app.bg_canvas.create_text( 48, y, anchor="nw", text=text, fill="black", font=("SF Pro Text", 11), width=wrap, ) app._canvas_items.append(item) def canvas_text( app, x: int, y: int, text: str, *, fill: str = "black", font: tuple = ("SF Pro Text", 11), anchor: str = "nw", width: int | None = None, justify: str | None = None, state: str | None = None, ) -> int: """Create a text item on the app's main canvas and track it. Returns the created canvas item id. """ if getattr(app, "bg_canvas", None) is None: return -1 kwargs = dict(anchor=anchor, text=text, fill=fill, font=font) if width is not None: kwargs["width"] = width if justify is not None: kwargs["justify"] = justify if state is not None: kwargs["state"] = state item = app.bg_canvas.create_text(x, y, **kwargs) app._canvas_items.append(item) return item def canvas_oval( app, x1: int, y1: int, x2: int, y2: int, *, fill: str | None = None, outline: str | None = None, width: int = 1, state: str | None = None, ) -> int: """Create an oval on the app's main canvas and track it.""" if getattr(app, "bg_canvas", None) is None: return -1 kwargs = dict(fill=fill or "", outline=outline or "", width=width) if state is not None: kwargs["state"] = state item = app.bg_canvas.create_oval(x1, y1, x2, y2, **kwargs) app._canvas_items.append(item) return item def canvas_rectangle( app, x1: int, y1: int, x2: int, y2: int, *, outline: str = "#6e6e73", width: int = 1, fill: str | None = None, state: str | None = None, ) -> int: if getattr(app, "bg_canvas", None) is None: return -1 kwargs = dict(outline=outline, width=width, fill=fill or "") if state is not None: kwargs["state"] = state item = app.bg_canvas.create_rectangle(x1, y1, x2, y2, **kwargs) app._canvas_items.append(item) return item def canvas_line( app, x1: int, y1: int, x2: int, y2: int, *, fill: str = "black", width: int = 2, state: str | None = None, ) -> int: if getattr(app, "bg_canvas", None) is None: return -1 kwargs = dict(fill=fill, width=width) if state is not None: kwargs["state"] = state item = app.bg_canvas.create_line(x1, y1, x2, y2, **kwargs) app._canvas_items.append(item) return item def render_link( app, x: int, y: int, text: str, *, color: str = "#0a84ff", font: tuple = ("Helvetica", 12, "underline"), ) -> int: """Render a link-styled text on canvas and return its item id.""" return canvas_text(app, x, y, text, fill=color, font=font) def canvas_image(app, x: int, y: int, image, *, anchor: str = "center") -> int: """Create an image on the app's main canvas. Not tracked in _canvas_items by default.""" if getattr(app, "bg_canvas", None) is None: return -1 try: item = app.bg_canvas.create_image(x, y, anchor=anchor, image=image) return item except Exception: return -1 # Explicit-canvas helpers for non-background canvases def canvas_delete(app, item_id: int): try: app.bg_canvas.delete(item_id) except Exception: pass def canvas_clear_all(app): try: app.bg_canvas.delete("all") except Exception: pass def canvas_coords(app, item_id: int, *coords): try: app.bg_canvas.coords(item_id, *coords) except Exception: pass def canvas_clear(cnv: tk.Canvas): try: cnv.delete("all") except Exception: pass def canvas_text_on( cnv: tk.Canvas, x: int, y: int, text: str, *, fill: str = "#1d1d1f", font: tuple = ("Helvetica", 12), anchor: str = "center", ) -> int: return cnv.create_text(x, y, text=text, fill=fill, font=font, anchor=anchor) def canvas_oval_on( cnv: tk.Canvas, x1: int, y1: int, x2: int, y2: int, *, fill: str | None = None, outline: str | None = None, width: int = 1, ) -> int: return cnv.create_oval( x1, y1, x2, y2, fill=fill or "", outline=outline or "", width=width ) def canvas_line_on( cnv: tk.Canvas, x1: int, y1: int, x2: int, y2: int, *, fill: str = "white", width: int = 2, ) -> int: return cnv.create_line(x1, y1, x2, y2, fill=fill, width=width) def create_nav_footer( parent, buttons: list[tuple[int, str]], commands: dict[int, callable] | None = None, style_name: str = "Nav.TButton", gear_command: callable | None = None, ) -> dict[int, tk.Button]: """Create a right-aligned navigation footer with uniform button styling. Uses tk.Button instead of ttk.Button for better color control on macOS. """ footer = tk.Frame(parent, bg="#F5F5DC", height=64) footer.pack(fill="x", side="bottom") footer.pack_propagate(False) # Top divider line for the footer divider = tk.Frame(footer, bg="#CCCCCC", height=1) divider.pack(side="top", fill="x") btn_map: dict[int | str, tk.Button] = {} if gear_command: gear_btn = tk.Button( footer, text="⚙️", command=gear_command, bg="#F5F5DC", fg="black", activebackground="#E5E5D5", activeforeground="black", highlightbackground="#F5F5DC", highlightthickness=0, relief="flat", font=("SF Pro Text", 18), padx=10, pady=8, ) gear_btn.pack(side="left", padx=(20, 0), pady=12) btn_map["gear"] = gear_btn # Flexible spacer to push buttons to the right spacer = tk.Frame(footer, bg="#F5F5DC") spacer.pack(side="left", expand=True, fill="x") cmds = commands or {} for btn_id, title in buttons: cmd = cmds.get(btn_id) # Use tk.Button for full control over background and borders on macOS b = tk.Button( footer, text=title, command=cmd, bg="#F5F5DC", fg="black", activebackground="#E5E5D5", activeforeground="black", highlightbackground="#F5F5DC", # Essential for macOS to avoid black boxes highlightthickness=0, relief="flat", font=("SF Pro Text", 11), padx=16, pady=8, ) # Right-aligned order (pack to the right in the declared order) pad = (0, 20) if title.lower() in ("finish", "exit", "done", "next") else (0, 8) b.pack(side="right", padx=pad, pady=12) btn_map[btn_id] = b # Return both the frame and button map if needed later by callers btn_map["_footer"] = footer # type: ignore[index] return btn_map class TerminalConsole(tk.Frame): """A scrollable text widget that mimics a terminal console.""" def __init__(self, parent, **kwargs): bg = "#F5F5DC" # Cream/Light background kwargs.setdefault("bg", bg) font_override = kwargs.pop("font", None) wrap_mode = kwargs.pop("wrap", None) show_horizontal = kwargs.pop("show_horizontal", False) super().__init__(parent, **kwargs) # Standard Console Theme (Light background, Dark text) fg = "#1d1d1f" # Dark text font = font_override or ( ("Menlo", 10) if "Darwin" in platform.system() else ("Consolas", 10) ) self.text = tk.Text( self, bg=bg, fg=fg, font=font, padx=10, pady=10, insertbackground=fg, highlightthickness=0, bd=0, relief="flat", ) if wrap_mode is not None: self.text.configure(wrap=wrap_mode) # Force a light scrollbar style on macOS to avoid dark mode shifts self.scroll = tk.Scrollbar( self, orient="vertical", command=self.text.yview, highlightthickness=0, bd=0, bg=bg, activebackground=bg, troughcolor=bg, ) self.text.configure(yscrollcommand=self.scroll.set) self.hscroll = None if show_horizontal: self.hscroll = tk.Scrollbar( self, orient="horizontal", command=self.text.xview, highlightthickness=0, bd=0, bg=bg, activebackground=bg, troughcolor=bg, ) self.text.configure(xscrollcommand=self.hscroll.set) self.scroll.pack(side="right", fill="y") if self.hscroll: self.hscroll.pack(side="bottom", fill="x") self.text.pack(side="left", fill="both", expand=True) # Make read-only by default but allow selection and copying self.text.configure(state="disabled", relief="flat") def write(self, content: str): """Append text to the console and scroll to the bottom.""" def _do_write(): try: if not self.winfo_exists(): return if not self.text.winfo_exists(): return self.text.configure(state="normal") self.text.insert("end", content) self.text.see("end") self.text.configure(state="disabled") # Force immediate update to ensure real-time feedback self.update() except (tk.TclError, RuntimeError): pass try: self.after(0, _do_write) except (tk.TclError, RuntimeError): pass def clear(self): """Clear all content from the console.""" def _do_clear(): try: if not self.winfo_exists(): return if not self.text.winfo_exists(): return self.text.configure(state="normal") self.text.delete("1.0", "end") self.text.configure(state="disabled") self.update_idletasks() except (tk.TclError, RuntimeError): pass try: self.after(0, _do_clear) except (tk.TclError, RuntimeError): pass