Intial Release
Some checks failed
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Has been cancelled
Some checks failed
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Has been cancelled
This commit is contained in:
385
sealoader_gui.py
Normal file
385
sealoader_gui.py
Normal file
@@ -0,0 +1,385 @@
|
||||
import configparser
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from steam_required_ids import extract_required_item_ids, resolve_workshop_names
|
||||
|
||||
|
||||
# Minimal dark theme colors
|
||||
BG = "#16181c"
|
||||
PANEL_BG = "#1f232a"
|
||||
FG = "#e6e6e6"
|
||||
MUTED = "#a0a6ad"
|
||||
ACCENT = "#4f8cff"
|
||||
|
||||
|
||||
DEFAULT_INI_PATH = str(Path.home() / "AppData" / "LocalLow" / "Triassic Games" / "Sea Power" / "usersettings.ini")
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstalledMod:
|
||||
mod_id: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
def read_installed_mods(ini_path: str) -> Dict[str, InstalledMod]:
|
||||
parser = configparser.ConfigParser()
|
||||
parser.optionxform = str
|
||||
if not os.path.exists(ini_path):
|
||||
raise FileNotFoundError(f"usersettings.ini not found at: {ini_path}")
|
||||
parser.read(ini_path, encoding="utf-8")
|
||||
|
||||
if "[LoadOrder]" in "\n".join(open(ini_path, encoding="utf-8").readlines()):
|
||||
section = "LoadOrder"
|
||||
else:
|
||||
section = "LoadOrder"
|
||||
|
||||
if section not in parser:
|
||||
return {}
|
||||
|
||||
mods: Dict[str, InstalledMod] = {}
|
||||
for key, value in parser[section].items():
|
||||
if not key.lower().startswith("mod"):
|
||||
continue
|
||||
# Format: ModXDirectory=<id or name>,<True|False>
|
||||
parts = value.split(",")
|
||||
if not parts:
|
||||
continue
|
||||
left_token = parts[0].strip()
|
||||
# Prefer numeric Workshop ID if present at start, else keep full token
|
||||
num_prefix = ""
|
||||
for ch in left_token:
|
||||
if ch.isdigit():
|
||||
num_prefix += ch
|
||||
else:
|
||||
break
|
||||
mod_id = num_prefix if num_prefix else left_token
|
||||
enabled = False
|
||||
if len(parts) > 1:
|
||||
enabled = parts[1].strip().lower() == "true"
|
||||
mods[mod_id] = InstalledMod(mod_id=mod_id, enabled=enabled)
|
||||
return mods
|
||||
|
||||
|
||||
def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, int]:
|
||||
parser = configparser.ConfigParser()
|
||||
parser.optionxform = str
|
||||
parser.read(ini_path, encoding="utf-8")
|
||||
|
||||
section = "LoadOrder"
|
||||
if section not in parser:
|
||||
raise ValueError("[LoadOrder] section not found in usersettings.ini")
|
||||
|
||||
# Backup before changes
|
||||
backup_path = ini_path + ".bak"
|
||||
shutil.copyfile(ini_path, backup_path)
|
||||
|
||||
enabled_count = 0
|
||||
missing_count = 0
|
||||
|
||||
# Maintain order and preserve original left token (may include name after ID)
|
||||
items = list(parser[section].items())
|
||||
# Maintain order
|
||||
for target_id in ids_to_enable:
|
||||
found_key = None
|
||||
for key, value in items:
|
||||
if not key.lower().startswith("mod"):
|
||||
continue
|
||||
parts = value.split(",")
|
||||
if not parts:
|
||||
continue
|
||||
left_token = parts[0].strip()
|
||||
# Extract numeric prefix for matching
|
||||
num_prefix = ""
|
||||
for ch in left_token:
|
||||
if ch.isdigit():
|
||||
num_prefix += ch
|
||||
else:
|
||||
break
|
||||
mod_id = num_prefix if num_prefix else left_token
|
||||
if mod_id == target_id:
|
||||
found_key = key
|
||||
current_enabled = False
|
||||
if len(parts) > 1:
|
||||
current_enabled = parts[1].strip().lower() == "true"
|
||||
if not current_enabled:
|
||||
# Preserve original left token, only toggle enabled flag
|
||||
new_value = f"{left_token},True"
|
||||
parser[section][key] = new_value
|
||||
enabled_count += 1
|
||||
break
|
||||
if not found_key:
|
||||
missing_count += 1
|
||||
|
||||
with open(ini_path, "w", encoding="utf-8") as f:
|
||||
parser.write(f)
|
||||
|
||||
return enabled_count, missing_count
|
||||
|
||||
|
||||
class SeaLoaderApp(tk.Tk):
|
||||
def __init__(self, ini_path: str):
|
||||
super().__init__()
|
||||
self.title("SeaLoader")
|
||||
self.configure(bg=BG)
|
||||
self.geometry("960x560")
|
||||
self.minsize(800, 480)
|
||||
|
||||
self.ini_path = ini_path
|
||||
self.installed_mods: Dict[str, InstalledMod] = {}
|
||||
self.required_ids: List[str] = []
|
||||
self.installed_names_map: Dict[str, str] = {}
|
||||
self._icon_img = None
|
||||
|
||||
self._style()
|
||||
self._layout()
|
||||
self._load_installed_mods()
|
||||
|
||||
# App icon from PNG (optional)
|
||||
try:
|
||||
icon_path = self._resource_path("SeaLoader.png")
|
||||
if icon_path.exists():
|
||||
self._icon_img = tk.PhotoImage(file=str(icon_path))
|
||||
self.iconphoto(True, self._icon_img)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Help shortcut
|
||||
try:
|
||||
self.bind("<F1>", lambda e: self._on_help())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _style(self) -> None:
|
||||
style = ttk.Style(self)
|
||||
try:
|
||||
style.theme_use("clam")
|
||||
except Exception:
|
||||
pass
|
||||
style.configure("TFrame", background=BG)
|
||||
style.configure("Panel.TFrame", background=PANEL_BG)
|
||||
style.configure("TLabel", background=PANEL_BG, foreground=FG)
|
||||
style.configure("Header.TLabel", background=BG, foreground=FG, font=("Segoe UI", 12, "bold"))
|
||||
style.configure("TButton", background=PANEL_BG, foreground=FG, borderwidth=0)
|
||||
style.map("TButton", background=[("active", ACCENT)])
|
||||
style.configure("TEntry", fieldbackground=PANEL_BG, foreground=FG, insertcolor=FG)
|
||||
style.configure("TScrollbar", background=PANEL_BG, troughcolor=BG)
|
||||
style.configure("Treeview", background=PANEL_BG, fieldbackground=PANEL_BG, foreground=FG, borderwidth=0)
|
||||
style.configure("Treeview.Heading", background=PANEL_BG, foreground=MUTED)
|
||||
|
||||
def _layout(self) -> None:
|
||||
top = ttk.Frame(self, style="TFrame")
|
||||
top.pack(fill=tk.X, padx=12, pady=(12, 6))
|
||||
|
||||
url_label = ttk.Label(top, text="Workshop URL:", style="TLabel")
|
||||
url_label.pack(side=tk.LEFT)
|
||||
self.url_var = tk.StringVar()
|
||||
self.url_entry = ttk.Entry(top, textvariable=self.url_var, width=70)
|
||||
self.url_entry.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True)
|
||||
self.fetch_btn = ttk.Button(top, text="Fetch Required Mods", command=self._on_fetch)
|
||||
self.fetch_btn.pack(side=tk.LEFT)
|
||||
|
||||
main = ttk.Frame(self, style="TFrame")
|
||||
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=6)
|
||||
|
||||
left_panel = ttk.Frame(main, style="Panel.TFrame")
|
||||
left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
right_panel = ttk.Frame(main, style="Panel.TFrame")
|
||||
right_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0))
|
||||
|
||||
left_header = ttk.Label(left_panel, text="Installed Mods", style="Header.TLabel")
|
||||
left_header.pack(anchor="w", padx=8, pady=8)
|
||||
self.installed_tree = self._create_tree(left_panel, ["Mod ID", "Name", "Enabled"], [0.30, 0.55, 0.15])
|
||||
self.installed_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
|
||||
|
||||
right_header = ttk.Label(right_panel, text="Scenario Required Mods", style="Header.TLabel")
|
||||
right_header.pack(anchor="w", padx=8, pady=8)
|
||||
self.required_tree = self._create_tree(right_panel, ["Mod ID", "Name", "Status"], [0.25, 0.60, 0.15])
|
||||
self.required_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
|
||||
|
||||
bottom = ttk.Frame(self, style="TFrame")
|
||||
bottom.pack(fill=tk.X, padx=12, pady=(0, 12))
|
||||
self.enable_btn = ttk.Button(bottom, text="Enable Matching Mods", command=self._on_enable)
|
||||
self.enable_btn.pack(side=tk.LEFT)
|
||||
self.help_btn = ttk.Button(bottom, text="Help", command=self._on_help)
|
||||
self.help_btn.pack(side=tk.RIGHT)
|
||||
|
||||
def _create_tree(self, parent, columns: List[str], ratios: List[float]):
|
||||
tree = ttk.Treeview(parent, columns=columns, show="headings", height=12)
|
||||
for col in columns:
|
||||
tree.heading(col, text=col)
|
||||
tree.column(col, width=50, anchor=tk.W, stretch=True)
|
||||
|
||||
vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview)
|
||||
hsb = ttk.Scrollbar(parent, orient="horizontal", command=tree.xview)
|
||||
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
||||
|
||||
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
vsb.pack(side=tk.LEFT, fill=tk.Y)
|
||||
hsb.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
self._bind_auto_columns(parent, tree, ratios)
|
||||
return tree
|
||||
|
||||
def _bind_auto_columns(self, container, tree: ttk.Treeview, ratios: List[float]) -> None:
|
||||
ratios = [r / sum(ratios) for r in ratios]
|
||||
def on_resize(event):
|
||||
# Reserve some space for vertical scrollbar and paddings
|
||||
total = max(event.width - 18, 100)
|
||||
for col, ratio in zip(tree["columns"], ratios):
|
||||
tree.column(col, width=int(total * ratio))
|
||||
container.bind("<Configure>", on_resize, add="+")
|
||||
|
||||
def _load_installed_mods(self) -> None:
|
||||
try:
|
||||
self.installed_mods = read_installed_mods(self.ini_path)
|
||||
except FileNotFoundError as e:
|
||||
messagebox.showerror("SeaLoader", str(e))
|
||||
self.installed_mods = {}
|
||||
self._refresh_installed_list()
|
||||
self._resolve_installed_names_async()
|
||||
|
||||
def _refresh_installed_list(self) -> None:
|
||||
for row in self.installed_tree.get_children():
|
||||
self.installed_tree.delete(row)
|
||||
for mod in self.installed_mods.values():
|
||||
status = "Yes" if mod.enabled else "No"
|
||||
display_name = self.installed_names_map.get(mod.mod_id, "")
|
||||
self.installed_tree.insert("", tk.END, values=(mod.mod_id, display_name, status))
|
||||
|
||||
def _resolve_installed_names_async(self) -> None:
|
||||
ids = [mid for mid in self.installed_mods.keys() if mid.isdigit()]
|
||||
ids_to_lookup = [mid for mid in ids if mid not in self.installed_names_map]
|
||||
if not ids_to_lookup:
|
||||
return
|
||||
|
||||
def task():
|
||||
try:
|
||||
names = resolve_workshop_names(ids_to_lookup)
|
||||
except Exception:
|
||||
names = {}
|
||||
|
||||
def update():
|
||||
if names:
|
||||
self.installed_names_map.update(names)
|
||||
self._refresh_installed_list()
|
||||
|
||||
self.after(0, update)
|
||||
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def _on_fetch(self) -> None:
|
||||
url = self.url_var.get().strip()
|
||||
if not url:
|
||||
messagebox.showwarning("SeaLoader", "Please paste a Workshop URL.")
|
||||
return
|
||||
|
||||
self.fetch_btn.configure(state=tk.DISABLED)
|
||||
self.enable_btn.configure(state=tk.DISABLED)
|
||||
|
||||
def task():
|
||||
try:
|
||||
ids = extract_required_item_ids(url)
|
||||
# Reuse installed cache to reduce API calls
|
||||
names_map = {i: self.installed_names_map[i] for i in ids if i in self.installed_names_map}
|
||||
ids_to_lookup = [i for i in ids if i not in names_map]
|
||||
if ids_to_lookup:
|
||||
fetched = resolve_workshop_names(ids_to_lookup)
|
||||
names_map.update(fetched)
|
||||
except Exception as exc:
|
||||
self.after(0, lambda: messagebox.showerror("SeaLoader", f"Failed to fetch IDs: {exc}"))
|
||||
self.after(0, lambda: self.fetch_btn.configure(state=tk.NORMAL))
|
||||
self.after(0, lambda: self.enable_btn.configure(state=tk.NORMAL))
|
||||
return
|
||||
|
||||
def update():
|
||||
self.required_ids = ids
|
||||
self._refresh_required_list(names_map)
|
||||
self.fetch_btn.configure(state=tk.NORMAL)
|
||||
self.enable_btn.configure(state=tk.NORMAL)
|
||||
|
||||
self.after(0, update)
|
||||
|
||||
threading.Thread(target=task, daemon=True).start()
|
||||
|
||||
def _refresh_required_list(self, names_map: Dict[str, str] | None = None) -> None:
|
||||
for row in self.required_tree.get_children():
|
||||
self.required_tree.delete(row)
|
||||
installed_ids = set(self.installed_mods.keys())
|
||||
names_map = names_map or {}
|
||||
for mod_id in self.required_ids:
|
||||
status = "Installed" if mod_id in installed_ids else "Missing"
|
||||
display_name = names_map.get(mod_id, "")
|
||||
self.required_tree.insert("", tk.END, values=(mod_id, display_name, status))
|
||||
|
||||
def _on_enable(self) -> None:
|
||||
if not self.required_ids:
|
||||
messagebox.showinfo("SeaLoader", "No required mods loaded. Fetch from URL first.")
|
||||
return
|
||||
|
||||
try:
|
||||
enabled_count, missing_count = enable_mods_in_ini(self.ini_path, self.required_ids)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("SeaLoader", f"Failed to update INI: {exc}")
|
||||
return
|
||||
|
||||
# Reload to reflect changes
|
||||
self._load_installed_mods()
|
||||
self._refresh_required_list()
|
||||
|
||||
msg_lines = [f"Enabled {enabled_count} mod(s)."]
|
||||
if missing_count:
|
||||
msg_lines.append(f"{missing_count} required mod(s) not found in usersettings.ini.")
|
||||
else:
|
||||
msg_lines.append("All required mods were found.")
|
||||
messagebox.showinfo("SeaLoader", "\n".join(msg_lines))
|
||||
|
||||
def _on_help(self) -> None:
|
||||
ini_hint = (
|
||||
f"Default INI: {self.ini_path}\n\n"
|
||||
)
|
||||
text = (
|
||||
"Usage:\n\n"
|
||||
"1) Paste a Steam Workshop URL at the top and click 'Fetch Required Mods'.\n"
|
||||
" - Right list shows required Mod IDs, names and whether they are installed.\n"
|
||||
"2) Installed mods load automatically from usersettings.ini (left list).\n"
|
||||
"3) Click 'Enable Matching Mods' to turn on any installed required mods.\n"
|
||||
" - A .bak backup of usersettings.ini is created before changes.\n"
|
||||
"4) Mods marked Missing must be installed separately in Sea Power.\n\n"
|
||||
"Tips:\n"
|
||||
"- Run with a custom INI path: python sealoader_gui.py \"<path-to-usersettings.ini>\"\n"
|
||||
"- Press F1 to open this help.\n\n"
|
||||
+ ini_hint
|
||||
)
|
||||
messagebox.showinfo("SeaLoader Help", text)
|
||||
|
||||
def _resource_path(self, name: str) -> Path:
|
||||
try:
|
||||
base = getattr(sys, "_MEIPASS", None)
|
||||
if base:
|
||||
return Path(base) / name
|
||||
except Exception:
|
||||
pass
|
||||
return Path(__file__).resolve().parent / name
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ini_path = DEFAULT_INI_PATH
|
||||
if len(sys.argv) > 1:
|
||||
ini_path = sys.argv[1]
|
||||
app = SeaLoaderApp(ini_path)
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user