885 lines
26 KiB
Python
885 lines
26 KiB
Python
"""
|
|
Steam-like Python Game Launcher
|
|
Install dependencies:
|
|
python -m pip install customtkinter pillow
|
|
Run:
|
|
python launcher.py
|
|
"""
|
|
|
|
import customtkinter as ctk
|
|
import tkinter as tk
|
|
from tkinter import filedialog, messagebox
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import uuid
|
|
import subprocess
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# DATA FILE
|
|
# ------------------------------------------------------------
|
|
DATA_FILE = os.path.join(os.path.expanduser("~"), ".steam_like_launcher.json")
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# STEAM-LIKE COLORS
|
|
# ------------------------------------------------------------
|
|
BG = "#1b2838"
|
|
SIDEBAR = "#171a21"
|
|
TOPBAR = "#141920"
|
|
PANEL = "#2a475e"
|
|
HOVER = "#3b5068"
|
|
ENTRY_BG = "#232a35"
|
|
|
|
TEXT = "#c7d5e0"
|
|
MUTED = "#8f98a0"
|
|
ACCENT = "#66c0f4"
|
|
|
|
GREEN = "#5c7e10"
|
|
GREEN_HOVER = "#6f9a12"
|
|
|
|
DANGER = "#a63d3d"
|
|
DANGER_HOVER = "#bf4c4c"
|
|
|
|
|
|
ctk.set_appearance_mode("dark")
|
|
ctk.set_default_color_theme("blue")
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# IMAGE HELPERS
|
|
# ------------------------------------------------------------
|
|
def get_font(size):
|
|
"""Try to load a decent font for placeholder icons."""
|
|
candidates = [
|
|
"arialbd.ttf",
|
|
"arial.ttf",
|
|
"segoeui.ttf",
|
|
"DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"Helvetica.ttc",
|
|
]
|
|
|
|
for candidate in candidates:
|
|
try:
|
|
return ImageFont.truetype(candidate, size)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
return ImageFont.load_default()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def make_placeholder(text, size=(180, 180)):
|
|
"""Create a placeholder icon image with initials."""
|
|
img = Image.new("RGBA", size, (23, 26, 33, 255))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
initials = "".join(part[0] for part in text.split() if part)[:2].upper() or "G"
|
|
font = get_font(int(min(size) * 0.32))
|
|
|
|
try:
|
|
draw.text(
|
|
(size[0] / 2, size[1] / 2),
|
|
initials,
|
|
fill=(102, 192, 244, 255),
|
|
font=font,
|
|
anchor="mm",
|
|
)
|
|
except Exception:
|
|
draw.text((10, 10), initials, fill=(102, 192, 244, 255), font=font)
|
|
|
|
return img
|
|
|
|
|
|
def load_image(path, size, fallback_text="?"):
|
|
"""Load icon and center it on a transparent square canvas."""
|
|
try:
|
|
if not path or not os.path.isfile(path):
|
|
raise FileNotFoundError("No icon path provided.")
|
|
|
|
img = Image.open(path).convert("RGBA")
|
|
|
|
resample = Image.Resampling.LANCZOS if hasattr(Image, "Resampling") else Image.LANCZOS
|
|
img.thumbnail(size, resample)
|
|
|
|
canvas = Image.new("RGBA", size, (0, 0, 0, 0))
|
|
offset = ((size[0] - img.width) // 2, (size[1] - img.height) // 2)
|
|
canvas.paste(img, offset, img)
|
|
|
|
return canvas
|
|
|
|
except Exception:
|
|
return make_placeholder(fallback_text, size)
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# ADD GAME DIALOG
|
|
# ------------------------------------------------------------
|
|
class AddGameDialog(ctk.CTkToplevel):
|
|
def __init__(self, master, on_save):
|
|
super().__init__(master)
|
|
|
|
self.on_save = on_save
|
|
|
|
self.title("Add Game")
|
|
self.geometry("580x360")
|
|
self.resizable(False, False)
|
|
self.configure(fg_color=BG)
|
|
|
|
self.transient(master)
|
|
self.after(20, self.center_window)
|
|
self.after(20, self.grab_set)
|
|
self.after(20, self.lift)
|
|
|
|
pad = ctk.CTkFrame(self, fg_color="transparent")
|
|
pad.pack(fill="both", expand=True, padx=22, pady=20)
|
|
|
|
ctk.CTkLabel(
|
|
pad,
|
|
text="Add a Game",
|
|
font=ctk.CTkFont(size=24, weight="bold"),
|
|
text_color=TEXT,
|
|
).pack(anchor="w", pady=(0, 14))
|
|
|
|
self.name_entry = self._field(pad, "Game Name")
|
|
|
|
self.exe_entry, _ = self._field_with_browse(
|
|
pad,
|
|
"Executable",
|
|
self.browse_exe,
|
|
)
|
|
|
|
self.icon_entry, _ = self._field_with_browse(
|
|
pad,
|
|
"Icon Image (.ico, .png, .jpg)",
|
|
self.browse_icon,
|
|
)
|
|
|
|
buttons = ctk.CTkFrame(pad, fg_color="transparent")
|
|
buttons.pack(fill="x", pady=(18, 0))
|
|
|
|
ctk.CTkButton(
|
|
buttons,
|
|
text="Cancel",
|
|
width=110,
|
|
height=38,
|
|
fg_color=PANEL,
|
|
hover_color=HOVER,
|
|
text_color=TEXT,
|
|
command=self.destroy,
|
|
).pack(side="right")
|
|
|
|
ctk.CTkButton(
|
|
buttons,
|
|
text="Add Game",
|
|
width=125,
|
|
height=38,
|
|
fg_color=GREEN,
|
|
hover_color=GREEN_HOVER,
|
|
text_color="white",
|
|
font=ctk.CTkFont(weight="bold"),
|
|
command=self.save,
|
|
).pack(side="right", padx=(0, 10))
|
|
|
|
def center_window(self):
|
|
try:
|
|
self.update_idletasks()
|
|
|
|
master = self.master
|
|
x = master.winfo_rootx() + (master.winfo_width() - self.winfo_width()) // 2
|
|
y = master.winfo_rooty() + (master.winfo_height() - self.winfo_height()) // 2
|
|
|
|
self.geometry(f"+{max(x, 0)}+{max(y, 0)}")
|
|
except Exception:
|
|
pass
|
|
|
|
def _field(self, parent, label):
|
|
block = ctk.CTkFrame(parent, fg_color="transparent")
|
|
block.pack(fill="x", pady=7)
|
|
|
|
ctk.CTkLabel(
|
|
block,
|
|
text=label,
|
|
text_color=MUTED,
|
|
anchor="w",
|
|
).pack(fill="x")
|
|
|
|
entry = ctk.CTkEntry(
|
|
block,
|
|
height=36,
|
|
fg_color=ENTRY_BG,
|
|
border_color="#39434f",
|
|
text_color=TEXT,
|
|
)
|
|
entry.pack(fill="x", pady=(4, 0))
|
|
|
|
return entry
|
|
|
|
def _field_with_browse(self, parent, label, command):
|
|
block = ctk.CTkFrame(parent, fg_color="transparent")
|
|
block.pack(fill="x", pady=7)
|
|
|
|
ctk.CTkLabel(
|
|
block,
|
|
text=label,
|
|
text_color=MUTED,
|
|
anchor="w",
|
|
).pack(fill="x")
|
|
|
|
row = ctk.CTkFrame(block, fg_color="transparent")
|
|
row.pack(fill="x", pady=(4, 0))
|
|
|
|
entry = ctk.CTkEntry(
|
|
row,
|
|
height=36,
|
|
fg_color=ENTRY_BG,
|
|
border_color="#39434f",
|
|
text_color=TEXT,
|
|
)
|
|
entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
|
|
|
button = ctk.CTkButton(
|
|
row,
|
|
text="Browse",
|
|
width=90,
|
|
height=36,
|
|
fg_color=PANEL,
|
|
hover_color=HOVER,
|
|
text_color=TEXT,
|
|
command=command,
|
|
)
|
|
button.pack(side="right")
|
|
|
|
return entry, button
|
|
|
|
def browse_exe(self):
|
|
if sys.platform.startswith("win"):
|
|
filetypes = [
|
|
("Executable", "*.exe *.bat *.cmd *.lnk"),
|
|
("All files", "*.*"),
|
|
]
|
|
else:
|
|
filetypes = [
|
|
("All files", "*"),
|
|
("Executable", "*"),
|
|
]
|
|
|
|
path = filedialog.askopenfilename(
|
|
title="Select Game Executable",
|
|
filetypes=filetypes,
|
|
)
|
|
|
|
if path:
|
|
self.exe_entry.delete(0, "end")
|
|
self.exe_entry.insert(0, path)
|
|
|
|
def browse_icon(self):
|
|
path = filedialog.askopenfilename(
|
|
title="Select Icon Image",
|
|
filetypes=[
|
|
("Images", "*.ico *.png *.jpg *.jpeg *.bmp"),
|
|
("All files", "*.*"),
|
|
],
|
|
)
|
|
|
|
if path:
|
|
self.icon_entry.delete(0, "end")
|
|
self.icon_entry.insert(0, path)
|
|
|
|
def save(self):
|
|
name = self.name_entry.get().strip()
|
|
exe = self.exe_entry.get().strip()
|
|
icon = self.icon_entry.get().strip()
|
|
|
|
if not name:
|
|
messagebox.showwarning(
|
|
"Missing Name",
|
|
"Please enter a game name.",
|
|
parent=self,
|
|
)
|
|
return
|
|
|
|
if not exe:
|
|
messagebox.showwarning(
|
|
"Missing Executable",
|
|
"Please choose the game executable.",
|
|
parent=self,
|
|
)
|
|
return
|
|
|
|
self.on_save(name, exe, icon)
|
|
self.destroy()
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# MAIN LAUNCHER WINDOW
|
|
# ------------------------------------------------------------
|
|
class Launcher(ctk.CTk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
self.title("Game Library")
|
|
self.geometry("1180x700")
|
|
self.minsize(980, 600)
|
|
self.configure(fg_color=BG)
|
|
|
|
self.games = self.load_games()
|
|
self.selected_id = None
|
|
self.detail_refs = []
|
|
|
|
# Root grid
|
|
self.grid_columnconfigure(0, weight=0)
|
|
self.grid_columnconfigure(1, weight=1)
|
|
self.grid_rowconfigure(0, weight=1)
|
|
|
|
# ------------------------------------------------------------
|
|
# SIDEBAR
|
|
# ------------------------------------------------------------
|
|
sidebar = ctk.CTkFrame(self, corner_radius=0, fg_color=SIDEBAR, width=290)
|
|
sidebar.grid(row=0, column=0, sticky="nsew")
|
|
sidebar.grid_propagate(False)
|
|
|
|
side_head = ctk.CTkFrame(sidebar, fg_color="transparent")
|
|
side_head.pack(fill="x", padx=16, pady=(18, 10))
|
|
|
|
ctk.CTkLabel(
|
|
side_head,
|
|
text="LIBRARY",
|
|
font=ctk.CTkFont(size=25, weight="bold"),
|
|
text_color=TEXT,
|
|
).pack(anchor="w")
|
|
|
|
ctk.CTkButton(
|
|
side_head,
|
|
text="+ Add Game",
|
|
height=38,
|
|
corner_radius=8,
|
|
fg_color=ACCENT,
|
|
hover_color="#4fa3d1",
|
|
text_color="#08121c",
|
|
font=ctk.CTkFont(size=14, weight="bold"),
|
|
command=self.open_add_dialog,
|
|
).pack(fill="x", pady=(14, 0))
|
|
|
|
self.search = ctk.CTkEntry(
|
|
sidebar,
|
|
placeholder_text="Search",
|
|
height=36,
|
|
corner_radius=8,
|
|
fg_color=ENTRY_BG,
|
|
border_color="#39434f",
|
|
text_color=TEXT,
|
|
)
|
|
self.search.pack(fill="x", padx=16, pady=(0, 10))
|
|
self.search.bind("<KeyRelease>", lambda e: self.render_list())
|
|
|
|
self.list_frame = ctk.CTkScrollableFrame(sidebar, fg_color="transparent")
|
|
self.list_frame.pack(fill="both", expand=True, padx=10, pady=(0, 14))
|
|
|
|
self.list_content = ctk.CTkFrame(self.list_frame, fg_color="transparent")
|
|
self.list_content.pack(fill="both", expand=True)
|
|
|
|
# ------------------------------------------------------------
|
|
# MAIN AREA
|
|
# ------------------------------------------------------------
|
|
main = ctk.CTkFrame(self, corner_radius=0, fg_color=BG)
|
|
main.grid(row=0, column=1, sticky="nsew")
|
|
main.grid_columnconfigure(0, weight=1)
|
|
main.grid_rowconfigure(1, weight=1)
|
|
|
|
topbar = ctk.CTkFrame(main, corner_radius=0, fg_color=TOPBAR, height=68)
|
|
topbar.grid(row=0, column=0, sticky="ew")
|
|
topbar.grid_propagate(False)
|
|
|
|
self.top_title = ctk.CTkLabel(
|
|
topbar,
|
|
text="Library",
|
|
font=ctk.CTkFont(size=20, weight="bold"),
|
|
text_color=TEXT,
|
|
)
|
|
self.top_title.pack(side="left", padx=26)
|
|
|
|
self.top_status = ctk.CTkLabel(
|
|
topbar,
|
|
text="",
|
|
text_color=MUTED,
|
|
)
|
|
self.top_status.pack(side="right", padx=26)
|
|
|
|
self.details = ctk.CTkScrollableFrame(main, fg_color="transparent")
|
|
self.details.grid(row=1, column=0, sticky="nsew", padx=26, pady=22)
|
|
|
|
self.details_content = ctk.CTkFrame(self.details, fg_color="transparent")
|
|
self.details_content.pack(fill="both", expand=True)
|
|
|
|
# Initial render
|
|
self.render_list()
|
|
|
|
if self.games:
|
|
first = sorted(self.games, key=lambda g: g.get("name", "").lower())[0]
|
|
self.select_game(first["id"])
|
|
else:
|
|
self.show_home()
|
|
|
|
# ------------------------------------------------------------
|
|
# DATA
|
|
# ------------------------------------------------------------
|
|
def load_games(self):
|
|
try:
|
|
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
if isinstance(data, list):
|
|
cleaned = []
|
|
|
|
for item in data:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
|
|
if not item.get("name"):
|
|
continue
|
|
|
|
if not item.get("id"):
|
|
item["id"] = str(uuid.uuid4())
|
|
|
|
cleaned.append(item)
|
|
|
|
return cleaned
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
return []
|
|
|
|
def save_games(self):
|
|
try:
|
|
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
|
json.dump(self.games, f, indent=2)
|
|
except Exception as e:
|
|
messagebox.showerror(
|
|
"Save Error",
|
|
f"Could not save library:\n{e}",
|
|
parent=self,
|
|
)
|
|
|
|
def get_game(self, game_id):
|
|
for game in self.games:
|
|
if game.get("id") == game_id:
|
|
return game
|
|
return None
|
|
|
|
# ------------------------------------------------------------
|
|
# SIDEBAR LIST
|
|
# ------------------------------------------------------------
|
|
def render_list(self):
|
|
for child in self.list_content.winfo_children():
|
|
child.destroy()
|
|
|
|
query = self.search.get().strip().lower()
|
|
games = sorted(self.games, key=lambda g: g.get("name", "").lower())
|
|
|
|
shown = 0
|
|
|
|
for game in games:
|
|
if query and query not in game.get("name", "").lower():
|
|
continue
|
|
|
|
selected = game.get("id") == self.selected_id
|
|
|
|
button = ctk.CTkButton(
|
|
self.list_content,
|
|
text=game.get("name", "Unknown"),
|
|
anchor="w",
|
|
height=40,
|
|
corner_radius=8,
|
|
fg_color=PANEL if selected else "transparent",
|
|
hover_color=HOVER,
|
|
text_color="#ffffff" if selected else TEXT,
|
|
font=ctk.CTkFont(size=14),
|
|
command=lambda gid=game.get("id"): self.select_game(gid),
|
|
)
|
|
|
|
button.pack(fill="x", padx=4, pady=2)
|
|
button.bind(
|
|
"<Button-3>",
|
|
lambda e, g=game: self.show_context_menu(e, g),
|
|
)
|
|
|
|
shown += 1
|
|
|
|
if shown == 0:
|
|
ctk.CTkLabel(
|
|
self.list_content,
|
|
text="No games found",
|
|
text_color=MUTED,
|
|
).pack(pady=18)
|
|
|
|
self.top_status.configure(text=f"{len(self.games)} games")
|
|
|
|
def show_context_menu(self, event, game):
|
|
menu = tk.Menu(
|
|
self,
|
|
tearoff=0,
|
|
bg="#202630",
|
|
fg=TEXT,
|
|
activebackground=HOVER,
|
|
activeforeground="white",
|
|
relief="flat",
|
|
)
|
|
|
|
menu.add_command(
|
|
label="Play",
|
|
command=lambda: self.launch_game(game),
|
|
)
|
|
|
|
menu.add_command(
|
|
label="Open Folder",
|
|
command=lambda: self.open_folder(game),
|
|
)
|
|
|
|
menu.add_separator()
|
|
|
|
menu.add_command(
|
|
label="Delete",
|
|
foreground="#ff7b7b",
|
|
activeforeground="#ff7b7b",
|
|
command=lambda: self.delete_game(game),
|
|
)
|
|
|
|
try:
|
|
menu.tk_popup(event.x_root, event.y_root)
|
|
finally:
|
|
menu.grab_release()
|
|
|
|
# ------------------------------------------------------------
|
|
# DETAILS AREA
|
|
# ------------------------------------------------------------
|
|
def clear_details(self):
|
|
for child in self.details_content.winfo_children():
|
|
child.destroy()
|
|
|
|
self.detail_refs.clear()
|
|
|
|
def show_home(self):
|
|
self.selected_id = None
|
|
self.top_title.configure(text="Library")
|
|
self.clear_details()
|
|
|
|
wrap = ctk.CTkFrame(self.details_content, fg_color="transparent")
|
|
wrap.pack(expand=True, pady=80)
|
|
|
|
if self.games:
|
|
ctk.CTkLabel(
|
|
wrap,
|
|
text="Select a game from the library.",
|
|
font=ctk.CTkFont(size=24),
|
|
text_color=MUTED,
|
|
).pack()
|
|
else:
|
|
ctk.CTkLabel(
|
|
wrap,
|
|
text="Your library is empty",
|
|
font=ctk.CTkFont(size=30, weight="bold"),
|
|
text_color=TEXT,
|
|
).pack(pady=(0, 10))
|
|
|
|
ctk.CTkLabel(
|
|
wrap,
|
|
text="Click + Add Game to add your first game.",
|
|
text_color=MUTED,
|
|
).pack(pady=(0, 18))
|
|
|
|
ctk.CTkButton(
|
|
wrap,
|
|
text="+ Add Game",
|
|
width=160,
|
|
height=42,
|
|
fg_color=ACCENT,
|
|
hover_color="#4fa3d1",
|
|
text_color="#08121c",
|
|
font=ctk.CTkFont(weight="bold"),
|
|
command=self.open_add_dialog,
|
|
).pack()
|
|
|
|
def show_game_details(self, game):
|
|
self.clear_details()
|
|
self.top_title.configure(text=game.get("name", "Game"))
|
|
|
|
# Hero panel
|
|
hero = ctk.CTkFrame(self.details_content, corner_radius=14, fg_color=PANEL)
|
|
hero.pack(fill="x")
|
|
hero.grid_columnconfigure(1, weight=1)
|
|
|
|
# Icon
|
|
image = load_image(
|
|
game.get("icon", ""),
|
|
(180, 180),
|
|
game.get("name", "G"),
|
|
)
|
|
|
|
ctk_image = ctk.CTkImage(
|
|
light_image=image,
|
|
dark_image=image,
|
|
size=(180, 180),
|
|
)
|
|
|
|
# Keep reference so image does not disappear
|
|
self.detail_refs.append(ctk_image)
|
|
|
|
icon_label = ctk.CTkLabel(hero, image=ctk_image, text="")
|
|
icon_label.grid(row=0, column=0, rowspan=2, padx=30, pady=30, sticky="w")
|
|
|
|
# Info
|
|
info = ctk.CTkFrame(hero, fg_color="transparent")
|
|
info.grid(row=0, column=1, sticky="nsew", padx=(8, 30), pady=(30, 8))
|
|
|
|
ctk.CTkLabel(
|
|
info,
|
|
text=game.get("name", "Unknown"),
|
|
anchor="w",
|
|
font=ctk.CTkFont(size=36, weight="bold"),
|
|
text_color="#f2f6fa",
|
|
).pack(anchor="w")
|
|
|
|
ctk.CTkLabel(
|
|
info,
|
|
text="Installed game",
|
|
anchor="w",
|
|
text_color=ACCENT,
|
|
font=ctk.CTkFont(size=13),
|
|
).pack(anchor="w", pady=(2, 16))
|
|
|
|
exe = game.get("exe", "")
|
|
ctk.CTkLabel(
|
|
info,
|
|
text="Executable",
|
|
anchor="w",
|
|
text_color=MUTED,
|
|
).pack(anchor="w")
|
|
|
|
ctk.CTkLabel(
|
|
info,
|
|
text=exe if exe else "Not set",
|
|
anchor="w",
|
|
text_color=TEXT,
|
|
wraplength=560,
|
|
justify="left",
|
|
).pack(anchor="w", pady=(0, 10))
|
|
|
|
icon = game.get("icon", "")
|
|
ctk.CTkLabel(
|
|
info,
|
|
text="Icon",
|
|
anchor="w",
|
|
text_color=MUTED,
|
|
).pack(anchor="w")
|
|
|
|
ctk.CTkLabel(
|
|
info,
|
|
text=icon if icon else "Default placeholder",
|
|
anchor="w",
|
|
text_color=TEXT,
|
|
wraplength=560,
|
|
justify="left",
|
|
).pack(anchor="w")
|
|
|
|
# Actions
|
|
actions = ctk.CTkFrame(hero, fg_color="transparent")
|
|
actions.grid(row=1, column=1, sticky="nsew", padx=(8, 30), pady=(8, 30))
|
|
|
|
ctk.CTkButton(
|
|
actions,
|
|
text="Play",
|
|
width=170,
|
|
height=52,
|
|
corner_radius=10,
|
|
fg_color=GREEN,
|
|
hover_color=GREEN_HOVER,
|
|
text_color="white",
|
|
font=ctk.CTkFont(size=20, weight="bold"),
|
|
command=lambda: self.launch_game(game),
|
|
).pack(side="left")
|
|
|
|
ctk.CTkButton(
|
|
actions,
|
|
text="Open Folder",
|
|
width=140,
|
|
height=52,
|
|
corner_radius=10,
|
|
fg_color="#3a4858",
|
|
hover_color=HOVER,
|
|
text_color=TEXT,
|
|
command=lambda: self.open_folder(game),
|
|
).pack(side="left", padx=12)
|
|
|
|
ctk.CTkButton(
|
|
actions,
|
|
text="Delete",
|
|
width=110,
|
|
height=52,
|
|
corner_radius=10,
|
|
fg_color=DANGER,
|
|
hover_color=DANGER_HOVER,
|
|
text_color="white",
|
|
command=lambda: self.delete_game(game),
|
|
).pack(side="left")
|
|
|
|
# Extra card to make it feel more like a library page
|
|
card = ctk.CTkFrame(self.details_content, corner_radius=14, fg_color="#20303f")
|
|
card.pack(fill="x", pady=(18, 0))
|
|
|
|
ctk.CTkLabel(
|
|
card,
|
|
text="Quick Notes",
|
|
anchor="w",
|
|
font=ctk.CTkFont(size=18, weight="bold"),
|
|
text_color=TEXT,
|
|
).pack(anchor="w", padx=22, pady=(18, 6))
|
|
|
|
ctk.CTkLabel(
|
|
card,
|
|
text=(
|
|
"Right-click a game in the left list for quick actions.\n"
|
|
"Your library is saved locally in a JSON file."
|
|
),
|
|
anchor="w",
|
|
text_color=MUTED,
|
|
justify="left",
|
|
).pack(anchor="w", padx=22, pady=(0, 20))
|
|
|
|
# ------------------------------------------------------------
|
|
# GAME ACTIONS
|
|
# ------------------------------------------------------------
|
|
def select_game(self, game_id):
|
|
game = self.get_game(game_id)
|
|
if not game:
|
|
return
|
|
|
|
self.selected_id = game_id
|
|
self.render_list()
|
|
self.show_game_details(game)
|
|
|
|
def open_add_dialog(self):
|
|
AddGameDialog(self, self.add_game)
|
|
|
|
def add_game(self, name, exe, icon):
|
|
game = {
|
|
"id": str(uuid.uuid4()),
|
|
"name": name,
|
|
"exe": exe,
|
|
"icon": icon,
|
|
}
|
|
|
|
self.games.append(game)
|
|
self.save_games()
|
|
|
|
self.selected_id = game["id"]
|
|
self.render_list()
|
|
self.show_game_details(game)
|
|
|
|
def delete_game(self, game):
|
|
name = game.get("name", "this game")
|
|
|
|
confirmed = messagebox.askyesno(
|
|
"Delete Game",
|
|
f"Remove '{name}' from the library?\n\n"
|
|
"This only removes the shortcut, not the game files.",
|
|
parent=self,
|
|
)
|
|
|
|
if not confirmed:
|
|
return
|
|
|
|
self.games = [g for g in self.games if g.get("id") != game.get("id")]
|
|
self.save_games()
|
|
|
|
if self.selected_id == game.get("id"):
|
|
self.selected_id = None
|
|
|
|
self.render_list()
|
|
|
|
if self.games:
|
|
first = sorted(self.games, key=lambda g: g.get("name", "").lower())[0]
|
|
self.select_game(first["id"])
|
|
else:
|
|
self.show_home()
|
|
|
|
def launch_game(self, game):
|
|
exe = os.path.normpath(game.get("exe", ""))
|
|
|
|
if not exe or not os.path.isfile(exe):
|
|
messagebox.showerror(
|
|
"Executable not found",
|
|
f"Cannot find:\n{exe}\n\nUpdate or restore the game executable.",
|
|
parent=self,
|
|
)
|
|
return
|
|
|
|
cwd = os.path.dirname(exe)
|
|
|
|
try:
|
|
if sys.platform.startswith("win"):
|
|
ext = os.path.splitext(exe)[1].lower()
|
|
|
|
if ext == ".exe":
|
|
subprocess.Popen([exe], cwd=cwd)
|
|
else:
|
|
# Handles .lnk, .bat, .cmd, etc.
|
|
os.startfile(exe) # type: ignore[attr-defined]
|
|
|
|
elif sys.platform == "darwin":
|
|
subprocess.Popen(["open", exe], cwd=cwd)
|
|
|
|
else:
|
|
subprocess.Popen(["xdg-open", exe], cwd=cwd)
|
|
|
|
except Exception as e:
|
|
messagebox.showerror(
|
|
"Launch Failed",
|
|
f"Could not start game:\n{e}",
|
|
parent=self,
|
|
)
|
|
|
|
def open_folder(self, game):
|
|
exe = os.path.normpath(game.get("exe", ""))
|
|
folder = os.path.dirname(exe) if exe else os.path.expanduser("~")
|
|
|
|
if not os.path.isdir(folder):
|
|
messagebox.showerror(
|
|
"Folder not found",
|
|
f"Cannot find folder:\n{folder}",
|
|
parent=self,
|
|
)
|
|
return
|
|
|
|
try:
|
|
if sys.platform.startswith("win"):
|
|
os.startfile(folder) # type: ignore[attr-defined]
|
|
|
|
elif sys.platform == "darwin":
|
|
subprocess.Popen(["open", folder])
|
|
|
|
else:
|
|
subprocess.Popen(["xdg-open", folder])
|
|
|
|
except Exception as e:
|
|
messagebox.showerror(
|
|
"Open Folder Failed",
|
|
f"Could not open folder:\n{e}",
|
|
parent=self,
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# RUN
|
|
# ------------------------------------------------------------
|
|
if __name__ == "__main__":
|
|
app = Launcher()
|
|
app.mainloop() |