mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
414 lines
11 KiB
Python
414 lines
11 KiB
Python
"""
|
|
Shared UI helpers for the Knoe 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
|