From 344c0f6f7c191cf2e3adc7d61c66528a1d3f3aa2 Mon Sep 17 00:00:00 2001 From: rina Date: Sun, 1 Dec 2024 11:14:21 +1000 Subject: [PATCH] address mrcat review comments - support show all <-> show less - add comment about font choice other things: - remove all buttons on timeout - send text file as a separate message, keeping image in original reponse - tweak some comments and rename print_leaderboard --- uqcsbot/advent.py | 81 +++++++++++++++++++++-------------- uqcsbot/utils/advent_utils.py | 16 ++++--- 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/uqcsbot/advent.py b/uqcsbot/advent.py index 67a8ecd..0814981 100644 --- a/uqcsbot/advent.py +++ b/uqcsbot/advent.py @@ -14,6 +14,7 @@ from uqcsbot.models import AOCRegistrations, AOCWinners from uqcsbot.utils.err_log_utils import FatalErrorWithLog from uqcsbot.utils.advent_utils import ( + Leaderboard, Member, Day, Json, @@ -22,7 +23,7 @@ CACHE_TIME, HL_COLOUR, parse_leaderboard_column_string, - print_leaderboard, + build_leaderboard, render_leaderboard_to_image, render_leaderboard_to_text, ) @@ -139,11 +140,13 @@ class LeaderboardView(discord.ui.View): - INITIAL_VISIBLE_ROWS = 20 + TRUNCATED_COUNT = 20 + TIMEOUT = 180 # seconds def __init__( self, bot: UQCSBot, + inter: discord.Interaction, code: int, year: int, day: Optional[Day], @@ -151,9 +154,11 @@ def __init__( leaderboard_style: str, sortby: Optional[SortingMethod], ): - super().__init__(timeout=300) + super().__init__(timeout=self.TIMEOUT) + # constant within one embed self.bot = bot + self.inter = inter self.code = code self.year = year self.day = day @@ -161,24 +166,25 @@ def __init__( self.leaderboard_style = leaderboard_style self.sortby = sortby self.timestamp = datetime.now() + self.basename = f"advent_{self.code}_{self.year}_{self.day}" # can be changed by interaction - self.visible_members = members[: self.INITIAL_VISIBLE_ROWS] - self.show_text = False + self._visible_members = members[: self.TRUNCATED_COUNT] @property def is_truncated(self): - return len(self.visible_members) < len(self.all_members) + return len(self._visible_members) < len(self.all_members) - def make_message_arguments(self) -> Dict[str, Any]: - view_url = LEADERBOARD_VIEW_URL.format(year=self.year, code=self.code) - - leaderboard = print_leaderboard( + def _build_leaderboard(self, members: List[Member]) -> Leaderboard: + return build_leaderboard( parse_leaderboard_column_string(self.leaderboard_style, self.bot), - self.visible_members, + members, self.day, ) + def make_message_arguments(self) -> Dict[str, Any]: + view_url = LEADERBOARD_VIEW_URL.format(year=self.year, code=self.code) + title = ( "Advent of Code UQCS Leaderboard" if self.code == UQCS_LEADERBOARD @@ -193,12 +199,10 @@ def make_message_arguments(self) -> Dict[str, Any]: notes.append(f"sorted by {self.sortby}") if self.is_truncated: notes.append( - f"top {len(self.visible_members)} shown out of {len(self.all_members)}" + f"top {len(self._visible_members)} shown out of {len(self.all_members)}" ) body = f"({', '.join(notes)})" if notes else "" - basename = f"advent_{self.code}_{self.year}_{self.day}" - embed = discord.Embed( title=title, url=view_url, @@ -207,19 +211,16 @@ def make_message_arguments(self) -> Dict[str, Any]: timestamp=self.timestamp, ) - if not self.show_text: - scoreboard_image = render_leaderboard_to_image(leaderboard) - file = discord.File(io.BytesIO(scoreboard_image), basename + ".png") - embed.set_image(url=f"attachment://{file.filename}") - else: - scoreboard_text = render_leaderboard_to_text(leaderboard) - file = discord.File( - io.BytesIO(scoreboard_text.encode("utf-8")), basename + ".txt" - ) + leaderboard = self._build_leaderboard(self._visible_members) + scoreboard_image = render_leaderboard_to_image(leaderboard) + file = discord.File(io.BytesIO(scoreboard_image), self.basename + ".png") + embed.set_image(url=f"attachment://{file.filename}") - self.show_all_interaction.disabled = not self.is_truncated - self.get_text_interaction.label = ( - "Show as image" if self.show_text else "Show as text" + self.show_all_interaction.disabled = ( + len(self.all_members) <= self.TRUNCATED_COUNT + ) + self.show_all_interaction.label = ( + "Show all" if self.is_truncated else "Show less" ) return { @@ -232,15 +233,33 @@ def make_message_arguments(self) -> Dict[str, Any]: async def show_all_interaction( self, inter: discord.Interaction, btn: discord.ui.Button["LeaderboardView"] ): - self.visible_members = self.all_members + self._visible_members = ( + self.all_members + if self.is_truncated + else self.all_members[: self.TRUNCATED_COUNT] + ) await inter.response.edit_message(**self.make_message_arguments()) - @discord.ui.button(label="Show text", style=discord.ButtonStyle.gray) + @discord.ui.button(label="Export as text", style=discord.ButtonStyle.gray) async def get_text_interaction( self, inter: discord.Interaction, btn: discord.ui.Button["LeaderboardView"] ): - self.show_text = not self.show_text - await inter.response.edit_message(**self.make_message_arguments()) + """ + Sends the text leaderboard as a file attachment within a new reply. + """ + leaderboard = self._build_leaderboard(self.all_members) + text = render_leaderboard_to_text(leaderboard) + file = discord.File(io.BytesIO(text.encode("utf-8")), self.basename + ".txt") + await inter.response.send_message(file=file) + + btn.disabled = True + await self.inter.edit_original_response(view=self) + + async def on_timeout(self) -> None: + """ + Detach interactable view on timeout. + """ + await self.inter.edit_original_response(view=None) class Advent(commands.Cog): @@ -629,7 +648,7 @@ async def leaderboard_command( return view = LeaderboardView( - self.bot, code, year, day, members, leaderboard_style, sortby + self.bot, interaction, code, year, day, members, leaderboard_style, sortby ) await interaction.edit_original_response(**view.make_message_arguments()) diff --git a/uqcsbot/utils/advent_utils.py b/uqcsbot/utils/advent_utils.py index 5cb2d70..0bb35e1 100644 --- a/uqcsbot/utils/advent_utils.py +++ b/uqcsbot/utils/advent_utils.py @@ -8,6 +8,7 @@ Callable, NamedTuple, Tuple, + cast, ) from collections import defaultdict from datetime import datetime, timedelta @@ -43,6 +44,7 @@ CACHE_TIME = timedelta(minutes=15) # Colours borrowed from adventofcode.com website +# https://adventofcode.com/static/style.css BG_COLOUR = "#0f0f23" FG_COLOUR = "#cccccc" HL_COLOUR = "#009900" @@ -428,14 +430,17 @@ def _isolate_leaderboard_layers( continue layers[k] += "".join(c if c.isspace() else " " for c in text) - spaces = layers[None] - del layers[None] - return spaces, layers # type: ignore + spaces_str = layers.pop(None) + return spaces_str, cast(Dict[str, Any], layers) def render_leaderboard_to_image(leaderboard: Leaderboard) -> bytes: spaces, layers = _isolate_leaderboard_layers(leaderboard) + # NOTE: font choice should support as wide a range of glyphs as possible, + # since discord display names are arbitrary and pillow does not support + # fallback fonts. + # font must also be monospace, in order for the colour layers to be aligned. font = PIL.ImageFont.truetype("./uqcsbot/static/NotoSansMono-Regular.ttf", 20) img = PIL.Image.new("RGB", (1, 1)) @@ -457,11 +462,12 @@ def render_leaderboard_to_image(leaderboard: Leaderboard) -> bytes: return buf.getvalue() # XXX: why do we need to getvalue()? -def print_leaderboard( +def build_leaderboard( columns: List[LeaderboardColumn], members: List[Member], day: Optional[Day] ): """ - Returns a string of the leaderboard of the given format. + Returns a leaderboard made up of fragments, with the given column configuration + and member rows. """ header = "".join(column.title[0] for column in columns) header += "\n"