Uploaded Launcher
This commit is contained in:
+885
@@ -0,0 +1,885 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user