mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 15:34:30 +00:00
Separation of concerns: merge silent/UI actions & modularize screens. ProleInstallerBase (actions.py): Created shared base class with 37 deduplicated methods previously duplicated between ProleSilentInstaller and ProleInstaller. Namespace, environment, secret, deployment, port-forward, authority/repair, image, and logging helpers now defined once. Subclasses override _get_input() to bridge their data-access layers. screens.py -> screens/ package (18 mixin modules): Split 10,234-line monolithic screens.py into focused mixin modules: base, navigation, welcome, dependencies, network, environment, database, cluster, services, security, ollama, supabase, docker, build, packaging, deploy, validate, cfg. __init__.py composes ProleInstaller from all mixins and re-exports has_display(), main() for full backward compatibility. All 37 tests pass with no regressions.
562 lines
24 KiB
Python
562 lines
24 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 installer import config as inst_config
|
|
from installer.core.env import get_resource_path
|
|
|
|
|
|
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))
|
|
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 == 'init_password':
|
|
self.show_page('init_cluster')
|
|
return
|
|
if current_id == 'init_db_build':
|
|
self.show_page('init_password')
|
|
return
|
|
if current_id == 'init_scripts':
|
|
if self.kerberos_enabled.get():
|
|
self.show_page('kerberos_config')
|
|
else:
|
|
self.show_page('init_db_build')
|
|
return
|
|
if current_id == 'kerberos_config':
|
|
self.show_page('init_db_build')
|
|
return
|
|
if current_id == 'ollama_config':
|
|
self.show_page('init_scripts')
|
|
return
|
|
if current_id == 'supabase_config':
|
|
self.show_page('ollama_config')
|
|
return
|
|
if current_id == 'init_cnpg_deploy':
|
|
if self.supabase_enabled.get():
|
|
self.show_page('supabase_config')
|
|
return
|
|
self.show_page('ollama_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 = {}
|
|
try:
|
|
for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'):
|
|
vals[k] = self._env_entries[k].get().strip()
|
|
self.prole_cfg_data['System Environment'][k] = vals[k]
|
|
vals['NAMESPACE'] = (self.db_namespace.get() or '').strip()
|
|
except Exception:
|
|
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
|
|
self._run_preparation_overlay()
|
|
return
|
|
|
|
if current_id == 'init_db_build':
|
|
if self.kerberos_enabled.get():
|
|
self.show_page('kerberos_config')
|
|
else:
|
|
self.show_page('init_scripts')
|
|
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
|
|
if not getattr(self, '_common_services_success', False):
|
|
self._run_service_layer_overlay(next_page='init_password')
|
|
return
|
|
self.show_page('init_password')
|
|
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()
|
|
self.show_page('ollama_config')
|
|
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('init_scripts')
|
|
return
|
|
|
|
if current_id == 'ollama_config':
|
|
self._save_ollama_config()
|
|
if self.supabase_enabled.get():
|
|
self.show_page('supabase_config')
|
|
else:
|
|
self.show_page('init_cnpg_deploy')
|
|
return
|
|
|
|
if current_id == 'supabase_config':
|
|
self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get())
|
|
self.prole_cfg_data['Supabase'] = {'STATUS': 'Deployed' if getattr(self, '_supabase_success', False) else 'Attempted'}
|
|
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 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: Next is disabled until success
|
|
if getattr(self, '_supabase_success', False):
|
|
self.next_button.configure(state='normal')
|
|
else:
|
|
self.next_button.configure(state='disabled')
|
|
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')
|
|
# 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
|