diff --git a/ptlinux/paths.py b/ptlinux/paths.py index 07c7815..7842c1e 100644 --- a/ptlinux/paths.py +++ b/ptlinux/paths.py @@ -166,6 +166,12 @@ class AppPaths: def news_cache(self) -> Path: return self.cache / "news.json" + @property + def patches_cache(self) -> Path: + """Cached official patch changelog shown beside the news feed.""" + + return self.cache / "patches.json" + @property def broadcast_cache(self) -> Path: """Cached latest announcement from the broadcast service.""" diff --git a/ptlinux/ui.py b/ptlinux/ui.py index 70beec3..827d96f 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -17,6 +17,13 @@ import webbrowser from typing import Callable, Mapping from . import __version__ +from .changelog import ( + MAX_PATCHES as MAX_PATCH_NOTES, + PatchNote, + fetch_patch_notes, + patch_change_tag, + read_cached_patch_notes, +) from .depot import ( DEFAULT_DEPOT_START_OPTION, DepotClient, @@ -69,6 +76,8 @@ WINDOW_SIZE = "1000x750" NEWS_CARD_HEIGHT = 242 NEWS_IMAGE_WIDTH = 292 NEWS_IMAGE_HEIGHT = 118 +PATCH_CARD_WRAP_LENGTH = 900 +PATCH_CHANGE_WRAP_LENGTH = 790 LAUNCH_STAGE_TOTAL = 4 EVENT_BATCH_SIZE = 48 POLL_INTERVAL_MILLISECONDS = 33 @@ -93,6 +102,7 @@ DASHBOARD_STARTUP_COMPONENTS = frozenset( "logo", "news", "news_artwork", + "patches", "server_status", "version", } @@ -235,6 +245,20 @@ def server_status_presentation(status: RealmServerStatus) -> ServerStatusPresent ) +def patch_change_colors(type_key: str) -> tuple[str, str]: + """Return a restrained card-background palette for one patch change tag.""" + + return { + "fixed": ("#321c1a", "#e88879"), + "hotfix": ("#352018", "#e6a06a"), + "new-feature": ("#192a20", "#77c795"), + "improvement": ("#19252c", "#82b8d2"), + "security": ("#302719", "#ddbb69"), + "server": ("#282035", "#b8a0d8"), + "game": ("#242b1b", "#a8ca75"), + }.get(type_key.casefold(), ("#2a201c", "#d8aa61")) + + def launcher_update_presentation( check: LauncherUpdateCheck, ) -> LauncherUpdatePresentation: @@ -650,7 +674,7 @@ def run_dashboard(paths: AppPaths) -> None: bordercolor="#4a3528", lightcolor="#4a3528", darkcolor="#4a3528", - font=("Sans", 10, "bold"), + font=("Sans", 9, "bold"), padding=(10, 7), relief="flat", ) @@ -684,7 +708,7 @@ def run_dashboard(paths: AppPaths) -> None: root.option_add("*realm_selector*Listbox.foreground", "#fff0d2") root.option_add("*realm_selector*Listbox.selectBackground", "#2a201c") root.option_add("*realm_selector*Listbox.selectForeground", "#f0d7a2") - root.option_add("*realm_selector*Listbox.font", "Sans 10 bold") + root.option_add("*realm_selector*Listbox.font", "Sans 9 bold") root.option_add("*realm_selector*Listbox.justify", "center") root.option_add("*realm_selector*Listbox.selectBorderWidth", 4) root.option_add("*realm_selector*Listbox.borderWidth", 1) @@ -732,6 +756,46 @@ def run_dashboard(paths: AppPaths) -> None: foreground="#c9baaa", font=("Sans", 8), ) + style.configure( + "NewsTab.TButton", + background="#191311", + foreground="#9f8d7c", + font=("Sans", 8, "bold"), + padding=(10, 4), + bordercolor="#32251f", + borderwidth=1, + relief="flat", + ) + style.map( + "NewsTab.TButton", + background=[("active", "#2a201c")], + foreground=[("active", "#f0d7a2")], + bordercolor=[("active", "#4a3528")], + ) + style.configure( + "NewsTabActive.TButton", + background="#2a201c", + foreground="#f0d7a2", + font=("Sans", 8, "bold"), + padding=(10, 4), + bordercolor="#6a4d38", + borderwidth=1, + relief="flat", + ) + style.map( + "NewsTabActive.TButton", + background=[("active", "#32251f")], + foreground=[("active", "#fff0d2")], + ) + style.configure( + "News.Vertical.TScrollbar", + background="#4a3528", + troughcolor="#191311", + arrowcolor="#d8aa61", + bordercolor="#191311", + lightcolor="#4a3528", + darkcolor="#32251f", + ) style.configure( "Play.TButton", background="#d98927", @@ -1134,11 +1198,11 @@ def run_dashboard(paths: AppPaths) -> None: values=(DEFAULT_DEPOT_START_OPTION.name,), state="readonly", style="Realm.TCombobox", - font=("Sans", 10, "bold"), + font=("Sans", 9, "bold"), justify="center", width=20, ) - realm_selector.pack(fill="x", pady=(5, 0)) + realm_selector.pack(fill="x", pady=(6, 5)) activity_copy = ttk.Frame(activity_panel, style="Surface.TFrame") activity_copy.pack(side="left", fill="both", expand=True) status = tk.StringVar(value="Ready") @@ -1200,21 +1264,58 @@ def run_dashboard(paths: AppPaths) -> None: news_heading = ttk.Frame(news_panel, style="Surface.TFrame") news_heading.pack(fill="x") ttk.Label(news_heading, text="LATEST NEWS", style="Section.TLabel").pack(side="left") + news_tabs = ttk.Frame(news_heading, style="Surface.TFrame") + news_tabs.pack(side="left", padx=(14, 0)) + news_tab_button = ttk.Button( + news_tabs, + text="NEWS", + style="NewsTabActive.TButton", + ) + news_tab_button.pack(side="left") + patches_tab_button = ttk.Button( + news_tabs, + text="PATCHES", + style="NewsTab.TButton", + ) + patches_tab_button.pack(side="left", padx=(5, 0)) ttk.Label( news_heading, text="OFFICIAL FEED", style="Muted.TLabel", ).pack(side="right") + news_viewport = ttk.Frame(news_panel, style="Surface.TFrame") + news_viewport.pack(fill="both", expand=True, pady=(6, 0)) + news_viewport.grid_columnconfigure(0, weight=1) + news_viewport.grid_rowconfigure(0, weight=1) news_canvas = tk.Canvas( - news_panel, + news_viewport, background="#191311", highlightthickness=0, borderwidth=0, height=252, ) - news_canvas.pack(fill="both", expand=True, pady=(6, 0)) - news_content = ttk.Frame(news_canvas, style="Surface.TFrame") - news_window = news_canvas.create_window((0, 0), window=news_content, anchor="nw") + news_canvas.grid(row=0, column=0, sticky="nsew") + news_scrollbar = ttk.Scrollbar( + news_viewport, + orient="vertical", + style="News.Vertical.TScrollbar", + command=news_canvas.yview, + ) + news_scrollbar.grid(row=0, column=1, sticky="ns", padx=(5, 0)) + news_scrollbar.grid_remove() + news_canvas.configure(yscrollcommand=news_scrollbar.set) + news_content_host = ttk.Frame(news_canvas, style="Surface.TFrame") + news_content_host.grid_columnconfigure(0, weight=1) + news_content = ttk.Frame(news_content_host, style="Surface.TFrame") + patches_content = ttk.Frame(news_content_host, style="Surface.TFrame") + news_content.grid(row=0, column=0, sticky="nsew") + patches_content.grid(row=0, column=0, sticky="nsew") + patches_content.grid_remove() + news_window = news_canvas.create_window( + (0, 0), + window=news_content_host, + anchor="nw", + ) broadcast_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) broadcast_panel.pack(fill="x", pady=(0, 8), before=news_panel) @@ -1269,6 +1370,9 @@ def run_dashboard(paths: AppPaths) -> None: news_image_targets: dict[int, tk.Canvas] = {} news_image_references: dict[int, tk.PhotoImage] = {} startup_news_images_pending: set[int] = set() + active_news_tab = "news" + rendered_news_articles: tuple[NewsArticle, ...] | None = None + rendered_patch_notes: tuple[PatchNote, ...] | None = None logo_image_references: list[tk.PhotoImage] = [] preloader: tk.Toplevel | None = None preloader_status: tk.StringVar | None = None @@ -1632,12 +1736,37 @@ def run_dashboard(paths: AppPaths) -> None: def open_article(url: str) -> None: webbrowser.open(url) - def make_news_card_clickable(card: tk.Frame, url: str) -> None: + def scroll_news_view(event: object) -> str | None: + if active_news_tab != "patches": + return None + delta = getattr(event, "delta", 0) + button = getattr(event, "num", None) + if isinstance(delta, int) and delta: + direction = -1 if delta > 0 else 1 + elif button == 4: + direction = -1 + elif button == 5: + direction = 1 + else: + return None + news_canvas.yview_scroll(direction * 3, "units") + return "break" + + def make_news_card_clickable( + card: tk.Frame, + url: str, + *, + border_only: bool = False, + ) -> None: def enter(_event: object) -> None: - card.configure(background="#2a1e17", highlightbackground="#e4a34c", highlightthickness=1) + card.configure(highlightbackground="#e4a34c", highlightthickness=1) + if not border_only: + card.configure(background="#2a1e17") def leave(_event: object) -> None: - card.configure(background="#211916", highlightbackground="#4a3528", highlightthickness=1) + card.configure(highlightbackground="#4a3528", highlightthickness=1) + if not border_only: + card.configure(background="#211916") def open_card(_event: object) -> None: open_article(url) @@ -1648,6 +1777,9 @@ def run_dashboard(paths: AppPaths) -> None: bind("", enter) bind("", leave) bind("", open_card) + bind("", scroll_news_view, "+") + bind("", scroll_news_view, "+") + bind("", scroll_news_view, "+") configure(cursor="hand2") for child in getattr(widget, "winfo_children")(): bind_widget(child) @@ -1695,12 +1827,18 @@ def run_dashboard(paths: AppPaths) -> None: ).pack(anchor="w", fill="x", pady=(3, 0)) make_news_card_clickable(card, broadcast.url) - def show_news(articles: tuple[NewsArticle, ...]) -> None: + def render_news(articles: tuple[NewsArticle, ...]) -> None: + nonlocal rendered_news_articles + visible_articles = articles[:MAX_NEWS_CARDS] + if rendered_news_articles == visible_articles: + return news_image_targets.clear() news_image_references.clear() for child in news_content.winfo_children(): child.destroy() - if not articles: + for column in range(MAX_NEWS_CARDS): + news_content.grid_columnconfigure(column, weight=0, uniform="") + if not visible_articles: ttk.Label( news_content, text="Official news is temporarily unavailable. Your cached game can still play.", @@ -1711,8 +1849,8 @@ def run_dashboard(paths: AppPaths) -> None: "news_artwork", "News layout ready", ) + rendered_news_articles = visible_articles return - visible_articles = articles[:MAX_NEWS_CARDS] for column in range(len(visible_articles)): news_content.grid_columnconfigure(column, weight=1, uniform="news-card") news_content.grid_rowconfigure(0, weight=1) @@ -1775,6 +1913,163 @@ def run_dashboard(paths: AppPaths) -> None: ).pack(anchor="w", fill="x") make_news_card_clickable(card, article.url) load_news_images(visible_articles) + rendered_news_articles = visible_articles + + def show_news(articles: tuple[NewsArticle, ...]) -> None: + render_news(articles) + + def render_patch_notes(patches: tuple[PatchNote, ...]) -> None: + nonlocal rendered_patch_notes + visible_patches = patches[:MAX_PATCH_NOTES] + if rendered_patch_notes == visible_patches: + return + for child in patches_content.winfo_children(): + child.destroy() + if not visible_patches: + ttk.Label( + patches_content, + text=( + "Official patch notes are temporarily unavailable. " + "Your cached game can still play." + ), + style="Muted.TLabel", + ).pack(anchor="w", padx=8, pady=8) + rendered_patch_notes = visible_patches + return + for patch in visible_patches: + card = tk.Frame( + patches_content, + background="#211916", + highlightbackground="#4a3528", + highlightthickness=1, + padx=16, + pady=13, + ) + card.pack(fill="x", padx=4, pady=(4, 9)) + tk.Label( + card, + text=patch.title, + background="#211916", + foreground="#fff0d2", + font=("Sans", 12, "bold"), + wraplength=PATCH_CARD_WRAP_LENGTH, + justify="left", + anchor="w", + ).pack(anchor="w", fill="x") + metadata = tk.Frame(card, background="#211916") + metadata.pack(anchor="w", fill="x", pady=(7, 0)) + tk.Label( + metadata, + text=f"VERSION {patch.version or '—'}", + background="#30231d", + foreground="#d8aa61", + font=("Sans", 8, "bold"), + padx=6, + pady=2, + ).pack(side="left", padx=(0, 7)) + for tag in patch.tags or ("general",): + tk.Label( + metadata, + text=tag.upper(), + background="#2a201c", + foreground="#d8aa61", + font=("Sans", 7, "bold"), + padx=6, + pady=2, + ).pack(side="left", padx=(0, 7)) + if patch.published_at: + tk.Label( + metadata, + text=patch.published_at, + background="#211916", + foreground="#c9baaa", + font=("Sans", 8), + ).pack(side="right") + tk.Label( + card, + text=patch.summary or "No summary provided.", + background="#211916", + foreground="#ded1c4", + font=("Sans", 10), + wraplength=PATCH_CARD_WRAP_LENGTH, + justify="left", + anchor="w", + ).pack(anchor="w", fill="x", pady=(9, 3)) + if not patch.sections: + tk.Label( + card, + text="No detailed changes were published for this patch.", + background="#211916", + foreground="#ded1c4", + font=("Sans", 9), + ).pack(anchor="w", pady=(8, 0)) + for section in patch.sections: + tk.Label( + card, + text=section.title.upper(), + background="#211916", + foreground="#d8aa61", + font=("Sans", 9, "bold"), + ).pack(anchor="w", pady=(12, 5)) + for change in section.items: + change_row = tk.Frame(card, background="#211916") + change_row.pack(anchor="w", fill="x", pady=3) + badge_background, badge_foreground = patch_change_colors( + change.type_key + ) + tk.Label( + change_row, + text=patch_change_tag(change.type_key), + background=badge_background, + foreground=badge_foreground, + font=("Sans", 8, "bold"), + width=14, + anchor="center", + padx=5, + pady=2, + ).pack(side="left", anchor="n", padx=(0, 8)) + tk.Label( + change_row, + text=change.text, + background="#211916", + foreground="#ded1c4", + font=("Sans", 10), + wraplength=PATCH_CHANGE_WRAP_LENGTH, + justify="left", + anchor="w", + ).pack(side="left", anchor="w", fill="x", expand=True) + make_news_card_clickable(card, patch.url, border_only=True) + rendered_patch_notes = visible_patches + + def show_patch_notes(patches: tuple[PatchNote, ...]) -> None: + render_patch_notes(patches) + + def switch_news_tab(tab: str) -> None: + nonlocal active_news_tab + if tab not in {"news", "patches"} or tab == active_news_tab: + return + active_news_tab = tab + news_tab_button.configure( + style="NewsTabActive.TButton" if tab == "news" else "NewsTab.TButton" + ) + patches_tab_button.configure( + style=( + "NewsTabActive.TButton" if tab == "patches" else "NewsTab.TButton" + ) + ) + news_canvas.yview_moveto(0.0) + if tab == "patches": + news_content.grid_remove() + patches_content.grid() + news_scrollbar.grid() + else: + patches_content.grid_remove() + news_content.grid() + news_scrollbar.grid_remove() + root.after_idle(update_news_viewport) + + news_tab_button.configure(command=lambda: switch_news_tab("news")) + patches_tab_button.configure(command=lambda: switch_news_tab("patches")) def load_news_images(articles: tuple[NewsArticle, ...]) -> None: image_articles = tuple( @@ -2144,6 +2439,9 @@ def run_dashboard(paths: AppPaths) -> None: def news_worker() -> None: events.put(("news", fetch_news(paths.news_cache))) + def patches_worker() -> None: + events.put(("patches", fetch_patch_notes(paths.patches_cache))) + def server_status_worker() -> None: try: statuses = fetch_server_status() @@ -2171,6 +2469,7 @@ def run_dashboard(paths: AppPaths) -> None: threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start() threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start() + threading.Thread(target=patches_worker, name="ptlinux-patches", daemon=True).start() threading.Thread( target=server_status_worker, name="ptlinux-server-status", @@ -2355,6 +2654,16 @@ def run_dashboard(paths: AppPaths) -> None: "news", "News cards ready", ) + elif ( + kind == "patches" + and isinstance(value, tuple) + and all(isinstance(item, PatchNote) for item in value) + ): + show_patch_notes(value) + mark_startup_component_ready( + "patches", + "Patch notes ready", + ) elif kind == "news_image": complete_news_image(value) elif kind == "logo_image": @@ -2909,13 +3218,25 @@ def run_dashboard(paths: AppPaths) -> None: if isinstance(width, int) and width > 0: news_canvas.itemconfigure(news_window, width=width) + news_content_host.bind("", update_news_viewport) news_content.bind("", update_news_viewport) + patches_content.bind("", update_news_viewport) news_canvas.bind("", resize_news_content) + for scroll_widget in ( + news_canvas, + news_content_host, + news_content, + patches_content, + ): + scroll_widget.bind("", scroll_news_view, add="+") + scroll_widget.bind("", scroll_news_view, add="+") + scroll_widget.bind("", scroll_news_view, add="+") startup_overlay.lift() startup_status.set("Restoring dashboard content…") startup_overlay.update_idletasks() show_broadcast(read_cached_broadcast(paths.broadcast_cache)) show_news(read_cached_news(paths.news_cache)) + show_patch_notes(read_cached_patch_notes(paths.patches_cache)) cached_launcher_update = read_cached_launcher_update_check( paths.launcher_update_check_cache )