rewrite: Integrate GUI plugin.

This commit is contained in:
2022-12-23 20:34:58 +02:00
parent ee1abcacea
commit b777bd33e4
15 changed files with 13 additions and 4880 deletions

View File

@@ -3,7 +3,8 @@ cachetools==4.2.2
configparser==5.0.2
discord.py=2.0.1
iso8601==0.1.16
psycopg2==2.9.1
psycopg
pytz==2021.1
topggpy
psutil
pillow

View File

@@ -1,6 +1,13 @@
# !/bin/python3
import sys
import os
import asyncio
from src.server import main
sys.path.insert(0, os.path.join(os.getcwd()))
sys.path.insert(0, os.path.join(os.getcwd(), "src"))
if __name__ == '__main__':
from gui.server import main
asyncio.run(main())

Submodule src/gui updated: fd34ea70d6...4f3d5740a3

View File

@@ -1,8 +0,0 @@
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

View File

@@ -1,752 +0,0 @@
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()
}

View File

@@ -1,504 +0,0 @@
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
}

View File

@@ -1,143 +0,0 @@
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

View File

@@ -1,750 +0,0 @@
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)
}

View File

@@ -1,623 +0,0 @@
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),
}

View File

@@ -1,508 +0,0 @@
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)
}

View File

@@ -1,389 +0,0 @@
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'
),
}

View File

@@ -1,38 +0,0 @@
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)

View File

@@ -1,449 +0,0 @@
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"),
]
}

View File

@@ -1,713 +0,0 @@
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)
}

View File

@@ -581,8 +581,10 @@ class TasklistUI(BasePager):
await self.redraw()
async def delete_selector_refresh(self):
t = self.bot.translator.t
self.delete_selector.placeholder = t(_p('ui:tasklist|menu:delete|placeholder', "Select to Delete"))
self.delete_selector.options = self.toggle_selector.options
self.delete_selector.max_values = len(self.toggle_selector.options)
@button(label="ClOSE_PLACEHOLDER", style=ButtonStyle.red)
async def close_pressed(self, interaction: discord.Interaction, pressed: Button):