prole/installer/screen.py
chrisfu 325d180e9e fix(installer, scripts): improve read-only text handling and streamline db password input
- Update `screen.py` to make text read-only using `state='disabled'`, while allowing text selection and copying.
- Simplify `init_primary_domain.sh` by removing unnecessary local variable declaration for `db_pass`.
2025-12-20 20:55:52 -08:00

216 lines
7.9 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(40, y, anchor='nw', text=text, fill='#111', font=('Helvetica Neue', 22, 'bold'))
app._canvas_items.append(item)
def render_paragraph(app, text: str, y: int, wrap: int = 860):
"""Render a paragraph on the main background canvas."""
if getattr(app, 'bg_canvas', None) is None:
return
item = app.bg_canvas.create_text(40, y, anchor='nw', text=text, fill='#1d1d1f', font=('Helvetica', 12), width=wrap)
app._canvas_items.append(item)
def canvas_text(app, x: int, y: int, text: str, *, fill: str = '#1d1d1f',
font: tuple = ('Helvetica', 12), anchor: str = 'nw', width: int | None = None,
justify: 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
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) -> int:
"""Create an oval on the app's main canvas and track it."""
if getattr(app, 'bg_canvas', None) is None:
return -1
item = app.bg_canvas.create_oval(x1, y1, x2, y2, fill=fill or '', outline=outline or '', width=width)
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) -> int:
if getattr(app, 'bg_canvas', None) is None:
return -1
item = app.bg_canvas.create_rectangle(x1, y1, x2, y2, outline=outline, width=width, fill=fill or '')
app._canvas_items.append(item)
return item
def canvas_line(app, x1: int, y1: int, x2: int, y2: int, *, fill: str = '#1d1d1f', width: int = 2) -> int:
if getattr(app, 'bg_canvas', None) is None:
return -1
item = app.bg_canvas.create_line(x1, y1, x2, y2, fill=fill, width=width)
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, ttk.Button]:
"""Create a right-aligned navigation footer with uniform button styling.
Parameters:
- parent: the container (typically the root installer container)
- buttons: list of (button_id, title) in the order to display
- commands: optional mapping of button_id -> callback function
- style_name: ttk style to apply to all buttons
Returns: {button_id: ttk.Button}
Examples:
create_nav_footer(footer, [(1, 'Next')])
create_nav_footer(footer, [(1, 'Prev'), (2, 'Next')])
create_nav_footer(footer, [(1, 'Exit')])
"""
# Ensure a consistent style for navigation buttons
try:
style = ttk.Style()
# Keep padding modest to allow Aqua to size buttons naturally.
# Avoid forcing a font so the native Aqua metrics are used.
style.configure(style_name, padding=(10, 6))
except Exception:
pass
footer = ttk.Frame(parent)
footer.pack(fill='x', side='bottom')
# Let the footer naturally size to its contents to avoid clipping text on macOS
# Flexible spacer to push buttons to the right
spacer = ttk.Frame(footer)
spacer.pack(side='left', expand=True, fill='x')
cmds = commands or {}
btn_map: dict[int, ttk.Button] = {}
for btn_id, title in buttons:
cmd = cmds.get(btn_id)
b = ttk.Button(footer, text=title, style=style_name, command=cmd)
# Right-aligned order (pack to the right in the declared order)
# Small extra right padding on the last (right-most) button
pad = (0, 20) if title.lower() in ('finish', 'exit', 'done') else (0, 8)
# Use modest vertical padding; let Aqua compute proper height.
b.pack(side='right', padx=pad, pady=10)
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."""
self.text.configure(state='normal')
self.text.insert('end', content)
self.text.see('end')
self.text.configure(state='disabled')
self.update_idletasks()
def clear(self):
"""Clear all content from the console."""
self.text.configure(state='normal')
self.text.delete('1.0', 'end')
self.text.configure(state='disabled')
self.update_idletasks()