prole/knoe/ui/screens/navigation.py
chrisfu a1177d71bc Add GitOps provider choice screen and cluster UI improvements
- Add _render_gitops_choice_page() to GitOpsScreenMixin for explicit
  Gitea / ArgoCD / None provider selection with radio buttons
- Extend cluster screen with GitOps provider radio group and canvas layout
- Refactor services screen layout and navigation registration
- Register new gitops choice screen in screens __init__ / navigation
- Expand Kong init scripts (etc/ and mock_val/) with additional logic
- Update init_cnpg_backup.sh with minor fix
- Refresh conf/service and conf/prod prole.cfg generated configs
- Update conf/port-mapping.cfg port entries
- Update Supabase Helm values.yaml
- Bump modes/k3s/knoe-db/.version
- Update network scan description
- Add/expand tests: test_cluster_screen_layout, test_gitops_choice_screen

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-24 22:12:25 -07:00

853 lines
32 KiB
Python

"""Sidebar navigation, page transitions, footer and app identity."""
import os
import platform
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from knoe import config as inst_config
from knoe.core.env import get_resource_path, _deployment_mode_from_env
class NavigationMixin:
"""Sidebar navigation, page transitions, footer and app identity."""
def _set_app_identity(self):
"""Set the installer identity: process/menu name and Dock icon on macOS.
Notes:
- We set Tk's appname for consistency.
- On macOS, attempt to set the process/menu name to 'Prole Database Installer'
via NSProcessInfo if PyObjC is available.
- Prefer the Prole Tools.app .icns from the built app; fallback to local PNG/GIF.
"""
# Set Tk application name
try:
self.root.tk.call("tk", "appname", "Prole Database Installer")
except Exception:
pass
# macOS: set process/menu name and Dock icon via AppKit/Foundation
if platform.system() == "Darwin":
# Try to set the visible process name for the menu bar
try:
from Foundation import NSProcessInfo
NSProcessInfo.processInfo().setProcessName_("Prole Database Installer")
except Exception:
pass
# Also try to retitle the first main menu item so the menu next to the Apple logo reads 'Prole Database Installer'
try:
from AppKit import NSApplication
app = NSApplication.sharedApplication()
main_menu = app.mainMenu()
if main_menu is not None and main_menu.numberOfItems() > 0:
first_item = main_menu.itemAtIndex_(0)
if first_item is not None:
first_item.setTitle_("Prole Database Installer")
except Exception:
pass
icns_candidates = [
get_resource_path(
"prole-app/dist/Prole Tools.app/Contents/Resources/Prole Tools.icns"
),
get_resource_path(
"prole-app/dist/Prole Tools.app/Contents/Resources/Prole.icns"
),
]
icns_path = next((p for p in icns_candidates if p.exists()), None)
if icns_path is not None:
try:
# Use PyObjC if available
from AppKit import NSApplication, NSImage
img = NSImage.alloc().initWithContentsOfFile_(str(icns_path))
if img is not None:
NSApplication.sharedApplication().setApplicationIconImage_(img)
return
except Exception:
pass
# If no .icns was found, try setting Dock icon from configured PNG
try:
from AppKit import NSApplication, NSImage
cfg_icon = inst_config.get_ui_icon_image_path()
if cfg_icon.exists():
png_img = NSImage.alloc().initWithContentsOfFile_(str(cfg_icon))
if png_img is not None:
NSApplication.sharedApplication().setApplicationIconImage_(
png_img
)
# do not return; still set Tk icon below for consistency
except Exception:
pass
# Fallback: Tk icon from image assets (PNG/GIF)
# Prefer config-defined icon image
try:
cfg_icon = inst_config.get_ui_icon_image_path()
except Exception:
cfg_icon = get_resource_path("img/proleIcon.png")
img_candidates = [
cfg_icon,
get_resource_path("img/prole-type.png"),
get_resource_path("img/prole-type.gif"),
get_resource_path("img/Prole.png"),
get_resource_path("img/proleLogoSepia.png"),
]
for p in img_candidates:
try:
if p.exists():
self._app_iconphoto = tk.PhotoImage(file=str(p))
try:
self.root.iconphoto(True, self._app_iconphoto)
except Exception:
pass
break
except Exception:
continue
def show_config_dialog(self):
"""Show configuration dialog for Docker image import directory."""
dialog = tk.Toplevel(self.root)
dialog.title("Configuration")
dialog.geometry("500x200")
dialog.resizable(False, False)
dialog.transient(self.root)
dialog.grab_set()
# Center on parent
x = self.root.winfo_x() + (self.root.winfo_width() // 2) - 250
y = self.root.winfo_y() + (self.root.winfo_height() // 2) - 100
dialog.geometry(f"+{x}+{y}")
container = tk.Frame(dialog, padx=20, pady=20)
container.pack(fill="both", expand=True)
tk.Label(
container,
text="Docker Image Import Directory:",
font=("SF Pro Text", 12, "bold"),
).pack(anchor="w")
tk.Label(
container,
text="Images found here will be loaded instead of pulled from Docker Hub.",
font=("SF Pro Text", 10),
fg="#666666",
).pack(anchor="w", pady=(0, 10))
row = tk.Frame(container)
row.pack(fill="x")
entry = tk.Entry(
row,
textvariable=self.docker_import_dir,
font=("SF Pro Text", 11),
bg="white",
fg="black",
insertbackground="black",
)
entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
def browse():
path = filedialog.askdirectory(
initialdir=self.docker_import_dir.get() or Path.home()
)
if path:
self.docker_import_dir.set(path)
tk.Button(row, text="Browse...", command=browse).pack(side="right")
def save():
path = self.docker_import_dir.get().strip()
self.prole_cfg_data["Global"]["DOCKER_IMPORT_DIR"] = path
self._save_prole_cfg()
dialog.destroy()
btns = tk.Frame(container)
btns.pack(fill="x", pady=(20, 0))
tk.Button(btns, text="Save", command=save, width=10).pack(side="right")
tk.Button(btns, text="Cancel", command=dialog.destroy, width=10).pack(
side="right", padx=10
)
def configure_styles(self):
"""Configure ttk styles"""
base_bg = "white"
self.style.configure("TFrame", background="white")
self.style.configure(
"TLabel", background="white", foreground="black", font=("SF Pro Text", 11)
)
self.style.configure("Header.TLabel", font=("SF Pro Text", 16, "bold"))
self.style.configure("Small.TLabel", font=("SF Pro Text", 10))
# Consistent button styling
self.style.configure(
"Nav.TButton",
font=("SF Pro Text", 11),
padding=(12, 8),
background="#F5F5DC",
bordercolor="#F5F5DC",
lightcolor="#F5F5DC",
darkcolor="#F5F5DC",
relief="flat",
)
# Map state colors to avoid black boxes on hover/active
self.style.map(
"Nav.TButton",
background=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")],
bordercolor=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")],
lightcolor=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")],
darkcolor=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")],
)
# Notebook styling to match light theme and avoid dark mode shifts on macOS
self.style.theme_use("default")
self.style.configure(
"TNotebook", background="white", borderwidth=0, highlightthickness=0
)
self.style.configure(
"TNotebook.Tab",
background="#F5F5DC",
foreground="black",
lightcolor="#F5F5DC",
bordercolor="#CCCCCC",
darkcolor="#F5F5DC",
borderwidth=1,
padding=[10, 5],
)
self.style.map(
"TNotebook.Tab",
background=[("selected", "white")],
bordercolor=[("selected", "#CCCCCC")],
lightcolor=[("selected", "white")],
focuscolor=[("selected", "white")],
)
# Ensure common controls inherit white background
try:
self.style.configure("TCheckbutton", background=base_bg, foreground="black")
self.style.configure(
"TCombobox", fieldbackground="white", background=base_bg
)
except Exception:
pass
def _create_sidebar_nav(self):
"""Create the left-hand navigation menu."""
tk.Label(
self.sidebar,
text="INSTALLER",
bg="#F5F5DC",
fg="#8B8B7A",
font=("SF Pro Text", 10, "bold"),
anchor="w",
).pack(fill="x", padx=20, pady=(20, 10))
for text, page_id in self.nav_items:
lbl = tk.Label(
self.sidebar,
text=text,
bg="#F5F5DC",
fg="black",
font=("SF Pro Text", 11),
anchor="w",
padx=20,
pady=5,
cursor="hand2",
)
lbl.pack(fill="x")
lbl.bind("<Button-1>", lambda e, p=page_id: self.show_page(p))
self.nav_widgets[page_id] = lbl
def _update_nav_highlight(self, active_id):
"""Highlight the current active page in the sidebar."""
# Handle dep_id mapping to 'Dependencies'
if active_id.startswith("dep_"):
active_id = "deps_summary"
for page_id, widget in self.nav_widgets.items():
if page_id == active_id:
widget.configure(bg="#E5E5D5", font=("SF Pro Text", 11, "bold"))
else:
widget.configure(bg="#F5F5DC", font=("SF Pro Text", 11))
def create_navigation(self):
pass
def on_prev(self):
# Custom prev navigation for dependency pages when filtering
current_id = self.pages[self.page_index][0]
# Close Terminal if leaving build summary via Prev
if current_id == "build_summary":
try:
self.close_build_terminal()
except Exception:
pass
if current_id.startswith("dep_"):
seq = self._dep_navigation_sequence()
try:
i = seq.index(current_id)
except ValueError:
i = -1
if i > 0:
self.show_page(seq[i - 1])
return
else:
# Go back to summary if there is no previous in sequence
self.show_page("deps_summary")
return
if current_id == "deps_summary":
self.show_page("welcome")
return
if current_id == "network_scan":
# Go back to last relevant dep or deps_summary
if self.all_dependencies_installed():
self.show_page("deps_summary")
else:
seq = self._dep_navigation_sequence()
self.show_page(seq[-1] if seq else "deps_summary")
return
if current_id == "env_setup":
self.show_page("network_scan")
return
if current_id == "init_cluster":
if getattr(self, "_showing_service_overlay", False):
self._showing_service_overlay = False
self.show_page("init_cluster")
return
self.show_page("env_setup")
return
if current_id == "cluster_nodes":
self.show_page("init_cluster")
return
if current_id == "database_options":
if self._should_show_cluster_nodes_screen():
self.show_page("cluster_nodes")
else:
self.show_page("init_cluster")
return
if current_id == "init_password":
self.show_page("init_db_build")
return
if current_id == "init_db_build":
self.show_page("database_options")
return
if current_id == "init_scripts":
self.show_page("init_password")
return
if current_id == "kerberos_config":
self.show_page("init_scripts")
return
if current_id == "gitops_choice":
self.show_page("kerberos_config")
return
if current_id == "argocd_config":
self.show_page("gitops_choice")
return
if current_id == "gitops_config":
self.show_page("argocd_config")
return
if current_id == "supabase_config":
self.show_page("gitops_config")
return
if current_id == "init_cnpg_deploy":
self.show_page("supabase_config")
return
if current_id == "create_installer":
self.show_page("init_cnpg_deploy")
return
# Default prev
if self.page_index > 0:
self.show_page(self.page_index - 1)
return
def on_next(self):
# Special handling for dynamic labels
current_id = self.pages[self.page_index][0]
print(
f"[DEBUG] on_next: current_id='{current_id}', page_index={self.page_index}"
)
if current_id == "welcome":
self.show_page("deps_summary")
return
if current_id == "deps_summary":
# Determine where to go from summary
# Always go to the next dependency or Network Scan
if self.all_dependencies_installed():
print("[DEBUG] on_next: all deps installed, going to network_scan")
self.show_page("network_scan")
return
# Go to first missing dependency page
seq = self._dep_navigation_sequence()
print(f"[DEBUG] on_next: missing deps sequence: {seq}")
if seq:
self.show_page(seq[0])
else:
print(
"[DEBUG] on_next: all deps seem OK in sequence, going to network_scan"
)
self.show_page("network_scan")
return
if current_id.startswith("dep_"):
# Navigate within dependency sequence
seq = self._dep_navigation_sequence()
print(f"[DEBUG] on_next: dep sequence: {seq}")
try:
i = seq.index(current_id)
except ValueError:
i = -1
if i >= 0 and i < len(seq) - 1:
self.show_page(seq[i + 1])
return
else:
# After last relevant dep page, go back to summary if anything is still missing
if not self.all_dependencies_installed():
print(
"[DEBUG] on_next: some deps still missing, returning to deps_summary"
)
self.show_page("deps_summary")
else:
print(
"[DEBUG] on_next: all deps now installed, going to network_scan"
)
self.show_page("network_scan")
return
if current_id == "network_scan":
# Capture network scan info
try:
self.prole_cfg_data["Network"][
"KDC_AUTO_DETECTED"
] = self.kerberos_kdc.get()
self.prole_cfg_data["Network"]["KERBEROS_AUTO_ENABLED"] = str(
self.kerberos_enabled.get()
)
self._save_prole_cfg()
except Exception:
pass
self.show_page("env_setup")
return
if current_id == "env_setup":
# Collect values, validate, write env.sh, then go to Database Creation
vals: dict[str, str] = {}
raw_vals: dict[str, str] = {}
try:
for k in (
"PROLE_HOME",
"PROLE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"PROLE_SERVICE",
):
raw_vals[k] = self._env_entries[k].get().strip()
# Persist to prole.cfg as *literal* overrides only.
# New policy: do not store variable expressions for filesystem paths.
defaults_raw = {}
try:
defaults_raw = self._env_setup_defaults_raw() or {}
except Exception:
defaults_raw = {}
defaults_resolved = {}
resolved = {}
try:
defaults_resolved = self._resolve_env_paths_for_fs(defaults_raw) or {}
except Exception:
defaults_resolved = {}
try:
resolved = self._resolve_env_paths_for_fs({**defaults_raw, **raw_vals}) or {}
except Exception:
resolved = {}
sys_env = self.prole_cfg_data.get("System Environment")
if not isinstance(sys_env, dict):
sys_env = {}
for k in (
"PROLE_HOME",
"PROLE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"PROLE_SERVICE",
):
rv = (resolved.get(k) or "").strip()
dv = (defaults_resolved.get(k) or "").strip()
if not rv:
sys_env.pop(k, None)
continue
if "$" in rv:
# Should not happen (resolved values should be literal), but
# never persist variable tokens for filesystem paths.
sys_env.pop(k, None)
continue
if dv and rv == dv:
# Default value: omit from config.
sys_env.pop(k, None)
continue
sys_env[k] = rv
if sys_env:
self.prole_cfg_data["System Environment"] = sys_env
else:
self.prole_cfg_data.pop("System Environment", None)
vals.update(raw_vals)
vals["DATABASE_NAMESPACE"] = (self.db_namespace.get() or "").strip()
vals["CLUSTER_NAME"] = (
self.cnpg_cluster_name.get() if hasattr(self, "cnpg_cluster_name") else ""
).strip()
except Exception:
# Best-effort fallback: use internal defaults for env.sh.
vals = self._env_defaults()
# Basic validation: require non-empty PROLE_HOME
if not vals.get("PROLE_HOME"):
try:
messagebox.showerror("Environment", "Please specify PROLE_HOME")
except Exception:
pass
return
try:
self._save_env_to_file(vals)
# Reload our environment so subsequent steps (Build) inherit it
self._after_env_saved()
except Exception as e:
try:
messagebox.showwarning("Environment", f"Could not save env.sh: {e}")
except Exception:
pass
return
self.show_page("init_cluster")
return
if current_id == "init_password":
p1 = self.db_password.get()
p2 = self.db_password_confirm.get()
if not p1:
try:
messagebox.showerror("Password", "Password cannot be empty.")
except Exception:
pass
return
if p1 != p2:
try:
messagebox.showerror("Password", "Passwords do not match.")
except Exception:
pass
return
if not self._validate_port_forward_overlaps():
return
# Preparing actions are executed as the first part of Initialization Scripts.
self.show_page("init_scripts")
return
if current_id == "init_db_build":
self.show_page("init_password")
return
if current_id == "build":
if getattr(self, "_built_success", False):
self.show_page("create_installer")
else:
self.perform_build()
return
if current_id == "init_cluster":
if not self._validate_and_save_cluster_config():
return
if not self._cluster_ready_for_navigation():
return
# Cluster Nodes placement is only relevant for multi-node, non-k3d clusters.
if self._should_show_cluster_nodes_screen():
self.show_page("cluster_nodes")
else:
self.show_page("database_options")
return
if current_id == "cluster_nodes":
policy = getattr(self, "_cluster_nodes_current_policy", None) or {}
if hasattr(self, "_validate_and_save_cluster_nodes_policy"):
if not self._validate_and_save_cluster_nodes_policy(policy):
return
self.show_page("database_options")
return
if current_id == "database_options":
if self._generate_knoe_db_dockerfile():
self.show_page("init_db_build")
return
if current_id == "init_scripts":
self.prole_cfg_data["Initialization Scripts"]["STATUS"] = (
"Completed" if getattr(self, "_scripts_success", False) else "Attempted"
)
self._save_prole_cfg()
if self.kerberos_enabled.get():
self.show_page("kerberos_config")
else:
self.show_page("gitops_choice")
return
if current_id == "kerberos_config":
# Capture kerberos config
self.prole_cfg_data["Kerberos Authentication"]["ENABLED"] = str(
self.kerberos_enabled.get()
)
self.prole_cfg_data["Kerberos Authentication"][
"REALM"
] = self.kerberos_realm.get()
self.prole_cfg_data["Kerberos Authentication"][
"KDC"
] = self.kerberos_kdc.get()
self.prole_cfg_data["Kerberos Authentication"][
"SERVER"
] = self.kerberos_kdc.get()
self.prole_cfg_data["Kerberos Authentication"][
"USER"
] = self.kerberos_user.get()
self.prole_cfg_data["Kerberos Authentication"][
"PASSWORD"
] = self.kerberos_password.get()
self.prole_cfg_data["Kerberos Authentication"]["AD_PORT_FORWARD"] = (
os.environ.get("KRB5_AD_PORT_FORWARD", "1")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_TCP_PORTS"] = (
os.environ.get("KRB5_AD_TCP_PORTS", "88 389 445 464 636")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_UDP_PORTS"] = (
os.environ.get("KRB5_AD_UDP_PORTS", "88 464")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_HOST_NETWORK"] = (
os.environ.get("KRB5_AD_PROXY_HOST_NETWORK", "1")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_IMAGE"] = (
os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_SERVICE"] = (
os.environ.get("KRB5_AD_SERVICE_NAME", "prole-kerberos-ad-dc")
)
self._save_prole_cfg()
self.show_page("gitops_choice")
return
if current_id == "gitops_choice":
if "Optional Features" not in self.prole_cfg_data:
self.prole_cfg_data["Optional Features"] = {}
self.prole_cfg_data["Optional Features"]["GITOPS_PROVIDER"] = (
self.gitops_provider.get()
)
self._save_prole_cfg()
self.show_page("argocd_config")
return
if current_id == "argocd_config":
self.prole_cfg_data["Optional Features"]["ARGOCD_ENABLED"] = str(
self.argocd_enabled.get()
)
argocd_status = (
"Skipped"
if not self.argocd_enabled.get()
else (
"Deployed"
if getattr(self, "_argocd_success", False)
else "Attempted"
)
)
argocd_ns = (self.argocd_namespace.get() or "argocd").strip() or "argocd"
self.prole_cfg_data.setdefault("Global", {})["ARGOCD_NAMESPACE"] = argocd_ns
self.prole_cfg_data["ArgoCD"] = {
"STATUS": argocd_status,
"ARGOCD_NAMESPACE": argocd_ns,
}
self._save_prole_cfg()
self.show_page("gitops_config")
return
if current_id == "gitops_config":
self.prole_cfg_data["Optional Features"]["GITOPS_ENABLED"] = str(
self.gitops_enabled.get()
)
gitops_status = (
"Skipped"
if not self.gitops_enabled.get()
else (
"Deployed"
if getattr(self, "_gitops_success", False)
else "Attempted"
)
)
self.prole_cfg_data["GitOps"] = {
"STATUS": gitops_status,
"GITOPS_NAMESPACE": (self.gitops_namespace.get() or "gitea").strip()
or "gitea",
}
self._save_prole_cfg()
self.show_page("supabase_config")
return
if current_id == "supabase_config":
self.prole_cfg_data["Optional Features"]["SUPABASE_ENABLED"] = str(
self.supabase_enabled.get()
)
supabase_status = (
"Skipped"
if not self.supabase_enabled.get()
else (
"Deployed"
if getattr(self, "_supabase_success", False)
else "Attempted"
)
)
self.prole_cfg_data["Supabase"] = {
"STATUS": supabase_status,
}
self._save_prole_cfg()
self.show_page("init_cnpg_deploy")
return
if current_id == "init_cnpg_deploy":
self.prole_cfg_data["Deployment"]["STATUS"] = (
"Deployed" if getattr(self, "_cnpg_success", False) else "Attempted"
)
self._save_prole_cfg()
self.show_page("create_installer")
return
if current_id == "create_installer":
print("[DEBUG] on_next: at create_installer, Finish clicked. Closing.")
if "Install" in self.prole_cfg_data:
self.prole_cfg_data["Install"]["STATUS"] = "Finished"
self._save_prole_cfg()
self.root.destroy()
return
# Default next
if self.page_index < len(self.pages) - 1:
print(f"[DEBUG] on_next: default next to index {self.page_index + 1}")
self.show_page(self.page_index + 1)
else:
print("[DEBUG] on_next: already at last page")
return
def _should_show_cluster_nodes_screen(self) -> bool:
"""Show Cluster Nodes only for multi-node, non-k3d clusters."""
try:
env_key = (self.cluster_env.get() or "").strip()
except Exception:
env_key = ""
if _deployment_mode_from_env(env_key) == "k3d":
return False
topo = getattr(self, "ansible_topology", None) or {}
groups = topo.get("groups") or {}
hosts = topo.get("hosts") or {}
k3s_hosts = groups.get("k3s_hosts") or []
if isinstance(k3s_hosts, list) and len(k3s_hosts) > 1:
return True
try:
return len(hosts) > 1
except Exception:
return False
def update_footer(self):
# Default states for tk.Button
self.prev_button.configure(state="normal")
self.next_button.configure(state="normal")
first = self.page_index == 0
# Base label
self.next_button.configure(text="Next")
if getattr(self, "_showing_service_overlay", False):
if not getattr(self, "_common_services_success", False):
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
# Page-specific adjustments
pid = self.pages[self.page_index][0]
if pid == "build":
# Build page: show Build or Next depending on state
if getattr(self, "_built_success", False):
self.next_button.configure(text="Next")
else:
self.next_button.configure(text="Build")
elif pid == "supabase_config":
# Supabase screen: allow Next when disabled or after success
if self.supabase_enabled.get():
if getattr(self, "_supabase_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
elif pid == "gitops_config":
if self.gitops_enabled.get():
if getattr(self, "_gitops_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
elif pid == "gitops_choice":
self.next_button.configure(state="normal")
elif pid == "argocd_config":
if self.argocd_enabled.get():
if getattr(self, "_argocd_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
elif pid == "init_scripts":
# Initialization Scripts: Next is disabled until success
if getattr(self, "_scripts_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
elif pid == "kerberos_config":
if self.kerberos_enabled.get():
if getattr(self, "_kerberos_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
# Visibility rules
self.prev_button.pack_forget()
self.next_button.pack_forget()
if self.deploy_button:
self.deploy_button.pack_forget()
if self.launch_button:
self.launch_button.pack_forget()
if self.gear_button:
self.gear_button.pack_forget()
if pid == "init_password" and self.gear_button:
self.gear_button.pack(side="left", padx=(20, 0), pady=12)
if pid == "create_installer":
self.next_button.configure(text="Finish")
self.next_button.pack(side="right", padx=(0, 20), pady=12)
return
if first:
self.next_button.pack(side="right", padx=(0, 20), pady=12)
else:
# [Prev] [Next] clustered right
self.next_button.pack(side="right", padx=(0, 20), pady=12)
self.prev_button.pack(side="right", padx=(0, 8), pady=12)
def _splash_should_hide_nav(self) -> bool:
"""Deprecated: Welcome page no longer performs dependency checks or gates navigation."""
return False