diff --git a/setup_tool_responsive.py b/setup_tool_responsive.py index 68fcfa8..1b57083 100644 --- a/setup_tool_responsive.py +++ b/setup_tool_responsive.py @@ -1,15 +1,38 @@ +import json +import os import tkinter as tk -from tkinter import ttk +from tkinter import messagebox, ttk +import dxvk_fps import setup_tool_dynamic class ResponsiveModernWowSetupTool(setup_tool_dynamic.ModernWowSetupTool): - """Production entry point with DPI/display-scaling-safe window behavior.""" + """Production entry point with display-scaling-safe UI and DXVK FPS control.""" def __init__(self, root): + # Define these before the parent constructor: WowSetupTool.__init__ calls + # overridden UI/settings methods while it is still initializing. + self.detected_refresh_rate = dxvk_fps.detect_max_refresh_rate() + self.limit_dxvk_fps = tk.BooleanVar(master=root, value=True) + self.dxvk_fps_limit = tk.IntVar( + master=root, + value=self.detected_refresh_rate, + ) + self.dxvk_fps_checkbox = None + self.dxvk_fps_entry = None + self.dxvk_fps_unit_label = None + super().__init__(root) + # Keep the FPS controls synchronized when the renderer radio selection + # changes or when saved settings restore a different renderer. + self._rendering_mode_trace = self.rendering_mode.trace_add( + "write", + lambda *_args: self._update_dxvk_fps_state(), + ) + self._update_dxvk_fps_state() + # Preserve all existing installer behavior and only change how the # top-level Tk window allocates and exposes its controls. root.resizable(True, True) @@ -44,6 +67,192 @@ class ResponsiveModernWowSetupTool(setup_tool_dynamic.ModernWowSetupTool): root.geometry(f"{width}x{height}") root.minsize(min(640, max_width), min(520, max_height)) + def build_main_tab(self, parent): + super().build_main_tab(parent) + + optional_label = None + for child in parent.winfo_children(): + try: + if child.cget("text") == "Optional Mods:": + optional_label = child + break + except tk.TclError: + continue + + fps_frame = ttk.Frame(parent) + pack_options = { + "fill": "x", + "padx": 20, + "pady": (8, 2), + } + if optional_label is not None: + pack_options["before"] = optional_label + fps_frame.pack(**pack_options) + + controls = ttk.Frame(fps_frame) + controls.pack(anchor="w") + + self.dxvk_fps_checkbox = ttk.Checkbutton( + controls, + text="Limit DXVK FPS", + variable=self.limit_dxvk_fps, + command=self._update_dxvk_fps_state, + ) + self.dxvk_fps_checkbox.pack(side="left") + + self.dxvk_fps_entry = ttk.Entry( + controls, + textvariable=self.dxvk_fps_limit, + width=7, + ) + self.dxvk_fps_entry.pack(side="left", padx=(10, 4)) + + self.dxvk_fps_unit_label = ttk.Label(controls, text="FPS") + self.dxvk_fps_unit_label.pack(side="left") + + help_label = ttk.Label( + fps_frame, + text=( + "Auto-detected from the fastest active display at startup. " + "You can change it manually. DXVK only." + ), + font=("Segoe UI", 8, "italic"), + ) + help_label.pack(anchor="w", padx=(22, 0), pady=(1, 0)) + + tooltip = ( + "Limits DXVK with d3d9.maxFrameRate. The initial value uses the " + f"highest active display refresh rate detected at startup " + f"({self.detected_refresh_rate} Hz). You can enter another positive " + "integer. DirectX 9 is never modified by this option." + ) + setup_tool_dynamic.ToolTip(self.dxvk_fps_checkbox, tooltip) + setup_tool_dynamic.ToolTip(self.dxvk_fps_entry, tooltip) + + self._update_dxvk_fps_state() + + def _update_dxvk_fps_state(self): + is_dxvk = getattr(self, "rendering_mode", None) is not None and ( + self.rendering_mode.get() == "dxvk" + ) + enabled = bool(self.limit_dxvk_fps.get()) + + checkbox = getattr(self, "dxvk_fps_checkbox", None) + entry = getattr(self, "dxvk_fps_entry", None) + unit_label = getattr(self, "dxvk_fps_unit_label", None) + + if checkbox is not None: + checkbox.configure(state="normal" if is_dxvk else "disabled") + if entry is not None: + entry.configure(state="normal" if is_dxvk and enabled else "disabled") + if unit_label is not None: + unit_label.configure(state="normal" if is_dxvk and enabled else "disabled") + + def _collect_settings(self): + settings = super()._collect_settings() + try: + fps_value = int(self.dxvk_fps_limit.get()) + except (tk.TclError, TypeError, ValueError): + fps_value = int(self.detected_refresh_rate) + + settings["dxvk_fps_limit"] = { + "enabled": bool(self.limit_dxvk_fps.get()), + "value": fps_value, + } + return settings + + def _apply_settings_dict(self, saved): + super()._apply_settings_dict(saved) + + fps_settings = saved.get("dxvk_fps_limit") if isinstance(saved, dict) else None + if isinstance(fps_settings, dict): + enabled = fps_settings.get("enabled") + value = fps_settings.get("value") + if isinstance(enabled, bool): + self.limit_dxvk_fps.set(enabled) + if ( + isinstance(value, int) + and not isinstance(value, bool) + and value > 0 + ): + self.dxvk_fps_limit.set(value) + + self._update_dxvk_fps_state() + + def load_settings(self, target_dir): + # Existing v2.3 installations predate this option. Preserve their + # current DXVK limit state instead of silently enabling a new cap. + has_saved_fps_setting = False + settings_path = self._settings_path(target_dir) + try: + with open(settings_path, "r", encoding="utf-8") as handle: + saved = json.load(handle) + has_saved_fps_setting = isinstance( + saved.get("dxvk_fps_limit") if isinstance(saved, dict) else None, + dict, + ) + except (OSError, json.JSONDecodeError, ValueError, TypeError): + pass + + loaded = super().load_settings(target_dir) + + if ( + not has_saved_fps_setting + and self._looks_like_managed_install(target_dir) + and self.rendering_mode.get() == "dxvk" + ): + existing = dxvk_fps.read_dxvk_fps_limit( + os.path.join(target_dir, "dxvk.conf") + ) + if existing is None: + self.limit_dxvk_fps.set(False) + else: + enabled, value = existing + self.limit_dxvk_fps.set(enabled) + self.dxvk_fps_limit.set(value) + + self._update_dxvk_fps_state() + return loaded + + def validate_limits(self): + if not super().validate_limits(): + return False + + if self.rendering_mode.get() != "dxvk" or not self.limit_dxvk_fps.get(): + return True + + try: + value = int(self.dxvk_fps_limit.get()) + except (tk.TclError, TypeError, ValueError): + messagebox.showerror( + "Input Error", + "DXVK FPS Limit must contain a positive whole number.", + ) + return False + + if value <= 0: + messagebox.showerror( + "Input Error", + "DXVK FPS Limit must be greater than 0.", + ) + return False + + return True + + def configure_dxvk(self, target): + super().configure_dxvk(target) + + # DirectX 9 follows the existing renderer cleanup path above. Never + # create or modify dxvk.conf when DXVK is not selected. + if self.rendering_mode.get() != "dxvk": + return + + dxvk_fps.apply_dxvk_fps_limit( + os.path.join(target, "dxvk.conf"), + bool(self.limit_dxvk_fps.get()), + int(self.dxvk_fps_limit.get()), + ) + if __name__ == "__main__": root = tk.Tk()