""" This file is part of the SplendidBear Websites' projects. Copyright (c) 2026 @ www.splendidbear.org For the full copyright and license information, please view the LICENSE file that was distributed with this source code. """ from __future__ import annotations import threading from collections.abc import Callable import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, GLib from mineseeker.api import game as game_api from mineseeker import assets from mineseeker.state import session as session_mod class LobbyPage(Gtk.Box): """ Lobby screen — shows waiting players and a "New Game" button. Flow: - "New Game" → fetch token → start game → on_game_start() - Click a waiting player → challenge them → on_game_start() when accepted """ def __init__(self, on_game_start: Callable[[str, str, str], None]) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) self._on_game_start = on_game_start self._waiting: list[dict] = [] # Header bar action area header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) header_box.set_margin_top(12) header_box.set_margin_bottom(12) header_box.set_margin_start(16) header_box.set_margin_end(16) title = Gtk.Label(label="Lobby") title.add_css_class("title-2") title.set_hexpand(True) title.set_xalign(0) header_box.append(title) self._refresh_btn = Gtk.Button(label="Refresh") self._refresh_btn.connect("clicked", lambda *_: self.refresh()) header_box.append(self._refresh_btn) new_game_btn = Gtk.Button(label="New Game") new_game_btn.add_css_class("suggested-action") new_game_btn.connect("clicked", self._on_new_game) header_box.append(new_game_btn) self.append(header_box) self.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) # Waiting players list scrolled = Gtk.ScrolledWindow() scrolled.set_vexpand(True) scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) self._list_box = Gtk.ListBox() self._list_box.set_selection_mode(Gtk.SelectionMode.NONE) self._list_box.add_css_class("boxed-list") self._list_box.set_margin_top(12) self._list_box.set_margin_bottom(12) self._list_box.set_margin_start(16) self._list_box.set_margin_end(16) scrolled.set_child(self._list_box) self.append(scrolled) self._status_label = Gtk.Label(label="No players waiting.") self._status_label.add_css_class("dim-label") self._status_label.set_margin_top(24) self._status_label.set_visible(True) self.append(self._status_label) def refresh(self) -> None: """Fetch waiting players list from the server.""" self._refresh_btn.set_sensitive(False) threading.Thread(target=self._do_refresh, daemon=True).start() def _do_refresh(self) -> None: try: waiting = game_api.waiting() GLib.idle_add(self._update_list, waiting) except Exception: GLib.idle_add(self._refresh_btn.set_sensitive, True) def _update_list(self, waiting: list[dict]) -> bool: self._waiting = waiting # Clear existing rows while True: row = self._list_box.get_first_child() if row is None: break self._list_box.remove(row) my_assoc = session_mod.get().game_assoc for player in waiting: if player["gameAssoc"] == my_assoc: continue # don't show ourselves row = self._make_player_row(player) self._list_box.append(row) has_players = bool([p for p in waiting if p.get("gameAssoc") != my_assoc]) self._status_label.set_visible(not has_players) self._refresh_btn.set_sensitive(True) return GLib.SOURCE_REMOVE def _make_player_row(self, player: dict) -> Adw.ActionRow: row = Adw.ActionRow() row.set_title(player.get("name", "Guest")) row.set_subtitle(f"Waiting since {player.get('since', '')[:19].replace('T', ' ')}") challenge_btn = Gtk.Button(label="Challenge") challenge_btn.add_css_class("flat") challenge_btn.set_valign(Gtk.Align.CENTER) challenge_btn.connect( "clicked", lambda _btn, p=player: self._on_challenge(p), ) row.add_suffix(challenge_btn) return row def _on_new_game(self, *_) -> None: threading.Thread(target=self._do_new_game, daemon=True).start() def _do_new_game(self) -> None: try: token_data = game_api.fetch_token() game_assoc = token_data["gameAssoc"] mercure_jwt = token_data["mercureJwt"] sess = session_mod.get() sess.game_assoc = game_assoc sess.mercure_jwt = mercure_jwt sess.color = "red" # first player always red # Load images while we wait for an opponent assets.load_images() GLib.idle_add(self._on_game_start, game_assoc, mercure_jwt, "red") except Exception as e: GLib.idle_add(self._show_error_toast, str(e)) def _on_challenge(self, player: dict) -> None: threading.Thread( target=self._do_challenge, args=(player,), daemon=True ).start() def _do_challenge(self, player: dict) -> None: try: token_data = game_api.fetch_token() game_assoc = token_data["gameAssoc"] mercure_jwt = token_data["mercureJwt"] sess = session_mod.get() sess.game_assoc = game_assoc sess.mercure_jwt = mercure_jwt game_api.challenge( target_game_assoc=player["gameAssoc"], challenger_game_assoc=game_assoc, ) assets.load_images() # GamePage will determine color from subscribe payload GLib.idle_add(self._on_game_start, game_assoc, mercure_jwt, "") except Exception as e: GLib.idle_add(self._show_error_toast, str(e)) def _show_error_toast(self, message: str) -> bool: # Find the nearest Adw.ToastOverlay ancestor if available, otherwise print print(f"[LobbyPage] Error: {message}") return GLib.SOURCE_REMOVE