prole/installer/screen.py
chrisfu 906392d462 feat(installer): improve UI and add test coverage for core features
- Refactored installer UI with updated canvas rendering, sidebar navigation, and footer buttons.
- Enhanced styling for macOS compatibility and consistent design across controls.
- Added Pytest-based unit tests for `screen.py` and `config.py`.
- Expanded dependency catalog with new tools like `tshark` and `pyshark`.
- Improved error tolerance for background rendering and added placeholders for Kerberos configuration.
2026-01-08 21:51:30 -08:00

233 lines
8.4 KiB
Python

"""
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
from tkinter import ttk
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') -> 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')
# 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 {}
btn_map: dict[int, tk.Button] = {}
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(ttk.Frame):
"""A scrollable text widget that mimics a terminal console."""
def __init__(self, parent, **kwargs):
super().__init__(parent, **kwargs)
self.text = tk.Text(self, bg='#1e1e1e', fg='#f0f0f0', font=('Menlo', 11) if 'Darwin' in platform.system() else ('Consolas', 11),
padx=10, pady=10, insertbackground='white')
self.scroll = ttk.Scrollbar(self, orient='vertical', command=self.text.yview)
self.text.configure(yscrollcommand=self.scroll.set)
self.scroll.pack(side='right', fill='y')
self.text.pack(side='left', fill='both', expand=True)
# Make read-only by default but allow selection and copying
self.text.configure(state='disabled')
def write(self, content: str):
"""Append text to the console and scroll to the bottom."""
try:
if not self.winfo_exists():
return
self.text.configure(state='normal')
self.text.insert('end', content)
self.text.see('end')
self.text.configure(state='disabled')
self.update_idletasks()
except tk.TclError:
pass
def clear(self):
"""Clear all content from the console."""
try:
if not self.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:
pass