"""Launcher settings dialog, isolated from dashboard orchestration.""" from __future__ import annotations from typing import Any, Callable from ...paths import AppPaths from ...settings import Settings, ensure_settings, update_settings from ...tk_chrome import ( centered_window_geometry, create_window_chrome, show_themed_dialog, ) from ..viewmodels.dashboard import ( UI_SCALE_LABELS, fitted_window_size, setting_label, setting_value, ) from ..viewmodels.downloader import ( DOWNLOAD_SPEED_CUSTOM_LABEL, DOWNLOAD_SPEED_PRESETS_MBPS, download_speed_setting_label, download_speed_setting_value, ) RUNTIME_LABELS: dict[str, str] = { "Automatic (recommended)": "auto", "Proton 7 — Steam Runtime container": "proton7-container", "Proton 7 — Direct": "proton7", "UMU Launcher": "umu", } PROTON_SYNC_LABELS: dict[str, str] = { "Automatic (recommended)": "auto", "Fsync": "fsync", "Esync": "esync", } SETTINGS_STACKED_LAYOUT_WIDTH = 720 SETTINGS_STACKED_FOOTER_WIDTH = 640 SETTINGS_MINIMUM_WIDTH = 620 SETTINGS_MINIMUM_HEIGHT = 520 def open_settings_dialog( root: Any, *, paths: AppPaths, launcher_ui_scale: float, window_icon: Any | None, apply_window_icon: Callable[[Any], None], on_saved: Callable[[Settings], None], existing: Any = None, ) -> Any | None: """Create, or focus, the single settings window owned by a dashboard.""" import tkinter as tk from tkinter import filedialog, ttk active_window: Any = existing if active_window is not None and active_window.winfo_exists(): active_window.deiconify() active_window.lift() active_window.focus_set() return active_window try: current_settings = ensure_settings(paths) except (OSError, ValueError) as settings_error: show_themed_dialog( root, title="Settings", message=f"The launcher could not load its settings:\n\n{settings_error}", kind="error", window_icon=window_icon, ) return None dialog = tk.Toplevel(root) dialog.withdraw() dialog.title("PristonTale EU — Settings") dialog.resizable(True, True) dialog.configure(background="#0c0908") dialog.transient(root) apply_window_icon(dialog) settings_chrome = create_window_chrome( dialog, title="PRISTONTALE EU", subtitle="LAUNCHER SETTINGS", window_icon=window_icon, show_close=True, show_minimize=False, allow_close=True, ) settings_header = tk.Frame( settings_chrome.content, background="#24170f", ) settings_header.pack(fill="x") tk.Frame(settings_header, background="#d98927", width=5).pack(side="left", fill="y") header_copy = tk.Frame(settings_header, background="#24170f") header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) tk.Label( header_copy, text="PRISTONTALE EU", background="#24170f", foreground="#e5b66c", font=("Sans", 9, "bold"), ).pack(anchor="w") tk.Label( header_copy, text="LAUNCHER SETTINGS", background="#24170f", foreground="#fff0d2", font=("Georgia", 18, "bold"), ).pack(anchor="w", pady=(1, 0)) tk.Label( header_copy, text=( "Tune launcher accessibility, downloads, the game window, and runtime " "preferences." ), background="#24170f", foreground="#c9baaa", font=("Sans", 9), ).pack(anchor="w", pady=(2, 0)) body = ttk.Frame(settings_chrome.content, style="Root.TFrame") body.pack(fill="both", expand=True) body.columnconfigure(0, weight=1) body.rowconfigure(0, weight=1) settings_viewport = ttk.Frame(body, name="settings_viewport", style="Root.TFrame") settings_viewport.grid( row=0, column=0, sticky="nsew", padx=(16, 10), pady=(14, 8), ) settings_viewport.columnconfigure(0, weight=1) settings_viewport.rowconfigure(0, weight=1) settings_canvas = tk.Canvas( settings_viewport, name="settings_canvas", background="#0c0908", borderwidth=0, highlightthickness=0, takefocus=True, yscrollincrement=max(1, round(24 * launcher_ui_scale)), ) settings_canvas.grid(row=0, column=0, sticky="nsew") settings_scrollbar = ttk.Scrollbar( settings_viewport, name="settings_scrollbar", orient="vertical", style="News.Vertical.TScrollbar", command=settings_canvas.yview, ) settings_scrollbar.grid(row=0, column=1, sticky="ns", padx=(6, 0)) settings_canvas.configure(yscrollcommand=settings_scrollbar.set) content = ttk.Frame( settings_canvas, name="settings_content", style="Root.TFrame", padding=(0, 0, 0, 8), ) content_window = settings_canvas.create_window( (0, 0), window=content, anchor="nw", ) content.columnconfigure(0, weight=1, uniform="settings-card") content.columnconfigure(1, weight=1, uniform="settings-card") borderless_value = tk.BooleanVar(value=current_settings.borderless) allow_window_resizing_value = tk.BooleanVar( value=current_settings.allow_window_resizing ) ui_scale_value = tk.StringVar( value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS) ) telemetry_value = tk.BooleanVar(value=current_settings.telemetry_enabled) suppress_unresponsive_value = tk.BooleanVar( value=current_settings.suppress_unresponsive_warnings ) runtime_value = tk.StringVar( value=setting_label(current_settings.runtime, RUNTIME_LABELS) ) proton_sync_value = tk.StringVar( value=setting_label(current_settings.proton_sync, PROTON_SYNC_LABELS) ) proton_path_value = tk.StringVar(value=current_settings.proton_path or "") game_source_value = tk.StringVar(value=current_settings.game_source or "") download_speed_value = tk.StringVar( value=download_speed_setting_label( current_settings.download_speed_limit_mbps ) ) custom_download_speed_value = tk.StringVar( value=( f"{current_settings.download_speed_limit_mbps:g}" if current_settings.download_speed_limit_mbps is not None else "5" ) ) low_bandwidth_value = tk.BooleanVar( value=current_settings.low_bandwidth_mode ) def add_choice( parent: ttk.LabelFrame, row: int, name: str, label: str, description: str, variable: tk.StringVar, choices: tuple[str, ...], ) -> None: ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( row=row, column=0, sticky="w", ) ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( row=row, column=1, sticky="e", ) ttk.Combobox( parent, name=name, textvariable=variable, values=choices, state="readonly", style="Settings.TCombobox", cursor="hand2", exportselection=False, takefocus=True, ).grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9)) def add_path( parent: ttk.LabelFrame, row: int, name: str, label: str, description: str, variable: tk.StringVar, browse: Callable[[], None], ) -> None: ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( row=row, column=0, sticky="w", ) ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( row=row, column=1, sticky="e", ) ttk.Entry( parent, name=name, textvariable=variable, style="Settings.TEntry", exportselection=False, takefocus=True, ).grid( row=row + 1, column=0, columnspan=1, sticky="ew", pady=(3, 8), ) ttk.Button(parent, text="BROWSE", style="Secondary.TButton", command=browse).grid( row=row + 1, column=1, sticky="e", padx=(8, 0), pady=(3, 8), ) def choose_proton_path() -> None: selected = filedialog.askopenfilename( parent=dialog, title="Choose the Proton script", ) if selected: proton_path_value.set(selected) def choose_game_source() -> None: selected = filedialog.askdirectory( parent=dialog, title="Choose an existing PristonTale EU game directory", mustexist=True, ) if selected: game_source_value.set(selected) def close_settings() -> None: if dialog.winfo_exists(): dialog.destroy() settings_chrome.set_close_command(close_settings) def save_current_settings() -> None: try: download_speed_limit = download_speed_setting_value( download_speed_value.get(), custom_download_speed_value.get(), ) updated_settings = update_settings( paths, borderless=borderless_value.get(), allow_window_resizing=allow_window_resizing_value.get(), ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS), telemetry_enabled=telemetry_value.get(), suppress_unresponsive_warnings=suppress_unresponsive_value.get(), runtime=setting_value(runtime_value.get(), RUNTIME_LABELS), proton_sync=setting_value( proton_sync_value.get(), PROTON_SYNC_LABELS, ), proton_path=proton_path_value.get().strip() or None, game_source=game_source_value.get().strip() or None, download_speed_limit_mbps=download_speed_limit, low_bandwidth_mode=low_bandwidth_value.get(), ) except (OSError, ValueError) as save_error: show_themed_dialog( dialog, title="Settings", message=str(save_error), kind="error", window_icon=window_icon, ) return on_saved(updated_settings) close_settings() display_card = ttk.LabelFrame( content, text="GAME WINDOW", style="SettingsCard.TLabelframe", padding=14, ) display_card.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) display_card.columnconfigure(0, weight=1) display_card.columnconfigure(1, weight=1) display_hint = ttk.Label( display_card, text=( "Gamescope can resize the desktop window while keeping the game's 32-bit " "Direct3D surface fixed. The game keeps its aspect ratio as the window " "resizes, with black bars when the window has a different shape. Maximize " "the window or press Super+F to toggle fullscreen. " "If Gamescope is unavailable, the launcher keeps the native game window " "at its configured size." ), style="SettingsHint.TLabel", wraplength=360, ) display_hint.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) presentation = ttk.Frame(display_card, style="SettingsCard.TFrame") presentation.grid(row=1, column=0, columnspan=2, sticky="ew") ttk.Checkbutton( presentation, text="Remove title bar and borders (Borderless)", variable=borderless_value, style="Settings.TCheckbutton", cursor="hand2", takefocus=True, ).pack(fill="x") ttk.Checkbutton( presentation, text="Use a resizable scaled window (Gamescope)", variable=allow_window_resizing_value, style="Settings.TCheckbutton", cursor="hand2", takefocus=True, ).pack(fill="x", pady=(7, 0)) ttk.Checkbutton( presentation, text="Suppress false 'not responding' warnings", variable=suppress_unresponsive_value, style="Settings.TCheckbutton", cursor="hand2", takefocus=True, ).pack(fill="x", pady=(7, 0)) add_choice( display_card, 2, "settings_selector_ui_scale", "Launcher size", "Applies after launcher restart", ui_scale_value, tuple(UI_SCALE_LABELS), ) rendering_card = ttk.LabelFrame( content, text="RUNTIME", style="SettingsCard.TLabelframe", padding=14, ) rendering_card.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) rendering_card.columnconfigure(0, weight=1) rendering_card.columnconfigure(1, weight=1) rendering_hint = ttk.Label( rendering_card, text="Choose which compatibility runtime launches the proven game setup.", style="SettingsHint.TLabel", wraplength=360, ) rendering_hint.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) add_choice( rendering_card, 1, "settings_selector_runtime", "Runtime", "Compatibility layer", runtime_value, tuple(RUNTIME_LABELS), ) add_choice( rendering_card, 3, "settings_selector_proton_sync", "Proton synchronization", "Automatic selects the compatible mode", proton_sync_value, tuple(PROTON_SYNC_LABELS), ) downloads_card = ttk.LabelFrame( content, text="DOWNLOADS", style="SettingsCard.TLabelframe", padding=14, ) downloads_card.grid( row=1, column=0, columnspan=2, sticky="ew", pady=(12, 0), ) downloads_card.columnconfigure(0, weight=1) downloads_card.columnconfigure(1, weight=1) downloads_card.columnconfigure(2, weight=1) ttk.Label( downloads_card, text=( "Concurrency adapts to CDN latency, retries, transfer and disk speed, " "CPU load, and the remaining files." ), style="SettingsHint.TLabel", ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 9)) ttk.Checkbutton( downloads_card, text="Low-bandwidth mode (cached news, up to 2 workers and 5 MB/s)", variable=low_bandwidth_value, style="Settings.TCheckbutton", cursor="hand2", takefocus=True, ).grid(row=1, column=0, rowspan=2, sticky="ew", padx=(0, 16)) ttk.Label( downloads_card, text="Speed limit", style="SettingsText.TLabel", ).grid(row=1, column=1, sticky="w") ttk.Label( downloads_card, text="Custom MB/s", style="SettingsText.TLabel", ).grid(row=1, column=2, sticky="w", padx=(8, 0)) download_speed_selector = ttk.Combobox( downloads_card, name="settings_selector_download_speed", textvariable=download_speed_value, values=tuple(DOWNLOAD_SPEED_PRESETS_MBPS) + ( DOWNLOAD_SPEED_CUSTOM_LABEL, ), state="readonly", style="Settings.TCombobox", cursor="hand2", exportselection=False, takefocus=True, ) download_speed_selector.grid( row=2, column=1, sticky="ew", pady=(3, 0), ) custom_download_speed = ttk.Entry( downloads_card, name="settings_download_speed", textvariable=custom_download_speed_value, style="Settings.TEntry", width=12, justify="right", exportselection=False, takefocus=True, ) custom_download_speed.grid( row=2, column=2, sticky="ew", padx=(8, 0), pady=(3, 0), ) def update_custom_download_state(_event: object | None = None) -> None: custom_download_speed.configure( state=( "normal" if download_speed_value.get() == DOWNLOAD_SPEED_CUSTOM_LABEL else "disabled" ) ) download_speed_selector.bind( "<>", update_custom_download_state, ) update_custom_download_state() diagnostics_card = ttk.LabelFrame( content, text="PRIVACY & DIAGNOSTICS", style="SettingsCard.TLabelframe", padding=14, ) diagnostics_card.grid( row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0), ) diagnostics_card.columnconfigure(0, weight=1) ttk.Checkbutton( diagnostics_card, name="settings_telemetry_enabled", text="Send anonymous diagnostic and usage information", variable=telemetry_value, style="Settings.TCheckbutton", cursor="hand2", takefocus=True, ).grid(row=0, column=0, sticky="w") diagnostics_hint = ttk.Label( diagnostics_card, text=( "Helps improve PristonTale EU Linux compatibility by sending anonymous " "launcher errors, Linux and display versions, graphics/runtime compatibility, " "and installation or update reliability. Game accounts, personal files, " "hostnames, and hardware serial numbers are never collected." ), style="SettingsHint.TLabel", wraplength=720, ) diagnostics_hint.grid(row=1, column=0, sticky="w", pady=(7, 0)) overrides_card = ttk.LabelFrame( content, text="OPTIONAL OVERRIDES", style="SettingsCard.TLabelframe", padding=14, ) overrides_card.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0)) overrides_card.columnconfigure(0, weight=1) ttk.Label( overrides_card, text="Leave these blank for automatic runtime and managed-game detection.", style="SettingsHint.TLabel", ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) add_path( overrides_card, 1, "settings_proton_path", "Proton script", "Use a specific Proton executable", proton_path_value, choose_proton_path, ) add_path( overrides_card, 3, "settings_game_source", "Import source", "Seed a new managed installation only", game_source_value, choose_game_source, ) footer = ttk.Frame( body, name="settings_footer", style="SettingsFooter.TFrame", padding=(12, 9), ) footer.grid(row=1, column=0, sticky="ew", padx=16, pady=(0, 16)) footer.columnconfigure(0, weight=1) footer_note = ttk.Label( footer, text="Only the settings shown here affect the next launcher operation.", style="SettingsFooter.TLabel", ) footer_note.grid(row=0, column=0, sticky="w") buttons_row = ttk.Frame( footer, name="settings_buttons", style="SettingsFooter.TFrame", ) buttons_row.grid(row=0, column=1, sticky="e") ttk.Button( buttons_row, name="settings_cancel", text="CANCEL", style="Secondary.TButton", command=close_settings, ).pack(side="left") ttk.Button( buttons_row, name="settings_save", text="SAVE SETTINGS", style="Play.TButton", command=save_current_settings, ).pack(side="left", padx=(8, 0)) settings_layout_mode: tuple[bool, bool] | None = None settings_layout_width: int | None = None pending_settings_width = 1 settings_layout_after: str | None = None def arrange_settings(viewport_width: int) -> None: """Reflow cards and footer controls for the available logical width.""" nonlocal settings_layout_mode, settings_layout_width if settings_layout_width == viewport_width: return settings_layout_width = viewport_width logical_width = viewport_width / max(launcher_ui_scale, 0.01) stacked = logical_width < SETTINGS_STACKED_LAYOUT_WIDTH footer_stacked = logical_width < SETTINGS_STACKED_FOOTER_WIDTH card_width = ( viewport_width if stacked else (viewport_width - round(12 * launcher_ui_scale)) // 2 ) card_wraplength = max(140, card_width - round(42 * launcher_ui_scale)) display_hint.configure(wraplength=card_wraplength) rendering_hint.configure(wraplength=card_wraplength) diagnostics_hint.configure( wraplength=max(140, viewport_width - round(42 * launcher_ui_scale)) ) footer_note.configure( wraplength=( max(140, viewport_width - round(42 * launcher_ui_scale)) if footer_stacked else max(140, viewport_width - round(300 * launcher_ui_scale)) ) ) mode = (stacked, footer_stacked) if settings_layout_mode == mode: return settings_layout_mode = mode if stacked: display_card.grid_configure( row=0, column=0, columnspan=2, sticky="ew", padx=0, pady=0, ) rendering_card.grid_configure( row=1, column=0, columnspan=2, sticky="ew", padx=0, pady=(12, 0), ) downloads_card.grid_configure(row=2, pady=(12, 0)) diagnostics_card.grid_configure(row=3, pady=(12, 0)) overrides_card.grid_configure(row=4, pady=(12, 0)) else: display_card.grid_configure( row=0, column=0, columnspan=1, sticky="nsew", padx=(0, 6), pady=0, ) rendering_card.grid_configure( row=0, column=1, columnspan=1, sticky="nsew", padx=(6, 0), pady=0, ) downloads_card.grid_configure(row=1, pady=(12, 0)) diagnostics_card.grid_configure(row=2, pady=(12, 0)) overrides_card.grid_configure(row=3, pady=(12, 0)) if footer_stacked: footer_note.grid_configure(row=0, column=0, columnspan=2) buttons_row.grid_configure( row=1, column=0, columnspan=2, pady=(8, 0), ) else: footer_note.grid_configure(row=0, column=0, columnspan=1) buttons_row.grid_configure( row=0, column=1, columnspan=1, pady=0, ) def refresh_scroll_region(_event: object | None = None) -> None: bounds = settings_canvas.bbox("all") if bounds is not None: settings_canvas.configure(scrollregion=bounds) def apply_pending_settings_layout() -> None: nonlocal settings_layout_after settings_layout_after = None arrange_settings(pending_settings_width) def resize_settings_content(event: Any) -> None: nonlocal pending_settings_width, settings_layout_after pending_settings_width = max(1, int(event.width)) settings_canvas.itemconfigure(content_window, width=pending_settings_width) if settings_layout_after is None: settings_layout_after = dialog.after_idle(apply_pending_settings_layout) def scroll_settings(event: Any) -> str | None: canvas_left = settings_canvas.winfo_rootx() canvas_top = settings_canvas.winfo_rooty() pointer_x = dialog.winfo_pointerx() pointer_y = dialog.winfo_pointery() if not ( canvas_left <= pointer_x < canvas_left + settings_canvas.winfo_width() and canvas_top <= pointer_y < canvas_top + settings_canvas.winfo_height() ): return None if getattr(event.widget, "winfo_class", lambda: "")() == "TCombobox": return None first, last = settings_canvas.yview() if last - first >= 0.999: return None if getattr(event, "num", None) == 4: units = -3 elif getattr(event, "num", None) == 5: units = 3 else: delta = int(getattr(event, "delta", 0)) if delta == 0: return None steps = max(1, min(4, abs(delta) // 120 or 1)) units = -steps if delta > 0 else steps settings_canvas.yview_scroll(units, "units") return "break" content.bind("", refresh_scroll_region) settings_canvas.bind("", resize_settings_content) dialog.bind("", scroll_settings, add="+") dialog.bind("", scroll_settings, add="+") dialog.bind("", scroll_settings, add="+") settings_canvas.bind("", lambda _event: settings_canvas.yview_moveto(0.0)) settings_canvas.bind("", lambda _event: settings_canvas.yview_moveto(1.0)) settings_canvas.bind( "", lambda _event: settings_canvas.yview_scroll(-1, "pages"), ) settings_canvas.bind( "", lambda _event: settings_canvas.yview_scroll(1, "pages"), ) dialog.protocol("WM_DELETE_WINDOW", close_settings) dialog.bind("", lambda _event: close_settings()) dialog.bind("", lambda _event: save_current_settings()) dialog.update_idletasks() settings_width, settings_height = fitted_window_size( 842, 880, scale=launcher_ui_scale, screen_width=dialog.winfo_screenwidth(), screen_height=dialog.winfo_screenheight(), ) minimum_width, minimum_height = fitted_window_size( SETTINGS_MINIMUM_WIDTH, SETTINGS_MINIMUM_HEIGHT, scale=launcher_ui_scale, screen_width=dialog.winfo_screenwidth(), screen_height=dialog.winfo_screenheight(), ) dialog.minsize(minimum_width, minimum_height) dialog.geometry( centered_window_geometry( settings_width, settings_height, parent_x=root.winfo_rootx(), parent_y=root.winfo_rooty(), parent_width=root.winfo_width(), parent_height=root.winfo_height(), ) ) dialog.deiconify() dialog.lift() dialog.grab_set() dialog.focus_set() return dialog