mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
289 lines
11 KiB
Python
289 lines
11 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
|
|
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
|