From 1cef97cdd468207e7a720c04bd02fe5290a1300f Mon Sep 17 00:00:00 2001 From: Conatum Date: Fri, 23 Dec 2022 07:02:26 +0200 Subject: [PATCH] rewrite: Move GUI cards into main repo. --- .../pending-rewrite/gui-cards/__init__.py | 8 + .../pending-rewrite/gui-cards/goals.py | 752 ++++++++++++++++++ .../pending-rewrite/gui-cards/leaderboard.py | 504 ++++++++++++ .../pending-rewrite/gui-cards/mixins.py | 143 ++++ .../pending-rewrite/gui-cards/monthly.py | 750 +++++++++++++++++ .../pending-rewrite/gui-cards/profile.py | 623 +++++++++++++++ .../pending-rewrite/gui-cards/stats.py | 508 ++++++++++++ .../pending-rewrite/gui-cards/tasklist.py | 389 +++++++++ src/modules/pending-rewrite/gui-cards/test.py | 38 + .../pending-rewrite/gui-cards/timer.py | 449 +++++++++++ .../pending-rewrite/gui-cards/weekly.py | 713 +++++++++++++++++ .../pending-rewrite/gui-commands/__init__.py | 0 .../pending-rewrite/gui-commands/goals.py | 320 ++++++++ .../gui-commands/leaderboard.py | 198 +++++ .../pending-rewrite/gui-commands/module.py | 90 +++ .../pending-rewrite/gui-commands/reloadgui.py | 24 + .../pending-rewrite/gui-commands/stats.py | 278 +++++++ .../pending-rewrite/gui-commands/tasklist.py | 111 +++ .../pending-rewrite/gui-commands/timer.py | 169 ++++ .../gui-commands/weekly_test.py | 43 + 20 files changed, 6110 insertions(+) create mode 100644 src/modules/pending-rewrite/gui-cards/__init__.py create mode 100644 src/modules/pending-rewrite/gui-cards/goals.py create mode 100644 src/modules/pending-rewrite/gui-cards/leaderboard.py create mode 100644 src/modules/pending-rewrite/gui-cards/mixins.py create mode 100644 src/modules/pending-rewrite/gui-cards/monthly.py create mode 100644 src/modules/pending-rewrite/gui-cards/profile.py create mode 100644 src/modules/pending-rewrite/gui-cards/stats.py create mode 100644 src/modules/pending-rewrite/gui-cards/tasklist.py create mode 100644 src/modules/pending-rewrite/gui-cards/test.py create mode 100644 src/modules/pending-rewrite/gui-cards/timer.py create mode 100644 src/modules/pending-rewrite/gui-cards/weekly.py create mode 100644 src/modules/pending-rewrite/gui-commands/__init__.py create mode 100644 src/modules/pending-rewrite/gui-commands/goals.py create mode 100644 src/modules/pending-rewrite/gui-commands/leaderboard.py create mode 100644 src/modules/pending-rewrite/gui-commands/module.py create mode 100644 src/modules/pending-rewrite/gui-commands/reloadgui.py create mode 100644 src/modules/pending-rewrite/gui-commands/stats.py create mode 100644 src/modules/pending-rewrite/gui-commands/tasklist.py create mode 100644 src/modules/pending-rewrite/gui-commands/timer.py create mode 100644 src/modules/pending-rewrite/gui-commands/weekly_test.py diff --git a/src/modules/pending-rewrite/gui-cards/__init__.py b/src/modules/pending-rewrite/gui-cards/__init__.py new file mode 100644 index 00000000..27a258cb --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/__init__.py @@ -0,0 +1,8 @@ +from .stats import StatsCard +from .profile import ProfileCard +from .goals import WeeklyGoalCard, MonthlyGoalCard +from .monthly import MonthlyStatsCard +from .weekly import WeeklyStatsCard +from .tasklist import TasklistCard +from .leaderboard import LeaderboardCard +from .timer import BreakTimerCard, FocusTimerCard diff --git a/src/modules/pending-rewrite/gui-cards/goals.py b/src/modules/pending-rewrite/gui-cards/goals.py new file mode 100644 index 00000000..c353c83e --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/goals.py @@ -0,0 +1,752 @@ +import math +import datetime +from io import BytesIO +from PIL import Image, ImageDraw, ImageOps, ImageColor + +from ..base import Card, Layout, fielded, Skin, FieldDesc +from ..base.Avatars import avatar_manager +from ..base.Skin import ( + AssetField, RGBAAssetField, BlobField, AssetPathField, StringField, NumberField, + FontField, ColourField, PointField, ComputedField +) + +from ..utils import get_avatar_key + +from .mixins import MiniProfileLayout + + +@fielded +class _GoalSkin(Skin): + _env = { + 'scale': 2 # General size scale to match background resolution + } + + background: AssetField = "goals/background.png" + + help_frame: AssetField = None + + # Title section + title_pre_gap: NumberField = 40 + title_text: StringField = "" + title_font: FontField = ('ExtraBold', 76) + title_size: ComputedField = lambda skin: skin.title_font.getsize(skin.title_text) + title_colour: ColourField = '#DDB21D' + title_underline_gap: NumberField = 10 + title_underline_width: NumberField = 0 + title_gap: NumberField = 50 + + # Profile section + mini_profile_indent: NumberField = 125 + mini_profile_size: ComputedField = lambda skin: ( + skin.background.width - 2 * skin.mini_profile_indent, + int(skin._env['scale'] * 200) + ) + mini_profile_avatar_mask: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_mask.png', convert=None) + mini_profile_avatar_frame: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_frame.png', convert='RGBA') + mini_profile_avatar_sep: NumberField = 50 + + mini_profile_name_font: FontField = ('BoldItalic', 55) + mini_profile_name_colour: ColourField = '#DDB21D' + mini_profile_discrim_font: FontField = mini_profile_name_font + mini_profile_discrim_colour: ColourField = '#BABABA' + mini_profile_name_gap: NumberField = 20 + + mini_profile_badge_end: AssetField = "mini-profile/badge_end.png" + mini_profile_badge_font: FontField = ('Black', 30) + mini_profile_badge_colour: ColourField = '#FFFFFF' + mini_profile_badge_text_colour: ColourField = '#051822' + mini_profile_badge_gap: NumberField = 20 + mini_profile_badge_min_sep: NumberField = 10 + + # Progress bars + progress_mask: RGBAAssetField = 'goals/progressbar_mask.png' + progress_bg_colour: ColourField = '#273341' + progress_bg: BlobField = FieldDesc( + BlobField, + mask_field='progress_mask', + colour_field='progress_bg_colour', + colour_override_field=None + ) + progress_end: RGBAAssetField = 'goals/progressbar_end_mask.png' + progress_colour: ColourField = '#6CB7D0' + progress_colour_override: ColourField = None + progress_full: BlobField = FieldDesc( + BlobField, + mask_field='progress_mask', + colour_field='progress_colour', + colour_override_field='progress_colour_override' + ) + + line_gap: NumberField = 5 + progress_text_at: ComputedField = lambda skin: 7 * (skin.progress_bg.height // 10) + + task_count_font: FontField = ('Bold', 76) + task_count_colour: ColourField = '#DDB21D' + task_done_font: FontField = ('Bold', 37) + task_done_colour: ColourField = '#FFFFFF' + task_goal_font: FontField = ('Bold', 27) + task_goal_colour: ColourField = '#FFFFFF' + task_goal_number_font: FontField = ('Light', 27) + task_goal_number_colour: ColourField = '#FFFFFF' + task_text_size: ComputedField = lambda skin: ( + skin.task_count_font.getsize("00")[0] + + skin.task_done_font.getsize("TASKS DONE")[0] + + skin.task_goal_font.getsize("GOAL")[0] + + 3 * skin.line_gap, + skin.task_done_font.getsize("TASKS DONE")[1] + ) + task_progress_text_height: ComputedField = lambda skin: ( + skin.task_count_font.getsize('100')[1] + + skin.task_done_font.getsize('TASKS DONE')[1] + + skin.task_goal_font.getsize('GOAL')[1] + + 2 * skin.line_gap + ) + + attendance_rate_font: FontField = ('Bold', 76) + attendance_rate_colour: ColourField = '#DDB21D' + attendance_font: FontField = ('Bold', 37) + attendance_colour: ColourField = '#FFFFFF' + attendance_text_height: ComputedField = lambda skin: ( + skin.attendance_rate_font.getsize('100%')[1] + + skin.attendance_font.getsize('ATTENDANCE')[1] * 2 + + 2 * skin.line_gap + ) + + studied_text_font: FontField = ('Bold', 37) + studied_text_colour: ColourField = '#FFFFFF' + studied_hour_font: FontField = ('Bold', 60) + studied_hour_colour: ColourField = '#DDB21D' + studied_text_height: ComputedField = lambda skin: ( + skin.studied_text_font.getsize('STUDIED')[1] * 2 + + skin.studied_hour_font.getsize('400')[1] + + 2 * skin.line_gap + ) + + progress_gap: NumberField = 50 + + # Tasks + task_frame: AssetField = "goals/task_frame.png" + task_margin: PointField = (100, 50) + task_column_sep: NumberField = 100 + + task_header: StringField = "" + task_header_font: FontField = ('Black', 50) + task_header_colour: ColourField = '#DDB21D' + task_header_gap: NumberField = 25 + task_underline_gap: NumberField = 10 + task_underline_width: NumberField = 5 + + task_done_number_bg: AssetField = "goals/task_done.png" + task_done_number_font: FontField = ('Regular', 28) + task_done_number_colour: ColourField = '#292828' + + task_done_text_font: FontField = ('Regular', 35) + task_done_text_colour: ColourField = '#686868' + + task_done_line_width: NumberField = FieldDesc(NumberField, 7, scaled=False) + + task_undone_number_bg: AssetField = "goals/task_undone.png" + task_undone_number_font: FontField = ('Regular', 28) + task_undone_number_colour: ColourField = '#FFFFFF' + + task_undone_text_font: FontField = ('Regular', 35) + task_undone_text_colour: ColourField = '#FFFFFF' + + task_text_height: ComputedField = lambda skin: skin.task_done_text_font.getsize('TASK')[1] + task_num_sep: NumberField = 15 + task_inter_gap: NumberField = 25 + + # Date text + footer_pre_gap: NumberField = 25 + footer_font: FontField = ('Bold', 28) + footer_colour: ColourField = '#6f6e6f' + footer_gap: NumberField = 50 + + +@fielded +class WeeklyGoalSkin(_GoalSkin): + title_text: StringField = "WEEKLY STATISTICS" + task_header: StringField = "GOALS OF THE WEEK" + + help_frame: AssetField = "weekly/help_frame.png" + + +@fielded +class MonthlyGoalSkin(_GoalSkin): + title_text: StringField = "MONTHLY STATISTICS" + task_header: StringField = "GOALS OF THE MONTH" + + help_frame: AssetField = "monthly/help_frame.png" + + +class GoalPage(Layout, MiniProfileLayout): + def __init__(self, skin, + name, discrim, avatar, badges, + tasks_done, studied_hours, attendance, + tasks_goal, studied_goal, goals, + date): + self.skin = skin + + self.data_name = name + self.data_discrim = discrim + self.data_avatar = avatar + self.data_badges = badges + + self.data_tasks_done = tasks_done + self.data_studied_hours = studied_hours + self.data_attendance = attendance + self.data_tasks_goal = tasks_goal + + self.data_studied_goal = studied_goal + self.data_goals = goals + self.data_date = date + + self.image = None + + def draw(self) -> Image: + image = self.skin.background + draw = ImageDraw.Draw(image) + + xpos, ypos = 0, 0 + + # Draw header text + xpos = (image.width - self.skin.title_size[0]) // 2 + ypos += self.skin.title_pre_gap + draw.text( + (xpos, ypos), + self.skin.title_text, + fill=self.skin.title_colour, + font=self.skin.title_font + ) + + # Underline it + title_size = self.skin.title_font.getsize(self.skin.title_text) + ypos += title_size[1] + self.skin.title_gap + # ypos += title_size[1] + self.skin.title_underline_gap + # draw.line( + # (xpos, ypos, xpos + title_size[0], ypos), + # fill=self.skin.title_colour, + # width=self.skin.title_underline_width + # ) + # ypos += self.skin.title_underline_width + self.skin.title_gap + + # Draw the profile + xpos = self.skin.mini_profile_indent + profile = self._draw_profile() + image.alpha_composite( + profile, + (xpos, ypos) + ) + + # Start from the bottom + ypos = image.height + + # Draw the date text + ypos -= self.skin.footer_gap + date_text = self.data_date.strftime("As of %d %b • {} {}".format(self.data_name, self.data_discrim)) + size = self.skin.footer_font.getsize(date_text) + ypos -= size[1] + draw.text( + ((image.width - size[0]) // 2, ypos), + date_text, + font=self.skin.footer_font, + fill=self.skin.footer_colour + ) + ypos -= self.skin.footer_pre_gap + + if self.data_goals or self.data_tasks_goal or self.data_studied_goal: + # Draw the tasks + task_image = self._draw_tasks() + + ypos -= task_image.height + image.alpha_composite( + task_image, + ((image.width - task_image.width) // 2, ypos) + ) + + # Draw the progress bars + progress_image = self._draw_progress() + ypos -= progress_image.height + self.skin.progress_gap + image.alpha_composite( + progress_image, + ((image.width - progress_image.width) // 2, ypos) + ) + else: + ypos -= self.skin.help_frame.height + image.alpha_composite( + self.skin.help_frame, + ((image.width - self.skin.help_frame.width) // 2, ypos) + ) + + self.image = image + + return image + + def _draw_tasks(self): + image = self.skin.task_frame + draw = ImageDraw.Draw(image) + + # Task container is smaller than frame + xpos, ypos = self.skin.task_margin + + # Draw header text + draw.text( + (xpos, ypos), + self.skin.task_header, + fill=self.skin.task_header_colour, + font=self.skin.task_header_font + ) + + # Underline it + title_size = self.skin.task_header_font.getsize(self.skin.task_header) + ypos += title_size[1] + self.skin.task_underline_gap + draw.line( + (xpos, ypos, xpos + title_size[0], ypos), + fill=self.skin.task_header_colour, + width=self.skin.task_underline_width + ) + ypos += self.skin.task_underline_width + self.skin.task_header_gap + + if len(self.data_goals) > 5: + # Split remaining space into two boxes + task_box_1 = Image.new( + 'RGBA', + (image.width // 2 - self.skin.task_margin[0] - self.skin.task_column_sep // 2, + image.height - ypos) + ) + task_box_2 = Image.new( + 'RGBA', + (image.width // 2 - self.skin.task_margin[0] - self.skin.task_column_sep // 2, + image.height - ypos) + ) + self._draw_tasks_into(self.data_goals[:5], task_box_1) + self._draw_tasks_into(self.data_goals[5:], task_box_2) + image.alpha_composite( + task_box_1, + (xpos, ypos) + ) + image.alpha_composite( + task_box_2, + (xpos + task_box_1.width + self.skin.task_column_sep, ypos) + ) + else: + task_box = Image.new( + 'RGBA', + (image.width - 2 * self.skin.task_margin[0], image.height) + ) + self._draw_tasks_into(self.data_goals, task_box) + image.alpha_composite( + task_box, + (xpos, ypos) + ) + return image + + def _draw_progress(self): + image = Image.new('RGBA', (self.skin.background.width, self.skin.progress_bg.height)) + + sep = (self.skin.background.width - 3 * self.skin.progress_bg.width) // 4 + + xpos = sep + image.alpha_composite( + self._draw_task_progress(), + (xpos, 0) + ) + + xpos += self.skin.progress_bg.width + sep + image.alpha_composite( + self._draw_study_progress(), + (xpos, 0) + ) + + xpos += self.skin.progress_bg.width + sep + image.alpha_composite( + self._draw_attendance(), + (xpos, 0) + ) + + return image + + def _draw_task_progress(self): + if not self.data_tasks_goal: + amount = 1 + else: + amount = self.data_tasks_done / self.data_tasks_goal + + progress_image = self._draw_progress_bar(amount) + draw = ImageDraw.Draw(progress_image) + + # Draw text into the bar + ypos = self.skin.progress_text_at - self.skin.task_progress_text_height + xpos = progress_image.width // 2 + + text = str(self.data_tasks_done) + draw.text( + (xpos, ypos), + text, + font=self.skin.task_count_font, + fill=self.skin.task_count_colour, + anchor='mt' + ) + ypos += self.skin.task_count_font.getsize(text)[1] + self.skin.line_gap + + text = "TASKS DONE" + draw.text( + (xpos, ypos), + text, + font=self.skin.task_done_font, + fill=self.skin.task_done_colour, + anchor='mt' + ) + ypos += self.skin.task_done_font.getsize(text)[1] + self.skin.line_gap + + text1 = "GOAL: " + length1 = self.skin.task_goal_font.getlength(text1) + text2 = str(self.data_tasks_goal) if self.data_tasks_goal else "N/A" + length2 = self.skin.task_goal_number_font.getlength(text2) + draw.text( + (xpos - length2 // 2, ypos), + text1, + font=self.skin.task_goal_font, + fill=self.skin.task_goal_colour, + anchor='mt' + ) + draw.text( + (xpos + length1 // 2, ypos), + text2, + font=self.skin.task_goal_number_font, + fill=self.skin.task_goal_number_colour, + anchor='mt' + ) + return progress_image + + def _draw_study_progress(self): + if not self.data_studied_goal: + amount = 1 + else: + amount = self.data_studied_hours / self.data_studied_goal + + progress_image = self._draw_progress_bar(amount) + draw = ImageDraw.Draw(progress_image) + + ypos = self.skin.progress_text_at - self.skin.studied_text_height + xpos = progress_image.width // 2 + + text = "STUDIED" + draw.text( + (xpos, ypos), + text, + font=self.skin.studied_text_font, + fill=self.skin.studied_text_colour, + anchor='mt' + ) + ypos += self.skin.studied_text_font.getsize(text)[1] + self.skin.line_gap + + if self.data_studied_goal: + text = f"{self.data_studied_hours}/{self.data_studied_goal}" + else: + text = str(self.data_studied_hours) + draw.text( + (xpos, ypos), + text, + font=self.skin.studied_hour_font, + fill=self.skin.studied_hour_colour, + anchor='mt' + ) + ypos += self.skin.studied_hour_font.getsize(text)[1] + self.skin.line_gap + + text = "HOURS" + draw.text( + (xpos, ypos), + text, + font=self.skin.studied_text_font, + fill=self.skin.studied_text_colour, + anchor='mt' + ) + return progress_image + + def _draw_attendance(self): + amount = self.data_attendance or 0 + + progress_image = self._draw_progress_bar(amount) + draw = ImageDraw.Draw(progress_image) + + ypos = self.skin.progress_text_at - self.skin.attendance_text_height + xpos = progress_image.width // 2 + + if self.data_attendance is not None: + text = f"{int(self.data_attendance * 100)}%" + else: + text = "N/A" + draw.text( + (xpos, ypos), + text, + font=self.skin.attendance_rate_font, + fill=self.skin.attendance_rate_colour, + anchor='mt' + ) + ypos += self.skin.attendance_rate_font.getsize(text)[1] + self.skin.line_gap + + text = "ATTENDANCE" + draw.text( + (xpos, ypos), + text, + font=self.skin.attendance_font, + fill=self.skin.attendance_colour, + anchor='mt' + ) + ypos += self.skin.attendance_font.getsize(text)[1] + self.skin.line_gap + + text = "RATE" + draw.text( + (xpos, ypos), + text, + font=self.skin.attendance_font, + fill=self.skin.attendance_colour, + anchor='mt' + ) + return progress_image + + def _draw_tasks_into(self, tasks, image) -> Image: + """ + Draw as many tasks as possible into the given image background. + """ + draw = ImageDraw.Draw(image) + xpos, ypos = 0, 0 + + for n, task, done in tasks: + # Draw task first to check if it fits on the page + task_image = self._draw_text( + task, + image.width - xpos - self.skin.task_done_number_bg.width - self.skin.task_num_sep, + done + ) + if task_image.height + ypos > image.height: + break + + # Draw number background + bg = self.skin.task_done_number_bg if done else self.skin.task_undone_number_bg + image.alpha_composite( + bg, + (xpos, ypos) + ) + + # Draw number + font = self.skin.task_done_number_font if done else self.skin.task_undone_number_font + colour = self.skin.task_done_number_colour if done else self.skin.task_undone_number_colour + draw.text( + (xpos + bg.width // 2, ypos + bg.height // 2), + str(n), + fill=colour, + font=font, + anchor='mm' + ) + + # Draw text + image.alpha_composite( + task_image, + (xpos + bg.width + self.skin.task_num_sep, ypos - (bg.height - self.skin.task_text_height) // 2) + ) + + ypos += task_image.height + self.skin.task_inter_gap + + return image + + def _draw_text(self, task, maxwidth, done) -> Image: + """ + Draw the text of a given task. + """ + font = self.skin.task_done_text_font if done else self.skin.task_undone_text_font + colour = self.skin.task_done_text_colour if done else self.skin.task_undone_text_colour + + size = font.getsize(task) + image = Image.new('RGBA', (min(size[0], maxwidth), size[1])) + draw = ImageDraw.Draw(image) + + draw.text((0, 0), task, font=font, fill=colour) + + if done: + # Also strikethrough + y = 0 + x1, y1, x2, y2 = font.getbbox(task) + draw.line( + (x1, y + y1 + (y2 - y1) // 2, x2, y + y1 + (y2 - y1) // 2), + fill=self.skin.task_done_text_colour, + width=self.skin.task_done_line_width + ) + + return image + + def _draw_progress_bar(self, amount): + amount = min(amount, 1) + amount = max(amount, 0) + + end = self.skin.progress_end + mask = self.skin.progress_mask + + center = ( + mask.width // 2 + 1, + mask.height // 2 + ) + radius = 2 * 158 + theta = amount * math.pi * 2 - math.pi / 2 + x = int(center[0] + radius * math.cos(theta)) + y = int(center[1] + radius * math.sin(theta)) + + canvas = Image.new('RGBA', size=(mask.width, mask.height)) + draw = ImageDraw.Draw(canvas) + + if amount >= 0.01: + canvas.alpha_composite( + end, + ( + center[0] - end.width // 2, + 30 - end.height // 2 + ) + ) + canvas.alpha_composite( + end, + ( + x - end.width // 2, + y - end.height // 2 + ) + ) + + sidelength = mask.width // 2 + line_ends = ( + int(center[0] + sidelength * math.cos(theta)), + int(center[1] + sidelength * math.sin(theta)) + ) + if amount <= 0.25: + path = [ + center, + (center[0], center[1] - sidelength), + (mask.width, 0), + line_ends + ] + elif amount <= 0.5: + path = [ + center, + (center[0], center[1] - sidelength), + (mask.width, 0), + (mask.width, mask.height), + line_ends + ] + elif amount <= 0.75: + path = [ + center, + (center[0], center[1] - sidelength), + (mask.width, 0), + (mask.width, mask.height), + (0, mask.height), + line_ends + ] + else: + path = [ + center, + (center[0], center[1] - sidelength), + (mask.width, 0), + (mask.width, mask.height), + (0, mask.height), + (0, 0), + line_ends + ] + + draw.polygon( + path, + fill='#FFFFFF' + ) + # canvas.paste((0, 0, 0, 0), mask=mask) + + image = Image.composite( + self.skin.progress_full, + self.skin.progress_bg.copy(), + canvas + ) + + else: + image = self.skin.progress_bg.copy() + return image + + +class _GoalCard(Card): + layout = GoalPage + + @classmethod + async def card_route(cls, runner, args, kwargs): + kwargs['avatar'] = await avatar_manager().get_avatar(*kwargs['avatar'], 256) + return await super().card_route(runner, args, kwargs) + + @classmethod + def _execute(cls, *args, **kwargs): + with BytesIO(kwargs['avatar']) as image_data: + with Image.open(image_data).convert('RGBA') as avatar_image: + kwargs['avatar'] = avatar_image + return super()._execute(*args, **kwargs) + + +class WeeklyGoalCard(_GoalCard): + route = "weekly_goal_card" + card_id = "weekly_goals" + + skin = WeeklyGoalSkin + + display_name = "Weekly Goals" + + @classmethod + async def sample_args(cls, ctx, **kwargs): + return { + 'name': ctx.author.name if ctx else 'John Doe', + 'discrim': ('#' + ctx.author.discriminator) if ctx else '#0000', + 'avatar': get_avatar_key(ctx.client, ctx.author.id) if ctx else (0, None), + 'badges': ( + 'STUDYING: MEDICINE', + 'HOBBY: MATHS', + 'CAREER: STUDENT', + 'FROM: EUROPE', + 'LOVES CATS <3' + ), + 'tasks_done': 100, + 'tasks_goal': 300, + 'studied_hours': 16, + 'studied_goal': 48, + 'attendance': 0.9, + 'goals': [(0, 'Write a 200 page thesis', False), + (1, 'Feed the kangaroo', True), + (2, 'Cure world hunger', False), + (3, 'Finish assignment 2', True)], + 'date': datetime.datetime.now() + } + + +class MonthlyGoalCard(_GoalCard): + route = "monthly_goal_card" + card_id = "monthly_goals" + + skin = MonthlyGoalSkin + + display_name = "Monthly Goals" + + @classmethod + async def sample_args(cls, ctx, **kwargs): + return { + 'name': ctx.author.name if ctx else 'John Doe', + 'discrim': ('#' + ctx.author.discriminator) if ctx else '#0000', + 'avatar': get_avatar_key(ctx.client, ctx.author.id) if ctx else (0, None), + 'badges': ( + 'STUDYING: MEDICINE', + 'HOBBY: MATHS', + 'CAREER: STUDENT', + 'FROM: EUROPE', + 'LOVES CATS <3' + ), + 'tasks_done': 400, + 'tasks_goal': 1200, + 'studied_hours': 64, + 'studied_goal': 128, + 'attendance': 0.95, + 'goals': [(0, 'Meet 10 new people', False), + (1, 'Feed the lion', False), + (2, 'Realise I am awesome', False), + (3, 'Never going to give you up', True)], + 'date': datetime.datetime.now() + } diff --git a/src/modules/pending-rewrite/gui-cards/leaderboard.py b/src/modules/pending-rewrite/gui-cards/leaderboard.py new file mode 100644 index 00000000..2fd50363 --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/leaderboard.py @@ -0,0 +1,504 @@ +import asyncio +from io import BytesIO +from PIL import Image, ImageDraw + +from ..base import Card, Layout, fielded, Skin, FieldDesc +from ..base.Avatars import avatar_manager +from ..base.Skin import ( + AssetField, RGBAAssetField, AssetPathField, BlobField, StringField, NumberField, + FontField, ColourField, ComputedField +) + + +class LeaderboardEntry: + __slots__ = ( + 'userid', + 'position', + 'time', + 'name', + 'avatar_key', + 'image' + ) + + def __init__(self, userid, position, time, name, avatar_key): + self.userid = userid + + self.position = position + self.time = time + + self.name = name + self.name = ''.join(i if ord(i) < 128 or i == '∞' else '*' for i in self.name) + + self.avatar_key = avatar_key + + self.image = None + + async def get_avatar(self): + if not self.image: + self.image = await avatar_manager().get_avatar( + *self.avatar_key, + size=512 if self.position in (1, 2, 3) else 256 + ) + + def convert_avatar(self): + if self.image: + with BytesIO(self.image) as data: + self.image = Image.open(data).convert('RGBA') + + +@fielded +class LeaderboardSkin(Skin): + _env = { + 'scale': 2 # General size scale to match background resolution + } + + header_text_pre_gap: NumberField = 20 + header_text: StringField = "STUDY TIME LEADERBOARD" + header_text_font: FontField = ('ExtraBold', 80) + header_text_size: ComputedField = lambda skin: skin.header_text_font.getsize(skin.header_text) + header_text_colour: ColourField = '#DDB21D' + + header_text_gap: NumberField = 15 + header_text_line_width: NumberField = 0 + header_text_line_gap: NumberField = 20 + + subheader_name_font: FontField = ('SemiBold', 27) + subheader_name_colour: ColourField = '#FFFFFF' + subheader_value_font: FontField = ('Regular', 27) + subheader_value_colour: ColourField = '#FFFFFF' + + header_gap: NumberField = 20 + + # First page constants + first_bg_path: AssetPathField = "leaderboard/first_page_background.png" + header_bg_gap: NumberField = 20 + first_header_height: NumberField = 694 + + first_avatar_mask: RGBAAssetField = "leaderboard/medal_avatar_mask.png" + + first_avatar_bg: RGBAAssetField = "leaderboard/first_avatar_background.png" + second_avatar_bg: RGBAAssetField = "leaderboard/second_avatar_background.png" + third_avatar_bg: RGBAAssetField = "leaderboard/third_avatar_background.png" + + first_avatar_gap: NumberField = 20 + + first_top_gap: NumberField = 20 + + top_position_font: FontField = ('Bold', 30) + top_position_colour: ColourField = '#FFFFFF' + top_name_font: FontField = ('Bold', 30) + top_name_colour: ColourField = '#DDB21D' + top_hours_font: FontField = ('Medium', 30) + top_hours_colour: ColourField = '#FFFFFF' + top_text_sep: NumberField = 5 + + # Other page constants + other_bg_path: AssetPathField = "leaderboard/other_page_background.png" + other_header_height: NumberField = 276 + other_header_gap: NumberField = 20 + + # Entry constants + entry_position_font: FontField = ("SemiBold", 45) + entry_position_colour: ColourField = '#FFFFFF' + entry_position_highlight_colour: ColourField = '#FFFFFF' + entry_name_highlight_colour: ColourField = '#FFFFFF' + entry_hours_highlight_colour: ColourField = '#FFFFFF' + entry_name_font: FontField = ("SemiBold", 45) + entry_name_colour: ColourField = '#FFFFFF' + entry_hours_font: FontField = ("SemiBold", 45) + entry_hours_colour: ColourField = '#FFFFFF' + entry_position_at: NumberField = 200 + entry_name_at: NumberField = 300 + entry_time_at: NumberField = -150 + + entry_mask: AssetField = "leaderboard/entry_avatar_mask.png" + + entry_bg_mask: AssetField = "leaderboard/entry_bg_mask.png" + entry_bg_colour: ColourField = "#162D3C" + entry_bg_highlight_colour: ColourField = "#0D4865" + + entry_bg: BlobField = FieldDesc( + BlobField, + mask_field='entry_bg_mask', + colour_field='entry_bg_colour', + colour_override_field=None + ) + entry_highlight_bg: BlobField = FieldDesc( + BlobField, + mask_field='entry_bg_mask', + colour_field='entry_bg_highlight_colour', + colour_override_field=None + ) + + entry_gap: NumberField = 13 + + +class LeaderboardPage(Layout): + def __init__(self, skin, server_name, entries, highlight=None): + self.skin = skin + + self.server_name = server_name + self.entries = entries + self.highlight = highlight + self.first_page = any(entry.position in (1, 2, 3) for entry in entries) + + self.image = None + + def draw(self) -> Image: + if self.first_page: + self.image = self._draw_first_page() + else: + self.image = self._draw_other_page() + return self.image + + def _draw_first_page(self) -> Image: + # Collect background + image = Image.open(self.skin.first_bg_path) + draw = ImageDraw.Draw(image) + + xpos, ypos = 0, 0 + + # Draw the header text + ypos += self.skin.header_text_pre_gap + header = self._draw_header_text() + image.alpha_composite( + header, + (xpos + (image.width // 2 - header.width // 2), + ypos) + ) + ypos += header.height + self.skin.header_gap + + # Draw the top 3 + first_entry = self.entries[0] + first = self._draw_first(first_entry, level=1) + first_x = (image.width - first.width) // 2 + image.alpha_composite( + first, + (first_x, ypos) + ) + first_text_y = ypos + first.height + self.skin.first_top_gap + text_y = first_text_y + text_x = first_x + (first.width // 2) + draw.text( + (text_x, text_y), + '1ST', + font=self.skin.top_position_font, + fill=self.skin.top_position_colour, + anchor='mt' + ) + text_y += self.skin.top_name_font.getsize('1ST')[1] + self.skin.top_text_sep + draw.text( + (text_x, text_y), + first_entry.name, + font=self.skin.top_name_font, + fill=self.skin.top_name_colour, + anchor='mt' + ) + text_y += self.skin.top_name_font.getsize(first_entry.name)[1] + self.skin.top_text_sep + draw.text( + (text_x, text_y), + "{} hours".format(first_entry.time // 3600), + font=self.skin.top_hours_font, + fill=self.skin.top_hours_colour, + anchor='mt' + ) + + if len(self.entries) >= 2: + second_entry = self.entries[1] + second = self._draw_first(second_entry, level=2) + second_x = image.width // 4 - second.width // 2 + image.alpha_composite( + second, + ( + second_x, + ypos + (first.height - second.height) // 2 + ) + ) + text_y = first_text_y + text_x = second_x + (second.width // 2) + draw.text( + (text_x, text_y), + '2ND', + font=self.skin.top_position_font, + fill=self.skin.top_position_colour, + anchor='mt' + ) + text_y += self.skin.top_name_font.getsize('2ND')[1] + self.skin.top_text_sep + draw.text( + (text_x, text_y), + second_entry.name, + font=self.skin.top_name_font, + fill=self.skin.top_name_colour, + anchor='mt' + ) + text_y += self.skin.top_name_font.getsize(second_entry.name)[1] + self.skin.top_text_sep + draw.text( + (text_x, text_y), + "{} hours".format(second_entry.time // 3600), + font=self.skin.top_hours_font, + fill=self.skin.top_hours_colour, + anchor='mt' + ) + + if len(self.entries) >= 3: + third_entry = self.entries[2] + third = self._draw_first(third_entry, level=3) + third_x = 3 * image.width // 4 - third.width // 2 + image.alpha_composite( + third, + ( + third_x, + ypos + (first.height - third.height) // 2 + ) + ) + text_y = first_text_y + text_x = third_x + (third.width // 2) + draw.text( + (text_x, text_y), + '3RD', + font=self.skin.top_position_font, + fill=self.skin.top_position_colour, + anchor='mt' + ) + text_y += self.skin.top_name_font.getsize('3ND')[1] + self.skin.top_text_sep + draw.text( + (text_x, text_y), + third_entry.name, + font=self.skin.top_name_font, + fill=self.skin.top_name_colour, + anchor='mt' + ) + text_y += self.skin.top_name_font.getsize(third_entry.name)[1] + self.skin.top_text_sep + draw.text( + (text_x, text_y), + "{} hours".format(third_entry.time // 3600), + font=self.skin.top_hours_font, + fill=self.skin.top_hours_colour, + anchor='mt' + ) + + # Draw the entries + xpos = (image.width - self.skin.entry_bg_mask.width) // 2 + ypos = self.skin.first_header_height + self.skin.header_bg_gap + + for entry in self.entries[3:]: + entry_image = self._draw_entry( + entry, + highlight=self.highlight and (entry.position == self.highlight) + ) + image.alpha_composite( + entry_image, + (xpos, ypos) + ) + ypos += self.skin.entry_bg_mask.height + self.skin.entry_gap + + return image + + def _draw_other_page(self) -> Image: + # Collect background + image = Image.open(self.skin.other_bg_path).convert('RGBA') + + # Draw header onto background + header = self._draw_header_text() + image.alpha_composite( + header, + ( + (image.width - header.width) // 2, + (self.skin.other_header_height - header.height) // 2 + ) + ) + + # Draw the entries + xpos = (image.width - self.skin.entry_bg.width) // 2 + ypos = ( + image.height - 10 * self.skin.entry_bg.height - 9 * self.skin.entry_gap + + self.skin.other_header_height - self.skin.other_header_gap + ) // 2 + + for entry in self.entries: + entry_image = self._draw_entry( + entry, + highlight=self.highlight and (entry.position == self.highlight) + ) + image.alpha_composite( + entry_image, + (xpos, ypos) + ) + ypos += self.skin.entry_bg.height + self.skin.entry_gap + + return image + + def _draw_entry(self, entry, highlight=False) -> Image: + # Get the appropriate background + image = (self.skin.entry_bg if not highlight else self.skin.entry_highlight_bg).copy() + draw = ImageDraw.Draw(image) + ypos = image.height // 2 + + # Mask the avatar, if it exists + avatar = entry.image + avatar.thumbnail((187, 187)) + avatar.paste((0, 0, 0, 0), mask=self.skin.entry_mask) + + # Paste avatar onto image + image.alpha_composite(avatar, (0, 0)) + + # Write position + draw.text( + (self.skin.entry_position_at, ypos), + str(entry.position), + fill=self.skin.entry_position_highlight_colour if highlight else self.skin.entry_position_colour, + font=self.skin.entry_position_font, + anchor='mm' + ) + + # Write name + draw.text( + (self.skin.entry_name_at, ypos), + entry.name, + fill=self.skin.entry_name_highlight_colour if highlight else self.skin.entry_name_colour, + font=self.skin.entry_name_font, + anchor='lm' + ) + + # Write time + time_str = "{:02d}:{:02d}".format( + entry.time // 3600, + (entry.time % 3600) // 60 + ) + draw.text( + (image.width + self.skin.entry_time_at, ypos), + time_str, + fill=self.skin.entry_hours_highlight_colour if highlight else self.skin.entry_hours_colour, + font=self.skin.entry_hours_font, + anchor='mm' + ) + + return image + + def _draw_first(self, entry, level) -> Image: + if level == 1: + image = self.skin.first_avatar_bg + elif level == 2: + image = self.skin.second_avatar_bg + elif level == 3: + image = self.skin.third_avatar_bg + + # Retrieve and mask avatar + avatar = entry.image + avatar.paste((0, 0, 0, 0), mask=self.skin.first_avatar_mask) + + # Resize for background with gap + dest_width = image.width - 2 * self.skin.first_avatar_gap + avatar.thumbnail((dest_width, dest_width)) + + # Paste on the background + image.alpha_composite( + avatar.convert('RGBA'), + ( + (image.width - avatar.width) // 2, + image.height - self.skin.first_avatar_gap - avatar.height) + ) + + return image + + def _draw_header_text(self) -> Image: + image = Image.new( + 'RGBA', + (self.skin.header_text_size[0], + self.skin.header_text_size[1] + self.skin.header_text_gap + self.skin.header_text_line_width + + self.skin.header_text_line_gap + + self.skin.subheader_name_font.getsize("THIS MONTHghjyp")[1]), + ) + draw = ImageDraw.Draw(image) + xpos, ypos = 0, 0 + + # Draw the top text + draw.text( + (0, 0), + self.skin.header_text, + font=self.skin.header_text_font, + fill=self.skin.header_text_colour + ) + ypos += self.skin.header_text_size[1] + self.skin.header_text_gap + + # Draw the underline + # draw.line( + # (xpos, ypos, + # xpos + self.skin.header_text_size[0], ypos), + # fill=self.skin.header_text_colour, + # width=self.skin.header_text_line_width + # ) + # ypos += self.skin.header_text_line_gap + + # Draw the subheader + text_name = "SERVER: " + text_name_width = self.skin.subheader_name_font.getlength(text_name) + text_value = self.server_name + text_value_width = self.skin.subheader_value_font.getlength(text_value) + total_width = text_name_width + text_value_width + xpos += (image.width - total_width) // 2 + draw.text( + (xpos, ypos), + text_name, + fill=self.skin.subheader_name_colour, + font=self.skin.subheader_name_font + ) + xpos += text_name_width + draw.text( + (xpos, ypos), + text_value, + fill=self.skin.subheader_value_colour, + font=self.skin.subheader_value_font + ) + + return image + + +class LeaderboardCard(Card): + route = 'leaderboard_card' + card_id = 'leaderboard' + + layout = LeaderboardPage + skin = LeaderboardSkin + + display_name = "Leaderboard" + + @classmethod + async def card_route(cls, runner, args, kwargs): + entries = [LeaderboardEntry(*entry) for entry in kwargs['entries']] + await asyncio.gather( + *(entry.get_avatar() for entry in entries) + ) + kwargs['entries'] = entries + return await super().card_route(runner, args, kwargs) + + @classmethod + def _execute(cls, *args, **kwargs): + for entry in kwargs['entries']: + entry.convert_avatar() + return super()._execute(*args, **kwargs) + + @classmethod + async def sample_args(cls, ctx, **kwargs): + from ..utils import get_avatar_key + + return { + 'server_name': (ctx.guild.name if ctx.guild else f"{ctx.author.name}'s DMs") if ctx else "No Server", + 'entries': [ + ( + (ctx.author.id, 1, 1474481, ctx.author.name, get_avatar_key(ctx.client, ctx.author.id)) + if ctx else + (0, 1, 1474481, "John Doe", (0, None)) + ), + (1, 2, 1445975, 'Abioye', (0, None)), + (2, 3, 1127296, 'Lacey', (0, None)), + (3, 4, 1112495, 'Chesed', (0, None)), + (4, 5, 854514, 'Almas', (0, None)), + (5, 6, 824414, 'Uche', (0, None)), + (6, 7, 634560, 'Boitumelo', (0, None)), + (7, 8, 540633, 'Abimbola', (0, None)), + (8, 9, 417487, 'Keone', (0, None)), + (9, 10, 257274, 'Desta', (0, None)) + ], + 'highlight': 4 + } diff --git a/src/modules/pending-rewrite/gui-cards/mixins.py b/src/modules/pending-rewrite/gui-cards/mixins.py new file mode 100644 index 00000000..10f9d1e8 --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/mixins.py @@ -0,0 +1,143 @@ +from PIL import Image, ImageDraw, ImageOps, ImageColor + +from ..base import Card, Layout, fielded, Skin, FieldDesc +from ..base.Avatars import avatar_manager +from ..base.Skin import ( + AssetField, RGBAAssetField, BlobField, AssetPathField, StringField, NumberField, + FontField, ColourField, PointField, ComputedField +) + + +@fielded +class MiniProfileSkin(Skin): + + # Profile section + mini_profile_indent: NumberField = 125 + mini_profile_size: ComputedField = lambda skin: ( + skin.background.width - 2 * skin.mini_profile_indent, + int(skin._env['scale'] * 200) + ) + mini_profile_avatar_mask: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_mask.png', convert=None) + mini_profile_avatar_frame: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_frame.png', convert=None) + mini_profile_avatar_sep: NumberField = 50 + + mini_profile_name_font: FontField = ('BoldItalic', 55) + mini_profile_name_colour: ColourField = '#DDB21D' + mini_profile_discrim_font: FontField = mini_profile_name_font + mini_profile_discrim_colour: ColourField = '#BABABA' + mini_profile_name_gap: NumberField = 20 + + mini_profile_badge_end: AssetField = "mini-profile/badge_end.png" + mini_profile_badge_font: FontField = ('Black', 30) + mini_profile_badge_colour: ColourField = '#FFFFFF' + mini_profile_badge_text_colour: ColourField = '#051822' + mini_profile_badge_gap: NumberField = 20 + mini_profile_badge_min_sep: NumberField = 10 + + +class MiniProfileLayout: + def _draw_profile(self) -> Image: + image = Image.new('RGBA', self.skin.mini_profile_size) + draw = ImageDraw.Draw(image) + xpos, ypos = 0, 0 + + frame = self.skin.mini_profile_avatar_frame + if frame.height >= image.height: + frame.thumbnail((image.height, image.height)) + + # Draw avatar + avatar = self.data_avatar + avatar.paste((0, 0, 0, 0), mask=self.skin.mini_profile_avatar_mask) + avatar_image = Image.new('RGBA', frame.size) + avatar_image.paste( + avatar, + ( + (frame.width - avatar.width) // 2, + (frame.height - avatar.height) // 2 + ) + ) + avatar_image.alpha_composite(frame) + avatar_image = avatar_image.resize( + (self.skin.mini_profile_size[1], self.skin.mini_profile_size[1]) + ) + image.alpha_composite(avatar_image, (0, 0)) + + xpos += avatar_image.width + self.skin.mini_profile_avatar_sep + + # Draw name + name_text = self.data_name + name_length = self.skin.mini_profile_name_font.getlength(name_text + ' ') + name_height = self.skin.mini_profile_name_font.getsize(name_text)[1] + draw.text( + (xpos, ypos), + name_text, + fill=self.skin.mini_profile_name_colour, + font=self.skin.mini_profile_name_font + ) + draw.text( + (xpos + name_length, ypos), + self.data_discrim, + fill=self.skin.mini_profile_discrim_colour, + font=self.skin.mini_profile_discrim_font + ) + ypos += name_height + self.skin.mini_profile_name_gap + + # Draw badges + _x = 0 + max_x = self.skin.mini_profile_size[0] - xpos + + badges = [self._draw_badge(text) for text in self.data_badges] + for badge in badges: + if badge.width + _x > max_x: + _x = 0 + ypos += badge.height + self.skin.mini_profile_badge_gap + image.paste( + badge, + (xpos + _x, ypos) + ) + _x += badge.width + self.skin.mini_profile_badge_min_sep + return image + + def _draw_badge(self, text) -> Image: + """ + Draw a single profile badge, with the given text. + """ + text_length = self.skin.mini_profile_badge_font.getsize(text)[0] + + height = self.skin.mini_profile_badge_end.height + width = text_length + self.skin.mini_profile_badge_end.width + + badge = Image.new('RGBA', (width, height), color=(0, 0, 0, 0)) + + # Add blobs to ends + badge.paste( + self.skin.mini_profile_badge_end, + (0, 0) + ) + badge.paste( + self.skin.mini_profile_badge_end, + (width - self.skin.mini_profile_badge_end.width, 0) + ) + + # Add rectangle to middle + draw = ImageDraw.Draw(badge) + draw.rectangle( + ( + (self.skin.mini_profile_badge_end.width // 2, 0), + (width - self.skin.mini_profile_badge_end.width // 2, height), + ), + fill='#FFFFFF', + width=0 + ) + badge.paste(ImageColor.getrgb(self.skin.mini_profile_badge_colour), mask=badge) + + # Write badge text + draw.text( + (self.skin.mini_profile_badge_end.width // 2, height // 2), + text, + font=self.skin.mini_profile_badge_font, + fill=self.skin.mini_profile_badge_text_colour, + anchor='lm' + ) + + return badge diff --git a/src/modules/pending-rewrite/gui-cards/monthly.py b/src/modules/pending-rewrite/gui-cards/monthly.py new file mode 100644 index 00000000..4281b5d1 --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/monthly.py @@ -0,0 +1,750 @@ +import math +import calendar +from collections import defaultdict +from PIL import Image, ImageDraw +from datetime import timedelta +import datetime + +from ..base import Card, Layout, fielded, Skin +from ..base.Skin import ( + FieldDesc, + AssetField, RGBAAssetField, BlobField, StringField, NumberField, RawField, + FontField, ColourField, PointField, ComputedField +) + + +@fielded +class MonthlyStatsSkin(Skin): + _env = { + 'scale': 2 # General size scale to match background resolution + } + + background: AssetField = 'monthly/background.png' + + # Header + title_pre_gap: NumberField = 40 + title_text: StringField = "STUDY HOURS" + title_font: FontField = ('ExtraBold', 76) + title_size: ComputedField = lambda skin: skin.title_font.getsize(skin.title_text) + title_colour: ColourField = '#DDB21D' + title_underline_gap: NumberField = 10 + title_underline_width: NumberField = 0 + title_gap: NumberField = 10 + + # Top + top_grid_x: NumberField = 37 + top_grid_y: NumberField = 100 + + top_hours_font: FontField = ('Black', 36) + top_hours_colour: ColourField = '#FFFFFF' + + top_hours_bg_mask: AssetField = 'monthly/hours_bg_mask.png' + top_hours_bg_colour: ColourField = '#0B465E' + top_hours_bg_colour_override: ColourField = None + top_hours_bg: BlobField = FieldDesc( + BlobField, + mask_field='top_hours_bg_mask', + colour_field='top_hours_bg_colour', + colour_field_override='top_hours_bg_colour_override' + ) + + top_hours_sep: NumberField = 100 + + top_line_width: NumberField = 10 + top_line_colour: ColourField = '#042231' + + top_date_pre_gap: NumberField = 20 + top_date_font: FontField = ('Light', 25) + top_date_colour: ColourField = '#FFFFFF' + top_date_height: ComputedField = lambda skin: skin.top_date_font.getsize('31')[1] + + top_bar_mask: RGBAAssetField = 'monthly/bar_mask.png' + + top_this_colour: ColourField = '#DDB21D' + top_this_color_override: ColourField = None + + top_last_colour: ColourField = '#377689CC' + top_last_color_override: ColourField = None + + top_this_bar_full: BlobField = FieldDesc( + BlobField, + mask_field='top_bar_mask', + colour_field='top_this_colour', + colour_field_override='top_this_colour_override' + ) + + top_last_bar_full: BlobField = FieldDesc( + BlobField, + mask_field='top_bar_mask', + colour_field='top_last_colour', + colour_field_override='top_last_colour_override' + ) + + top_this_hours_font: FontField = ('Medium', 20) + top_this_hours_colour: ColourField = '#DDB21D' + + top_time_bar_sep: NumberField = 7 + top_time_sep: NumberField = 5 + + top_last_hours_font: FontField = ('Medium', 20) + top_last_hours_colour: ColourField = '#5F91A1' + + top_gap: NumberField = 40 + + weekdays: RawField = ('M', 'T', 'W', 'T', 'F', 'S', 'S') + + # Summary + summary_pre_gap: NumberField = 50 + + summary_mask: AssetField = 'monthly/summary_mask.png' + this_month_image: BlobField = FieldDesc( + BlobField, + mask_field='summary_mask', + colour_field='top_this_colour', + colour_field_override='top_this_colour_override' + ) + this_month_font: FontField = ('Light', 23) + this_month_colour: ColourField = '#BABABA' + + summary_sep: NumberField = 300 + + last_month_font: FontField = ('Light', 23) + last_month_colour: ColourField = '#BABABA' + last_month_image: BlobField = FieldDesc( + BlobField, + mask_field='summary_mask', + colour_field='top_last_colour', + colour_field_override='top_last_colour_override' + ) + + summary_gap: NumberField = 50 + + # Bottom + bottom_frame: AssetField = 'monthly/bottom_frame.png' + bottom_margins: PointField = (100, 100) + + heatmap_mask: AssetField = 'monthly/heatmap_blob_mask.png' + heatmap_empty_colour: ColourField = "#082534" + heatmap_empty_colour_override: ColourField = None + heatmap_empty: BlobField = FieldDesc( + BlobField, + mask_field='heatmap_mask', + colour_field='heatmap_empty_colour', + colour_field_override='heatmap_empty_colour_override' + ) + heatmap_colours: RawField = [ + '#0E2A77', + '#15357D', + '#1D3F82', + '#244A88', + '#2C548E', + '#335E93', + '#3B6998', + '#43729E', + '#4B7CA3', + '#5386A8', + '#5B8FAD', + '#6398B2', + '#6BA1B7', + '#73A9BC', + '#7CB1C1', + '#85B9C5', + ] + heatmap_colours.reverse() + + weekday_background_mask: AssetField = 'monthly/weekday_mask.png' + weekday_background_colour: ColourField = '#60606038' + weekday_background_colour_override: ColourField = None + weekday_background: BlobField = FieldDesc( + BlobField, + mask_field='weekday_background_mask', + colour_field='weekday_background_colour', + colour_field_override='weekday_background_colour_override' + ) + + weekday_font: FontField = ('Black', 26.85) + weekday_colour: ColourField = '#FFFFFF' + weekday_sep: NumberField = 20 + + month_background_mask: AssetField = 'monthly/month_mask.png' + month_background_colour: ColourField = '#60606038' + month_background_colour_override: ColourField = None + month_background: BlobField = FieldDesc( + BlobField, + mask_field='month_background_mask', + colour_field='month_background_colour', + colour_field_override='month_background_colour_override' + ) + month_font: FontField = ('Bold', 25.75) + month_colour: ColourField = '#FFFFFF' + month_sep: ComputedField = lambda skin: ( + skin.bottom_frame.width - 2 * skin.bottom_margins[0] + - skin.weekday_background.width + - skin.weekday_sep + - 4 * skin.month_background.width + ) // 3 + month_gap: NumberField = 25 + + btm_grid_x: ComputedField = lambda skin: (skin.month_background.width - skin.heatmap_mask.width) // 5 + btm_grid_y: ComputedField = lambda skin: skin.btm_grid_x + + # Stats + stats_key_font: FontField = ('Medium', 23.65) + stats_key_colour: ColourField = '#FFFFFF' + stats_value_font: FontField = ('Light', 23.65) + stats_value_colour: ColourField = '#808080' + stats_sep: ComputedField = lambda skin: ( + skin.month_background.width + skin.month_sep + (skin.weekday_background.width + skin.weekday_sep) // 3 + ) + + # Date text + footer_font: FontField = ('Bold', 28) + footer_colour: ColourField = '#6f6e6f' + footer_gap: NumberField = 50 + + +# TODO: Month hour bars.. Blobasset full bars and use them as masks, e.g. profile progress bar. + +class MonthlyStatsPage(Layout): + def __init__(self, skin, name, discrim, sessions, date, current_streak, longest_streak, first_session_start): + """ + `sessions` is a list of study sessions from the last two weeks. + """ + self.skin = skin + + self.data_sessions = sessions + self.data_date = date + + self.data_name = name + self.data_discrim = discrim + + self.current_streak = current_streak + self.longest_streak = longest_streak + + self.month_start = date.replace(day=1) + + self.data_time = defaultdict(int) + + for start, end in sessions: + day_start = start.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(hours=24) + + if end > day_end: + self.data_time[day_start.date()] += (day_end - start).total_seconds() + self.data_time[day_end.date()] += (end - day_end).total_seconds() + else: + self.data_time[day_start.date()] += (end - start).total_seconds() + + self.this_month_days = calendar.monthrange(self.month_start.year, self.month_start.month)[1] + self.hours_this_month = [ + self.data_time[self.month_start + timedelta(days=i)] / 3600 + for i in range(0, self.this_month_days) + ] + + self.months = [self.month_start] + for i in range(0, 3): + self.months.append((self.months[-1] - timedelta(days=1)).replace(day=1)) + + self.months.reverse() + + last_month_start = self.months[-2] + last_month_days = calendar.monthrange(last_month_start.year, last_month_start.month)[1] + self.hours_last_month = [ + self.data_time[last_month_start + timedelta(days=i)] / 3600 + for i in range(0, last_month_days) + ][:self.this_month_days] # Truncate to this month length + + max_hours = max(*self.hours_this_month, *self.hours_last_month) + + self.max_hour_label = (4 * math.ceil(max_hours / 4)) or 4 + + self.days_learned = sum(val != 0 for val in self.data_time.values()) + self.total_days = sum( + calendar.monthrange(month.year, month.month)[1] + for month in self.months + ) + self.days_since_start = min( + (date - first_session_start.date()).days, + (date - self.months[0]).days + ) + 1 + self.average_time = (sum(self.data_time.values()) / self.days_learned) if self.days_learned else 0 + + self.image = None + + def draw(self) -> Image: + image = self.image = self.skin.background + draw = ImageDraw.Draw(image) + + xpos, ypos = 0, 0 + + # Draw header text + xpos = (image.width - self.skin.title_size[0]) // 2 + ypos += self.skin.title_pre_gap + draw.text( + (xpos, ypos), + self.skin.title_text, + fill=self.skin.title_colour, + font=self.skin.title_font + ) + + # Underline it + title_size = self.skin.title_font.getsize(self.skin.title_text) + ypos += title_size[1] + self.skin.title_underline_gap + # draw.line( + # (xpos, ypos, xpos + title_size[0], ypos), + # fill=self.skin.title_colour, + # width=self.skin.title_underline_width + # ) + ypos += self.skin.title_underline_width + self.skin.title_gap + + # Draw the top box + top = self.draw_top() + image.alpha_composite( + top, + ((image.width - top.width) // 2, ypos) + ) + + ypos += top.height + self.skin.top_gap + + # Draw the summaries + summary_image = self.draw_summaries() + image.alpha_composite( + summary_image, + ((image.width - summary_image.width) // 2, ypos) + ) + ypos += summary_image.height + self.skin.summary_gap + + # Draw the bottom box + bottom = self.draw_bottom() + image.alpha_composite( + bottom, + ((image.width - bottom.width) // 2, ypos) + ) + + # Draw the footer + ypos = image.height + ypos -= self.skin.footer_gap + date_text = self.data_date.strftime( + "Monthly Statistics • As of %d %b • {} {}".format(self.data_name, self.data_discrim) + ) + size = self.skin.footer_font.getsize(date_text) + ypos -= size[1] + draw.text( + ((image.width - size[0]) // 2, ypos), + date_text, + font=self.skin.footer_font, + fill=self.skin.footer_colour + ) + return image + + def draw_summaries(self) -> Image: + this_month_text = " THIS MONTH: {} Hours".format(int(sum(self.hours_this_month))) + this_month_length = int(self.skin.this_month_font.getlength(this_month_text)) + last_month_text = " LAST MONTH: {} Hours".format(int(sum(self.hours_last_month))) + last_month_length = int(self.skin.last_month_font.getlength(last_month_text)) + + image = Image.new( + 'RGBA', + ( + self.skin.this_month_image.width + this_month_length + + self.skin.summary_sep + + self.skin.last_month_image.width + last_month_length, + self.skin.this_month_image.height + ) + ) + draw = ImageDraw.Draw(image) + + xpos = 0 + ypos = image.height // 2 + image.alpha_composite( + self.skin.this_month_image, + (0, 0) + ) + xpos += self.skin.this_month_image.width + draw.text( + (xpos, ypos), + this_month_text, + fill=self.skin.this_month_colour, + font=self.skin.this_month_font, + anchor='lm' + ) + + xpos += self.skin.summary_sep + this_month_length + + image.alpha_composite( + self.skin.last_month_image, + (xpos, 0) + ) + xpos += self.skin.last_month_image.width + draw.text( + (xpos, ypos), + last_month_text, + fill=self.skin.last_month_colour, + font=self.skin.last_month_font, + anchor='lm' + ) + return image + + def draw_top(self) -> Image: + size_x = ( + self.skin.top_hours_bg.width // 2 + self.skin.top_hours_sep + + (self.this_month_days - 1) * self.skin.top_grid_x + self.skin.top_bar_mask.width // 2 + + self.skin.top_hours_bg.width // 2 + ) + size_y = ( + self.skin.top_hours_bg.height // 2 + 4 * self.skin.top_grid_y + self.skin.top_date_pre_gap + + self.skin.top_date_height + + self.skin.top_time_bar_sep + int(self.skin.top_this_hours_font.getlength('24 H 24 H')) + ) + image = Image.new('RGBA', (size_x, size_y)) + draw = ImageDraw.Draw(image) + + x0 = self.skin.top_hours_bg.width // 2 + self.skin.top_hours_sep + y0 = self.skin.top_hours_bg.height // 2 + 4 * self.skin.top_grid_y + y0 += self.skin.top_time_bar_sep + int(self.skin.top_this_hours_font.getlength('24 H 24 H')) + + # Draw lines and numbers + labels = list(int(i * self.max_hour_label // 4) for i in range(0, 5)) + + xpos = x0 - self.skin.top_hours_sep + ypos = y0 + for label in labels: + draw.line( + ((xpos, ypos), (image.width, ypos)), + width=self.skin.top_line_width, + fill=self.skin.top_line_colour + ) + + image.alpha_composite( + self.skin.top_hours_bg, + (xpos - self.skin.top_hours_bg.width // 2, ypos - self.skin.top_hours_bg.height // 2) + ) + text = str(label) + draw.text( + (xpos, ypos), + text, + fill=self.skin.top_hours_colour, + font=self.skin.top_hours_font, + anchor='mm' + ) + ypos -= self.skin.top_grid_y + + # Draw dates + xpos = x0 + ypos = y0 + self.skin.top_date_pre_gap + for i in range(1, self.this_month_days + 1): + draw.text( + (xpos, ypos), + str(i), + fill=self.skin.top_date_colour, + font=self.skin.top_date_font, + anchor='mt' + ) + xpos += self.skin.top_grid_x + + # Draw bars + for i, (last_hours, this_hours) in enumerate(zip(self.hours_last_month, self.hours_this_month)): + xpos = x0 + i * self.skin.top_grid_x + + if not (last_hours or this_hours): + continue + + bar_height = 0 + for draw_last in (last_hours > this_hours, not last_hours > this_hours): + hours = last_hours if draw_last else this_hours + height = (4 * self.skin.top_grid_y) * (hours / self.max_hour_label) + height = int(height) + + if height >= self.skin.top_bar_mask.width: + bar = self.draw_vertical_bar( + height, + self.skin.top_last_bar_full if draw_last else self.skin.top_this_bar_full, + self.skin.top_bar_mask + ) + bar_height = max(height, bar_height) + image.alpha_composite( + bar, + (xpos - bar.width // 2, y0 - bar.height) + ) + + # Draw text + if bar_height: + text = ['{} H'.format(hours) for hours in (last_hours, this_hours) if hours] + text_size = self.skin.top_this_hours_font.getsize(' '.join(text)) + text_image = Image.new( + 'RGBA', + text_size + ) + text_draw = ImageDraw.Draw(text_image) + txpos = 0 + if last_hours: + last_text = "{} H ".format(int(last_hours)) + text_draw.text( + (txpos, 0), last_text, + fill=self.skin.top_last_hours_colour, + font=self.skin.top_last_hours_font + ) + txpos += self.skin.top_last_hours_font.getlength(last_text) + if this_hours: + this_text = "{} H ".format(int(this_hours)) + text_draw.text( + (txpos, 0), this_text, + fill=self.skin.top_this_hours_colour, + font=self.skin.top_this_hours_font + ) + + text_image = text_image.rotate(90, expand=True) + text_image = text_image.crop(text_image.getbbox()) + + image.alpha_composite( + text_image, + (xpos - text_image.width // 2, + y0 - bar_height - self.skin.top_time_bar_sep - text_image.height) + ) + + return image + + def draw_vertical_bar(self, height, full_bar, mask_bar, crop=False): + y_2 = mask_bar.height + y_1 = height + + image = Image.new('RGBA', full_bar.size) + image.paste(mask_bar, (0, y_2 - y_1), mask=mask_bar) + image.paste(full_bar, mask=image) + + if crop: + image = image.crop( + (0, y_2 - y_1), (image.width, y_2 - y_1), + (image.height, 0), (image.height, image.width) + ) + + return image + + def draw_bottom(self) -> Image: + image = self.skin.bottom_frame + draw = ImageDraw.Draw(image) + + xpos, ypos = self.skin.bottom_margins + + # Draw the weekdays + y0 = self.skin.month_background.height + self.skin.month_gap + for i, weekday in enumerate(self.skin.weekdays): + y = y0 + i * self.skin.btm_grid_y + image.alpha_composite( + self.skin.weekday_background, + (xpos, ypos + y) + ) + draw.text( + (xpos + self.skin.weekday_background.width // 2, ypos + y + self.skin.weekday_background.height // 2), + weekday, + fill=self.skin.weekday_colour, + font=self.skin.weekday_font, + anchor='mm' + ) + + # Draw the months + x0 = self.skin.weekday_background.width + self.skin.weekday_sep + for i, date in enumerate(self.months): + name = date.strftime('%B').upper() + + x = x0 + i * (self.skin.month_background.width + self.skin.month_sep) + image.alpha_composite( + self.skin.month_background, + (xpos + x, ypos) + ) + draw.text( + (xpos + x + self.skin.month_background.width // 2, + ypos + self.skin.month_background.height // 2), + name, + fill=self.skin.month_colour, + font=self.skin.month_font, + anchor='mm' + ) + + heatmap = self.draw_month_heatmap(date) + image.alpha_composite( + heatmap, + (xpos + x + self.skin.month_background.width // 2 - heatmap.width // 2, ypos + y0) + ) + + # Draw the streak and stats information + x = xpos + self.skin.weekday_background.width // 2 + y = image.height - self.skin.bottom_margins[1] + + key_text = "Current streak: " + key_len = self.skin.stats_key_font.getlength(key_text) + value_text = "{} day{}".format( + self.current_streak, + 's' if self.current_streak != 1 else '' + ) + draw.text( + (x, y), + key_text, + font=self.skin.stats_key_font, + fill=self.skin.stats_key_colour + ) + draw.text( + (x + key_len, y), + value_text, + font=self.skin.stats_value_font, + fill=self.skin.stats_value_colour + ) + x += self.skin.stats_sep + + key_text = "Daily average: " + key_len = self.skin.stats_key_font.getlength(key_text) + value_text = "{} hour{}".format( + (hours := int(self.average_time // 3600)), + 's' if hours != 1 else '' + ) + draw.text( + (x, y), + key_text, + font=self.skin.stats_key_font, + fill=self.skin.stats_key_colour + ) + draw.text( + (x + key_len, y), + value_text, + font=self.skin.stats_value_font, + fill=self.skin.stats_value_colour + ) + x += self.skin.stats_sep + + key_text = "Longest streak: " + key_len = self.skin.stats_key_font.getlength(key_text) + value_text = "{} day{}".format( + self.longest_streak, + 's' if self.current_streak != 1 else '' + ) + draw.text( + (x, y), + key_text, + font=self.skin.stats_key_font, + fill=self.skin.stats_key_colour + ) + draw.text( + (x + key_len, y), + value_text, + font=self.skin.stats_value_font, + fill=self.skin.stats_value_colour + ) + x += self.skin.stats_sep + + key_text = "Days learned: " + key_len = self.skin.stats_key_font.getlength(key_text) + value_text = "{}%".format( + int((100 * self.days_learned) // self.days_since_start) + ) + draw.text( + (x, y), + key_text, + font=self.skin.stats_key_font, + fill=self.skin.stats_key_colour + ) + draw.text( + (x + key_len, y), + value_text, + font=self.skin.stats_value_font, + fill=self.skin.stats_value_colour + ) + x += self.skin.stats_sep + + return image + + def draw_month_heatmap(self, month_start) -> Image: + cal = calendar.monthcalendar(month_start.year, month_start.month) + columns = len(cal) + + size_x = ( + (columns - 1) * self.skin.btm_grid_x + + self.skin.heatmap_mask.width + ) + size_y = ( + 6 * self.skin.btm_grid_y + self.skin.heatmap_mask.height + ) + + image = Image.new('RGBA', (size_x, size_y)) + + x0 = self.skin.heatmap_mask.width // 2 + y0 = self.skin.heatmap_mask.height // 2 + + for (i, week) in enumerate(cal): + xpos = x0 + i * self.skin.btm_grid_x + for (j, day) in enumerate(week): + if day: + ypos = y0 + j * self.skin.btm_grid_y + date = datetime.date(month_start.year, month_start.month, day) + time = self.data_time[date] + bubble = self.draw_bubble(time) + image.alpha_composite( + bubble, + (xpos - bubble.width // 2, ypos - bubble.width // 2) + ) + + return image + + def draw_bubble(self, time): + # Calculate colour level + if time == 0: + image = self.skin.heatmap_empty + colour = self.skin.heatmap_empty_colour + else: + amount = min((time / self.average_time) if self.average_time else 0, 2) / 2 + index = math.ceil(amount * len(self.skin.heatmap_colours)) - 1 + colour = self.skin.heatmap_colours[index] + + image = Image.new('RGBA', self.skin.heatmap_mask.size) + image.paste(colour, mask=self.skin.heatmap_mask) + return image + + +class MonthlyStatsCard(Card): + route = "monthly_stats_card" + card_id = "monthly_stats" + + layout = MonthlyStatsPage + skin = MonthlyStatsSkin + + display_name = "Monthly Stats" + + @classmethod + async def sample_args(cls, ctx, **kwargs): + import random + from datetime import timezone, datetime, timedelta + + sessions = [] + streak = 0 + longest_streak = 0 + day_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + day_start -= timedelta(hours=24) * 120 + for day in range(0, 120): + day_start += timedelta(hours=24) + + roll = random.randint(0, 30) + if roll == 0: + longest_streak = max(streak, longest_streak) + streak = 0 + continue + else: + streak += 1 + + # start of day + pointer = 6 * 60 + session_duration = int(abs(random.normalvariate(8 * 60, 2 * 60))) + sessions.append(( + day_start + timedelta(minutes=pointer), + day_start + timedelta(minutes=(pointer + session_duration)), + ) + ) + longest_streak = max(streak, longest_streak) + + return { + 'name': ctx.author.name if ctx else "John Doe", + 'discrim': ('#' + ctx.author.discriminator) if ctx else "#0000", + 'sessions': sessions, + 'date': datetime.now(timezone.utc).date(), + 'current_streak': streak, + 'longest_streak': longest_streak, + 'first_session_start': day_start - timedelta(days=200) + } diff --git a/src/modules/pending-rewrite/gui-cards/profile.py b/src/modules/pending-rewrite/gui-cards/profile.py new file mode 100644 index 00000000..a53fa51e --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/profile.py @@ -0,0 +1,623 @@ +from io import BytesIO +from PIL import Image, ImageDraw + +from ..utils import get_avatar_key +from ..base import Card, Layout, fielded, Skin, FieldDesc +from ..base.Avatars import avatar_manager +from ..base.Skin import ( + AssetField, RGBAAssetField, AssetPathField, NumberField, BlobField, + FontField, ColourField, PointField, ComputedField +) + + +@fielded +class ProfileSkin(Skin): + _env = { + 'scale': 2 # General size scale to match background resolution + } + + # Background images + bg_path: AssetField = "profile/background.png" + + # Inner container + container_position: PointField = (70, 65) # Position of top left corner + container_size: PointField = (1400, 600) # Size of the inner container + + # Header + header_font: FontField = ('BlackItalic', 28) + header_colour_1: ColourField = '#DDB21D' + header_colour_2: ColourField = '#BABABA' + header_gap: NumberField = 35 + header_height: ComputedField = lambda skin: skin.header_font.getsize("USERNAME #0000")[1] + + # Column 1 + avatar_mask: AssetField = FieldDesc(AssetField, 'profile/avatar_mask.png', convert=None) + avatar_outline: AssetField = FieldDesc(AssetField, 'profile/avatar_outline.png', convert=None) + avatar_size: ComputedField = lambda skin: skin.avatar_outline.size + avatar_gap: NumberField = 10 + + counter_bg_mask: AssetField = "profile/counter_bg_mask.png" + counter_bg_colour: ColourField = "#515B8D60" + counter_background: BlobField = FieldDesc( + BlobField, + mask_field='counter_bg_mask', + colour_field='counter_bg_colour' + ) + + coin_icon: AssetField = "icons/coin.png" + gem_icon: AssetField = "icons/gem.png" + gift_icon: AssetField = "icons/gift.png" + + counter_font: FontField = ('Black', 14) + counter_colour: ColourField = '#FFFFFF' + counter_icon_align: NumberField = 20 + counter_text_align: NumberField = 40 + + counter_gap: NumberField = 5 + + col1_size: ComputedField = lambda skin: ( + skin.avatar_size[0], + skin.avatar_size[1] + skin.avatar_gap + + 3 * skin.counter_background.height + 2 * skin.counter_gap + ) + + column_sep: NumberField = 20 + + # Column 2 + subheader_font: FontField = ('Black', 27) + subheader_colour: ColourField = '#DDB21D' + subheader_height: ComputedField = lambda skin: skin.subheader_font.getsize('PROFILE')[1] + subheader_gap: NumberField = 15 + + col2_size: ComputedField = lambda skin: ( + skin.container_size[0] - skin.col1_size[0] - skin.column_sep, + skin.container_size[1] - skin.header_gap - skin.header_height + ) + + col2_sep: NumberField = 40 # Minimum separation between profile and achievements + + # Achievement section + achievement_active_path: AssetPathField = 'profile/achievements_active/' + achievement_inactive_path: AssetPathField = 'profile/achievements_inactive/' + achievement_icon_size: PointField = (115, 96) # Individual achievement box size + achievement_gap: NumberField = 10 + achievement_sep: NumberField = 0 + achievement_size: ComputedField = lambda skin: ( + 4 * skin.achievement_icon_size[0] + 3 * skin.achievement_sep, + skin.subheader_height + skin.subheader_gap + + 2 * skin.achievement_icon_size[1] + 1 * skin.achievement_gap + ) + + # Profile section + badge_font: FontField = ('Black', 13) + badge_text_colour: ColourField = '#FFFFFF' + badge_blob_colour: ColourField = '#1473A2' + badge_blob_colour_override: ColourField = None + badge_blob_mask: AssetField = 'profile/badge_end_mask.png' + + badge_end_blob: BlobField = FieldDesc( + BlobField, + mask_field='badge_blob_mask', + colour_field='badge_blob_colour', + colour_override_field='badge_blob_colour_override' + ) + + badge_gap: NumberField = 5 + badge_min_sep: NumberField = 5 + profile_size: ComputedField = lambda skin: ( + skin.col2_size[0] - skin.achievement_size[0] - skin.col2_sep, + skin.subheader_height + skin.subheader_gap + + 4 * skin.badge_end_blob.height + 3 * skin.badge_gap + ) + + # Rank section + rank_name_font: FontField = ('Black', 23) + rank_name_colour: ColourField = '#DDB21D' + rank_name_height: ComputedField = lambda skin: skin.rank_name_font.getsize('VAMPIRE')[1] + rank_hours_font: FontField = ('Light', 18) + rank_hours_colour: ColourField = '#FFFFFF' + + bar_gap: NumberField = 5 + bar_mask: RGBAAssetField = 'profile/progress_mask.png' + bar_full_colour: ColourField = '#DDB21D' + bar_full_colour_override: ColourField = None + bar_full: BlobField = FieldDesc( + BlobField, + mask_field='bar_mask', + colour_field='bar_full_colour', + colour_override_field='bar_full_colour_override' + ) + bar_empty_colour: ColourField = '#2F4858' + bar_empty_colour_override: ColourField = None + bar_empty: BlobField = FieldDesc( + BlobField, + mask_field='bar_mask', + colour_field='bar_empty_colour', + colour_override_field='bar_empty_colour_override' + ) + + next_rank_font: FontField = ('Italic', 15) + next_rank_colour: ColourField = '#FFFFFF' + next_rank_height: ComputedField = lambda skin: skin.next_rank_font.getsize('NEXT RANK:')[1] + + rank_size: ComputedField = lambda skin: ( + skin.col2_size[0], + skin.rank_name_height + skin.bar_gap + + skin.bar_full.height + skin.bar_gap + + skin.next_rank_height + skin.next_rank_height // 2 # Adding skin.height skin.for skin.taller skin.glyphs + ) + + +class ProfileLayout(Layout): + def __init__(self, skin, name, discrim, + coins, time, gems, gifts, + avatar, + badges=(), + achievements=(), + current_rank=None, + next_rank=None, + draft=False, **kwargs): + + self.skin = skin + + self.draft = draft + + self.data_name = name + self.data_discrim = discrim + + self.data_avatar = avatar + + self.data_coins = coins + self.data_time = time + self.data_hours = self.data_time / 3600 + self.data_gems = gems + self.data_gifts = gifts + + self.data_badges = badges + self.data_achievements = achievements + + self.data_current_rank = current_rank + self.data_next_rank = next_rank + + self.image: Image = None # Final Image + + def draw(self): + # Load/copy background + image = self.skin.bg_path + + # Draw inner container + inner_container = self.draw_inner_container() + + # Paste inner container on background + image.alpha_composite(inner_container, self.skin.container_position) + + self.image = image + return image + + def draw_inner_container(self) -> Image: + container = Image.new('RGBA', self.skin.container_size) + draw = ImageDraw.Draw(container) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.container_size[0]-1, self.skin.container_size[1]-1))) + + position = 0 + + # Draw header + xposition = 0 + draw.text( + (xposition, position), + self.data_name, + font=self.skin.header_font, + fill=self.skin.header_colour_1 + ) + xposition += self.skin.header_font.getlength(self.data_name + ' ') + draw.text( + (xposition, position), + self.data_discrim, + font=self.skin.header_font, + fill=self.skin.header_colour_2 + ) + position += self.skin.header_height + self.skin.header_gap + + # Draw column 1 + col1 = self.draw_column_1() + container.alpha_composite(col1, (0, position)) + + # Draw column 2 + col2 = self.draw_column_2() + container.alpha_composite(col2, (container.width - col2.width, position)) + + return container + + def draw_column_1(self) -> Image: + # Create new image for column 1 + col1 = Image.new('RGBA', self.skin.col1_size) + draw = ImageDraw.Draw(col1) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.col1_size[0]-1, self.skin.col1_size[1]-1))) + + # Tracking current drawing height + position = 0 + + # Draw avatar + _avatar = self.data_avatar + + # Mask the avatar image to the desired shape + _avatar.paste((0, 0, 0, 0), mask=self.skin.avatar_mask) + + # Place the image on a larger canvas + avatar_image = Image.new('RGBA', self.skin.avatar_outline.size) + avatar_image.paste( + _avatar, + ( + (self.skin.avatar_outline.width - _avatar.width) // 2, + (self.skin.avatar_outline.height - _avatar.height) // 2, + ) + ) + + # Add the outline over the masked avatar + avatar_image.alpha_composite(self.skin.avatar_outline) + + # Paste onto column + col1.alpha_composite( + avatar_image, + (0, position) + ) + position += self.skin.avatar_size[1] + self.skin.avatar_gap + + # Draw counters + counters = ( + (self.skin.coin_icon, self.data_coins), + (self.skin.gem_icon, self.data_gems), + (self.skin.gift_icon, self.data_gifts), + ) + for icon, amount in counters: + col1.alpha_composite( + self.draw_counter(icon, amount), + ((self.skin.avatar_outline.width - self.skin.counter_background.width) // 2, position) + ) + position += self.skin.counter_background.height + self.skin.counter_gap + + return col1 + + def draw_counter(self, icon, amount): + image = self.skin.counter_background.copy() + draw = ImageDraw.Draw(image) + + image.alpha_composite( + icon, + ( + self.skin.counter_icon_align - icon.width // 2, + (self.skin.counter_background.height - icon.height) // 2 + ) + ) + + draw.text( + (self.skin.counter_text_align, self.skin.counter_background.height // 2), + f"{amount:,}", + font=self.skin.counter_font, + fill=self.skin.counter_colour, + anchor='lm' + ) + return image + + def draw_column_2(self) -> Image: + # Create new image for column 1 + col2 = Image.new('RGBA', self.skin.col2_size) + draw = ImageDraw.Draw(col2) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.col2_size[0]-1, self.skin.col2_size[1]-1))) + + # Tracking current drawing position + position = 0 + xposition = 0 + + # Draw Profile box + profile = self.draw_profile() + col2.paste( + profile, + (xposition, position) + ) + xposition += profile.width + self.skin.col2_sep + + # Draw Achievements box + achievements = self.draw_achievements() + col2.paste( + achievements, + (xposition, position) + ) + + # Draw ranking box + position = self.skin.col2_size[1] - self.skin.rank_size[1] + + ranking = self.draw_rank() + col2.alpha_composite( + ranking, + (0, position) + ) + + return col2 + + def draw_profile(self) -> Image: + profile = Image.new('RGBA', self.skin.profile_size) + draw = ImageDraw.Draw(profile) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.profile_size[0]-1, self.skin.profile_size[1]-1))) + + position = 0 + + # Draw subheader + draw.text( + (0, position), + 'PROFILE', + font=self.skin.subheader_font, + fill=self.skin.subheader_colour + ) + position += self.skin.subheader_height + self.skin.subheader_gap + + # Draw badges + # TODO: Nicer/Smarter layout + xposition = 0 + max_x = self.skin.profile_size[0] + + badges = [self.draw_badge(text) for text in self.data_badges] + for badge in badges: + if badge.width + xposition > max_x: + xposition = 0 + position += badge.height + self.skin.badge_gap + profile.paste( + badge, + (xposition, position) + ) + xposition += badge.width + self.skin.badge_min_sep + + return profile + + def draw_badge(self, text) -> Image: + """ + Draw a single profile badge, with the given text. + """ + text_length = self.skin.badge_font.getsize(text)[0] + + height = self.skin.badge_end_blob.height + width = text_length + self.skin.badge_end_blob.width + + badge = Image.new('RGBA', (width, height), color=(0, 0, 0, 0)) + + # Add blobs to ends + badge.paste( + self.skin.badge_end_blob, + (0, 0) + ) + badge.paste( + self.skin.badge_end_blob, + (width - self.skin.badge_end_blob.width, 0) + ) + + # Add rectangle to middle + draw = ImageDraw.Draw(badge) + draw.rectangle( + ( + (self.skin.badge_end_blob.width // 2, 0), + (width - self.skin.badge_end_blob.width // 2, height), + ), + fill=self.skin.badge_blob_colour, + width=0 + ) + + # Write badge text + draw.text( + (self.skin.badge_end_blob.width // 2, height // 2), + text, + font=self.skin.badge_font, + fill=self.skin.badge_text_colour, + anchor='lm' + ) + + return badge + + def draw_achievements(self) -> Image: + achievements = Image.new('RGBA', self.skin.achievement_size) + draw = ImageDraw.Draw(achievements) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.achievement_size[0]-1, self.skin.achievement_size[1]-1))) + + position = 0 + + # Draw subheader + draw.text( + (0, position), + 'ACHIEVEMENTS', + font=self.skin.subheader_font, + fill=self.skin.subheader_colour + ) + position += self.skin.subheader_height + self.skin.subheader_gap + xposition = 0 + + for i in range(0, 8): + # Top left corner of grid box + nxpos = (i % 4) * (self.skin.achievement_icon_size[0] + self.skin.achievement_sep) + nypos = (i // 4) * (self.skin.achievement_icon_size[1] + self.skin.achievement_gap) + + # Choose the active or inactive icon as given by data + icon_path = "{}{}.png".format( + self.skin.achievement_active_path if (i in self.data_achievements) else self.skin.achievement_inactive_path, + i + 1 + ) + icon = Image.open(icon_path).convert('RGBA') + + # Offset to top left corner of pasted icon + xoffset = (self.skin.achievement_icon_size[0] - icon.width) // 2 + # xoffset = 0 + yoffset = self.skin.achievement_icon_size[1] - icon.height + + # Paste the icon + achievements.alpha_composite( + icon, + (xposition + nxpos + xoffset, position + nypos + yoffset) + ) + + return achievements + + def draw_rank(self) -> Image: + rank = Image.new('RGBA', self.skin.rank_size) + draw = ImageDraw.Draw(rank) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.rank_size[0]-1, self.skin.rank_size[1]-1))) + + position = 0 + + # Draw the current rank + if self.data_current_rank: + rank_name, hour_1, hour_2 = self.data_current_rank + + xposition = 0 + draw.text( + (xposition, position), + rank_name, + font=self.skin.rank_name_font, + fill=self.skin.rank_name_colour, + ) + name_size = self.skin.rank_name_font.getsize(rank_name + ' ') + position += name_size[1] + xposition += name_size[0] + + if hour_2: + progress = (self.data_hours - hour_1) / (hour_2 - hour_1) + if hour_1: + hour_str = '{} - {}h'.format(hour_1, hour_2) + else: + hour_str = '≤{}h'.format(hour_2) + else: + progress = 1 + hour_str = '≥{}h'.format(hour_1) + + draw.text( + (xposition, position), + hour_str, + font=self.skin.rank_hours_font, + fill=self.skin.rank_hours_colour, + anchor='lb' + ) + position += self.skin.bar_gap + else: + draw.text( + (0, position), + 'UNRANKED', + font=self.skin.rank_name_font, + fill=self.skin.rank_name_colour, + ) + position += self.skin.rank_name_height + self.skin.bar_gap + progress = 0 + + # Draw rankbar + rankbar = self.draw_rankbar(progress) + rank.alpha_composite( + rankbar, + (0, position) + ) + position += rankbar.height + self.skin.bar_gap + + # Draw next rank text + if self.data_next_rank: + rank_name, hour_1, hour_2 = self.data_next_rank + if hour_2: + if hour_1: + hour_str = '{} - {}h'.format(hour_1, hour_2) + else: + hour_str = '≤{}h'.format(hour_2) + else: + hour_str = '≥{}h'.format(hour_1) + rank_str = "NEXT RANK: {} {}".format(rank_name, hour_str) + else: + if self.data_current_rank: + rank_str = "YOU HAVE REACHED THE MAXIMUM RANK!" + else: + rank_str = "NO RANKS AVAILABLE!" + + draw.text( + (0, position), + rank_str, + font=self.skin.next_rank_font, + fill=self.skin.next_rank_colour, + ) + + return rank + + def draw_rankbar(self, progress: float) -> Image: + """ + Draw the rank progress bar with the given progress filled. + `progress` should be given as a proportion between `0` and `1`. + """ + # Ensure sane values + progress = min(progress, 1) + progress = max(progress, 0) + + if progress == 0: + return self.skin.bar_empty + elif progress == 1: + return self.skin.bar_full + else: + _bar = self.skin.bar_empty + x = -1 * int((1 - progress) * self.skin.bar_full.width) + _bar.paste( + self.skin.bar_full, + (x, 0), + mask=self.skin.bar_mask + ) + bar = Image.new('RGBA', _bar.size) + bar.paste( + _bar, + mask=self.skin.bar_mask + ) + return bar + + +class ProfileCard(Card): + route = 'profile_card' + card_id = 'profile' + + layout = ProfileLayout + skin = ProfileSkin + + display_name = "User Profile" + + @classmethod + async def card_route(cls, runner, args, kwargs): + kwargs['avatar'] = await avatar_manager().get_avatar(*kwargs['avatar'], 256) + return await super().card_route(runner, args, kwargs) + + @classmethod + def _execute(cls, *args, **kwargs): + with BytesIO(kwargs['avatar']) as image_data: + with Image.open(image_data).convert('RGBA') as avatar_image: + kwargs['avatar'] = avatar_image + return super()._execute(*args, **kwargs) + + @classmethod + async def sample_args(cls, ctx, **kwargs): + return { + 'name': ctx.author.name if ctx else 'John Doe', + 'discrim': ('#' + ctx.author.discriminator) if ctx else '#0000', + 'avatar': get_avatar_key(ctx.client, ctx.author.id) if ctx else (0, None), + 'coins': 58596, + 'time': 3750 * 3600, + 'gems': 10000, + 'gifts': 100, + 'badges': ( + 'STUDYING: MEDICINE', + 'HOBBY: MATHS', + 'CAREER: STUDENT', + 'FROM: EUROPE', + 'LOVES CATS <3' + ), + 'achievements': (0, 2, 5, 7), + 'current_rank': ('VAMPIRE', 3000, 4000), + 'next_rank': ('WIZARD', 4000, 8000), + } diff --git a/src/modules/pending-rewrite/gui-cards/stats.py b/src/modules/pending-rewrite/gui-cards/stats.py new file mode 100644 index 00000000..4492cf1c --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/stats.py @@ -0,0 +1,508 @@ +import itertools +from datetime import datetime, timedelta +from PIL import Image, ImageDraw + +from ..base import Card, Layout, fielded, Skin +from ..base.Skin import ( + AssetField, BlobField, StringField, NumberField, RawField, + FontField, ColourField, PointField, ComputedField, FieldDesc +) + + +def format_lb(pos): + """ + Format a leaderboard position into a string. + """ + if pos is None: + return 'Unranked' + + if pos % 10 == 1 and pos % 100 != 11: + return f"{pos}ST" + + if pos % 10 == 2 and pos % 100 != 12: + return f"{pos}ND" + + if pos % 10 == 3 and pos % 100 != 13: + return f"{pos}RD" + + return f"{pos}TH" + + +def format_time(seconds): + return "{:02}:{:02}".format( + int(seconds // 3600), + int(seconds % 3600 // 60) + ) + + +@fielded +class StatsSkin(Skin): + _env = { + 'scale': 2 # General size scale to match background resolution + } + + # Background images + background: AssetField = "stats/background.png" + + # Inner container + container_position: PointField = (60, 50) # Position of top left corner + container_size: PointField = (1410, 800) # Size of the inner container + + # Major (topmost) header + header_font: FontField = ('Black', 27) + header_colour: ColourField = '#DDB21D' + header_gap: NumberField = 35 # Gap between header and column contents + header_height: ComputedField = lambda skin: skin.header_font.getsize('STATISTICS')[1] + + # First column + col1_header: StringField = 'STATISTICS' + stats_subheader_pregap: NumberField = 8 + stats_subheader_font: FontField = ('Black', 21) + stats_subheader_colour: ColourField = '#FFFFFF' + stats_subheader_size: ComputedField = lambda skin: skin.stats_subheader_font.getsize('LEADERBOARD POSITION') + stats_text_gap: NumberField = 13 # Gap between stat lines + stats_text_font: FontField = ('SemiBold', 19) + stats_text_height: ComputedField = lambda skin: skin.stats_text_font.getsize('DAILY')[1] + stats_text_colour: ColourField = '#BABABA' + + col1_size: ComputedField = lambda skin: ( + skin.stats_subheader_size[0], + skin.header_height + skin.header_gap + + 3 * skin.stats_subheader_size[1] + + 2 * skin.stats_subheader_pregap + + 6 * skin.stats_text_height + + 8 * skin.stats_text_gap + ) + + # Second column + col2_header: StringField = 'STUDY STREAK' + col2_date_font: FontField = ('Black', 21) + col2_date_colour: ColourField = '#FFFFFF' + col2_hours_colour: ColourField = '#1473A2' + col2_date_gap: NumberField = 25 # Gap between date line and calender + col2_subheader_height: ComputedField = lambda skin: skin.col2_date_font.getsize('JANUARY')[1] + cal_column_sep: NumberField = 35 + cal_weekday_text: RawField = ('S', 'M', 'T', 'W', 'T', 'F', 'S') + cal_weekday_font: FontField = ('ExtraBold', 21) + cal_weekday_colour: ColourField = '#FFFFFF' + cal_weekday_height: ComputedField = lambda skin: skin.cal_weekday_font.getsize('S')[1] + cal_weekday_gap: NumberField = 23 + cal_number_font: FontField = ('Medium', 20) + cal_number_end_colour: ColourField = '#BABABA' + cal_number_colour: ColourField = '#BABABA' + cal_number_gap: NumberField = 28 + alt_cal_number_gap: NumberField = 20 + cal_number_size: ComputedField = lambda skin: skin.cal_number_font.getsize('88') + + cal_streak_mask: AssetField = 'stats/streak_mask.png' + + cal_streak_end_colour: ColourField = '#1473A2' + cal_streak_end_colour_override: ColourField = None + cal_streak_end: BlobField = FieldDesc( + BlobField, + mask_field='cal_streak_mask', + colour_field='cal_streak_end_colour', + colour_override_field='cal_streak_end_colour_override' + ) + + cal_streak_middle_colour: ColourField = '#1B3343' + cal_streak_middle_colour_override: ColourField = None + cal_streak_middle: BlobField = FieldDesc( + BlobField, + mask_field='cal_streak_mask', + colour_field='cal_streak_middle_colour', + colour_override_field='cal_streak_middle_colour_override' + ) + + cal_size: ComputedField = lambda skin: ( + 7 * skin.cal_number_size[0] + 6 * skin.cal_column_sep + skin.cal_streak_end.width // 2, + 5 * skin.cal_number_size[1] + 4 * skin.cal_number_gap + + skin.cal_weekday_height + skin.cal_weekday_gap + + skin.cal_streak_end.height // 2 + ) + + alt_cal_size: ComputedField = lambda skin: ( + 7 * skin.cal_number_size[0] + 6 * skin.cal_column_sep + skin.cal_streak_end.width // 2, + 6 * skin.cal_number_size[1] + 5 * skin.alt_cal_number_gap + + skin.cal_weekday_height + skin.cal_weekday_gap + + skin.cal_streak_end.height // 2 + ) + + col2_size: ComputedField = lambda skin: ( + skin.cal_size[0], + skin.header_height + skin.header_gap + + skin.col2_subheader_height + skin.col2_date_gap + + skin.cal_size[1] + ) + + alt_col2_size: ComputedField = lambda skin: ( + skin.alt_cal_size[0], + skin.header_height + skin.header_gap + + skin.col2_subheader_height + skin.col2_date_gap + + skin.alt_cal_size[1] + ) + + +class StatsLayout(Layout): + def __init__(self, skin, lb_data, time_data, workouts, streak_data, date=None, draft=False, **kwargs): + self.draft = draft + + self.skin = skin + + self.data_lb_time = lb_data[0] # Position on time leaderboard, or None + self.data_lb_lc = lb_data[1] # Position on coin leaderboard, or None + + self.data_time_daily = int(time_data[0]) # Daily time in seconds + self.data_time_weekly = int(time_data[1]) # Weekly time in seconds + self.data_time_monthly = int(time_data[2]) # Monthly time in seconds + self.data_time_all = int(time_data[3]) # All time in seconds + + self.data_workouts = workouts # Number of workout sessions + self.data_streaks = streak_data # List of streak day ranges to show + + # Extract date info + date = date if date else datetime.today() # Date to show for month/year + month_first_day = date.replace(day=1) + month_days = (month_first_day.replace(month=(month_first_day.month % 12) + 1) - timedelta(days=1)).day + + self.date = date + self.month = date.strftime('%B').upper() + self.first_weekday = month_first_day.weekday() # Which weekday the month starts on + self.month_days = month_days + self.alt_layout = (month_days + self.first_weekday + 1) > 35 # Whether to use the alternate layout + + if self.alt_layout: + self.skin.fields['cal_number_gap'].value = self.skin.alt_cal_number_gap + self.skin.fields['cal_size'].value = self.skin.alt_cal_size + self.skin.fields['col2_size'].value = self.skin.alt_col2_size + + self.image: Image = None # Final Image + + def draw(self): + # Load/copy background + image = self.skin.background + + # Draw inner container + inner_container = self.draw_inner_container() + + # Paste inner container on background + image.alpha_composite(inner_container, self.skin.container_position) + + self.image = image + return image + + def draw_inner_container(self): + container = Image.new('RGBA', self.skin.container_size) + + col1 = self.draw_column_1() + col2 = self.draw_column_2() + + container.alpha_composite(col1) + container.alpha_composite(col2, (container.width - col2.width, 0)) + + if self.draft: + draw = ImageDraw.Draw(container) + draw.rectangle(((0, 0), (self.skin.container_size[0]-1, self.skin.container_size[1]-1))) + + return container + + def draw_column_1(self) -> Image: + # Create new image for column 1 + col1 = Image.new('RGBA', self.skin.col1_size) + draw = ImageDraw.Draw(col1) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.col1_size[0]-1, self.skin.col1_size[1]-1))) + + # Tracking current drawing height + position = 0 + + # Write header + draw.text( + (0, position), + self.skin.col1_header, + font=self.skin.header_font, + fill=self.skin.header_colour + ) + position += self.skin.header_height + self.skin.header_gap + + # Leaderboard + draw.text( + (0, position), + 'LEADERBOARD POSITION', + font=self.skin.stats_subheader_font, + fill=self.skin.stats_subheader_colour + ) + position += self.skin.col2_subheader_height + self.skin.stats_text_gap + + draw.text( + (0, position), + f"TIME: {format_lb(self.data_lb_time)}", + font=self.skin.stats_text_font, + fill=self.skin.stats_text_colour + ) + position += self.skin.stats_text_height + self.skin.stats_text_gap + + draw.text( + (0, position), + "ANKI: COMING SOON", + font=self.skin.stats_text_font, + fill=self.skin.stats_text_colour + ) + position += self.skin.stats_text_height + self.skin.stats_text_gap + + position += self.skin.stats_subheader_pregap + # Study time + draw.text( + (0, position), + 'STUDY TIME', + font=self.skin.stats_subheader_font, + fill=self.skin.stats_subheader_colour + ) + position += self.skin.col2_subheader_height + self.skin.stats_text_gap + + draw.text( + (0, position), + f'DAILY: {format_time(self.data_time_daily)}', + font=self.skin.stats_text_font, + fill=self.skin.stats_text_colour + ) + position += self.skin.stats_text_height + self.skin.stats_text_gap + + draw.text( + (0, position), + f'MONTHLY: {format_time(self.data_time_monthly)}', + font=self.skin.stats_text_font, + fill=self.skin.stats_text_colour + ) + position += self.skin.stats_text_height + self.skin.stats_text_gap + + draw.text( + (0, position), + f'WEEKLY: {format_time(self.data_time_weekly)}', + font=self.skin.stats_text_font, + fill=self.skin.stats_text_colour + ) + position += self.skin.stats_text_height + self.skin.stats_text_gap + + draw.text( + (0, position), + f'ALL TIME: {format_time(self.data_time_all)}', + font=self.skin.stats_text_font, + fill=self.skin.stats_text_colour + ) + position += self.skin.stats_text_height + self.skin.stats_text_gap + + position += self.skin.stats_subheader_size[1] // 2 + + position += self.skin.stats_subheader_pregap + # Workouts + workout_text = "WORKOUTS: " + draw.text( + (0, position), + workout_text, + font=self.skin.stats_subheader_font, + fill=self.skin.stats_subheader_colour, + anchor='lm' + ) + xposition = self.skin.stats_subheader_font.getlength(workout_text) + draw.text( + (xposition, position), + str(self.data_workouts), + font=self.skin.stats_text_font, + fill=self.skin.stats_subheader_colour, + anchor='lm' + ) + + return col1 + + def draw_column_2(self) -> Image: + # Create new image for column 1 + col2 = Image.new('RGBA', self.skin.col2_size) + draw = ImageDraw.Draw(col2) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.col2_size[0]-1, self.skin.col2_size[1]-1))) + + # Tracking current drawing height + position = 0 + + # Write header + draw.text( + (0, position), + self.skin.col2_header, + font=self.skin.header_font, + fill=self.skin.header_colour + ) + position += self.skin.header_height + self.skin.header_gap + + # Draw date line + month_text = "{}: ".format(self.month) + draw.text( + (0, position), + month_text, + font=self.skin.col2_date_font, + fill=self.skin.col2_date_colour + ) + xposition = self.skin.col2_date_font.getlength(month_text) + draw.text( + (xposition, position), + f"{self.data_time_monthly // 3600} HRS", + font=self.skin.col2_date_font, + fill=self.skin.col2_hours_colour + ) + year_text = str(self.date.year) + xposition = col2.width - self.skin.col2_date_font.getlength(year_text) + draw.text( + (xposition, position), + year_text, + font=self.skin.col2_date_font, + fill=self.skin.col2_date_colour + ) + position += self.skin.col2_subheader_height + self.skin.col2_date_gap + + # Draw calendar + cal = self.draw_calendar() + + col2.alpha_composite(cal, (0, position)) + + return col2 + + def draw_calendar(self) -> Image: + cal = Image.new('RGBA', self.skin.cal_size) + draw = ImageDraw.Draw(cal) + + if self.draft: + draw.rectangle(((0, 0), (self.skin.cal_size[0]-1, self.skin.cal_size[1]-1))) + + xpos, ypos = (0, 0) # Approximate position of top left corner to draw on + + # Constant offset to mid basepoint of text + xoffset = self.skin.cal_streak_end.width // 2 + yoffset = self.skin.cal_number_size[1] // 2 + + # Draw the weekdays + for i, l in enumerate(self.skin.cal_weekday_text): + draw.text( + (xpos + xoffset, ypos + yoffset), + l, + font=self.skin.cal_weekday_font, + fill=self.skin.cal_weekday_colour, + anchor='mm' + ) + xpos += self.skin.cal_number_size[0] + self.skin.cal_column_sep + ypos += self.skin.cal_weekday_height + self.skin.cal_weekday_gap + xpos = 0 + + streak_starts = list(itertools.chain(*self.data_streaks)) + streak_middles = list(itertools.chain(*(range(i+1, j) for i, j in self.data_streaks))) + streak_pairs = set(i for i, j in self.data_streaks if j-i == 1) + + # Draw the days of the month + num_diff_x = self.skin.cal_number_size[0] + self.skin.cal_column_sep + num_diff_y = self.skin.cal_number_size[1] + self.skin.cal_number_gap + offset = (self.first_weekday + 1) % 7 + + centres = [ + (xpos + xoffset + (i + offset) % 7 * num_diff_x, + ypos + yoffset + (i + offset) // 7 * num_diff_y) + for i in range(0, self.month_days) + ] + + for day in streak_middles: + if day < 1: + continue + i = day - 1 + if i >= len(centres): + # Shouldn't happen, but ignore + continue + x, y = centres[i] + week_day = (i + offset) % 7 + + top = y - self.skin.cal_streak_end.height // 2 + bottom = y + self.skin.cal_streak_end.height // 2 - 1 + + # Middle of streak on edges + if week_day == 0 or i == 0: + # Draw end bobble + cal.paste( + self.skin.cal_streak_middle, + (x - self.skin.cal_streak_end.width // 2, top) + ) + if week_day != 6: + # Draw rectangle forwards + draw.rectangle( + ((x, top), (x + num_diff_x, bottom)), + fill=self.skin.cal_streak_middle_colour, + width=0 + ) + elif week_day == 6 or i == self.month_days - 1: + # Draw end bobble + cal.paste( + self.skin.cal_streak_middle, + (x - self.skin.cal_streak_end.width // 2, top) + ) + if week_day != 0: + # Draw rectangle backwards + draw.rectangle( + ((x - num_diff_x, top), (x, bottom)), + fill=self.skin.cal_streak_middle_colour, + width=0 + ) + else: + # Draw rectangle on either side + draw.rectangle( + ((x - num_diff_x, top), (x + num_diff_x, bottom)), + fill=self.skin.cal_streak_middle_colour, + width=0 + ) + + for i, (x, y) in enumerate(centres): + # Streak endpoint + if i + 1 in streak_starts: + if i + 1 in streak_pairs and (i + offset) % 7 != 6: + # Draw rectangle forwards + top = y - self.skin.cal_streak_end.height // 2 + bottom = y + self.skin.cal_streak_end.height // 2 - 1 + draw.rectangle( + ((x, top), (x + num_diff_x, bottom)), + fill=self.skin.cal_streak_middle_colour, + width=0 + ) + cal.alpha_composite( + self.skin.cal_streak_end, + (x - self.skin.cal_streak_end.width // 2, y - self.skin.cal_streak_end.height // 2) + ) + + for i, (x, y) in enumerate(centres): + numstr = str(i + 1) + + draw.text( + (x, y), + numstr, + font=self.skin.cal_number_font, + fill=self.skin.cal_number_end_colour if (i+1 in streak_starts) else self.skin.cal_number_colour, + anchor='mm' + ) + + return cal + + +class StatsCard(Card): + route = 'stats_card' + card_id = 'stats' + + layout = StatsLayout + skin = StatsSkin + + display_name = "User Stats" + + @classmethod + async def sample_args(cls, ctx, **kwargs): + return { + 'lb_data': (21, 123), + 'time_data': (3600, 5 * 24 * 3600, 1.5 * 24 * 3600, 100 * 24 * 3600), + 'workouts': 50, + 'streak_data': [(1, 3), (7, 8), (10, 10), (12, 16), (18, 25), (27, 31)], + 'date': datetime(2022, 2, 1) + } diff --git a/src/modules/pending-rewrite/gui-cards/tasklist.py b/src/modules/pending-rewrite/gui-cards/tasklist.py new file mode 100644 index 00000000..a8bff4cf --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/tasklist.py @@ -0,0 +1,389 @@ +from io import BytesIO +import pickle + +from PIL import Image, ImageDraw + +from ..base import Card, Layout, fielded, Skin, FieldDesc +from ..base.Avatars import avatar_manager +from ..base.Skin import ( + AssetField, StringField, NumberField, + FontField, ColourField, PointField, ComputedField +) + +from .mixins import MiniProfileLayout + + +@fielded +class TasklistSkin(Skin): + _env = { + 'scale': 2 # General size scale to match background resolution + } + + # First page + first_page_bg: AssetField = "tasklist/first_page_background.png" + first_page_frame: AssetField = "tasklist/first_page_frame.png" + + title_pre_gap: NumberField = 40 + title_text: StringField = "TO DO LIST" + title_font: FontField = ('ExtraBold', 76) + title_size: ComputedField = lambda skin: skin.title_font.getsize(skin.title_text) + title_colour: ColourField = '#DDB21D' + title_underline_gap: NumberField = 10 + title_underline_width: NumberField = 5 + title_gap: NumberField = 50 + + # Profile section + mini_profile_indent: NumberField = 125 + mini_profile_size: ComputedField = lambda skin: ( + skin.first_page_bg.width - 2 * skin.mini_profile_indent, + int(skin._env['scale'] * 200) + ) + mini_profile_avatar_mask: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_mask.png', convert=None) + mini_profile_avatar_frame: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_frame.png', convert='RGBA') + mini_profile_avatar_sep: NumberField = 50 + + mini_profile_name_font: FontField = ('BoldItalic', 55) + mini_profile_name_colour: ColourField = '#DDB21D' + mini_profile_discrim_font: FontField = mini_profile_name_font + mini_profile_discrim_colour: ColourField = '#BABABA' + mini_profile_name_gap: NumberField = 20 + + mini_profile_badge_end: AssetField = "mini-profile/badge_end.png" + mini_profile_badge_font: FontField = ('Black', 30) + mini_profile_badge_colour: ColourField = '#FFFFFF' + mini_profile_badge_text_colour: ColourField = '#051822' + mini_profile_badge_gap: NumberField = 20 + mini_profile_badge_min_sep: NumberField = 10 + + # Other pages + other_page_bg: AssetField = "tasklist/other_page_background.png" + other_page_frame: AssetField = "tasklist/other_page_frame.png" + + # Help frame + help_frame: AssetField = "tasklist/help_frame.png" + + # Tasks + task_start_position: PointField = (100, 75) + + task_done_number_bg: AssetField = "tasklist/task_done_bg.png" + task_done_number_font: FontField = ('Regular', 45) + task_done_number_colour: ColourField = '#292828' + + task_done_text_font: FontField = ('Regular', 55) + task_done_text_colour: ColourField = '#686868' + + task_done_line_width: NumberField = 3.5 + + task_undone_number_bg: AssetField = "tasklist/task_undone_bg.png" + task_undone_number_font: FontField = ('Regular', 45) + task_undone_number_colour: ColourField = '#FFFFFF' + + task_undone_text_font: FontField = ('Regular', 55) + task_undone_text_colour: ColourField = '#FFFFFF' + + task_text_height: ComputedField = lambda skin: skin.task_done_text_font.getsize('TASK')[1] + task_num_sep: NumberField = 30 + task_inter_gap: NumberField = 32 + task_intra_gap: NumberField = 25 + + # Date text + footer_pre_gap: NumberField = 50 + footer_font: FontField = ('Bold', 28) + footer_colour: ColourField = '#686868' + footer_gap: NumberField = 50 + + +class TasklistLayout(Layout, MiniProfileLayout): + def __init__(self, skin, name, discrim, tasks, date, avatar, badges=()): + self.skin = skin + + self.data_name = name + self.data_discrim = discrim + self.data_avatar = avatar + self.data_tasks = tasks + self.data_date = date + self.data_badges = badges + + self.tasks_drawn = 0 + self.images = [] + + def _execute_draw(self): + image_data = [] + for image in self.draw(): + with BytesIO() as data: + image.save(data, format='PNG') + data.seek(0) + image_data.append(data.getvalue()) + return pickle.dumps(image_data) + + def draw(self): + self.images = [] + self.images.append(self._draw_first_page()) + while self.tasks_drawn < len(self.data_tasks): + self.images.append(self._draw_another_page()) + + return self.images + + def close(self): + if self.images: + for image in self.images: + image.close() + + def _draw_first_page(self) -> Image: + image = self.skin.first_page_bg + draw = ImageDraw.Draw(image) + xpos, ypos = 0, 0 + + # Draw header text + xpos = (image.width - self.skin.title_size[0]) // 2 + ypos += self.skin.title_pre_gap + draw.text( + (xpos, ypos), + self.skin.title_text, + fill=self.skin.title_colour, + font=self.skin.title_font + ) + + # Underline it + ypos += self.skin.title_size[1] + self.skin.title_underline_gap + # draw.line( + # (xpos, ypos, xpos + self.skin.title_size[0], ypos), + # fill=self.skin.title_colour, + # width=self.skin.title_underline_width + # ) + ypos += self.skin.title_underline_width + self.skin.title_gap + + # Draw the profile + xpos = self.skin.mini_profile_indent + profile = self._draw_profile() + image.alpha_composite( + profile, + (xpos, ypos) + ) + + # Start from the bottom + ypos = image.height + + if self.data_tasks: + # Draw the date text + ypos -= self.skin.footer_gap + date_text = self.data_date.strftime("As of %d %b") + size = self.skin.footer_font.getsize(date_text) + ypos -= size[1] + draw.text( + ((image.width - size[0]) // 2, ypos), + date_text, + font=self.skin.footer_font, + fill=self.skin.footer_colour + ) + ypos -= self.skin.footer_pre_gap + + # Draw the tasks + task_image = self._draw_tasks_into(self.skin.first_page_frame.copy()) + + ypos -= task_image.height + image.alpha_composite( + task_image, + ((image.width - task_image.width) // 2, ypos) + ) + else: + # Draw the help frame + ypos -= self.skin.footer_gap + image.alpha_composite( + self.skin.help_frame, + ((image.width - self.skin.help_frame.width) // 2, ypos - self.skin.help_frame.height) + ) + + return image + + def _draw_another_page(self) -> Image: + image = self.skin.other_page_bg.copy() + draw = ImageDraw.Draw(image) + + # Start from the bottom + ypos = image.height + + # Draw the date text + ypos -= self.skin.footer_gap + date_text = self.data_date.strftime("As of %d %b • {} {}".format(self.data_name, self.data_discrim)) + size = self.skin.footer_font.getsize(date_text) + ypos -= size[1] + draw.text( + ((image.width - size[0]) // 2, ypos), + date_text, + font=self.skin.footer_font, + fill=self.skin.footer_colour + ) + ypos -= self.skin.footer_pre_gap + + # Draw the tasks + task_image = self._draw_tasks_into(self.skin.other_page_frame.copy()) + ypos -= task_image.height + image.alpha_composite( + task_image, + ((image.width - task_image.width) // 2, ypos) + ) + return image + + def _draw_tasks_into(self, image) -> Image: + """ + Draw as many tasks as possible into the given image background. + """ + draw = ImageDraw.Draw(image) + xpos, ypos = self.skin.task_start_position + + for n, task, done in self.data_tasks[self.tasks_drawn:]: + # Draw task first to check if it fits on the page + task_image = self._draw_text( + task, + image.width - xpos - self.skin.task_done_number_bg.width - self.skin.task_num_sep, + done + ) + if task_image.height + ypos + self.skin.task_inter_gap > image.height: + break + + # Draw number background + bg = self.skin.task_done_number_bg if done else self.skin.task_undone_number_bg + image.alpha_composite( + bg, + (xpos, ypos) + ) + + # Draw number + font = self.skin.task_done_number_font if done else self.skin.task_undone_number_font + colour = self.skin.task_done_number_colour if done else self.skin.task_undone_number_colour + draw.text( + (xpos + bg.width // 2, ypos + bg.height // 2), + str(n), + fill=colour, + font=font, + anchor='mm' + ) + + # Draw text + image.alpha_composite( + task_image, + (xpos + bg.width + self.skin.task_num_sep, ypos - (bg.height - self.skin.task_text_height) // 2) + ) + + ypos += task_image.height + self.skin.task_inter_gap + self.tasks_drawn += 1 + + return image + + def _draw_text(self, task, maxwidth, done) -> Image: + """ + Draw the text of a given task. + """ + font = self.skin.task_done_text_font if done else self.skin.task_undone_text_font + colour = self.skin.task_done_text_colour if done else self.skin.task_undone_text_colour + + # Handle empty tasks + if not task.strip(): + task = '~' + + # First breakup the text + lines = [] + line = [] + width = 0 + for word in task.split(): + length = font.getlength(word + ' ') + if width + length > maxwidth: + if line: + lines.append(' '.join(line)) + line = [] + width = 0 + line.append(word) + width += length + if line: + lines.append(' '.join(line)) + + # Then draw it + bboxes = [font.getbbox(line) for line in lines] + heights = [font.getsize(line)[1] for line in lines] + height = sum(height for height in heights) + (len(lines) - 1) * self.skin.task_intra_gap + image = Image.new('RGBA', (maxwidth, height)) + draw = ImageDraw.Draw(image) + + x, y = 0, 0 + for line, (x1, y1, x2, y2), height in zip(lines, bboxes, heights): + draw.text( + (x, y), + line, + fill=colour, + font=font + ) + if done: + # Also strikethrough + draw.line( + (x1, y + y1 + (y2 - y1) // 2, x2, y + y1 + (y2 - y1) // 2), + fill=self.skin.task_done_text_colour, + width=self.skin.task_done_line_width + ) + y += height + self.skin.task_intra_gap + + return image + + +class TasklistCard(Card): + route = 'tasklist_card' + card_id = 'tasklist' + + layout = TasklistLayout + skin = TasklistSkin + + display_name = "Tasklist" + + @classmethod + async def request(cls, *args, **kwargs): + data = await super().request(*args, **kwargs) + return pickle.loads(data) + + @classmethod + async def card_route(cls, runner, args, kwargs): + kwargs['avatar'] = await avatar_manager().get_avatar(*kwargs['avatar'], 256) + return await super().card_route(runner, args, kwargs) + + @classmethod + def _execute(cls, *args, **kwargs): + with BytesIO(kwargs['avatar']) as image_data: + with Image.open(image_data).convert('RGBA') as avatar_image: + kwargs['avatar'] = avatar_image + return super()._execute(*args, **kwargs) + + @classmethod + async def generate_sample(cls, ctx=None, **kwargs): + from ..utils import image_as_file + + sample_kwargs = await cls.sample_args(ctx) + cards = await cls.request(**{**sample_kwargs, **kwargs}) + return image_as_file(cards[0], "sample.png") + + @classmethod + async def sample_args(cls, ctx, **kwargs): + import datetime + from ..utils import get_avatar_key + + return { + 'name': ctx.author.name if ctx else "John Doe", + 'discrim': '#' + ctx.author.discriminator if ctx else "#0000", + 'tasks': [ + (0, 'Run 50km', True), + (1, 'Read 5 books', False), + (2, 'Renovate bedroom', True), + (3, 'Learn a new language', False), + (4, 'Upload a vlog', False), + (5, 'Bibendum arcu vitae elementum curabitur vitae nunc sed velit', False), + (6, 'Dictum fusce ut placerat orci', True), + (7, 'Pharetra vel turpis nunc eget lorem dolor', True) + ], + 'date': datetime.datetime.now().replace(hour=0, minute=0, second=0), + 'avatar': get_avatar_key(ctx.client, ctx.author.id) if ctx else (0, None), + 'badges': ( + 'STUDYING: MEDICINE', + 'HOBBY: MATHS', + 'CAREER: STUDENT', + 'FROM: EUROPE', + 'LOVES CATS <3' + ), + } diff --git a/src/modules/pending-rewrite/gui-cards/test.py b/src/modules/pending-rewrite/gui-cards/test.py new file mode 100644 index 00000000..4a255183 --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/test.py @@ -0,0 +1,38 @@ +from io import BytesIO +from PIL import Image, ImageDraw +from .Card import Card +from .Avatars import avatar_manager + + +class TestCard(Card): + server_route = "testing" + + def __init__(self, text, avatar): + self.text = text + self.avatar = avatar + + self.image = None + + def draw(self): + bg = Image.new('RGBA', (100, 100)) + draw = ImageDraw.Draw(bg) + draw.text( + (0, 0), + self.text, + fill='#FF0000' + ) + bg.alpha_composite(self.avatar, (0, 30)) + + return bg + + @classmethod + async def card_route(cls, executor, args, kwargs): + kwargs['avatar'] = (await avatar_manager().get_avatars(kwargs['avatar']))[0] + return await super().card_route(executor, args, kwargs) + + @classmethod + def _execute(cls, *args, **kwargs): + with BytesIO(kwargs['avatar']) as image_data: + with Image.open(image_data).convert('RGBA') as avatar_image: + kwargs['avatar'] = avatar_image + return super()._execute(*args, **kwargs) diff --git a/src/modules/pending-rewrite/gui-cards/timer.py b/src/modules/pending-rewrite/gui-cards/timer.py new file mode 100644 index 00000000..2d0e9332 --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/timer.py @@ -0,0 +1,449 @@ +import math +from io import BytesIO +from PIL import Image, ImageDraw, ImageOps + +from ..base import Card, Layout, fielded, Skin +from ..base.Avatars import avatar_manager +from ..base.Skin import ( + AssetField, StringField, NumberField, + FontField, ColourField, PointField, ComputedField +) + + +@fielded +class _TimerSkin(Skin): + _env = { + 'scale': 2 # General size scale to match background resolution + } + + background: AssetField = "timer/background.png" + main_colour: ColourField + + header_field_height: NumberField = 171.5 + header_font: FontField = ('ExtraBold', 76) + + inner_margin: NumberField = 40 + inner_sep: NumberField = 7.5 + + # Timer section + # Outer progress bar + progress_end: AssetField + progress_start: AssetField + progress_bg: AssetField = "timer/break_timer.png" + progress_mask: ComputedField = lambda skin: ImageOps.invert(skin.progress_bg.split()[-1].convert('L')) + + timer_bg: AssetField = "timer/timer_bg.png" + + # Inner timer text + countdown_font: FontField = ('Light', 112) + countdown_gap: NumberField = 10 + stage_font: FontField = ('Light', 43.65) + stage_colour: ColourField = '#FFFFFF' + + mic_icon: AssetField + stage_text: StringField + + # Members + user_bg: AssetField = "timer/break_user.png" + user_mask: AssetField = "timer/avatar_mask.png" + + time_font: FontField = ('Black', 26) + time_colour: ColourField = '#FFFFFF' + + tag_gap: NumberField = 5.5 + tag: AssetField + tag_font: FontField = ('SemiBold', 25) + + # grid_x = (background.width - progress_mask.width - 2 * progress_end.width - grid_start[0] - user_bg.width) // 4 + grid: PointField = (344, 246) + + # Date text + date_font: FontField = ('Bold', 28) + date_colour: ColourField = '#6f6e6f' + date_gap: NumberField = 50 + + +@fielded +class FocusTimerSkin(_TimerSkin): + main_colour: ColourField = '#DDB21D' + user_bg: AssetField = "timer/focus_user.png" + mic_icon: AssetField = "timer/mute.png" + progress_end: AssetField = "timer/progress_end_focus.png" + progress_start: AssetField = "timer/progress_start_focus.png" + stage_text: StringField = "FOCUS" + tag: AssetField = "timer/focus_tag.png" + + +@fielded +class BreakTimerSkin(_TimerSkin): + main_colour: ColourField = '#78B7EF' + user_bg: AssetField = "timer/break_user.png" + mic_icon: AssetField = "timer/unmute.png" + progress_end: AssetField = "timer/progress_end_break.png" + progress_start: AssetField = "timer/progress_start_break.png" + stage_text: StringField = "BREAK" + tag: AssetField = "timer/break_tag.png" + + +class TimerLayout(Layout): + def __init__(self, skin, name, remaining, duration, users): + self.skin = skin + + self.data_name = name + self.data_remaining = 5 * math.ceil(remaining / 5) + self.data_duration = duration + self.data_amount = 1 - remaining / duration + self.data_users = sorted(users, key=lambda user: user[1], reverse=True) # (avatar, time) + + @staticmethod + def format_time(time, hours=True): + if hours: + return "{:02}:{:02}".format(int(time // 3600), int((time // 60) % 60)) + else: + return "{:02}:{:02}".format(int(time // 60), int(time % 60)) + + def draw(self): + image = self.skin.background + draw = ImageDraw.Draw(image) + + # Draw header + text = self.data_name + length = self.skin.header_font.getlength(text) + draw.text( + (image.width // 2, self.skin.header_field_height // 2), + text, + fill=self.skin.main_colour, + font=self.skin.header_font, + anchor='mm' + ) + + # Draw timer + timer_image = self._draw_progress_bar(self.data_amount) + ypos = timer_y = ( + self.skin.header_field_height + + (image.height - self.skin.header_field_height - timer_image.height) // 2 + - self.skin.progress_end.height // 2 + ) + xpos = timer_x = image.width - self.skin.inner_margin - timer_image.width + + image.alpha_composite( + timer_image, + (xpos, ypos) + ) + + # Draw timer text + stage_size = self.skin.stage_font.getsize(' ' + self.skin.stage_text) + + ypos += timer_image.height // 2 - stage_size[1] // 2 + xpos += timer_image.width // 2 + draw.text( + (xpos, ypos), + (text := self.format_time(self.data_remaining)), + fill=self.skin.main_colour, + font=self.skin.countdown_font, + anchor='mm' + ) + + size = int(self.skin.countdown_font.getsize(text)[1]) + ypos += size + + self.skin.mic_icon.thumbnail((stage_size[1], stage_size[1])) + length = int(self.skin.mic_icon.width + self.skin.stage_font.getlength(' ' + self.skin.stage_text)) + xpos -= length // 2 + + image.alpha_composite( + self.skin.mic_icon, + (xpos, ypos - self.skin.mic_icon.height) + ) + draw.text( + (xpos + self.skin.mic_icon.width, ypos), + ' ' + self.skin.stage_text, + fill=self.skin.stage_colour, + font=self.skin.stage_font, + anchor='ls' + ) + + # Draw user grid + if self.data_users: + grid_image = self.draw_user_grid() + + # ypos = self.skin.header_field_height + (image.height - self.skin.header_field_height - grid_image.height) // 2 + ypos = timer_y + (timer_image.height - grid_image.height) // 2 - stage_size[1] // 2 + xpos = ( + self.skin.inner_margin + + (timer_x - self.skin.inner_sep - self.skin.inner_margin) // 2 + - grid_image.width // 2 + ) + + image.alpha_composite( + grid_image, + (xpos, ypos) + ) + + # Draw the footer + ypos = image.height + ypos -= self.skin.date_gap + date_text = "Use !now [text] to show what you are working on!" + size = self.skin.date_font.getsize(date_text) + ypos -= size[1] + draw.text( + ((image.width - size[0]) // 2, ypos), + date_text, + font=self.skin.date_font, + fill=self.skin.date_colour + ) + return image + + def draw_user_grid(self) -> Image: + users = list(self.data_users)[:25] + + # Set these to 5 and 5 to force top left corner + rows = math.ceil(len(users) / 5) + columns = 5 + # columns = min(len(users), 5) + + size = ( + (columns - 1) * self.skin.grid[0] + self.skin.user_bg.width, + (rows - 1) * self.skin.grid[1] + self.skin.user_bg.height + self.skin.tag_gap + self.skin.tag.height + ) + + image = Image.new( + 'RGBA', + size + ) + for i, user in enumerate(users): + x = (i % 5) * self.skin.grid[0] + y = (i // 5) * self.skin.grid[1] + + user_image = self.draw_user(user) + image.alpha_composite( + user_image, + (x, y) + ) + return image + + def draw_user(self, user): + width = self.skin.user_bg.width + height = self.skin.user_bg.height + self.skin.tag_gap + self.skin.tag.height + image = Image.new('RGBA', (width, height)) + draw = ImageDraw.Draw(image) + + image.alpha_composite(self.skin.user_bg) + + avatar, time, tag = user + avatar = avatar + timestr = self.format_time(time, hours=True) + + # Mask avatar + avatar.paste((0, 0, 0, 0), mask=self.skin.user_mask.convert('RGBA')) + + # Resize avatar + avatar.thumbnail((self.skin.user_bg.height - 10, self.skin.user_bg.height - 10)) + + image.alpha_composite( + avatar, + (5, 5) + ) + draw.text( + (120, self.skin.user_bg.height // 2), + timestr, + anchor='lm', + font=self.skin.time_font, + fill=self.skin.time_colour + ) + + if tag: + ypos = self.skin.user_bg.height + self.skin.tag_gap + image.alpha_composite( + self.skin.tag, + ((image.width - self.skin.tag.width) // 2, ypos) + ) + draw.text( + (image.width // 2, ypos + self.skin.tag.height // 2), + tag, + font=self.skin.tag_font, + fill='#FFFFFF', + anchor='mm' + ) + return image + + def _draw_progress_bar(self, amount): + amount = min(amount, 1) + amount = max(amount, 0) + bg = self.skin.timer_bg + end = self.skin.progress_start + mask = self.skin.progress_mask + + center = ( + bg.width // 2 + 1, + bg.height // 2 + ) + radius = 553 + theta = amount * math.pi * 2 - math.pi / 2 + x = int(center[0] + radius * math.cos(theta)) + y = int(center[1] + radius * math.sin(theta)) + + canvas = Image.new('RGBA', size=(bg.width, bg.height)) + draw = ImageDraw.Draw(canvas) + + if amount >= 0.01: + canvas.alpha_composite( + end, + ( + center[0] - end.width // 2, + 26 - end.height // 2 + ) + ) + + sidelength = bg.width // 2 + line_ends = ( + int(center[0] + sidelength * math.cos(theta)), + int(center[1] + sidelength * math.sin(theta)) + ) + if amount <= 0.25: + path = [ + center, + (center[0], center[1] - sidelength), + (bg.width, 0), + line_ends + ] + elif amount <= 0.5: + path = [ + center, + (center[0], center[1] - sidelength), + (bg.width, 0), + (bg.width, bg.height), + line_ends + ] + elif amount <= 0.75: + path = [ + center, + (center[0], center[1] - sidelength), + (bg.width, 0), + (bg.width, bg.height), + (0, bg.height), + line_ends + ] + else: + path = [ + center, + (center[0], center[1] - sidelength), + (bg.width, 0), + (bg.width, bg.height), + (0, bg.height), + (0, 0), + line_ends + ] + + draw.polygon( + path, + fill=self.skin.main_colour + ) + + canvas.paste((0, 0, 0, 0), mask=mask) + + image = Image.new( + 'RGBA', + size=(bg.width + self.skin.progress_end.width, + bg.height + self.skin.progress_end.height) + ) + image.alpha_composite( + bg, + (self.skin.progress_end.width // 2, + self.skin.progress_end.height // 2) + ) + image.alpha_composite( + canvas, + (self.skin.progress_end.width // 2, + self.skin.progress_end.height // 2) + ) + + image.alpha_composite( + self.skin.progress_end, + ( + x, + y + ) + ) + + return image + + +class _TimerCard(Card): + layout = TimerLayout + + @classmethod + async def card_route(cls, runner, args, kwargs): + if kwargs['users']: + avatar_keys, times, tags = zip(*kwargs['users']) + avatars = await avatar_manager().get_avatars(*((*key, 512) for key in avatar_keys)) + kwargs['users'] = tuple(zip(avatars, times, tags)) + + return await super().card_route(runner, args, kwargs) + + @classmethod + def _execute(cls, *args, **kwargs): + if kwargs['users']: + avatar_data, times, tags = zip(*kwargs['users']) + avatars = [] + for datum in avatar_data: + with BytesIO(datum) as buffer: + buffer.seek(0) + avatars.append(Image.open(buffer).convert('RGBA')) + kwargs['users'] = tuple(zip(avatars, times, tags)) + + return super()._execute(*args, **kwargs) + + +class FocusTimerCard(_TimerCard): + route = 'focus_timer_card' + card_id = 'focus_timer' + + skin = FocusTimerSkin + display_name = "Focus Timer" + + @classmethod + async def sample_args(cls, ctx, **kwargs): + from ..utils import get_avatar_key + + return { + 'name': 'Pomodoro Timer', + 'remaining': 1658, + 'duration': 3000, + 'users': [ + (get_avatar_key(ctx.client, ctx.author.id), 7055, "SkinShop"), + ((0, None), 6543, "Never"), + ((0, None), 5432, "Going"), + ((0, None), 4321, "To"), + ((0, None), 3210, "Give"), + ((0, None), 2109, "You"), + ((0, None), 1098, "Up"), + ] + } + + +class BreakTimerCard(_TimerCard): + route = 'break_timer_card' + card_id = 'break_timer' + + skin = BreakTimerSkin + display_name = "Break Timer" + + @classmethod + async def sample_args(cls, ctx, **kwargs): + from ..utils import get_avatar_key + + return { + 'name': 'Pomodoro Timer', + 'remaining': 1658, + 'duration': 3000, + 'users': [ + (get_avatar_key(ctx.client, ctx.author.id), 7055, "SkinShop"), + ((0, None), 6543, "Never"), + ((0, None), 5432, "Going"), + ((0, None), 4321, "To"), + ((0, None), 3210, "Let"), + ((0, None), 2109, "You"), + ((0, None), 1098, "Down"), + ] + } diff --git a/src/modules/pending-rewrite/gui-cards/weekly.py b/src/modules/pending-rewrite/gui-cards/weekly.py new file mode 100644 index 00000000..c6ae61ee --- /dev/null +++ b/src/modules/pending-rewrite/gui-cards/weekly.py @@ -0,0 +1,713 @@ +import os +import math +from PIL import Image, ImageDraw, ImageColor +from datetime import timedelta, datetime, timezone + +from ..utils import resolve_asset_path +from ..base import Card, Layout, fielded, Skin +from ..base.Skin import ( + AssetField, RGBAAssetField, AssetPathField, BlobField, StringField, NumberField, PointField, RawField, + FontField, ColourField, ComputedField, FieldDesc +) + + +@fielded +class WeeklyStatsSkin(Skin): + _env = { + 'scale': 1 # General size scale to match background resolution + } + + background: AssetField = 'weekly/background.png' + + # Header + title_pre_gap: NumberField = 40 + title_text: StringField = "STUDY HOURS" + title_font: FontField = ('ExtraBold', 76) + title_size: ComputedField = lambda skin: skin.title_font.getsize(skin.title_text) + title_colour: ColourField = '#DDB21D' + title_underline_gap: NumberField = 10 + title_underline_width: NumberField = 5 + title_gap: NumberField = 50 + + # Top + top_grid_x: NumberField = 150 + top_grid_y: NumberField = 100 + + top_hours_font: FontField = ('Bold', 36.35) + top_hours_colour: ColourField = '#FFFFFF' + + top_hours_bg_mask: AssetField = 'weekly/hours_bg_mask.png' + top_hours_bg_colour: ColourField = '#0B465E' # TODO: Check this + top_hours_bg_colour_override: ColourField = None + top_hours_bg: BlobField = FieldDesc( + BlobField, + mask_field='top_hours_bg_mask', + colour_field='top_hours_bg_colour', + colour_field_override='top_hours_bg_colour_override' + ) + + top_hours_sep: NumberField = 100 + + top_line_width: NumberField = 10 + top_line_colour: ColourField = '#042231' + + top_weekday_pre_gap: NumberField = 20 + top_weekday_font: FontField = ('Bold', 36.35) + top_weekday_colour: ColourField = '#FFFFFF' + top_weekday_height: ComputedField = lambda skin: skin.top_weekday_font.getsize('MTWTFSS')[1] + top_weekday_gap: NumberField = 5 + top_date_font: FontField = ('SemiBold', 30) + top_date_colour: ColourField = '#808080' + top_date_height: ComputedField = lambda skin: skin.top_date_font.getsize('8/8')[1] + + top_bar_mask: RGBAAssetField = 'weekly/top_bar_mask.png' + + top_this_colour: ColourField = '#DDB21D' + top_this_color_override: ColourField = None + + top_last_colour: ColourField = '#377689CC' + top_last_color_override: ColourField = None + + top_this_bar_full: BlobField = FieldDesc( + BlobField, + mask_field='top_bar_mask', + colour_field='top_this_colour', + colour_field_override='top_this_colour_override' + ) + + top_last_bar_full: BlobField = FieldDesc( + BlobField, + mask_field='top_bar_mask', + colour_field='top_last_colour', + colour_field_override='top_last_colour_override' + ) + + top_gap: NumberField = 80 + + weekdays: RawField = ('M', 'T', 'W', 'T', 'F', 'S', 'S') + + # Bottom + btm_bar_horiz_colour: ColourField = "#052B3B93" + btm_bar_vert_colour: ColourField = "#042231B2" + btm_weekly_background_size: PointField = (66, 400) + btm_weekly_background_colour: ColourField = '#06324880' + btm_weekly_background: ComputedField = lambda skin: ( + Image.new( + 'RGBA', + skin.btm_weekly_background_size, + color=ImageColor.getrgb(skin.btm_weekly_background_colour) + ) + ) + + btm_timeline_end_mask: RGBAAssetField = 'weekly/timeline_end_mask.png' + + btm_this_colour: ColourField = '#DDB21D' + btm_this_colour_override: ColourField = None + btm_this_end: BlobField = FieldDesc( + BlobField, + mask_field='btm_timeline_end_mask', + colour_field='btm_this_colour', + colour_override_field='btm_this_colour_override' + ) + + btm_last_colour: ColourField = '#5E6C747F' + btm_last_colour_override: ColourField = None + btm_last_end: BlobField = FieldDesc( + BlobField, + mask_field='btm_timeline_end_mask', + colour_field='btm_last_colour', + colour_override_field='btm_last_colour_override' + ) + + btm_horiz_width: ComputedField = lambda skin: skin.btm_this_end.height + btm_sep: ComputedField = lambda skin: (skin.btm_weekly_background_size[1] - 7 * skin.btm_horiz_width) // 6 + + btm_vert_width: NumberField = 10 + + btm_grid_x: NumberField = 48 + btm_grid_y: ComputedField = lambda skin: skin.btm_horiz_width + skin.btm_sep + + btm_weekday_font: FontField = ('Bold', 36.35) + btm_weekday_colour: ColourField = '#FFFFFF' + + btm_day_font: FontField = ('SemiBold', 31) + btm_day_colour: ColourField = '#FFFFFF' + btm_day_height: ComputedField = lambda skin: skin.btm_day_font.getsize('88')[1] + btm_day_gap: NumberField = 15 + + btm_emoji_path: StringField = "weekly/emojis" + btm_emojis: ComputedField = lambda skin: { + state: Image.open( + resolve_asset_path( + skin._env['PATH'], + os.path.join(skin.btm_emoji_path, f"{state}.png") + ) + ).convert('RGBA') + for state in ('very_happy', 'happy', 'neutral', 'sad', 'shocked') + } + + # Summary + summary_pre_gap: NumberField = 50 + + summary_mask: AssetField = 'weekly/summary_mask.png' + + this_week_font: FontField = ('Light', 23) + this_week_colour: ColourField = '#BABABA' + this_week_image: BlobField = FieldDesc( + BlobField, + mask_field='summary_mask', + colour_field='top_this_colour', + colour_field_override='top_this_colour_override' + ) + + summary_sep: NumberField = 300 + + last_week_font: FontField = ('Light', 23) + last_week_colour: ColourField = '#BABABA' + last_week_image: BlobField = FieldDesc( + BlobField, + mask_field='summary_mask', + colour_field='top_last_colour', + colour_field_override='top_last_colour_override' + ) + + # Date text + footer_font: FontField = ('Bold', 28) + footer_colour: ColourField = '#6f6e6f' + footer_gap: NumberField = 50 + + +class WeeklyStatsPage(Layout): + def __init__(self, skin, name, discrim, sessions, date): + """ + `sessions` is a list of study sessions from the last two weeks. + """ + self.skin = skin + + self.data_sessions = sessions + self.data_date = date + + self.data_name = name + self.data_discrim = discrim + + self.week_start = date - timedelta(days=date.weekday()) + self.last_week_start = self.week_start - timedelta(days=7) + + periods = [] + times = [] + + day_start = self.last_week_start + day_time = 0 + day_periods = [] + current_period = [] + i = 0 + while i < len(sessions): + start, end = sessions[i] + i += 1 + + day_end = day_start + timedelta(hours=24) + + if end < day_start: + continue + + if start < day_start: + start = day_start + elif start >= day_end: + if current_period: + day_periods.append(current_period) + periods.append(day_periods) + times.append(day_time) + current_period = [] + day_periods = [] + day_time = 0 + day_start = day_end + i -= 1 + continue + + if (ended_after := (end - day_end).total_seconds()) > 0: + if ended_after > 60 * 20: + end = day_end + else: + end = day_end - timedelta(minutes=1) + + day_time += (end - start).total_seconds() + if not current_period: + current_period = [start, end] + elif (start - current_period[1]).total_seconds() < 60 * 60: + current_period[1] = end + else: + day_periods.append(current_period) + current_period = [start, end] + + if ended_after > 0: + if current_period: + day_periods.append(current_period) + periods.append(day_periods) + times.append(day_time) + current_period = [] + day_periods = [] + day_time = 0 + day_start = day_end + + if ended_after > 60 * 20: + i -= 1 + + if current_period: + day_periods.append(current_period) + periods.append(day_periods) + times.append(day_time) + + self.data_periods = periods + for i in range(len(periods), 14): + periods.append([]) + self.data_hours = [time / 3600 for time in times] + for i in range(len(self.data_hours), 14): + self.data_hours.append(0) + + self.date_labels = [ + (self.week_start + timedelta(days=i)).strftime('%d/%m') + for i in range(0, 7) + ] + + self.max_hour_label = (4 * math.ceil(max(self.data_hours) / 4)) or 4 + + self.image = None + + def draw(self) -> Image: + image = self.skin.background + self.image = image + + draw = ImageDraw.Draw(image) + + xpos, ypos = 0, 0 + + # Draw header text + xpos = (image.width - self.skin.title_size[0]) // 2 + ypos += self.skin.title_pre_gap + draw.text( + (xpos, ypos), + self.skin.title_text, + fill=self.skin.title_colour, + font=self.skin.title_font + ) + + # Underline it + title_size = self.skin.title_font.getsize(self.skin.title_text) + ypos += title_size[1] + self.skin.title_gap + # ypos += title_size[1] + self.skin.title_underline_gap + # draw.line( + # (xpos, ypos, xpos + title_size[0], ypos), + # fill=self.skin.title_colour, + # width=self.skin.title_underline_width + # ) + # ypos += self.skin.title_underline_width + self.skin.title_gap + + # Draw the top box + top = self.draw_top() + image.alpha_composite( + top, + ((image.width - top.width) // 2, ypos) + ) + + ypos += top.height + self.skin.top_gap + + # Draw the bottom box + bottom = self.draw_bottom() + image.alpha_composite( + bottom, + ((image.width - bottom.width) // 2, ypos) + ) + ypos += bottom.height + self.skin.summary_pre_gap + + # Draw the summaries + summary_image = self.draw_summaries() + image.alpha_composite( + summary_image, + ((image.width - summary_image.width) // 2, ypos) + ) + + # Draw the footer + ypos = image.height + ypos -= self.skin.footer_gap + date_text = self.data_date.strftime( + "Weekly Statistics • As of %d %b • {} {}".format(self.data_name, self.data_discrim) + ) + size = self.skin.footer_font.getsize(date_text) + ypos -= size[1] + draw.text( + ((image.width - size[0]) // 2, ypos), + date_text, + font=self.skin.footer_font, + fill=self.skin.footer_colour + ) + return image + + def draw_summaries(self) -> Image: + this_week_text = " THIS WEEK: {} Hours".format(int(sum(self.data_hours[7:]))) + this_week_length = int(self.skin.this_week_font.getlength(this_week_text)) + last_week_text = " LAST WEEK: {} Hours".format(int(sum(self.data_hours[:7]))) + last_week_length = int(self.skin.last_week_font.getlength(last_week_text)) + + image = Image.new( + 'RGBA', + ( + self.skin.this_week_image.width + this_week_length + + self.skin.summary_sep + + self.skin.last_week_image.width + last_week_length, + self.skin.this_week_image.height + ) + ) + draw = ImageDraw.Draw(image) + + xpos = 0 + ypos = image.height // 2 + image.alpha_composite( + self.skin.this_week_image, + (0, 0) + ) + xpos += self.skin.this_week_image.width + draw.text( + (xpos, ypos), + this_week_text, + fill=self.skin.this_week_colour, + font=self.skin.this_week_font, + anchor='lm' + ) + + xpos += self.skin.summary_sep + this_week_length + + image.alpha_composite( + self.skin.last_week_image, + (xpos, 0) + ) + xpos += self.skin.last_week_image.width + draw.text( + (xpos, ypos), + last_week_text, + fill=self.skin.last_week_colour, + font=self.skin.last_week_font, + anchor='lm' + ) + return image + + def draw_top(self) -> Image: + size_x = ( + self.skin.top_hours_bg.width // 2 + self.skin.top_hours_sep + + 6 * self.skin.top_grid_x + self.skin.top_bar_mask.width // 2 + + self.skin.top_hours_bg.width // 2 + ) + size_y = ( + self.skin.top_hours_bg.height // 2 + 4 * self.skin.top_grid_y + self.skin.top_weekday_pre_gap + + self.skin.top_weekday_height + self.skin.top_weekday_gap + self.skin.top_date_height + ) + image = Image.new('RGBA', (size_x, size_y)) + draw = ImageDraw.Draw(image) + + x0 = self.skin.top_hours_bg.width // 2 + self.skin.top_hours_sep + y0 = self.skin.top_hours_bg.height // 2 + 4 * self.skin.top_grid_y + + # Draw lines and numbers + labels = list(int(i * self.max_hour_label // 4) for i in range(0, 5)) + + xpos = x0 - self.skin.top_hours_sep + ypos = y0 + for label in labels: + draw.line( + ((xpos, ypos), (image.width, ypos)), + width=self.skin.top_line_width, + fill=self.skin.top_line_colour + ) + + image.alpha_composite( + self.skin.top_hours_bg, + (xpos - self.skin.top_hours_bg.width // 2, ypos - self.skin.top_hours_bg.height // 2) + ) + text = str(label) + draw.text( + (xpos, ypos), + text, + fill=self.skin.top_hours_colour, + font=self.skin.top_hours_font, + anchor='mm' + ) + ypos -= self.skin.top_grid_y + + # Draw dates + xpos = x0 + ypos = y0 + self.skin.top_weekday_pre_gap + for letter, datestr in zip(self.skin.weekdays, self.date_labels): + draw.text( + (xpos, ypos), + letter, + fill=self.skin.top_weekday_colour, + font=self.skin.top_weekday_font, + anchor='mt' + ) + draw.text( + (xpos, ypos + self.skin.top_weekday_height + self.skin.top_weekday_gap), + datestr, + fill=self.skin.top_date_colour, + font=self.skin.top_date_font, + anchor='mt' + ) + xpos += self.skin.top_grid_x + + # Draw bars + for i, (last_hours, this_hours) in enumerate(zip(self.data_hours[:7], self.data_hours[7:])): + day = i % 7 + xpos = x0 + day * self.skin.top_grid_x + + for draw_last in (last_hours > this_hours, not last_hours > this_hours): + hours = last_hours if draw_last else this_hours + height = (4 * self.skin.top_grid_y) * (hours / self.max_hour_label) + height = int(height) + + if height >= 2 * self.skin.top_bar_mask.width: + bar = self.draw_vertical_bar( + height, + self.skin.top_last_bar_full if draw_last else self.skin.top_this_bar_full, + self.skin.top_bar_mask + ) + image.alpha_composite( + bar, + (xpos - bar.width // 2, y0 - bar.height) + ) + + return image + + def draw_vertical_bar(self, height, full_bar, mask_bar, crop=False): + y_2 = mask_bar.height + y_1 = height + + image = Image.new('RGBA', full_bar.size) + image.paste(mask_bar, (0, y_2 - y_1), mask=mask_bar) + image.paste(full_bar, mask=image) + + if crop: + image = image.crop( + (0, y_2 - y_1), (image.width, y_2 - y_1), + (image.height, 0), (image.height, image.width) + ) + + return image + + def draw_horizontal_bar(self, length, full_bar, mask_bar, crop=False): + x_2 = mask_bar.length + x_1 = length + + image = Image.new('RGBA', full_bar.size) + image.paste(mask_bar, (x_2 - x_1, 0), mask=mask_bar) + image.paste(full_bar, mask=image) + + if crop: + image = image.crop( + (x_2 - x_1, 0), (image.width, 0), + (x_2 - x_1, image.height), (image.width, image.height) + ) + + return image + + def draw_bottom(self) -> Image: + size_x = int( + self.skin.btm_weekly_background_size[0] + + self.skin.btm_grid_x * 25 + + self.skin.btm_day_font.getlength('24') // 2 + + self.skin.btm_vert_width // 2 + ) + size_y = int( + 7 * self.skin.btm_horiz_width + 6 * self.skin.btm_sep + + self.skin.btm_day_gap + + self.skin.btm_day_height + ) + image = Image.new('RGBA', (size_x, size_y)) + draw = ImageDraw.Draw(image) + + # Grid origin + x0 = self.skin.btm_weekly_background_size[0] + self.skin.btm_vert_width // 2 + self.skin.btm_grid_x + y0 = self.skin.btm_day_gap + self.skin.btm_day_height + self.skin.btm_horiz_width // 2 + + # Draw the hours + ypos = y0 - self.skin.btm_horiz_width // 2 - self.skin.btm_day_gap + for i in range(-1, 25): + xpos = x0 + i * self.skin.btm_grid_x + if i >= 0: + draw.text( + (xpos, ypos), + str(i), + fill=self.skin.btm_day_colour, + font=self.skin.btm_day_font, + anchor='ms' + ) + + draw.line( + ( + (xpos, y0 - self.skin.btm_horiz_width // 2), + (xpos, image.height) + ), + fill=self.skin.btm_bar_vert_colour, + width=self.skin.btm_vert_width + ) + + # Draw the day bars + bar_image = Image.new( + 'RGBA', + (image.width, self.skin.btm_horiz_width), + self.skin.btm_bar_horiz_colour + ) + for i in range(0, 7): + ypos = y0 + i * self.skin.btm_grid_y - self.skin.btm_horiz_width // 2 + image.alpha_composite( + bar_image, + (0, ypos) + ) + + # Draw the weekday background + image.alpha_composite( + self.skin.btm_weekly_background, + (0, y0 - self.skin.btm_horiz_width // 2) + ) + + # Draw the weekdays + xpos = self.skin.btm_weekly_background_size[0] // 2 + for i, l in enumerate(self.skin.weekdays): + ypos = y0 + i * self.skin.btm_grid_y + draw.text( + (xpos, ypos), + l, + font=self.skin.btm_weekday_font, + fill=self.skin.btm_weekday_colour, + anchor='mm' + ) + + # Draw the sessions + seconds_in_day = 60 * 60 * 24 + day_width = 24 * self.skin.btm_grid_x + for i, day in enumerate(reversed(self.data_periods)): + last = (i // 7) + ypos = y0 + (6 - i % 7) * self.skin.btm_grid_y + + for start, end in day: + if end <= start: + continue + day_start = start.replace(hour=0, minute=0, second=0, microsecond=0) + + flat_start = (start == day_start) + duration = (end - start).total_seconds() + xpos = x0 + int((start - day_start).total_seconds() / seconds_in_day * day_width) + + flat_end = (end == day_start + timedelta(days=1)) + + if flat_end: + width = image.width - xpos + else: + width = int(duration / seconds_in_day * day_width) + + bar = self.draw_timeline_bar( + width, + last=last, + flat_start=flat_start, + flat_end=flat_end + ) + + image.alpha_composite( + bar, + (xpos, ypos - bar.height // 2) + ) + + # Draw the emojis + xpos = x0 - self.skin.btm_grid_x // 2 + average_study = sum(self.data_hours[7:]) / 7 + for i, hours in enumerate(self.data_hours[7:]): + if hours: + ypos = y0 + i * self.skin.btm_grid_y + relative = hours / average_study + if relative > 1: + state = 'very_happy' + elif relative > 0.75: + state = 'happy' + elif relative > 0.25: + state = 'neutral' + else: + state = 'sad' + emoji = self.skin.btm_emojis[state] + image.alpha_composite( + emoji, + (xpos - emoji.width // 2, ypos - emoji.height // 2) + ) + return image + + def draw_timeline_bar(self, width, last=False, flat_start=False, flat_end=False) -> Image: + if last: + end = self.skin.btm_last_end + colour = self.skin.btm_last_colour + else: + end = self.skin.btm_this_end + colour = self.skin.btm_this_colour + + image = Image.new( + 'RGBA', + (width, end.height) + ) + draw = ImageDraw.Draw(image) + + # Draw endpoints + if not flat_start: + image.alpha_composite( + end, + (0, 0) + ) + + if not flat_end: + image.alpha_composite( + end, + (width - end.width, 0) + ) + + # Draw the rectangle + rstart = (not flat_start) * (end.width // 2) + rend = width - (not flat_end) * (end.width // 2) + draw.rectangle( + ((rstart, 0), (rend, image.height)), + fill=colour, + width=0 + ) + + return image + + +class WeeklyStatsCard(Card): + route = 'weekly_stats_card' + card_id = 'weekly_stats' + + layout = WeeklyStatsPage + skin = WeeklyStatsSkin + + display_name = "Weekly Stats" + + @classmethod + async def sample_args(cls, ctx, **kwargs): + import random + sessions = [] + day_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + day_start -= timedelta(hours=24) * 14 + for day in range(0, 14): + day_start += timedelta(hours=24) + + # start of day + pointer = int(abs(random.normalvariate(6 * 60, 1 * 60))) + while pointer < 20 * 60: + session_duration = int(abs(random.normalvariate(4 * 60, 1 * 60))) + sessions.append(( + day_start + timedelta(minutes=pointer), + day_start + timedelta(minutes=(pointer + session_duration)), + ) + ) + pointer += session_duration + pointer += int(abs(random.normalvariate(2.5 * 60, 1 * 60))) + + return { + 'name': ctx.author.name if ctx else "John Doe", + 'discrim': ('#' + ctx.author.discriminator) if ctx else "#0000", + 'sessions': sessions, + 'date': datetime.now(timezone.utc).replace(hour=0, minute=0, second=0) + } diff --git a/src/modules/pending-rewrite/gui-commands/__init__.py b/src/modules/pending-rewrite/gui-commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/pending-rewrite/gui-commands/goals.py b/src/modules/pending-rewrite/gui-commands/goals.py new file mode 100644 index 00000000..fe77babf --- /dev/null +++ b/src/modules/pending-rewrite/gui-commands/goals.py @@ -0,0 +1,320 @@ +from datetime import timedelta +import asyncio + +from data.conditions import GEQ + +from modules.stats import goals + +from ..module import module, ratelimit + +from ...cards import WeeklyGoalCard, MonthlyGoalCard +from ...cards import WeeklyStatsCard, MonthlyStatsCard +from ...utils import get_avatar_key, image_as_file + + +async def _get_weekly_goals(ctx): + # Fetch goal data + goal_row = ctx.client.data.weekly_goals.fetch_or_create( + (ctx.guild.id, ctx.author.id, ctx.alion.week_timestamp) + ) + tasklist_rows = ctx.client.data.member_weekly_goal_tasks.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + weekid=ctx.alion.week_timestamp, + _extra="ORDER BY taskid ASC" + ) + tasklist = [ + (i, task['content'], task['completed']) + for i, task in enumerate(tasklist_rows) + ] + + day_start = ctx.alion.day_start + week_start = day_start - timedelta(days=day_start.weekday()) + + # Fetch study data + week_study_time = ctx.client.data.session_history.queries.study_time_since( + ctx.guild.id, ctx.author.id, week_start + ) + study_hours = week_study_time // 3600 + + # Fetch task data + tasks_done = ctx.client.data.tasklist.select_one_where( + userid=ctx.author.id, + completed_at=GEQ(week_start), + select_columns=('COUNT(*)',) + )[0] + + # Fetch accountability data + accountability = ctx.client.data.accountability_member_info.select_where( + userid=ctx.author.id, + start_at=GEQ(week_start), + select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"), + ) + if len(accountability): + acc_attended = sum(row['attended'] for row in accountability) + acc_total = len(accountability) + acc_rate = acc_attended / acc_total + else: + acc_rate = None + + goalpage = await WeeklyGoalCard.request( + name=ctx.author.name, + discrim=f"#{ctx.author.discriminator}", + avatar=get_avatar_key(ctx.client, ctx.author.id), + badges=ctx.alion.profile_tags, + tasks_done=tasks_done, + studied_hours=study_hours, + attendance=acc_rate, + tasks_goal=goal_row.task_goal, + studied_goal=goal_row.study_goal, + goals=tasklist, + date=ctx.alion.day_start, + skin=WeeklyGoalCard.skin_args_for(ctx) + ) + return goalpage + + +@ratelimit.ward() +async def show_weekly_goals(ctx): + image = await _get_weekly_goals(ctx) + await ctx.reply(file=image_as_file(image, 'weekly_stats_1.png')) + +goals.display_weekly_goals_for = show_weekly_goals + + +@module.cmd( + "weekly", + group="Statistics", + desc="View your weekly study statistics!" +) +@ratelimit.ward() +async def cmd_weekly(ctx): + """ + Usage``: + {prefix}weekly + Description: + View your weekly study profile. + See `{prefix}weeklygoals` to edit your goals! + """ + day_start = ctx.alion.day_start + last_week_start = day_start - timedelta(days=7 + day_start.weekday()) + + history = ctx.client.data.session_history.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + start_time=GEQ(last_week_start - timedelta(days=1)), + select_columns=( + "start_time", + "(start_time + duration * interval '1 second') AS end_time" + ), + _extra="ORDER BY start_time ASC" + ) + timezone = ctx.alion.timezone + sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history] + + page_1_task = asyncio.create_task(_get_weekly_goals(ctx)) + page_2_task = asyncio.create_task( + WeeklyStatsCard.request( + ctx.author.name, + f"#{ctx.author.discriminator}", + sessions, + day_start, + skin=WeeklyStatsCard.skin_args_for(ctx) + ) + ) + + await asyncio.gather(page_1_task, page_2_task) + page_1 = page_1_task.result() + page_2 = page_2_task.result() + + await ctx.reply( + files=[ + image_as_file(page_1, "weekly_stats_1.png"), + image_as_file(page_2, "weekly_stats_2.png") + ] + ) + + +async def _get_monthly_goals(ctx): + # Fetch goal data + goal_row = ctx.client.data.monthly_goals.fetch_or_create( + (ctx.guild.id, ctx.author.id, ctx.alion.month_timestamp) + ) + tasklist_rows = ctx.client.data.member_monthly_goal_tasks.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + monthid=ctx.alion.month_timestamp, + _extra="ORDER BY taskid ASC" + ) + tasklist = [ + (i, task['content'], task['completed']) + for i, task in enumerate(tasklist_rows) + ] + + day_start = ctx.alion.day_start + month_start = day_start.replace(day=1) + + # Fetch study data + month_study_time = ctx.client.data.session_history.queries.study_time_since( + ctx.guild.id, ctx.author.id, month_start + ) + study_hours = month_study_time // 3600 + + # Fetch task data + tasks_done = ctx.client.data.tasklist.select_one_where( + userid=ctx.author.id, + completed_at=GEQ(month_start), + select_columns=('COUNT(*)',) + )[0] + + # Fetch accountability data + accountability = ctx.client.data.accountability_member_info.select_where( + userid=ctx.author.id, + start_at=GEQ(month_start), + select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"), + ) + if len(accountability): + acc_attended = sum(row['attended'] for row in accountability) + acc_total = len(accountability) + acc_rate = acc_attended / acc_total + else: + acc_rate = None + + goalpage = await MonthlyGoalCard.request( + name=ctx.author.name, + discrim=f"#{ctx.author.discriminator}", + avatar=get_avatar_key(ctx.client, ctx.author.id), + badges=ctx.alion.profile_tags, + tasks_done=tasks_done, + studied_hours=study_hours, + attendance=acc_rate, + tasks_goal=goal_row.task_goal, + studied_goal=goal_row.study_goal, + goals=tasklist, + date=ctx.alion.day_start, + skin=MonthlyGoalCard.skin_args_for(ctx) + ) + return goalpage + + +@ratelimit.ward() +async def show_monthly_goals(ctx): + image = await _get_monthly_goals(ctx) + await ctx.reply(file=image_as_file(image, 'monthly_stats_1.png')) + +goals.display_monthly_goals_for = show_monthly_goals + + +@module.cmd( + "monthly", + group="Statistics", + desc="View your monthly study statistics!" +) +async def cmd_monthly(ctx): + """ + Usage``: + {prefix}monthly + Description: + View your monthly study profile. + See `{prefix}monthlygoals` to edit your goals! + """ + day_start = ctx.alion.day_start + period_start = day_start - timedelta(days=31*4) + + history = ctx.client.data.session_history.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + select_columns=( + "start_time", + "(start_time + duration * interval '1 second') AS end_time" + ), + _extra="ORDER BY start_time DESC" + ) + timezone = ctx.alion.timezone + sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history] + if not sessions: + return await ctx.error_reply( + "No statistics to show, because you have never studied in this server before!" + ) + + # Streak statistics + streak = 0 + current_streak = None + max_streak = 0 + + day_attended = True if 'sessions' in ctx.client.objects and ctx.alion.session else None + date = day_start + daydiff = timedelta(days=1) + + periods = sessions + + i = 0 + while i < len(periods): + row = periods[i] + i += 1 + if row[1] > date: + # They attended this day + day_attended = True + continue + elif day_attended is None: + # Didn't attend today, but don't break streak + day_attended = False + date -= daydiff + i -= 1 + continue + elif not day_attended: + # Didn't attend the day, streak broken + date -= daydiff + i -= 1 + pass + else: + # Attended the day + streak += 1 + + # Move window to the previous day and try the row again + day_attended = False + prev_date = date + date -= daydiff + i -= 1 + + # Special case, when the last session started in the previous day + # Then the day is already attended + if i > 1 and date < periods[i-2][0] <= prev_date: + day_attended = True + + continue + + max_streak = max(max_streak, streak) + if current_streak is None: + current_streak = streak + streak = 0 + + # Handle loop exit state, i.e. the last streak + if day_attended: + streak += 1 + max_streak = max(max_streak, streak) + if current_streak is None: + current_streak = streak + + first_session_start = sessions[-1][0] + sessions = [session for session in sessions if session[1] > period_start] + page_1_task = asyncio.create_task(_get_monthly_goals(ctx)) + page_2_task = asyncio.create_task(MonthlyStatsCard.request( + ctx.author.name, + f"#{ctx.author.discriminator}", + sessions, + day_start.date(), + current_streak or 0, + max_streak or 0, + first_session_start, + skin=MonthlyStatsCard.skin_args_for(ctx) + )) + await asyncio.gather(page_1_task, page_2_task) + page_1 = page_1_task.result() + page_2 = page_2_task.result() + await ctx.reply( + files=[ + image_as_file(page_1, "monthly_stats_1.png"), + image_as_file(page_2, "monthly_stats_2.png") + ] + ) diff --git a/src/modules/pending-rewrite/gui-commands/leaderboard.py b/src/modules/pending-rewrite/gui-commands/leaderboard.py new file mode 100644 index 00000000..554258ca --- /dev/null +++ b/src/modules/pending-rewrite/gui-commands/leaderboard.py @@ -0,0 +1,198 @@ +import gc +import asyncio +import discord +from cmdClient.checks import in_guild + +import data +from data import tables +from utils.interactive import discord_shield +from meta import conf + +from ...cards import LeaderboardCard +from ...utils import image_as_file, edit_files, get_avatar_key + +from ..module import module, ratelimit + + +next_emoji = conf.emojis.forward +my_emoji = conf.emojis.person +prev_emoji = conf.emojis.backward + + +@module.cmd( + "top", + desc="View the Study Time leaderboard.", + group="Statistics", + aliases=('ttop', 'toptime', 'top100') +) +@in_guild() +@ratelimit.ward(member=False) +async def cmd_top(ctx): + """ + Usage``: + {prefix}top + {prefix}top100 + Description: + Display the study time leaderboard, or the top 100. + """ + # Handle args + if ctx.args and not ctx.args == "100": + return await ctx.error_reply( + "**Usage:**`{prefix}top` or `{prefix}top100`.".format(prefix=ctx.best_prefix) + ) + top100 = (ctx.args == "100" or ctx.alias == "top100") + + # Fetch the leaderboard + exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) + exclude.update(ctx.client.user_blacklist()) + exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) + + args = { + 'guildid': ctx.guild.id, + 'select_columns': ('userid', 'total_tracked_time::INTEGER', 'display_name'), + '_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "") + } + if exclude: + args['userid'] = data.NOT(list(exclude)) + + user_data = tables.members_totals.select_where(**args) + + # Quit early if the leaderboard is empty + if not user_data: + return await ctx.reply("No leaderboard entries yet!") + + # Extract entries + author_rank = None + entries = [] + for i, (userid, time, display_name) in enumerate(user_data): + if (member := ctx.guild.get_member(userid)): + name = member.display_name + elif display_name: + name = display_name + else: + name = str(userid) + + entries.append( + (userid, i + 1, time, name, get_avatar_key(ctx.client, userid)) + ) + + if ctx.author.id == userid: + author_rank = i + 1 + + # Break into pages + entry_pages = [entries[i:i+10] for i in range(0, len(entries), 10)] + page_count = len(entry_pages) + author_page = (author_rank - 1) // 10 if author_rank is not None else None + + if page_count == 1: + image = await LeaderboardCard.request( + ctx.guild.name, + entries=entry_pages[0], + highlight=author_rank, + skin=LeaderboardCard.skin_args_for(ctx) + ) + _file = image_as_file(image, "leaderboard.png") + await ctx.reply(file=_file) + del image + else: + page_i = 0 + + page_futures = {} + + def submit_page_request(i): + if (_existing := page_futures.get(i, None)) is not None: + # A future was already submitted + _future = _existing + else: + _future = asyncio.create_task( + LeaderboardCard.request( + ctx.guild.name, + entries=entry_pages[i % page_count], + highlight=author_rank, + skin=LeaderboardCard.skin_args_for(ctx) + ) + ) + page_futures[i] = _future + return _future + + # Draw first page + out_msg = await ctx.reply(file=image_as_file(await submit_page_request(0), "leaderboard.png")) + + # Prefetch likely next page + submit_page_request(author_page or 1) + + # Add reactions + try: + await out_msg.add_reaction(prev_emoji) + if author_page is not None: + await out_msg.add_reaction(my_emoji) + await out_msg.add_reaction(next_emoji) + except discord.Forbidden: + perms = ctx.ch.permissions_for(ctx.guild.me) + if not perms.add_reactions: + await ctx.error_reply( + "Cannot page leaderboard because I do not have the `add_reactions` permission!" + ) + elif not perms.read_message_history: + await ctx.error_reply( + "Cannot page leaderboard because I do not have the `read_message_history` permission!" + ) + else: + await ctx.error_reply( + "Cannot page leaderboard due to insufficient permissions!" + ) + return + + def reaction_check(reaction, user): + result = reaction.message.id == out_msg.id + result = result and reaction.emoji in [next_emoji, my_emoji, prev_emoji] + result = result and not (user.id == ctx.client.user.id) + return result + + while True: + try: + reaction, user = await ctx.client.wait_for('reaction_add', check=reaction_check, timeout=60) + except asyncio.TimeoutError: + break + + asyncio.create_task(discord_shield(out_msg.remove_reaction(reaction.emoji, user))) + + # Change the page number + if reaction.emoji == next_emoji: + page_i += 1 + page_i %= page_count + elif reaction.emoji == prev_emoji: + page_i -= 1 + page_i %= page_count + else: + page_i = author_page + + # Edit the message + image = await submit_page_request(page_i) + image_file = image_as_file(image, f"leaderboard_{page_i}.png") + + await edit_files( + ctx.client._connection.http, + ctx.ch.id, + out_msg.id, + files=[image_file] + ) + # Prefetch surrounding pages + submit_page_request((page_i + 1) % page_count) + submit_page_request((page_i - 1) % page_count) + + # Clean up reactions + try: + await out_msg.clear_reactions() + except discord.Forbidden: + try: + await out_msg.remove_reaction(next_emoji, ctx.client.user) + await out_msg.remove_reaction(prev_emoji, ctx.client.user) + except discord.NotFound: + pass + except discord.NotFound: + pass + + # Delete the image cache and explicit garbage collect + del page_futures + gc.collect() diff --git a/src/modules/pending-rewrite/gui-commands/module.py b/src/modules/pending-rewrite/gui-commands/module.py new file mode 100644 index 00000000..28610cb7 --- /dev/null +++ b/src/modules/pending-rewrite/gui-commands/module.py @@ -0,0 +1,90 @@ +import logging +import time +import traceback +import discord + +from LionModule import LionModule + +from meta import client +from utils.ratelimits import RateLimit + +from ..client import EmptyResponse, request + + +class PluginModule(LionModule): + def cmd(self, name, **kwargs): + # Remove any existing command with this name + for module in client.modules: + for i, cmd in enumerate(module.cmds): + if cmd.name == name: + module.cmds.pop(i) + + return super().cmd(name, **kwargs) + + async def on_exception(self, ctx, exception): + try: + raise exception + except (ConnectionError, EmptyResponse) as e: + full_traceback = traceback.format_exc() + only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only()) + + client.log( + ("Caught a communication exception while " + "executing command '{cmdname}' from module '{module}' " + "from user '{message.author}' (uid:{message.author.id}) " + "in guild '{message.guild}' (gid:{guildid}) " + "in channel '{message.channel}' (cid:{message.channel.id}).\n" + "Message Content:\n" + "{content}\n" + "{traceback}\n\n" + "{flat_ctx}").format( + cmdname=ctx.cmd.name, + module=ctx.cmd.module.name, + message=ctx.msg, + guildid=ctx.guild.id if ctx.guild else None, + content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()), + traceback=full_traceback, + flat_ctx=ctx.flatten() + ), + context="mid:{}".format(ctx.msg.id), + level=logging.ERROR + ) + error_embed = discord.Embed(title="Sorry, something went wrong!") + error_embed.description = ( + "An unexpected error occurred while communicating with our rendering server!\n" + "Our development team has been notified, and the issue should be fixed soon.\n" + ) + if logging.getLogger().getEffectiveLevel() < logging.INFO: + error_embed.add_field( + name="Exception", + value="`{}`".format(only_error) + ) + + await ctx.reply(embed=error_embed) + except Exception: + await super().on_exception(ctx, exception) + + +module = PluginModule("GUI") + +ratelimit = RateLimit(5, 30) + +logging.getLogger('PIL').setLevel(logging.WARNING) + + +@module.launch_task +async def ping_server(client): + start = time.time() + try: + await request('ping') + except Exception: + logging.error( + "Failed to ping the rendering server!", + exc_info=True + ) + else: + end = time.time() + client.log( + f"Rendering server responded in {end-start:.6f} seconds!", + context="GUI INIT", + ) diff --git a/src/modules/pending-rewrite/gui-commands/reloadgui.py b/src/modules/pending-rewrite/gui-commands/reloadgui.py new file mode 100644 index 00000000..811da06d --- /dev/null +++ b/src/modules/pending-rewrite/gui-commands/reloadgui.py @@ -0,0 +1,24 @@ +import importlib +from .. import drawing +from . import goals, leaderboard, stats, tasklist + +from cmdClient import cmd, checks + + +@cmd("reloadgui", + desc="Reload all GUI drawing modules.") +@checks.is_owner() +async def cmd_reload_gui(ctx): + importlib.reload(drawing.goals) + importlib.reload(drawing.leaderboard) + importlib.reload(drawing.profile) + importlib.reload(drawing.stats) + importlib.reload(drawing.tasklist) + importlib.reload(drawing.weekly) + importlib.reload(drawing.monthly) + + importlib.reload(goals) + importlib.reload(leaderboard) + importlib.reload(stats) + importlib.reload(tasklist) + await ctx.reply("GUI plugin reloaded.") diff --git a/src/modules/pending-rewrite/gui-commands/stats.py b/src/modules/pending-rewrite/gui-commands/stats.py new file mode 100644 index 00000000..a1e00232 --- /dev/null +++ b/src/modules/pending-rewrite/gui-commands/stats.py @@ -0,0 +1,278 @@ +import asyncio +import time +from datetime import datetime, timedelta +from cmdClient.checks import in_guild + +from utils.lib import utc_now +from data import tables +from data.conditions import LEQ +from core import Lion +from LionContext import LionContext as Context + +from modules.study.tracking.data import session_history +from modules.stats.achievements import get_achievements_for + +from ...cards import StatsCard, ProfileCard +from ...utils import get_avatar_key, image_as_file + +from ..module import module, ratelimit + + +async def get_stats_card_for(ctx: Context, target): + lion = Lion.fetch(ctx.guild.id, target.id) + + history = session_history.select_where( + guildid=ctx.guild.id, + userid=target.id, + select_columns=( + "start_time", + "(start_time + duration * interval '1 second') AS end_time" + ), + _extra="ORDER BY start_time DESC" + ) + + # Current economy balance (accounting for current session) + workout_total = lion.data.workout_count + + # Leaderboard ranks + exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members) + exclude.update(ctx.client.user_blacklist()) + exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id]) + if target.bot or target.id in exclude: + time_rank = None + coin_rank = None + else: + time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0])) + + # Study time + # First get the all/month/week/day timestamps + day_start = lion.day_start + month_start = day_start.replace(day=1) + period_timestamps = ( + datetime(1970, 1, 1), + month_start, + day_start - timedelta(days=day_start.weekday()), + day_start + ) + study_times = [0, 0, 0, 0] + for i, timestamp in enumerate(period_timestamps): + study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp) + if not study_time: + # So we don't make unecessary database calls + break + study_times[i] = study_time + + # Streak data for the study run view + streaks = [] + + streak = 0 + streak_end = None + date = day_start + daydiff = timedelta(days=1) + + if 'sessions' in ctx.client.objects and lion.session: + day_attended = True + streak_end = day_start.day + else: + day_attended = None + + periods = [(row['start_time'], row['end_time']) for row in history] + + i = 0 + while i < len(periods): + row = periods[i] + i += 1 + if row[1] > date: + # They attended this day + day_attended = True + if streak_end is None: + streak_end = (date - month_start).days + 1 + continue + elif day_attended is None: + # Didn't attend today, but don't break streak + day_attended = False + date -= daydiff + i -= 1 + continue + elif not day_attended: + # Didn't attend the day, streak broken + date -= daydiff + i -= 1 + pass + else: + # Attended the day + streak += 1 + + # Move window to the previous day and try the row again + day_attended = False + prev_date = date + date -= daydiff + i -= 1 + + # Special case, when the last session started in the previous day + # Then the day is already attended + if i > 1 and date < periods[i-2][0] <= prev_date: + day_attended = True + if streak_end is None: + streak_end = (date - month_start).days + 1 + + continue + + if streak_end: + streaks.append((streak_end - streak + 1, streak_end)) + streak_end = None + streak = 0 + if date.month != day_start.month: + break + + # Handle loop exit state, i.e. the last streak + if day_attended: + streak += 1 + streaks.append((streak_end - streak + 1, streak_end)) + + # We have all the data for the stats card + return await StatsCard.request( + (time_rank, coin_rank), + list(reversed(study_times)), + workout_total, + streaks, + skin=StatsCard.skin_args_for(ctx) + ) + + +async def get_profile_card_for(ctx: Context, target): + lion = Lion.fetch(ctx.guild.id, target.id) + + # Current economy balance (accounting for current session) + coins = lion.coins + season_time = lion.time + + # Study League + guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id) + if lion.data.last_study_badgeid: + current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid) + else: + current_badge = None + + next_badge = min( + (badge for badge in guild_badges + if badge.required_time > (current_badge.required_time if current_badge else 0)), + key=lambda badge: badge.required_time, + default=None + ) + if current_badge: + current_rank = ( + role.name if (role := ctx.guild.get_role(current_badge.roleid)) else str(current_badge.roleid), + current_badge.required_time // 3600, + next_badge.required_time // 3600 if next_badge else None + ) + else: + current_rank = None + if next_badge: + next_next_badge = min( + (badge for badge in guild_badges if badge.required_time > next_badge.required_time), + key=lambda badge: badge.required_time, + default=None + ) + next_rank = ( + role.name if (role := ctx.guild.get_role(next_badge.roleid)) else str(next_badge.roleid), + next_badge.required_time // 3600, + next_next_badge.required_time // 3600 if next_next_badge else None + ) + else: + next_rank = None + + achievements = await get_achievements_for(target) + + # We have all the data for the profile card + avatar = get_avatar_key(ctx.client, target.id) + return await ProfileCard.request( + target.name, + '#{}'.format(target.discriminator), + coins, + season_time, + avatar=avatar, + gems=ctx.client.data.gem_transactions.queries.get_gems_for(target.id), + gifts=ctx.client.data.gem_transactions.queries.get_gifts_for(target.id), + achievements=[i for i, ach in enumerate(achievements) if ach.level_id > 0], + current_rank=current_rank, + next_rank=next_rank, + badges=lion.profile_tags, + skin=ProfileCard.skin_args_for(ctx) + ) + + +@module.cmd( + "stats", + group="Statistics", + desc="View your server study statistics!" +) +@in_guild() +@ratelimit.ward(member=False) +async def cmd_stats(ctx): + """ + Usage``: + {prefix}stats + {prefix}stats + Description: + View your study statistics in this server, or those of the mentioned member. + """ + # Identify the target + if ctx.args: + if not ctx.msg.mentions: + return await ctx.error_reply("Please mention a user to view their statistics!") + target = ctx.msg.mentions[0] + else: + target = ctx.author + + # System sync + Lion.sync() + + # Fetch the cards + futures = ( + asyncio.create_task(get_profile_card_for(ctx, target)), + asyncio.create_task(get_stats_card_for(ctx, target)) + ) + await futures[0] + await futures[1] + + profile_image = futures[0].result() + stats_image = futures[1].result() + + profile_file = image_as_file(profile_image, f"profile_{target.id}.png") + stats_file = image_as_file(stats_image, f"stats_{target.id}.png") + + await ctx.reply(files=[profile_file, stats_file]) + + +@module.cmd( + "profile", + group="Statistics", + desc="View your personal study profile!" +) +@in_guild() +@ratelimit.ward(member=False) +async def cmd_profile(ctx): + """ + Usage``: + {prefix}profile + {prefix}profile + Description: + View your server study profile, or that of the mentioned user. + """ + # Identify the target + if ctx.args: + if not ctx.msg.mentions: + return await ctx.error_reply("Please mention a user to view their profile!") + target = ctx.msg.mentions[0] + else: + target = ctx.author + + # System sync + Lion.sync() + + # Fetch the cards + profile_image = await get_profile_card_for(ctx, target) + profile_file = image_as_file(profile_image, f"profile_{target.id}.png") + + await ctx.reply(file=profile_file) diff --git a/src/modules/pending-rewrite/gui-commands/tasklist.py b/src/modules/pending-rewrite/gui-commands/tasklist.py new file mode 100644 index 00000000..8f123490 --- /dev/null +++ b/src/modules/pending-rewrite/gui-commands/tasklist.py @@ -0,0 +1,111 @@ +import asyncio +import discord + +from core import Lion +from meta import client + +from modules.todo.Tasklist import Tasklist as TextTasklist + +from ...cards import TasklistCard +from ...utils import get_avatar_key, image_as_file, edit_files + + +widget_help = """ +Open your interactive tasklist with `{prefix}todo`, \ + then use the following commands to update your tasks. \ + The `` may be given as comma separated numbers and ranges. + +`` Toggle the status (done/notdone) of the provided tasks. +`add/+ ` Add a new TODO `task`. Each line is added as a separate task. +`d/rm/- ` Remove the specified tasks. +`c/check ` Check (mark as done) the specified tasks. +`u/uncheck ` Uncheck (mark incomplete) the specified tasks. +`cancel` Cancel the interactive tasklist mode. + +*You do not need to write `{prefix}todo` before each command when the list is visible.* + +**Examples** +`add Read chapter 1` Add a new task `Read chapter 1`. +`e 0 Notes chapter 1` Edit task `0` to say `Notes chapter 1`. +`d 0, 5-7, 9` Delete tasks `0, 5, 6, 7, 9`. +`0, 2-5, 9` Toggle the completion status of tasks `0, 2, 3, 4, 5, 9`. + +[Click here to jump back]({jump_link}) +""" + + +class GUITasklist(TextTasklist): + async def _format_tasklist(self): + tasks = [ + (i, task.content, bool(task.completed_at)) + for (i, task) in enumerate(self.tasklist) + ] + avatar = get_avatar_key(client, self.member.id) + lion = Lion.fetch(self.member.guild.id, self.member.id) + date = lion.day_start + self.pages = await TasklistCard.request( + self.member.name, + f"#{self.member.discriminator}", + tasks, + date, + avatar=avatar, + badges=lion.profile_tags, + skin=TasklistCard.skin_args_for(guildid=self.member.guild.id, userid=self.member.id) + ) + + return self.pages + + async def _post(self): + pages = self.pages + + message = await self.channel.send(file=image_as_file(pages[self.current_page], "tasklist.png")) + + # Add the reactions + self.has_paging = len(pages) > 1 + for emoji in (self.paged_reaction_order if self.has_paging else self.non_paged_reaction_order): + await message.add_reaction(emoji) + + # Register + if self.message: + self.messages.pop(self.message.id, None) + + self.message = message + self.messages[message.id] = self + + async def _update(self): + if self.show_help: + embed = discord.Embed( + title="Tasklist widget guide", + description=widget_help.format( + prefix=client.prefix, + jump_link=self.message.jump_url + ), + colour=discord.Colour.orange() + ) + try: + await self.member.send(embed=embed) + except discord.Forbidden: + await self.channel.send("Could not send you the guide! Please open your DMs first.") + except discord.HTTPException: + pass + self.show_help = False + await edit_files( + self.message._state.http, + self.channel.id, + self.message.id, + files=[image_as_file(self.pages[self.current_page], "tasklist.png")] + ) + + +# Monkey patch the Tasklist fetch method to conditionally point to the GUI tasklist +# TODO: Config setting for text/gui +@classmethod +def fetch_or_create(cls, ctx, flags, member, channel): + factory = TextTasklist if flags['text'] else GUITasklist + tasklist = GUITasklist.active.get((member.id, channel.id), None) + if type(tasklist) != factory: + tasklist = None + return tasklist if tasklist is not None else factory(member, channel) + + +TextTasklist.fetch_or_create = fetch_or_create diff --git a/src/modules/pending-rewrite/gui-commands/timer.py b/src/modules/pending-rewrite/gui-commands/timer.py new file mode 100644 index 00000000..1d22e04e --- /dev/null +++ b/src/modules/pending-rewrite/gui-commands/timer.py @@ -0,0 +1,169 @@ +import asyncio +import time +import logging +import traceback +from collections import defaultdict + +import discord +from utils.lib import utc_now +from core import Lion +from meta import client + +from modules.study.timers.Timer import Timer + +from ...cards import FocusTimerCard, BreakTimerCard + +from ...utils import get_avatar_key, image_as_file, edit_files, asset_path + + +async def status(self): + stage = self.current_stage + + name = self.data.pretty_name + remaining = int((stage.end - utc_now()).total_seconds()) + duration = int(stage.duration) + next_starts = int(stage.end.timestamp()) + users = [ + (get_avatar_key(client, member.id), + session.duration if (session := Lion.fetch(member.guild.id, member.id).session) else 0, + session.data.tag if session else None) + for member in self.members + ] + if stage.name == 'FOCUS': + card_class = FocusTimerCard + content = f"**Focus!** Session ends ." + else: + card_class = BreakTimerCard + content = f"**Have a rest!** Break finishes ." + + page = await card_class.request( + name, + remaining, + duration, + users=users, + skin=card_class.skin_args_for(guildid=self.data.guildid) + ) + + return { + 'content': content, + 'files': [image_as_file(page, name="timer.png")] + } + + +_guard_delay = 60 +_guarded = {} # timer channel id -> (last_executed_time, currently_waiting) + + +async def guard_request(id): + if (result := _guarded.get(id, None)): + last, currently = result + if currently: + return False + else: + _guarded[id] = (last, True) + await asyncio.sleep(_guard_delay - (time.time() - last)) + _guarded[id] = (time.time(), False) + return True + else: + _guarded[id] = (time.time(), False) + return True + + +async def update_last_status(self): + """ + Update the last posted status message, if it exists. + """ + old_message = self.reaction_message + + if not await guard_request(self.channelid): + return + if old_message != self.reaction_message: + return + + args = await self.status() + repost = True + if self.reaction_message: + try: + await edit_files( + client._connection.http, + self.reaction_message.channel.id, + self.reaction_message.id, + **args + ) + except discord.HTTPException: + pass + else: + repost = False + + if repost and self.text_channel: + try: + self.reaction_message = await self.text_channel.send(**args) + await self.reaction_message.add_reaction('✅') + except discord.HTTPException: + pass + return + + +guild_locks = defaultdict(asyncio.Lock) + + +async def play_alert(channel: discord.VoiceChannel, alert_file): + if not channel.members: + # Don't notify an empty channel + return + + async with guild_locks[channel.guild.id]: + try: + vc = channel.guild.voice_client + if not vc: + vc = await asyncio.wait_for( + channel.connect(timeout=10, reconnect=False), + 20 + ) + elif vc.channel != channel: + await vc.move_to(channel) + except asyncio.TimeoutError: + client.log( + f"Timed out while attempting to connect to '{channel.name}' (cid:{channel.id}) " + f"in '{channel.guild.name}' (gid:{channel.guild.id}).", + context="TIMER_ALERT", + level=logging.WARNING + ) + vc = channel.guild.voice_client + if vc: + await vc.disconnect(force=True) + return + + audio_stream = open(alert_file, 'rb') + try: + vc.play(discord.PCMAudio(audio_stream), after=lambda e: audio_stream.close()) + except discord.HTTPException: + pass + + count = 0 + while vc.is_playing() and count < 10: + await asyncio.sleep(1) + count += 1 + + await vc.disconnect(force=True) + + +async def notify_hook(self, old_stage, new_stage): + try: + if new_stage.name == 'BREAK': + await play_alert(self.channel, asset_path('timer/voice/break_alert.wav')) + else: + await play_alert(self.channel, asset_path('timer/voice/focus_alert.wav')) + except Exception: + full_traceback = traceback.format_exc() + client.log( + f"Caught an unhandled exception while playing timer alert in '{self.channel.name}' (cid:{self.channel.id})" + f" in '{self.channel.guild.name}' (gid:{self.channel.guild.id}).\n" + f"{full_traceback}", + context="TIMER_ALERT", + level=logging.ERROR + ) + +Timer.status = status +Timer.update_last_status = update_last_status +Timer.notify_hook = notify_hook diff --git a/src/modules/pending-rewrite/gui-commands/weekly_test.py b/src/modules/pending-rewrite/gui-commands/weekly_test.py new file mode 100644 index 00000000..0ad63048 --- /dev/null +++ b/src/modules/pending-rewrite/gui-commands/weekly_test.py @@ -0,0 +1,43 @@ +import importlib +from datetime import datetime, timedelta + +from data.conditions import GEQ + +from ..module import module + +from .. import drawing +from ..utils import get_avatar, image_as_file + + +@module.cmd( + 'tasktest' +) +async def cmd_tasktest(ctx): + importlib.reload(drawing.weekly) + WeeklyStatsPage = drawing.weekly.WeeklyStatsPage + + day_start = ctx.alion.day_start + last_week_start = day_start - timedelta(days=7 + day_start.weekday()) + + history = ctx.client.data.session_history.select_where( + guildid=ctx.guild.id, + userid=ctx.author.id, + start_time=GEQ(last_week_start - timedelta(days=1)), + select_columns=( + "start_time", + "(start_time + duration * interval '1 second') AS end_time" + ), + _extra="ORDER BY start_time ASC" + ) + timezone = ctx.alion.timezone + sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history] + + page = WeeklyStatsPage( + ctx.author.name, + f"#{ctx.author.discriminator}", + sessions, + day_start + ) + image = page.draw() + + await ctx.reply(file=image_as_file(image, 'weekly_stats.png'))