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' ), }