""" 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("", 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( "", 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()