rewrite: Move GUI cards into main repo.

This commit is contained in:
2022-12-23 07:02:26 +02:00
parent f328324747
commit 1cef97cdd4
20 changed files with 6110 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
from .stats import StatsCard
from .profile import ProfileCard
from .goals import WeeklyGoalCard, MonthlyGoalCard
from .monthly import MonthlyStatsCard
from .weekly import WeeklyStatsCard
from .tasklist import TasklistCard
from .leaderboard import LeaderboardCard
from .timer import BreakTimerCard, FocusTimerCard

View File

@@ -0,0 +1,752 @@
import math
import datetime
from io import BytesIO
from PIL import Image, ImageDraw, ImageOps, ImageColor
from ..base import Card, Layout, fielded, Skin, FieldDesc
from ..base.Avatars import avatar_manager
from ..base.Skin import (
AssetField, RGBAAssetField, BlobField, AssetPathField, StringField, NumberField,
FontField, ColourField, PointField, ComputedField
)
from ..utils import get_avatar_key
from .mixins import MiniProfileLayout
@fielded
class _GoalSkin(Skin):
_env = {
'scale': 2 # General size scale to match background resolution
}
background: AssetField = "goals/background.png"
help_frame: AssetField = None
# Title section
title_pre_gap: NumberField = 40
title_text: StringField = ""
title_font: FontField = ('ExtraBold', 76)
title_size: ComputedField = lambda skin: skin.title_font.getsize(skin.title_text)
title_colour: ColourField = '#DDB21D'
title_underline_gap: NumberField = 10
title_underline_width: NumberField = 0
title_gap: NumberField = 50
# Profile section
mini_profile_indent: NumberField = 125
mini_profile_size: ComputedField = lambda skin: (
skin.background.width - 2 * skin.mini_profile_indent,
int(skin._env['scale'] * 200)
)
mini_profile_avatar_mask: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_mask.png', convert=None)
mini_profile_avatar_frame: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_frame.png', convert='RGBA')
mini_profile_avatar_sep: NumberField = 50
mini_profile_name_font: FontField = ('BoldItalic', 55)
mini_profile_name_colour: ColourField = '#DDB21D'
mini_profile_discrim_font: FontField = mini_profile_name_font
mini_profile_discrim_colour: ColourField = '#BABABA'
mini_profile_name_gap: NumberField = 20
mini_profile_badge_end: AssetField = "mini-profile/badge_end.png"
mini_profile_badge_font: FontField = ('Black', 30)
mini_profile_badge_colour: ColourField = '#FFFFFF'
mini_profile_badge_text_colour: ColourField = '#051822'
mini_profile_badge_gap: NumberField = 20
mini_profile_badge_min_sep: NumberField = 10
# Progress bars
progress_mask: RGBAAssetField = 'goals/progressbar_mask.png'
progress_bg_colour: ColourField = '#273341'
progress_bg: BlobField = FieldDesc(
BlobField,
mask_field='progress_mask',
colour_field='progress_bg_colour',
colour_override_field=None
)
progress_end: RGBAAssetField = 'goals/progressbar_end_mask.png'
progress_colour: ColourField = '#6CB7D0'
progress_colour_override: ColourField = None
progress_full: BlobField = FieldDesc(
BlobField,
mask_field='progress_mask',
colour_field='progress_colour',
colour_override_field='progress_colour_override'
)
line_gap: NumberField = 5
progress_text_at: ComputedField = lambda skin: 7 * (skin.progress_bg.height // 10)
task_count_font: FontField = ('Bold', 76)
task_count_colour: ColourField = '#DDB21D'
task_done_font: FontField = ('Bold', 37)
task_done_colour: ColourField = '#FFFFFF'
task_goal_font: FontField = ('Bold', 27)
task_goal_colour: ColourField = '#FFFFFF'
task_goal_number_font: FontField = ('Light', 27)
task_goal_number_colour: ColourField = '#FFFFFF'
task_text_size: ComputedField = lambda skin: (
skin.task_count_font.getsize("00")[0]
+ skin.task_done_font.getsize("TASKS DONE")[0]
+ skin.task_goal_font.getsize("GOAL")[0]
+ 3 * skin.line_gap,
skin.task_done_font.getsize("TASKS DONE")[1]
)
task_progress_text_height: ComputedField = lambda skin: (
skin.task_count_font.getsize('100')[1] +
skin.task_done_font.getsize('TASKS DONE')[1] +
skin.task_goal_font.getsize('GOAL')[1] +
2 * skin.line_gap
)
attendance_rate_font: FontField = ('Bold', 76)
attendance_rate_colour: ColourField = '#DDB21D'
attendance_font: FontField = ('Bold', 37)
attendance_colour: ColourField = '#FFFFFF'
attendance_text_height: ComputedField = lambda skin: (
skin.attendance_rate_font.getsize('100%')[1] +
skin.attendance_font.getsize('ATTENDANCE')[1] * 2 +
2 * skin.line_gap
)
studied_text_font: FontField = ('Bold', 37)
studied_text_colour: ColourField = '#FFFFFF'
studied_hour_font: FontField = ('Bold', 60)
studied_hour_colour: ColourField = '#DDB21D'
studied_text_height: ComputedField = lambda skin: (
skin.studied_text_font.getsize('STUDIED')[1] * 2
+ skin.studied_hour_font.getsize('400')[1]
+ 2 * skin.line_gap
)
progress_gap: NumberField = 50
# Tasks
task_frame: AssetField = "goals/task_frame.png"
task_margin: PointField = (100, 50)
task_column_sep: NumberField = 100
task_header: StringField = ""
task_header_font: FontField = ('Black', 50)
task_header_colour: ColourField = '#DDB21D'
task_header_gap: NumberField = 25
task_underline_gap: NumberField = 10
task_underline_width: NumberField = 5
task_done_number_bg: AssetField = "goals/task_done.png"
task_done_number_font: FontField = ('Regular', 28)
task_done_number_colour: ColourField = '#292828'
task_done_text_font: FontField = ('Regular', 35)
task_done_text_colour: ColourField = '#686868'
task_done_line_width: NumberField = FieldDesc(NumberField, 7, scaled=False)
task_undone_number_bg: AssetField = "goals/task_undone.png"
task_undone_number_font: FontField = ('Regular', 28)
task_undone_number_colour: ColourField = '#FFFFFF'
task_undone_text_font: FontField = ('Regular', 35)
task_undone_text_colour: ColourField = '#FFFFFF'
task_text_height: ComputedField = lambda skin: skin.task_done_text_font.getsize('TASK')[1]
task_num_sep: NumberField = 15
task_inter_gap: NumberField = 25
# Date text
footer_pre_gap: NumberField = 25
footer_font: FontField = ('Bold', 28)
footer_colour: ColourField = '#6f6e6f'
footer_gap: NumberField = 50
@fielded
class WeeklyGoalSkin(_GoalSkin):
title_text: StringField = "WEEKLY STATISTICS"
task_header: StringField = "GOALS OF THE WEEK"
help_frame: AssetField = "weekly/help_frame.png"
@fielded
class MonthlyGoalSkin(_GoalSkin):
title_text: StringField = "MONTHLY STATISTICS"
task_header: StringField = "GOALS OF THE MONTH"
help_frame: AssetField = "monthly/help_frame.png"
class GoalPage(Layout, MiniProfileLayout):
def __init__(self, skin,
name, discrim, avatar, badges,
tasks_done, studied_hours, attendance,
tasks_goal, studied_goal, goals,
date):
self.skin = skin
self.data_name = name
self.data_discrim = discrim
self.data_avatar = avatar
self.data_badges = badges
self.data_tasks_done = tasks_done
self.data_studied_hours = studied_hours
self.data_attendance = attendance
self.data_tasks_goal = tasks_goal
self.data_studied_goal = studied_goal
self.data_goals = goals
self.data_date = date
self.image = None
def draw(self) -> Image:
image = self.skin.background
draw = ImageDraw.Draw(image)
xpos, ypos = 0, 0
# Draw header text
xpos = (image.width - self.skin.title_size[0]) // 2
ypos += self.skin.title_pre_gap
draw.text(
(xpos, ypos),
self.skin.title_text,
fill=self.skin.title_colour,
font=self.skin.title_font
)
# Underline it
title_size = self.skin.title_font.getsize(self.skin.title_text)
ypos += title_size[1] + self.skin.title_gap
# ypos += title_size[1] + self.skin.title_underline_gap
# draw.line(
# (xpos, ypos, xpos + title_size[0], ypos),
# fill=self.skin.title_colour,
# width=self.skin.title_underline_width
# )
# ypos += self.skin.title_underline_width + self.skin.title_gap
# Draw the profile
xpos = self.skin.mini_profile_indent
profile = self._draw_profile()
image.alpha_composite(
profile,
(xpos, ypos)
)
# Start from the bottom
ypos = image.height
# Draw the date text
ypos -= self.skin.footer_gap
date_text = self.data_date.strftime("As of %d %b • {} {}".format(self.data_name, self.data_discrim))
size = self.skin.footer_font.getsize(date_text)
ypos -= size[1]
draw.text(
((image.width - size[0]) // 2, ypos),
date_text,
font=self.skin.footer_font,
fill=self.skin.footer_colour
)
ypos -= self.skin.footer_pre_gap
if self.data_goals or self.data_tasks_goal or self.data_studied_goal:
# Draw the tasks
task_image = self._draw_tasks()
ypos -= task_image.height
image.alpha_composite(
task_image,
((image.width - task_image.width) // 2, ypos)
)
# Draw the progress bars
progress_image = self._draw_progress()
ypos -= progress_image.height + self.skin.progress_gap
image.alpha_composite(
progress_image,
((image.width - progress_image.width) // 2, ypos)
)
else:
ypos -= self.skin.help_frame.height
image.alpha_composite(
self.skin.help_frame,
((image.width - self.skin.help_frame.width) // 2, ypos)
)
self.image = image
return image
def _draw_tasks(self):
image = self.skin.task_frame
draw = ImageDraw.Draw(image)
# Task container is smaller than frame
xpos, ypos = self.skin.task_margin
# Draw header text
draw.text(
(xpos, ypos),
self.skin.task_header,
fill=self.skin.task_header_colour,
font=self.skin.task_header_font
)
# Underline it
title_size = self.skin.task_header_font.getsize(self.skin.task_header)
ypos += title_size[1] + self.skin.task_underline_gap
draw.line(
(xpos, ypos, xpos + title_size[0], ypos),
fill=self.skin.task_header_colour,
width=self.skin.task_underline_width
)
ypos += self.skin.task_underline_width + self.skin.task_header_gap
if len(self.data_goals) > 5:
# Split remaining space into two boxes
task_box_1 = Image.new(
'RGBA',
(image.width // 2 - self.skin.task_margin[0] - self.skin.task_column_sep // 2,
image.height - ypos)
)
task_box_2 = Image.new(
'RGBA',
(image.width // 2 - self.skin.task_margin[0] - self.skin.task_column_sep // 2,
image.height - ypos)
)
self._draw_tasks_into(self.data_goals[:5], task_box_1)
self._draw_tasks_into(self.data_goals[5:], task_box_2)
image.alpha_composite(
task_box_1,
(xpos, ypos)
)
image.alpha_composite(
task_box_2,
(xpos + task_box_1.width + self.skin.task_column_sep, ypos)
)
else:
task_box = Image.new(
'RGBA',
(image.width - 2 * self.skin.task_margin[0], image.height)
)
self._draw_tasks_into(self.data_goals, task_box)
image.alpha_composite(
task_box,
(xpos, ypos)
)
return image
def _draw_progress(self):
image = Image.new('RGBA', (self.skin.background.width, self.skin.progress_bg.height))
sep = (self.skin.background.width - 3 * self.skin.progress_bg.width) // 4
xpos = sep
image.alpha_composite(
self._draw_task_progress(),
(xpos, 0)
)
xpos += self.skin.progress_bg.width + sep
image.alpha_composite(
self._draw_study_progress(),
(xpos, 0)
)
xpos += self.skin.progress_bg.width + sep
image.alpha_composite(
self._draw_attendance(),
(xpos, 0)
)
return image
def _draw_task_progress(self):
if not self.data_tasks_goal:
amount = 1
else:
amount = self.data_tasks_done / self.data_tasks_goal
progress_image = self._draw_progress_bar(amount)
draw = ImageDraw.Draw(progress_image)
# Draw text into the bar
ypos = self.skin.progress_text_at - self.skin.task_progress_text_height
xpos = progress_image.width // 2
text = str(self.data_tasks_done)
draw.text(
(xpos, ypos),
text,
font=self.skin.task_count_font,
fill=self.skin.task_count_colour,
anchor='mt'
)
ypos += self.skin.task_count_font.getsize(text)[1] + self.skin.line_gap
text = "TASKS DONE"
draw.text(
(xpos, ypos),
text,
font=self.skin.task_done_font,
fill=self.skin.task_done_colour,
anchor='mt'
)
ypos += self.skin.task_done_font.getsize(text)[1] + self.skin.line_gap
text1 = "GOAL: "
length1 = self.skin.task_goal_font.getlength(text1)
text2 = str(self.data_tasks_goal) if self.data_tasks_goal else "N/A"
length2 = self.skin.task_goal_number_font.getlength(text2)
draw.text(
(xpos - length2 // 2, ypos),
text1,
font=self.skin.task_goal_font,
fill=self.skin.task_goal_colour,
anchor='mt'
)
draw.text(
(xpos + length1 // 2, ypos),
text2,
font=self.skin.task_goal_number_font,
fill=self.skin.task_goal_number_colour,
anchor='mt'
)
return progress_image
def _draw_study_progress(self):
if not self.data_studied_goal:
amount = 1
else:
amount = self.data_studied_hours / self.data_studied_goal
progress_image = self._draw_progress_bar(amount)
draw = ImageDraw.Draw(progress_image)
ypos = self.skin.progress_text_at - self.skin.studied_text_height
xpos = progress_image.width // 2
text = "STUDIED"
draw.text(
(xpos, ypos),
text,
font=self.skin.studied_text_font,
fill=self.skin.studied_text_colour,
anchor='mt'
)
ypos += self.skin.studied_text_font.getsize(text)[1] + self.skin.line_gap
if self.data_studied_goal:
text = f"{self.data_studied_hours}/{self.data_studied_goal}"
else:
text = str(self.data_studied_hours)
draw.text(
(xpos, ypos),
text,
font=self.skin.studied_hour_font,
fill=self.skin.studied_hour_colour,
anchor='mt'
)
ypos += self.skin.studied_hour_font.getsize(text)[1] + self.skin.line_gap
text = "HOURS"
draw.text(
(xpos, ypos),
text,
font=self.skin.studied_text_font,
fill=self.skin.studied_text_colour,
anchor='mt'
)
return progress_image
def _draw_attendance(self):
amount = self.data_attendance or 0
progress_image = self._draw_progress_bar(amount)
draw = ImageDraw.Draw(progress_image)
ypos = self.skin.progress_text_at - self.skin.attendance_text_height
xpos = progress_image.width // 2
if self.data_attendance is not None:
text = f"{int(self.data_attendance * 100)}%"
else:
text = "N/A"
draw.text(
(xpos, ypos),
text,
font=self.skin.attendance_rate_font,
fill=self.skin.attendance_rate_colour,
anchor='mt'
)
ypos += self.skin.attendance_rate_font.getsize(text)[1] + self.skin.line_gap
text = "ATTENDANCE"
draw.text(
(xpos, ypos),
text,
font=self.skin.attendance_font,
fill=self.skin.attendance_colour,
anchor='mt'
)
ypos += self.skin.attendance_font.getsize(text)[1] + self.skin.line_gap
text = "RATE"
draw.text(
(xpos, ypos),
text,
font=self.skin.attendance_font,
fill=self.skin.attendance_colour,
anchor='mt'
)
return progress_image
def _draw_tasks_into(self, tasks, image) -> Image:
"""
Draw as many tasks as possible into the given image background.
"""
draw = ImageDraw.Draw(image)
xpos, ypos = 0, 0
for n, task, done in tasks:
# Draw task first to check if it fits on the page
task_image = self._draw_text(
task,
image.width - xpos - self.skin.task_done_number_bg.width - self.skin.task_num_sep,
done
)
if task_image.height + ypos > image.height:
break
# Draw number background
bg = self.skin.task_done_number_bg if done else self.skin.task_undone_number_bg
image.alpha_composite(
bg,
(xpos, ypos)
)
# Draw number
font = self.skin.task_done_number_font if done else self.skin.task_undone_number_font
colour = self.skin.task_done_number_colour if done else self.skin.task_undone_number_colour
draw.text(
(xpos + bg.width // 2, ypos + bg.height // 2),
str(n),
fill=colour,
font=font,
anchor='mm'
)
# Draw text
image.alpha_composite(
task_image,
(xpos + bg.width + self.skin.task_num_sep, ypos - (bg.height - self.skin.task_text_height) // 2)
)
ypos += task_image.height + self.skin.task_inter_gap
return image
def _draw_text(self, task, maxwidth, done) -> Image:
"""
Draw the text of a given task.
"""
font = self.skin.task_done_text_font if done else self.skin.task_undone_text_font
colour = self.skin.task_done_text_colour if done else self.skin.task_undone_text_colour
size = font.getsize(task)
image = Image.new('RGBA', (min(size[0], maxwidth), size[1]))
draw = ImageDraw.Draw(image)
draw.text((0, 0), task, font=font, fill=colour)
if done:
# Also strikethrough
y = 0
x1, y1, x2, y2 = font.getbbox(task)
draw.line(
(x1, y + y1 + (y2 - y1) // 2, x2, y + y1 + (y2 - y1) // 2),
fill=self.skin.task_done_text_colour,
width=self.skin.task_done_line_width
)
return image
def _draw_progress_bar(self, amount):
amount = min(amount, 1)
amount = max(amount, 0)
end = self.skin.progress_end
mask = self.skin.progress_mask
center = (
mask.width // 2 + 1,
mask.height // 2
)
radius = 2 * 158
theta = amount * math.pi * 2 - math.pi / 2
x = int(center[0] + radius * math.cos(theta))
y = int(center[1] + radius * math.sin(theta))
canvas = Image.new('RGBA', size=(mask.width, mask.height))
draw = ImageDraw.Draw(canvas)
if amount >= 0.01:
canvas.alpha_composite(
end,
(
center[0] - end.width // 2,
30 - end.height // 2
)
)
canvas.alpha_composite(
end,
(
x - end.width // 2,
y - end.height // 2
)
)
sidelength = mask.width // 2
line_ends = (
int(center[0] + sidelength * math.cos(theta)),
int(center[1] + sidelength * math.sin(theta))
)
if amount <= 0.25:
path = [
center,
(center[0], center[1] - sidelength),
(mask.width, 0),
line_ends
]
elif amount <= 0.5:
path = [
center,
(center[0], center[1] - sidelength),
(mask.width, 0),
(mask.width, mask.height),
line_ends
]
elif amount <= 0.75:
path = [
center,
(center[0], center[1] - sidelength),
(mask.width, 0),
(mask.width, mask.height),
(0, mask.height),
line_ends
]
else:
path = [
center,
(center[0], center[1] - sidelength),
(mask.width, 0),
(mask.width, mask.height),
(0, mask.height),
(0, 0),
line_ends
]
draw.polygon(
path,
fill='#FFFFFF'
)
# canvas.paste((0, 0, 0, 0), mask=mask)
image = Image.composite(
self.skin.progress_full,
self.skin.progress_bg.copy(),
canvas
)
else:
image = self.skin.progress_bg.copy()
return image
class _GoalCard(Card):
layout = GoalPage
@classmethod
async def card_route(cls, runner, args, kwargs):
kwargs['avatar'] = await avatar_manager().get_avatar(*kwargs['avatar'], 256)
return await super().card_route(runner, args, kwargs)
@classmethod
def _execute(cls, *args, **kwargs):
with BytesIO(kwargs['avatar']) as image_data:
with Image.open(image_data).convert('RGBA') as avatar_image:
kwargs['avatar'] = avatar_image
return super()._execute(*args, **kwargs)
class WeeklyGoalCard(_GoalCard):
route = "weekly_goal_card"
card_id = "weekly_goals"
skin = WeeklyGoalSkin
display_name = "Weekly Goals"
@classmethod
async def sample_args(cls, ctx, **kwargs):
return {
'name': ctx.author.name if ctx else 'John Doe',
'discrim': ('#' + ctx.author.discriminator) if ctx else '#0000',
'avatar': get_avatar_key(ctx.client, ctx.author.id) if ctx else (0, None),
'badges': (
'STUDYING: MEDICINE',
'HOBBY: MATHS',
'CAREER: STUDENT',
'FROM: EUROPE',
'LOVES CATS <3'
),
'tasks_done': 100,
'tasks_goal': 300,
'studied_hours': 16,
'studied_goal': 48,
'attendance': 0.9,
'goals': [(0, 'Write a 200 page thesis', False),
(1, 'Feed the kangaroo', True),
(2, 'Cure world hunger', False),
(3, 'Finish assignment 2', True)],
'date': datetime.datetime.now()
}
class MonthlyGoalCard(_GoalCard):
route = "monthly_goal_card"
card_id = "monthly_goals"
skin = MonthlyGoalSkin
display_name = "Monthly Goals"
@classmethod
async def sample_args(cls, ctx, **kwargs):
return {
'name': ctx.author.name if ctx else 'John Doe',
'discrim': ('#' + ctx.author.discriminator) if ctx else '#0000',
'avatar': get_avatar_key(ctx.client, ctx.author.id) if ctx else (0, None),
'badges': (
'STUDYING: MEDICINE',
'HOBBY: MATHS',
'CAREER: STUDENT',
'FROM: EUROPE',
'LOVES CATS <3'
),
'tasks_done': 400,
'tasks_goal': 1200,
'studied_hours': 64,
'studied_goal': 128,
'attendance': 0.95,
'goals': [(0, 'Meet 10 new people', False),
(1, 'Feed the lion', False),
(2, 'Realise I am awesome', False),
(3, 'Never going to give you up', True)],
'date': datetime.datetime.now()
}

View File

@@ -0,0 +1,504 @@
import asyncio
from io import BytesIO
from PIL import Image, ImageDraw
from ..base import Card, Layout, fielded, Skin, FieldDesc
from ..base.Avatars import avatar_manager
from ..base.Skin import (
AssetField, RGBAAssetField, AssetPathField, BlobField, StringField, NumberField,
FontField, ColourField, ComputedField
)
class LeaderboardEntry:
__slots__ = (
'userid',
'position',
'time',
'name',
'avatar_key',
'image'
)
def __init__(self, userid, position, time, name, avatar_key):
self.userid = userid
self.position = position
self.time = time
self.name = name
self.name = ''.join(i if ord(i) < 128 or i == '' else '*' for i in self.name)
self.avatar_key = avatar_key
self.image = None
async def get_avatar(self):
if not self.image:
self.image = await avatar_manager().get_avatar(
*self.avatar_key,
size=512 if self.position in (1, 2, 3) else 256
)
def convert_avatar(self):
if self.image:
with BytesIO(self.image) as data:
self.image = Image.open(data).convert('RGBA')
@fielded
class LeaderboardSkin(Skin):
_env = {
'scale': 2 # General size scale to match background resolution
}
header_text_pre_gap: NumberField = 20
header_text: StringField = "STUDY TIME LEADERBOARD"
header_text_font: FontField = ('ExtraBold', 80)
header_text_size: ComputedField = lambda skin: skin.header_text_font.getsize(skin.header_text)
header_text_colour: ColourField = '#DDB21D'
header_text_gap: NumberField = 15
header_text_line_width: NumberField = 0
header_text_line_gap: NumberField = 20
subheader_name_font: FontField = ('SemiBold', 27)
subheader_name_colour: ColourField = '#FFFFFF'
subheader_value_font: FontField = ('Regular', 27)
subheader_value_colour: ColourField = '#FFFFFF'
header_gap: NumberField = 20
# First page constants
first_bg_path: AssetPathField = "leaderboard/first_page_background.png"
header_bg_gap: NumberField = 20
first_header_height: NumberField = 694
first_avatar_mask: RGBAAssetField = "leaderboard/medal_avatar_mask.png"
first_avatar_bg: RGBAAssetField = "leaderboard/first_avatar_background.png"
second_avatar_bg: RGBAAssetField = "leaderboard/second_avatar_background.png"
third_avatar_bg: RGBAAssetField = "leaderboard/third_avatar_background.png"
first_avatar_gap: NumberField = 20
first_top_gap: NumberField = 20
top_position_font: FontField = ('Bold', 30)
top_position_colour: ColourField = '#FFFFFF'
top_name_font: FontField = ('Bold', 30)
top_name_colour: ColourField = '#DDB21D'
top_hours_font: FontField = ('Medium', 30)
top_hours_colour: ColourField = '#FFFFFF'
top_text_sep: NumberField = 5
# Other page constants
other_bg_path: AssetPathField = "leaderboard/other_page_background.png"
other_header_height: NumberField = 276
other_header_gap: NumberField = 20
# Entry constants
entry_position_font: FontField = ("SemiBold", 45)
entry_position_colour: ColourField = '#FFFFFF'
entry_position_highlight_colour: ColourField = '#FFFFFF'
entry_name_highlight_colour: ColourField = '#FFFFFF'
entry_hours_highlight_colour: ColourField = '#FFFFFF'
entry_name_font: FontField = ("SemiBold", 45)
entry_name_colour: ColourField = '#FFFFFF'
entry_hours_font: FontField = ("SemiBold", 45)
entry_hours_colour: ColourField = '#FFFFFF'
entry_position_at: NumberField = 200
entry_name_at: NumberField = 300
entry_time_at: NumberField = -150
entry_mask: AssetField = "leaderboard/entry_avatar_mask.png"
entry_bg_mask: AssetField = "leaderboard/entry_bg_mask.png"
entry_bg_colour: ColourField = "#162D3C"
entry_bg_highlight_colour: ColourField = "#0D4865"
entry_bg: BlobField = FieldDesc(
BlobField,
mask_field='entry_bg_mask',
colour_field='entry_bg_colour',
colour_override_field=None
)
entry_highlight_bg: BlobField = FieldDesc(
BlobField,
mask_field='entry_bg_mask',
colour_field='entry_bg_highlight_colour',
colour_override_field=None
)
entry_gap: NumberField = 13
class LeaderboardPage(Layout):
def __init__(self, skin, server_name, entries, highlight=None):
self.skin = skin
self.server_name = server_name
self.entries = entries
self.highlight = highlight
self.first_page = any(entry.position in (1, 2, 3) for entry in entries)
self.image = None
def draw(self) -> Image:
if self.first_page:
self.image = self._draw_first_page()
else:
self.image = self._draw_other_page()
return self.image
def _draw_first_page(self) -> Image:
# Collect background
image = Image.open(self.skin.first_bg_path)
draw = ImageDraw.Draw(image)
xpos, ypos = 0, 0
# Draw the header text
ypos += self.skin.header_text_pre_gap
header = self._draw_header_text()
image.alpha_composite(
header,
(xpos + (image.width // 2 - header.width // 2),
ypos)
)
ypos += header.height + self.skin.header_gap
# Draw the top 3
first_entry = self.entries[0]
first = self._draw_first(first_entry, level=1)
first_x = (image.width - first.width) // 2
image.alpha_composite(
first,
(first_x, ypos)
)
first_text_y = ypos + first.height + self.skin.first_top_gap
text_y = first_text_y
text_x = first_x + (first.width // 2)
draw.text(
(text_x, text_y),
'1ST',
font=self.skin.top_position_font,
fill=self.skin.top_position_colour,
anchor='mt'
)
text_y += self.skin.top_name_font.getsize('1ST')[1] + self.skin.top_text_sep
draw.text(
(text_x, text_y),
first_entry.name,
font=self.skin.top_name_font,
fill=self.skin.top_name_colour,
anchor='mt'
)
text_y += self.skin.top_name_font.getsize(first_entry.name)[1] + self.skin.top_text_sep
draw.text(
(text_x, text_y),
"{} hours".format(first_entry.time // 3600),
font=self.skin.top_hours_font,
fill=self.skin.top_hours_colour,
anchor='mt'
)
if len(self.entries) >= 2:
second_entry = self.entries[1]
second = self._draw_first(second_entry, level=2)
second_x = image.width // 4 - second.width // 2
image.alpha_composite(
second,
(
second_x,
ypos + (first.height - second.height) // 2
)
)
text_y = first_text_y
text_x = second_x + (second.width // 2)
draw.text(
(text_x, text_y),
'2ND',
font=self.skin.top_position_font,
fill=self.skin.top_position_colour,
anchor='mt'
)
text_y += self.skin.top_name_font.getsize('2ND')[1] + self.skin.top_text_sep
draw.text(
(text_x, text_y),
second_entry.name,
font=self.skin.top_name_font,
fill=self.skin.top_name_colour,
anchor='mt'
)
text_y += self.skin.top_name_font.getsize(second_entry.name)[1] + self.skin.top_text_sep
draw.text(
(text_x, text_y),
"{} hours".format(second_entry.time // 3600),
font=self.skin.top_hours_font,
fill=self.skin.top_hours_colour,
anchor='mt'
)
if len(self.entries) >= 3:
third_entry = self.entries[2]
third = self._draw_first(third_entry, level=3)
third_x = 3 * image.width // 4 - third.width // 2
image.alpha_composite(
third,
(
third_x,
ypos + (first.height - third.height) // 2
)
)
text_y = first_text_y
text_x = third_x + (third.width // 2)
draw.text(
(text_x, text_y),
'3RD',
font=self.skin.top_position_font,
fill=self.skin.top_position_colour,
anchor='mt'
)
text_y += self.skin.top_name_font.getsize('3ND')[1] + self.skin.top_text_sep
draw.text(
(text_x, text_y),
third_entry.name,
font=self.skin.top_name_font,
fill=self.skin.top_name_colour,
anchor='mt'
)
text_y += self.skin.top_name_font.getsize(third_entry.name)[1] + self.skin.top_text_sep
draw.text(
(text_x, text_y),
"{} hours".format(third_entry.time // 3600),
font=self.skin.top_hours_font,
fill=self.skin.top_hours_colour,
anchor='mt'
)
# Draw the entries
xpos = (image.width - self.skin.entry_bg_mask.width) // 2
ypos = self.skin.first_header_height + self.skin.header_bg_gap
for entry in self.entries[3:]:
entry_image = self._draw_entry(
entry,
highlight=self.highlight and (entry.position == self.highlight)
)
image.alpha_composite(
entry_image,
(xpos, ypos)
)
ypos += self.skin.entry_bg_mask.height + self.skin.entry_gap
return image
def _draw_other_page(self) -> Image:
# Collect background
image = Image.open(self.skin.other_bg_path).convert('RGBA')
# Draw header onto background
header = self._draw_header_text()
image.alpha_composite(
header,
(
(image.width - header.width) // 2,
(self.skin.other_header_height - header.height) // 2
)
)
# Draw the entries
xpos = (image.width - self.skin.entry_bg.width) // 2
ypos = (
image.height - 10 * self.skin.entry_bg.height - 9 * self.skin.entry_gap
+ self.skin.other_header_height - self.skin.other_header_gap
) // 2
for entry in self.entries:
entry_image = self._draw_entry(
entry,
highlight=self.highlight and (entry.position == self.highlight)
)
image.alpha_composite(
entry_image,
(xpos, ypos)
)
ypos += self.skin.entry_bg.height + self.skin.entry_gap
return image
def _draw_entry(self, entry, highlight=False) -> Image:
# Get the appropriate background
image = (self.skin.entry_bg if not highlight else self.skin.entry_highlight_bg).copy()
draw = ImageDraw.Draw(image)
ypos = image.height // 2
# Mask the avatar, if it exists
avatar = entry.image
avatar.thumbnail((187, 187))
avatar.paste((0, 0, 0, 0), mask=self.skin.entry_mask)
# Paste avatar onto image
image.alpha_composite(avatar, (0, 0))
# Write position
draw.text(
(self.skin.entry_position_at, ypos),
str(entry.position),
fill=self.skin.entry_position_highlight_colour if highlight else self.skin.entry_position_colour,
font=self.skin.entry_position_font,
anchor='mm'
)
# Write name
draw.text(
(self.skin.entry_name_at, ypos),
entry.name,
fill=self.skin.entry_name_highlight_colour if highlight else self.skin.entry_name_colour,
font=self.skin.entry_name_font,
anchor='lm'
)
# Write time
time_str = "{:02d}:{:02d}".format(
entry.time // 3600,
(entry.time % 3600) // 60
)
draw.text(
(image.width + self.skin.entry_time_at, ypos),
time_str,
fill=self.skin.entry_hours_highlight_colour if highlight else self.skin.entry_hours_colour,
font=self.skin.entry_hours_font,
anchor='mm'
)
return image
def _draw_first(self, entry, level) -> Image:
if level == 1:
image = self.skin.first_avatar_bg
elif level == 2:
image = self.skin.second_avatar_bg
elif level == 3:
image = self.skin.third_avatar_bg
# Retrieve and mask avatar
avatar = entry.image
avatar.paste((0, 0, 0, 0), mask=self.skin.first_avatar_mask)
# Resize for background with gap
dest_width = image.width - 2 * self.skin.first_avatar_gap
avatar.thumbnail((dest_width, dest_width))
# Paste on the background
image.alpha_composite(
avatar.convert('RGBA'),
(
(image.width - avatar.width) // 2,
image.height - self.skin.first_avatar_gap - avatar.height)
)
return image
def _draw_header_text(self) -> Image:
image = Image.new(
'RGBA',
(self.skin.header_text_size[0],
self.skin.header_text_size[1] + self.skin.header_text_gap + self.skin.header_text_line_width
+ self.skin.header_text_line_gap
+ self.skin.subheader_name_font.getsize("THIS MONTHghjyp")[1]),
)
draw = ImageDraw.Draw(image)
xpos, ypos = 0, 0
# Draw the top text
draw.text(
(0, 0),
self.skin.header_text,
font=self.skin.header_text_font,
fill=self.skin.header_text_colour
)
ypos += self.skin.header_text_size[1] + self.skin.header_text_gap
# Draw the underline
# draw.line(
# (xpos, ypos,
# xpos + self.skin.header_text_size[0], ypos),
# fill=self.skin.header_text_colour,
# width=self.skin.header_text_line_width
# )
# ypos += self.skin.header_text_line_gap
# Draw the subheader
text_name = "SERVER: "
text_name_width = self.skin.subheader_name_font.getlength(text_name)
text_value = self.server_name
text_value_width = self.skin.subheader_value_font.getlength(text_value)
total_width = text_name_width + text_value_width
xpos += (image.width - total_width) // 2
draw.text(
(xpos, ypos),
text_name,
fill=self.skin.subheader_name_colour,
font=self.skin.subheader_name_font
)
xpos += text_name_width
draw.text(
(xpos, ypos),
text_value,
fill=self.skin.subheader_value_colour,
font=self.skin.subheader_value_font
)
return image
class LeaderboardCard(Card):
route = 'leaderboard_card'
card_id = 'leaderboard'
layout = LeaderboardPage
skin = LeaderboardSkin
display_name = "Leaderboard"
@classmethod
async def card_route(cls, runner, args, kwargs):
entries = [LeaderboardEntry(*entry) for entry in kwargs['entries']]
await asyncio.gather(
*(entry.get_avatar() for entry in entries)
)
kwargs['entries'] = entries
return await super().card_route(runner, args, kwargs)
@classmethod
def _execute(cls, *args, **kwargs):
for entry in kwargs['entries']:
entry.convert_avatar()
return super()._execute(*args, **kwargs)
@classmethod
async def sample_args(cls, ctx, **kwargs):
from ..utils import get_avatar_key
return {
'server_name': (ctx.guild.name if ctx.guild else f"{ctx.author.name}'s DMs") if ctx else "No Server",
'entries': [
(
(ctx.author.id, 1, 1474481, ctx.author.name, get_avatar_key(ctx.client, ctx.author.id))
if ctx else
(0, 1, 1474481, "John Doe", (0, None))
),
(1, 2, 1445975, 'Abioye', (0, None)),
(2, 3, 1127296, 'Lacey', (0, None)),
(3, 4, 1112495, 'Chesed', (0, None)),
(4, 5, 854514, 'Almas', (0, None)),
(5, 6, 824414, 'Uche', (0, None)),
(6, 7, 634560, 'Boitumelo', (0, None)),
(7, 8, 540633, 'Abimbola', (0, None)),
(8, 9, 417487, 'Keone', (0, None)),
(9, 10, 257274, 'Desta', (0, None))
],
'highlight': 4
}

View File

@@ -0,0 +1,143 @@
from PIL import Image, ImageDraw, ImageOps, ImageColor
from ..base import Card, Layout, fielded, Skin, FieldDesc
from ..base.Avatars import avatar_manager
from ..base.Skin import (
AssetField, RGBAAssetField, BlobField, AssetPathField, StringField, NumberField,
FontField, ColourField, PointField, ComputedField
)
@fielded
class MiniProfileSkin(Skin):
# Profile section
mini_profile_indent: NumberField = 125
mini_profile_size: ComputedField = lambda skin: (
skin.background.width - 2 * skin.mini_profile_indent,
int(skin._env['scale'] * 200)
)
mini_profile_avatar_mask: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_mask.png', convert=None)
mini_profile_avatar_frame: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_frame.png', convert=None)
mini_profile_avatar_sep: NumberField = 50
mini_profile_name_font: FontField = ('BoldItalic', 55)
mini_profile_name_colour: ColourField = '#DDB21D'
mini_profile_discrim_font: FontField = mini_profile_name_font
mini_profile_discrim_colour: ColourField = '#BABABA'
mini_profile_name_gap: NumberField = 20
mini_profile_badge_end: AssetField = "mini-profile/badge_end.png"
mini_profile_badge_font: FontField = ('Black', 30)
mini_profile_badge_colour: ColourField = '#FFFFFF'
mini_profile_badge_text_colour: ColourField = '#051822'
mini_profile_badge_gap: NumberField = 20
mini_profile_badge_min_sep: NumberField = 10
class MiniProfileLayout:
def _draw_profile(self) -> Image:
image = Image.new('RGBA', self.skin.mini_profile_size)
draw = ImageDraw.Draw(image)
xpos, ypos = 0, 0
frame = self.skin.mini_profile_avatar_frame
if frame.height >= image.height:
frame.thumbnail((image.height, image.height))
# Draw avatar
avatar = self.data_avatar
avatar.paste((0, 0, 0, 0), mask=self.skin.mini_profile_avatar_mask)
avatar_image = Image.new('RGBA', frame.size)
avatar_image.paste(
avatar,
(
(frame.width - avatar.width) // 2,
(frame.height - avatar.height) // 2
)
)
avatar_image.alpha_composite(frame)
avatar_image = avatar_image.resize(
(self.skin.mini_profile_size[1], self.skin.mini_profile_size[1])
)
image.alpha_composite(avatar_image, (0, 0))
xpos += avatar_image.width + self.skin.mini_profile_avatar_sep
# Draw name
name_text = self.data_name
name_length = self.skin.mini_profile_name_font.getlength(name_text + ' ')
name_height = self.skin.mini_profile_name_font.getsize(name_text)[1]
draw.text(
(xpos, ypos),
name_text,
fill=self.skin.mini_profile_name_colour,
font=self.skin.mini_profile_name_font
)
draw.text(
(xpos + name_length, ypos),
self.data_discrim,
fill=self.skin.mini_profile_discrim_colour,
font=self.skin.mini_profile_discrim_font
)
ypos += name_height + self.skin.mini_profile_name_gap
# Draw badges
_x = 0
max_x = self.skin.mini_profile_size[0] - xpos
badges = [self._draw_badge(text) for text in self.data_badges]
for badge in badges:
if badge.width + _x > max_x:
_x = 0
ypos += badge.height + self.skin.mini_profile_badge_gap
image.paste(
badge,
(xpos + _x, ypos)
)
_x += badge.width + self.skin.mini_profile_badge_min_sep
return image
def _draw_badge(self, text) -> Image:
"""
Draw a single profile badge, with the given text.
"""
text_length = self.skin.mini_profile_badge_font.getsize(text)[0]
height = self.skin.mini_profile_badge_end.height
width = text_length + self.skin.mini_profile_badge_end.width
badge = Image.new('RGBA', (width, height), color=(0, 0, 0, 0))
# Add blobs to ends
badge.paste(
self.skin.mini_profile_badge_end,
(0, 0)
)
badge.paste(
self.skin.mini_profile_badge_end,
(width - self.skin.mini_profile_badge_end.width, 0)
)
# Add rectangle to middle
draw = ImageDraw.Draw(badge)
draw.rectangle(
(
(self.skin.mini_profile_badge_end.width // 2, 0),
(width - self.skin.mini_profile_badge_end.width // 2, height),
),
fill='#FFFFFF',
width=0
)
badge.paste(ImageColor.getrgb(self.skin.mini_profile_badge_colour), mask=badge)
# Write badge text
draw.text(
(self.skin.mini_profile_badge_end.width // 2, height // 2),
text,
font=self.skin.mini_profile_badge_font,
fill=self.skin.mini_profile_badge_text_colour,
anchor='lm'
)
return badge

View File

@@ -0,0 +1,750 @@
import math
import calendar
from collections import defaultdict
from PIL import Image, ImageDraw
from datetime import timedelta
import datetime
from ..base import Card, Layout, fielded, Skin
from ..base.Skin import (
FieldDesc,
AssetField, RGBAAssetField, BlobField, StringField, NumberField, RawField,
FontField, ColourField, PointField, ComputedField
)
@fielded
class MonthlyStatsSkin(Skin):
_env = {
'scale': 2 # General size scale to match background resolution
}
background: AssetField = 'monthly/background.png'
# Header
title_pre_gap: NumberField = 40
title_text: StringField = "STUDY HOURS"
title_font: FontField = ('ExtraBold', 76)
title_size: ComputedField = lambda skin: skin.title_font.getsize(skin.title_text)
title_colour: ColourField = '#DDB21D'
title_underline_gap: NumberField = 10
title_underline_width: NumberField = 0
title_gap: NumberField = 10
# Top
top_grid_x: NumberField = 37
top_grid_y: NumberField = 100
top_hours_font: FontField = ('Black', 36)
top_hours_colour: ColourField = '#FFFFFF'
top_hours_bg_mask: AssetField = 'monthly/hours_bg_mask.png'
top_hours_bg_colour: ColourField = '#0B465E'
top_hours_bg_colour_override: ColourField = None
top_hours_bg: BlobField = FieldDesc(
BlobField,
mask_field='top_hours_bg_mask',
colour_field='top_hours_bg_colour',
colour_field_override='top_hours_bg_colour_override'
)
top_hours_sep: NumberField = 100
top_line_width: NumberField = 10
top_line_colour: ColourField = '#042231'
top_date_pre_gap: NumberField = 20
top_date_font: FontField = ('Light', 25)
top_date_colour: ColourField = '#FFFFFF'
top_date_height: ComputedField = lambda skin: skin.top_date_font.getsize('31')[1]
top_bar_mask: RGBAAssetField = 'monthly/bar_mask.png'
top_this_colour: ColourField = '#DDB21D'
top_this_color_override: ColourField = None
top_last_colour: ColourField = '#377689CC'
top_last_color_override: ColourField = None
top_this_bar_full: BlobField = FieldDesc(
BlobField,
mask_field='top_bar_mask',
colour_field='top_this_colour',
colour_field_override='top_this_colour_override'
)
top_last_bar_full: BlobField = FieldDesc(
BlobField,
mask_field='top_bar_mask',
colour_field='top_last_colour',
colour_field_override='top_last_colour_override'
)
top_this_hours_font: FontField = ('Medium', 20)
top_this_hours_colour: ColourField = '#DDB21D'
top_time_bar_sep: NumberField = 7
top_time_sep: NumberField = 5
top_last_hours_font: FontField = ('Medium', 20)
top_last_hours_colour: ColourField = '#5F91A1'
top_gap: NumberField = 40
weekdays: RawField = ('M', 'T', 'W', 'T', 'F', 'S', 'S')
# Summary
summary_pre_gap: NumberField = 50
summary_mask: AssetField = 'monthly/summary_mask.png'
this_month_image: BlobField = FieldDesc(
BlobField,
mask_field='summary_mask',
colour_field='top_this_colour',
colour_field_override='top_this_colour_override'
)
this_month_font: FontField = ('Light', 23)
this_month_colour: ColourField = '#BABABA'
summary_sep: NumberField = 300
last_month_font: FontField = ('Light', 23)
last_month_colour: ColourField = '#BABABA'
last_month_image: BlobField = FieldDesc(
BlobField,
mask_field='summary_mask',
colour_field='top_last_colour',
colour_field_override='top_last_colour_override'
)
summary_gap: NumberField = 50
# Bottom
bottom_frame: AssetField = 'monthly/bottom_frame.png'
bottom_margins: PointField = (100, 100)
heatmap_mask: AssetField = 'monthly/heatmap_blob_mask.png'
heatmap_empty_colour: ColourField = "#082534"
heatmap_empty_colour_override: ColourField = None
heatmap_empty: BlobField = FieldDesc(
BlobField,
mask_field='heatmap_mask',
colour_field='heatmap_empty_colour',
colour_field_override='heatmap_empty_colour_override'
)
heatmap_colours: RawField = [
'#0E2A77',
'#15357D',
'#1D3F82',
'#244A88',
'#2C548E',
'#335E93',
'#3B6998',
'#43729E',
'#4B7CA3',
'#5386A8',
'#5B8FAD',
'#6398B2',
'#6BA1B7',
'#73A9BC',
'#7CB1C1',
'#85B9C5',
]
heatmap_colours.reverse()
weekday_background_mask: AssetField = 'monthly/weekday_mask.png'
weekday_background_colour: ColourField = '#60606038'
weekday_background_colour_override: ColourField = None
weekday_background: BlobField = FieldDesc(
BlobField,
mask_field='weekday_background_mask',
colour_field='weekday_background_colour',
colour_field_override='weekday_background_colour_override'
)
weekday_font: FontField = ('Black', 26.85)
weekday_colour: ColourField = '#FFFFFF'
weekday_sep: NumberField = 20
month_background_mask: AssetField = 'monthly/month_mask.png'
month_background_colour: ColourField = '#60606038'
month_background_colour_override: ColourField = None
month_background: BlobField = FieldDesc(
BlobField,
mask_field='month_background_mask',
colour_field='month_background_colour',
colour_field_override='month_background_colour_override'
)
month_font: FontField = ('Bold', 25.75)
month_colour: ColourField = '#FFFFFF'
month_sep: ComputedField = lambda skin: (
skin.bottom_frame.width - 2 * skin.bottom_margins[0]
- skin.weekday_background.width
- skin.weekday_sep
- 4 * skin.month_background.width
) // 3
month_gap: NumberField = 25
btm_grid_x: ComputedField = lambda skin: (skin.month_background.width - skin.heatmap_mask.width) // 5
btm_grid_y: ComputedField = lambda skin: skin.btm_grid_x
# Stats
stats_key_font: FontField = ('Medium', 23.65)
stats_key_colour: ColourField = '#FFFFFF'
stats_value_font: FontField = ('Light', 23.65)
stats_value_colour: ColourField = '#808080'
stats_sep: ComputedField = lambda skin: (
skin.month_background.width + skin.month_sep + (skin.weekday_background.width + skin.weekday_sep) // 3
)
# Date text
footer_font: FontField = ('Bold', 28)
footer_colour: ColourField = '#6f6e6f'
footer_gap: NumberField = 50
# TODO: Month hour bars.. Blobasset full bars and use them as masks, e.g. profile progress bar.
class MonthlyStatsPage(Layout):
def __init__(self, skin, name, discrim, sessions, date, current_streak, longest_streak, first_session_start):
"""
`sessions` is a list of study sessions from the last two weeks.
"""
self.skin = skin
self.data_sessions = sessions
self.data_date = date
self.data_name = name
self.data_discrim = discrim
self.current_streak = current_streak
self.longest_streak = longest_streak
self.month_start = date.replace(day=1)
self.data_time = defaultdict(int)
for start, end in sessions:
day_start = start.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + timedelta(hours=24)
if end > day_end:
self.data_time[day_start.date()] += (day_end - start).total_seconds()
self.data_time[day_end.date()] += (end - day_end).total_seconds()
else:
self.data_time[day_start.date()] += (end - start).total_seconds()
self.this_month_days = calendar.monthrange(self.month_start.year, self.month_start.month)[1]
self.hours_this_month = [
self.data_time[self.month_start + timedelta(days=i)] / 3600
for i in range(0, self.this_month_days)
]
self.months = [self.month_start]
for i in range(0, 3):
self.months.append((self.months[-1] - timedelta(days=1)).replace(day=1))
self.months.reverse()
last_month_start = self.months[-2]
last_month_days = calendar.monthrange(last_month_start.year, last_month_start.month)[1]
self.hours_last_month = [
self.data_time[last_month_start + timedelta(days=i)] / 3600
for i in range(0, last_month_days)
][:self.this_month_days] # Truncate to this month length
max_hours = max(*self.hours_this_month, *self.hours_last_month)
self.max_hour_label = (4 * math.ceil(max_hours / 4)) or 4
self.days_learned = sum(val != 0 for val in self.data_time.values())
self.total_days = sum(
calendar.monthrange(month.year, month.month)[1]
for month in self.months
)
self.days_since_start = min(
(date - first_session_start.date()).days,
(date - self.months[0]).days
) + 1
self.average_time = (sum(self.data_time.values()) / self.days_learned) if self.days_learned else 0
self.image = None
def draw(self) -> Image:
image = self.image = self.skin.background
draw = ImageDraw.Draw(image)
xpos, ypos = 0, 0
# Draw header text
xpos = (image.width - self.skin.title_size[0]) // 2
ypos += self.skin.title_pre_gap
draw.text(
(xpos, ypos),
self.skin.title_text,
fill=self.skin.title_colour,
font=self.skin.title_font
)
# Underline it
title_size = self.skin.title_font.getsize(self.skin.title_text)
ypos += title_size[1] + self.skin.title_underline_gap
# draw.line(
# (xpos, ypos, xpos + title_size[0], ypos),
# fill=self.skin.title_colour,
# width=self.skin.title_underline_width
# )
ypos += self.skin.title_underline_width + self.skin.title_gap
# Draw the top box
top = self.draw_top()
image.alpha_composite(
top,
((image.width - top.width) // 2, ypos)
)
ypos += top.height + self.skin.top_gap
# Draw the summaries
summary_image = self.draw_summaries()
image.alpha_composite(
summary_image,
((image.width - summary_image.width) // 2, ypos)
)
ypos += summary_image.height + self.skin.summary_gap
# Draw the bottom box
bottom = self.draw_bottom()
image.alpha_composite(
bottom,
((image.width - bottom.width) // 2, ypos)
)
# Draw the footer
ypos = image.height
ypos -= self.skin.footer_gap
date_text = self.data_date.strftime(
"Monthly Statistics • As of %d %b • {} {}".format(self.data_name, self.data_discrim)
)
size = self.skin.footer_font.getsize(date_text)
ypos -= size[1]
draw.text(
((image.width - size[0]) // 2, ypos),
date_text,
font=self.skin.footer_font,
fill=self.skin.footer_colour
)
return image
def draw_summaries(self) -> Image:
this_month_text = " THIS MONTH: {} Hours".format(int(sum(self.hours_this_month)))
this_month_length = int(self.skin.this_month_font.getlength(this_month_text))
last_month_text = " LAST MONTH: {} Hours".format(int(sum(self.hours_last_month)))
last_month_length = int(self.skin.last_month_font.getlength(last_month_text))
image = Image.new(
'RGBA',
(
self.skin.this_month_image.width + this_month_length
+ self.skin.summary_sep
+ self.skin.last_month_image.width + last_month_length,
self.skin.this_month_image.height
)
)
draw = ImageDraw.Draw(image)
xpos = 0
ypos = image.height // 2
image.alpha_composite(
self.skin.this_month_image,
(0, 0)
)
xpos += self.skin.this_month_image.width
draw.text(
(xpos, ypos),
this_month_text,
fill=self.skin.this_month_colour,
font=self.skin.this_month_font,
anchor='lm'
)
xpos += self.skin.summary_sep + this_month_length
image.alpha_composite(
self.skin.last_month_image,
(xpos, 0)
)
xpos += self.skin.last_month_image.width
draw.text(
(xpos, ypos),
last_month_text,
fill=self.skin.last_month_colour,
font=self.skin.last_month_font,
anchor='lm'
)
return image
def draw_top(self) -> Image:
size_x = (
self.skin.top_hours_bg.width // 2 + self.skin.top_hours_sep
+ (self.this_month_days - 1) * self.skin.top_grid_x + self.skin.top_bar_mask.width // 2
+ self.skin.top_hours_bg.width // 2
)
size_y = (
self.skin.top_hours_bg.height // 2 + 4 * self.skin.top_grid_y + self.skin.top_date_pre_gap
+ self.skin.top_date_height
+ self.skin.top_time_bar_sep + int(self.skin.top_this_hours_font.getlength('24 H 24 H'))
)
image = Image.new('RGBA', (size_x, size_y))
draw = ImageDraw.Draw(image)
x0 = self.skin.top_hours_bg.width // 2 + self.skin.top_hours_sep
y0 = self.skin.top_hours_bg.height // 2 + 4 * self.skin.top_grid_y
y0 += self.skin.top_time_bar_sep + int(self.skin.top_this_hours_font.getlength('24 H 24 H'))
# Draw lines and numbers
labels = list(int(i * self.max_hour_label // 4) for i in range(0, 5))
xpos = x0 - self.skin.top_hours_sep
ypos = y0
for label in labels:
draw.line(
((xpos, ypos), (image.width, ypos)),
width=self.skin.top_line_width,
fill=self.skin.top_line_colour
)
image.alpha_composite(
self.skin.top_hours_bg,
(xpos - self.skin.top_hours_bg.width // 2, ypos - self.skin.top_hours_bg.height // 2)
)
text = str(label)
draw.text(
(xpos, ypos),
text,
fill=self.skin.top_hours_colour,
font=self.skin.top_hours_font,
anchor='mm'
)
ypos -= self.skin.top_grid_y
# Draw dates
xpos = x0
ypos = y0 + self.skin.top_date_pre_gap
for i in range(1, self.this_month_days + 1):
draw.text(
(xpos, ypos),
str(i),
fill=self.skin.top_date_colour,
font=self.skin.top_date_font,
anchor='mt'
)
xpos += self.skin.top_grid_x
# Draw bars
for i, (last_hours, this_hours) in enumerate(zip(self.hours_last_month, self.hours_this_month)):
xpos = x0 + i * self.skin.top_grid_x
if not (last_hours or this_hours):
continue
bar_height = 0
for draw_last in (last_hours > this_hours, not last_hours > this_hours):
hours = last_hours if draw_last else this_hours
height = (4 * self.skin.top_grid_y) * (hours / self.max_hour_label)
height = int(height)
if height >= self.skin.top_bar_mask.width:
bar = self.draw_vertical_bar(
height,
self.skin.top_last_bar_full if draw_last else self.skin.top_this_bar_full,
self.skin.top_bar_mask
)
bar_height = max(height, bar_height)
image.alpha_composite(
bar,
(xpos - bar.width // 2, y0 - bar.height)
)
# Draw text
if bar_height:
text = ['{} H'.format(hours) for hours in (last_hours, this_hours) if hours]
text_size = self.skin.top_this_hours_font.getsize(' '.join(text))
text_image = Image.new(
'RGBA',
text_size
)
text_draw = ImageDraw.Draw(text_image)
txpos = 0
if last_hours:
last_text = "{} H ".format(int(last_hours))
text_draw.text(
(txpos, 0), last_text,
fill=self.skin.top_last_hours_colour,
font=self.skin.top_last_hours_font
)
txpos += self.skin.top_last_hours_font.getlength(last_text)
if this_hours:
this_text = "{} H ".format(int(this_hours))
text_draw.text(
(txpos, 0), this_text,
fill=self.skin.top_this_hours_colour,
font=self.skin.top_this_hours_font
)
text_image = text_image.rotate(90, expand=True)
text_image = text_image.crop(text_image.getbbox())
image.alpha_composite(
text_image,
(xpos - text_image.width // 2,
y0 - bar_height - self.skin.top_time_bar_sep - text_image.height)
)
return image
def draw_vertical_bar(self, height, full_bar, mask_bar, crop=False):
y_2 = mask_bar.height
y_1 = height
image = Image.new('RGBA', full_bar.size)
image.paste(mask_bar, (0, y_2 - y_1), mask=mask_bar)
image.paste(full_bar, mask=image)
if crop:
image = image.crop(
(0, y_2 - y_1), (image.width, y_2 - y_1),
(image.height, 0), (image.height, image.width)
)
return image
def draw_bottom(self) -> Image:
image = self.skin.bottom_frame
draw = ImageDraw.Draw(image)
xpos, ypos = self.skin.bottom_margins
# Draw the weekdays
y0 = self.skin.month_background.height + self.skin.month_gap
for i, weekday in enumerate(self.skin.weekdays):
y = y0 + i * self.skin.btm_grid_y
image.alpha_composite(
self.skin.weekday_background,
(xpos, ypos + y)
)
draw.text(
(xpos + self.skin.weekday_background.width // 2, ypos + y + self.skin.weekday_background.height // 2),
weekday,
fill=self.skin.weekday_colour,
font=self.skin.weekday_font,
anchor='mm'
)
# Draw the months
x0 = self.skin.weekday_background.width + self.skin.weekday_sep
for i, date in enumerate(self.months):
name = date.strftime('%B').upper()
x = x0 + i * (self.skin.month_background.width + self.skin.month_sep)
image.alpha_composite(
self.skin.month_background,
(xpos + x, ypos)
)
draw.text(
(xpos + x + self.skin.month_background.width // 2,
ypos + self.skin.month_background.height // 2),
name,
fill=self.skin.month_colour,
font=self.skin.month_font,
anchor='mm'
)
heatmap = self.draw_month_heatmap(date)
image.alpha_composite(
heatmap,
(xpos + x + self.skin.month_background.width // 2 - heatmap.width // 2, ypos + y0)
)
# Draw the streak and stats information
x = xpos + self.skin.weekday_background.width // 2
y = image.height - self.skin.bottom_margins[1]
key_text = "Current streak: "
key_len = self.skin.stats_key_font.getlength(key_text)
value_text = "{} day{}".format(
self.current_streak,
's' if self.current_streak != 1 else ''
)
draw.text(
(x, y),
key_text,
font=self.skin.stats_key_font,
fill=self.skin.stats_key_colour
)
draw.text(
(x + key_len, y),
value_text,
font=self.skin.stats_value_font,
fill=self.skin.stats_value_colour
)
x += self.skin.stats_sep
key_text = "Daily average: "
key_len = self.skin.stats_key_font.getlength(key_text)
value_text = "{} hour{}".format(
(hours := int(self.average_time // 3600)),
's' if hours != 1 else ''
)
draw.text(
(x, y),
key_text,
font=self.skin.stats_key_font,
fill=self.skin.stats_key_colour
)
draw.text(
(x + key_len, y),
value_text,
font=self.skin.stats_value_font,
fill=self.skin.stats_value_colour
)
x += self.skin.stats_sep
key_text = "Longest streak: "
key_len = self.skin.stats_key_font.getlength(key_text)
value_text = "{} day{}".format(
self.longest_streak,
's' if self.current_streak != 1 else ''
)
draw.text(
(x, y),
key_text,
font=self.skin.stats_key_font,
fill=self.skin.stats_key_colour
)
draw.text(
(x + key_len, y),
value_text,
font=self.skin.stats_value_font,
fill=self.skin.stats_value_colour
)
x += self.skin.stats_sep
key_text = "Days learned: "
key_len = self.skin.stats_key_font.getlength(key_text)
value_text = "{}%".format(
int((100 * self.days_learned) // self.days_since_start)
)
draw.text(
(x, y),
key_text,
font=self.skin.stats_key_font,
fill=self.skin.stats_key_colour
)
draw.text(
(x + key_len, y),
value_text,
font=self.skin.stats_value_font,
fill=self.skin.stats_value_colour
)
x += self.skin.stats_sep
return image
def draw_month_heatmap(self, month_start) -> Image:
cal = calendar.monthcalendar(month_start.year, month_start.month)
columns = len(cal)
size_x = (
(columns - 1) * self.skin.btm_grid_x
+ self.skin.heatmap_mask.width
)
size_y = (
6 * self.skin.btm_grid_y + self.skin.heatmap_mask.height
)
image = Image.new('RGBA', (size_x, size_y))
x0 = self.skin.heatmap_mask.width // 2
y0 = self.skin.heatmap_mask.height // 2
for (i, week) in enumerate(cal):
xpos = x0 + i * self.skin.btm_grid_x
for (j, day) in enumerate(week):
if day:
ypos = y0 + j * self.skin.btm_grid_y
date = datetime.date(month_start.year, month_start.month, day)
time = self.data_time[date]
bubble = self.draw_bubble(time)
image.alpha_composite(
bubble,
(xpos - bubble.width // 2, ypos - bubble.width // 2)
)
return image
def draw_bubble(self, time):
# Calculate colour level
if time == 0:
image = self.skin.heatmap_empty
colour = self.skin.heatmap_empty_colour
else:
amount = min((time / self.average_time) if self.average_time else 0, 2) / 2
index = math.ceil(amount * len(self.skin.heatmap_colours)) - 1
colour = self.skin.heatmap_colours[index]
image = Image.new('RGBA', self.skin.heatmap_mask.size)
image.paste(colour, mask=self.skin.heatmap_mask)
return image
class MonthlyStatsCard(Card):
route = "monthly_stats_card"
card_id = "monthly_stats"
layout = MonthlyStatsPage
skin = MonthlyStatsSkin
display_name = "Monthly Stats"
@classmethod
async def sample_args(cls, ctx, **kwargs):
import random
from datetime import timezone, datetime, timedelta
sessions = []
streak = 0
longest_streak = 0
day_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
day_start -= timedelta(hours=24) * 120
for day in range(0, 120):
day_start += timedelta(hours=24)
roll = random.randint(0, 30)
if roll == 0:
longest_streak = max(streak, longest_streak)
streak = 0
continue
else:
streak += 1
# start of day
pointer = 6 * 60
session_duration = int(abs(random.normalvariate(8 * 60, 2 * 60)))
sessions.append((
day_start + timedelta(minutes=pointer),
day_start + timedelta(minutes=(pointer + session_duration)),
)
)
longest_streak = max(streak, longest_streak)
return {
'name': ctx.author.name if ctx else "John Doe",
'discrim': ('#' + ctx.author.discriminator) if ctx else "#0000",
'sessions': sessions,
'date': datetime.now(timezone.utc).date(),
'current_streak': streak,
'longest_streak': longest_streak,
'first_session_start': day_start - timedelta(days=200)
}

View File

@@ -0,0 +1,623 @@
from io import BytesIO
from PIL import Image, ImageDraw
from ..utils import get_avatar_key
from ..base import Card, Layout, fielded, Skin, FieldDesc
from ..base.Avatars import avatar_manager
from ..base.Skin import (
AssetField, RGBAAssetField, AssetPathField, NumberField, BlobField,
FontField, ColourField, PointField, ComputedField
)
@fielded
class ProfileSkin(Skin):
_env = {
'scale': 2 # General size scale to match background resolution
}
# Background images
bg_path: AssetField = "profile/background.png"
# Inner container
container_position: PointField = (70, 65) # Position of top left corner
container_size: PointField = (1400, 600) # Size of the inner container
# Header
header_font: FontField = ('BlackItalic', 28)
header_colour_1: ColourField = '#DDB21D'
header_colour_2: ColourField = '#BABABA'
header_gap: NumberField = 35
header_height: ComputedField = lambda skin: skin.header_font.getsize("USERNAME #0000")[1]
# Column 1
avatar_mask: AssetField = FieldDesc(AssetField, 'profile/avatar_mask.png', convert=None)
avatar_outline: AssetField = FieldDesc(AssetField, 'profile/avatar_outline.png', convert=None)
avatar_size: ComputedField = lambda skin: skin.avatar_outline.size
avatar_gap: NumberField = 10
counter_bg_mask: AssetField = "profile/counter_bg_mask.png"
counter_bg_colour: ColourField = "#515B8D60"
counter_background: BlobField = FieldDesc(
BlobField,
mask_field='counter_bg_mask',
colour_field='counter_bg_colour'
)
coin_icon: AssetField = "icons/coin.png"
gem_icon: AssetField = "icons/gem.png"
gift_icon: AssetField = "icons/gift.png"
counter_font: FontField = ('Black', 14)
counter_colour: ColourField = '#FFFFFF'
counter_icon_align: NumberField = 20
counter_text_align: NumberField = 40
counter_gap: NumberField = 5
col1_size: ComputedField = lambda skin: (
skin.avatar_size[0],
skin.avatar_size[1] + skin.avatar_gap
+ 3 * skin.counter_background.height + 2 * skin.counter_gap
)
column_sep: NumberField = 20
# Column 2
subheader_font: FontField = ('Black', 27)
subheader_colour: ColourField = '#DDB21D'
subheader_height: ComputedField = lambda skin: skin.subheader_font.getsize('PROFILE')[1]
subheader_gap: NumberField = 15
col2_size: ComputedField = lambda skin: (
skin.container_size[0] - skin.col1_size[0] - skin.column_sep,
skin.container_size[1] - skin.header_gap - skin.header_height
)
col2_sep: NumberField = 40 # Minimum separation between profile and achievements
# Achievement section
achievement_active_path: AssetPathField = 'profile/achievements_active/'
achievement_inactive_path: AssetPathField = 'profile/achievements_inactive/'
achievement_icon_size: PointField = (115, 96) # Individual achievement box size
achievement_gap: NumberField = 10
achievement_sep: NumberField = 0
achievement_size: ComputedField = lambda skin: (
4 * skin.achievement_icon_size[0] + 3 * skin.achievement_sep,
skin.subheader_height + skin.subheader_gap
+ 2 * skin.achievement_icon_size[1] + 1 * skin.achievement_gap
)
# Profile section
badge_font: FontField = ('Black', 13)
badge_text_colour: ColourField = '#FFFFFF'
badge_blob_colour: ColourField = '#1473A2'
badge_blob_colour_override: ColourField = None
badge_blob_mask: AssetField = 'profile/badge_end_mask.png'
badge_end_blob: BlobField = FieldDesc(
BlobField,
mask_field='badge_blob_mask',
colour_field='badge_blob_colour',
colour_override_field='badge_blob_colour_override'
)
badge_gap: NumberField = 5
badge_min_sep: NumberField = 5
profile_size: ComputedField = lambda skin: (
skin.col2_size[0] - skin.achievement_size[0] - skin.col2_sep,
skin.subheader_height + skin.subheader_gap
+ 4 * skin.badge_end_blob.height + 3 * skin.badge_gap
)
# Rank section
rank_name_font: FontField = ('Black', 23)
rank_name_colour: ColourField = '#DDB21D'
rank_name_height: ComputedField = lambda skin: skin.rank_name_font.getsize('VAMPIRE')[1]
rank_hours_font: FontField = ('Light', 18)
rank_hours_colour: ColourField = '#FFFFFF'
bar_gap: NumberField = 5
bar_mask: RGBAAssetField = 'profile/progress_mask.png'
bar_full_colour: ColourField = '#DDB21D'
bar_full_colour_override: ColourField = None
bar_full: BlobField = FieldDesc(
BlobField,
mask_field='bar_mask',
colour_field='bar_full_colour',
colour_override_field='bar_full_colour_override'
)
bar_empty_colour: ColourField = '#2F4858'
bar_empty_colour_override: ColourField = None
bar_empty: BlobField = FieldDesc(
BlobField,
mask_field='bar_mask',
colour_field='bar_empty_colour',
colour_override_field='bar_empty_colour_override'
)
next_rank_font: FontField = ('Italic', 15)
next_rank_colour: ColourField = '#FFFFFF'
next_rank_height: ComputedField = lambda skin: skin.next_rank_font.getsize('NEXT RANK:')[1]
rank_size: ComputedField = lambda skin: (
skin.col2_size[0],
skin.rank_name_height + skin.bar_gap
+ skin.bar_full.height + skin.bar_gap
+ skin.next_rank_height + skin.next_rank_height // 2 # Adding skin.height skin.for skin.taller skin.glyphs
)
class ProfileLayout(Layout):
def __init__(self, skin, name, discrim,
coins, time, gems, gifts,
avatar,
badges=(),
achievements=(),
current_rank=None,
next_rank=None,
draft=False, **kwargs):
self.skin = skin
self.draft = draft
self.data_name = name
self.data_discrim = discrim
self.data_avatar = avatar
self.data_coins = coins
self.data_time = time
self.data_hours = self.data_time / 3600
self.data_gems = gems
self.data_gifts = gifts
self.data_badges = badges
self.data_achievements = achievements
self.data_current_rank = current_rank
self.data_next_rank = next_rank
self.image: Image = None # Final Image
def draw(self):
# Load/copy background
image = self.skin.bg_path
# Draw inner container
inner_container = self.draw_inner_container()
# Paste inner container on background
image.alpha_composite(inner_container, self.skin.container_position)
self.image = image
return image
def draw_inner_container(self) -> Image:
container = Image.new('RGBA', self.skin.container_size)
draw = ImageDraw.Draw(container)
if self.draft:
draw.rectangle(((0, 0), (self.skin.container_size[0]-1, self.skin.container_size[1]-1)))
position = 0
# Draw header
xposition = 0
draw.text(
(xposition, position),
self.data_name,
font=self.skin.header_font,
fill=self.skin.header_colour_1
)
xposition += self.skin.header_font.getlength(self.data_name + ' ')
draw.text(
(xposition, position),
self.data_discrim,
font=self.skin.header_font,
fill=self.skin.header_colour_2
)
position += self.skin.header_height + self.skin.header_gap
# Draw column 1
col1 = self.draw_column_1()
container.alpha_composite(col1, (0, position))
# Draw column 2
col2 = self.draw_column_2()
container.alpha_composite(col2, (container.width - col2.width, position))
return container
def draw_column_1(self) -> Image:
# Create new image for column 1
col1 = Image.new('RGBA', self.skin.col1_size)
draw = ImageDraw.Draw(col1)
if self.draft:
draw.rectangle(((0, 0), (self.skin.col1_size[0]-1, self.skin.col1_size[1]-1)))
# Tracking current drawing height
position = 0
# Draw avatar
_avatar = self.data_avatar
# Mask the avatar image to the desired shape
_avatar.paste((0, 0, 0, 0), mask=self.skin.avatar_mask)
# Place the image on a larger canvas
avatar_image = Image.new('RGBA', self.skin.avatar_outline.size)
avatar_image.paste(
_avatar,
(
(self.skin.avatar_outline.width - _avatar.width) // 2,
(self.skin.avatar_outline.height - _avatar.height) // 2,
)
)
# Add the outline over the masked avatar
avatar_image.alpha_composite(self.skin.avatar_outline)
# Paste onto column
col1.alpha_composite(
avatar_image,
(0, position)
)
position += self.skin.avatar_size[1] + self.skin.avatar_gap
# Draw counters
counters = (
(self.skin.coin_icon, self.data_coins),
(self.skin.gem_icon, self.data_gems),
(self.skin.gift_icon, self.data_gifts),
)
for icon, amount in counters:
col1.alpha_composite(
self.draw_counter(icon, amount),
((self.skin.avatar_outline.width - self.skin.counter_background.width) // 2, position)
)
position += self.skin.counter_background.height + self.skin.counter_gap
return col1
def draw_counter(self, icon, amount):
image = self.skin.counter_background.copy()
draw = ImageDraw.Draw(image)
image.alpha_composite(
icon,
(
self.skin.counter_icon_align - icon.width // 2,
(self.skin.counter_background.height - icon.height) // 2
)
)
draw.text(
(self.skin.counter_text_align, self.skin.counter_background.height // 2),
f"{amount:,}",
font=self.skin.counter_font,
fill=self.skin.counter_colour,
anchor='lm'
)
return image
def draw_column_2(self) -> Image:
# Create new image for column 1
col2 = Image.new('RGBA', self.skin.col2_size)
draw = ImageDraw.Draw(col2)
if self.draft:
draw.rectangle(((0, 0), (self.skin.col2_size[0]-1, self.skin.col2_size[1]-1)))
# Tracking current drawing position
position = 0
xposition = 0
# Draw Profile box
profile = self.draw_profile()
col2.paste(
profile,
(xposition, position)
)
xposition += profile.width + self.skin.col2_sep
# Draw Achievements box
achievements = self.draw_achievements()
col2.paste(
achievements,
(xposition, position)
)
# Draw ranking box
position = self.skin.col2_size[1] - self.skin.rank_size[1]
ranking = self.draw_rank()
col2.alpha_composite(
ranking,
(0, position)
)
return col2
def draw_profile(self) -> Image:
profile = Image.new('RGBA', self.skin.profile_size)
draw = ImageDraw.Draw(profile)
if self.draft:
draw.rectangle(((0, 0), (self.skin.profile_size[0]-1, self.skin.profile_size[1]-1)))
position = 0
# Draw subheader
draw.text(
(0, position),
'PROFILE',
font=self.skin.subheader_font,
fill=self.skin.subheader_colour
)
position += self.skin.subheader_height + self.skin.subheader_gap
# Draw badges
# TODO: Nicer/Smarter layout
xposition = 0
max_x = self.skin.profile_size[0]
badges = [self.draw_badge(text) for text in self.data_badges]
for badge in badges:
if badge.width + xposition > max_x:
xposition = 0
position += badge.height + self.skin.badge_gap
profile.paste(
badge,
(xposition, position)
)
xposition += badge.width + self.skin.badge_min_sep
return profile
def draw_badge(self, text) -> Image:
"""
Draw a single profile badge, with the given text.
"""
text_length = self.skin.badge_font.getsize(text)[0]
height = self.skin.badge_end_blob.height
width = text_length + self.skin.badge_end_blob.width
badge = Image.new('RGBA', (width, height), color=(0, 0, 0, 0))
# Add blobs to ends
badge.paste(
self.skin.badge_end_blob,
(0, 0)
)
badge.paste(
self.skin.badge_end_blob,
(width - self.skin.badge_end_blob.width, 0)
)
# Add rectangle to middle
draw = ImageDraw.Draw(badge)
draw.rectangle(
(
(self.skin.badge_end_blob.width // 2, 0),
(width - self.skin.badge_end_blob.width // 2, height),
),
fill=self.skin.badge_blob_colour,
width=0
)
# Write badge text
draw.text(
(self.skin.badge_end_blob.width // 2, height // 2),
text,
font=self.skin.badge_font,
fill=self.skin.badge_text_colour,
anchor='lm'
)
return badge
def draw_achievements(self) -> Image:
achievements = Image.new('RGBA', self.skin.achievement_size)
draw = ImageDraw.Draw(achievements)
if self.draft:
draw.rectangle(((0, 0), (self.skin.achievement_size[0]-1, self.skin.achievement_size[1]-1)))
position = 0
# Draw subheader
draw.text(
(0, position),
'ACHIEVEMENTS',
font=self.skin.subheader_font,
fill=self.skin.subheader_colour
)
position += self.skin.subheader_height + self.skin.subheader_gap
xposition = 0
for i in range(0, 8):
# Top left corner of grid box
nxpos = (i % 4) * (self.skin.achievement_icon_size[0] + self.skin.achievement_sep)
nypos = (i // 4) * (self.skin.achievement_icon_size[1] + self.skin.achievement_gap)
# Choose the active or inactive icon as given by data
icon_path = "{}{}.png".format(
self.skin.achievement_active_path if (i in self.data_achievements) else self.skin.achievement_inactive_path,
i + 1
)
icon = Image.open(icon_path).convert('RGBA')
# Offset to top left corner of pasted icon
xoffset = (self.skin.achievement_icon_size[0] - icon.width) // 2
# xoffset = 0
yoffset = self.skin.achievement_icon_size[1] - icon.height
# Paste the icon
achievements.alpha_composite(
icon,
(xposition + nxpos + xoffset, position + nypos + yoffset)
)
return achievements
def draw_rank(self) -> Image:
rank = Image.new('RGBA', self.skin.rank_size)
draw = ImageDraw.Draw(rank)
if self.draft:
draw.rectangle(((0, 0), (self.skin.rank_size[0]-1, self.skin.rank_size[1]-1)))
position = 0
# Draw the current rank
if self.data_current_rank:
rank_name, hour_1, hour_2 = self.data_current_rank
xposition = 0
draw.text(
(xposition, position),
rank_name,
font=self.skin.rank_name_font,
fill=self.skin.rank_name_colour,
)
name_size = self.skin.rank_name_font.getsize(rank_name + ' ')
position += name_size[1]
xposition += name_size[0]
if hour_2:
progress = (self.data_hours - hour_1) / (hour_2 - hour_1)
if hour_1:
hour_str = '{} - {}h'.format(hour_1, hour_2)
else:
hour_str = '{}h'.format(hour_2)
else:
progress = 1
hour_str = '{}h'.format(hour_1)
draw.text(
(xposition, position),
hour_str,
font=self.skin.rank_hours_font,
fill=self.skin.rank_hours_colour,
anchor='lb'
)
position += self.skin.bar_gap
else:
draw.text(
(0, position),
'UNRANKED',
font=self.skin.rank_name_font,
fill=self.skin.rank_name_colour,
)
position += self.skin.rank_name_height + self.skin.bar_gap
progress = 0
# Draw rankbar
rankbar = self.draw_rankbar(progress)
rank.alpha_composite(
rankbar,
(0, position)
)
position += rankbar.height + self.skin.bar_gap
# Draw next rank text
if self.data_next_rank:
rank_name, hour_1, hour_2 = self.data_next_rank
if hour_2:
if hour_1:
hour_str = '{} - {}h'.format(hour_1, hour_2)
else:
hour_str = '{}h'.format(hour_2)
else:
hour_str = '{}h'.format(hour_1)
rank_str = "NEXT RANK: {} {}".format(rank_name, hour_str)
else:
if self.data_current_rank:
rank_str = "YOU HAVE REACHED THE MAXIMUM RANK!"
else:
rank_str = "NO RANKS AVAILABLE!"
draw.text(
(0, position),
rank_str,
font=self.skin.next_rank_font,
fill=self.skin.next_rank_colour,
)
return rank
def draw_rankbar(self, progress: float) -> Image:
"""
Draw the rank progress bar with the given progress filled.
`progress` should be given as a proportion between `0` and `1`.
"""
# Ensure sane values
progress = min(progress, 1)
progress = max(progress, 0)
if progress == 0:
return self.skin.bar_empty
elif progress == 1:
return self.skin.bar_full
else:
_bar = self.skin.bar_empty
x = -1 * int((1 - progress) * self.skin.bar_full.width)
_bar.paste(
self.skin.bar_full,
(x, 0),
mask=self.skin.bar_mask
)
bar = Image.new('RGBA', _bar.size)
bar.paste(
_bar,
mask=self.skin.bar_mask
)
return bar
class ProfileCard(Card):
route = 'profile_card'
card_id = 'profile'
layout = ProfileLayout
skin = ProfileSkin
display_name = "User Profile"
@classmethod
async def card_route(cls, runner, args, kwargs):
kwargs['avatar'] = await avatar_manager().get_avatar(*kwargs['avatar'], 256)
return await super().card_route(runner, args, kwargs)
@classmethod
def _execute(cls, *args, **kwargs):
with BytesIO(kwargs['avatar']) as image_data:
with Image.open(image_data).convert('RGBA') as avatar_image:
kwargs['avatar'] = avatar_image
return super()._execute(*args, **kwargs)
@classmethod
async def sample_args(cls, ctx, **kwargs):
return {
'name': ctx.author.name if ctx else 'John Doe',
'discrim': ('#' + ctx.author.discriminator) if ctx else '#0000',
'avatar': get_avatar_key(ctx.client, ctx.author.id) if ctx else (0, None),
'coins': 58596,
'time': 3750 * 3600,
'gems': 10000,
'gifts': 100,
'badges': (
'STUDYING: MEDICINE',
'HOBBY: MATHS',
'CAREER: STUDENT',
'FROM: EUROPE',
'LOVES CATS <3'
),
'achievements': (0, 2, 5, 7),
'current_rank': ('VAMPIRE', 3000, 4000),
'next_rank': ('WIZARD', 4000, 8000),
}

View File

@@ -0,0 +1,508 @@
import itertools
from datetime import datetime, timedelta
from PIL import Image, ImageDraw
from ..base import Card, Layout, fielded, Skin
from ..base.Skin import (
AssetField, BlobField, StringField, NumberField, RawField,
FontField, ColourField, PointField, ComputedField, FieldDesc
)
def format_lb(pos):
"""
Format a leaderboard position into a string.
"""
if pos is None:
return 'Unranked'
if pos % 10 == 1 and pos % 100 != 11:
return f"{pos}ST"
if pos % 10 == 2 and pos % 100 != 12:
return f"{pos}ND"
if pos % 10 == 3 and pos % 100 != 13:
return f"{pos}RD"
return f"{pos}TH"
def format_time(seconds):
return "{:02}:{:02}".format(
int(seconds // 3600),
int(seconds % 3600 // 60)
)
@fielded
class StatsSkin(Skin):
_env = {
'scale': 2 # General size scale to match background resolution
}
# Background images
background: AssetField = "stats/background.png"
# Inner container
container_position: PointField = (60, 50) # Position of top left corner
container_size: PointField = (1410, 800) # Size of the inner container
# Major (topmost) header
header_font: FontField = ('Black', 27)
header_colour: ColourField = '#DDB21D'
header_gap: NumberField = 35 # Gap between header and column contents
header_height: ComputedField = lambda skin: skin.header_font.getsize('STATISTICS')[1]
# First column
col1_header: StringField = 'STATISTICS'
stats_subheader_pregap: NumberField = 8
stats_subheader_font: FontField = ('Black', 21)
stats_subheader_colour: ColourField = '#FFFFFF'
stats_subheader_size: ComputedField = lambda skin: skin.stats_subheader_font.getsize('LEADERBOARD POSITION')
stats_text_gap: NumberField = 13 # Gap between stat lines
stats_text_font: FontField = ('SemiBold', 19)
stats_text_height: ComputedField = lambda skin: skin.stats_text_font.getsize('DAILY')[1]
stats_text_colour: ColourField = '#BABABA'
col1_size: ComputedField = lambda skin: (
skin.stats_subheader_size[0],
skin.header_height + skin.header_gap
+ 3 * skin.stats_subheader_size[1]
+ 2 * skin.stats_subheader_pregap
+ 6 * skin.stats_text_height
+ 8 * skin.stats_text_gap
)
# Second column
col2_header: StringField = 'STUDY STREAK'
col2_date_font: FontField = ('Black', 21)
col2_date_colour: ColourField = '#FFFFFF'
col2_hours_colour: ColourField = '#1473A2'
col2_date_gap: NumberField = 25 # Gap between date line and calender
col2_subheader_height: ComputedField = lambda skin: skin.col2_date_font.getsize('JANUARY')[1]
cal_column_sep: NumberField = 35
cal_weekday_text: RawField = ('S', 'M', 'T', 'W', 'T', 'F', 'S')
cal_weekday_font: FontField = ('ExtraBold', 21)
cal_weekday_colour: ColourField = '#FFFFFF'
cal_weekday_height: ComputedField = lambda skin: skin.cal_weekday_font.getsize('S')[1]
cal_weekday_gap: NumberField = 23
cal_number_font: FontField = ('Medium', 20)
cal_number_end_colour: ColourField = '#BABABA'
cal_number_colour: ColourField = '#BABABA'
cal_number_gap: NumberField = 28
alt_cal_number_gap: NumberField = 20
cal_number_size: ComputedField = lambda skin: skin.cal_number_font.getsize('88')
cal_streak_mask: AssetField = 'stats/streak_mask.png'
cal_streak_end_colour: ColourField = '#1473A2'
cal_streak_end_colour_override: ColourField = None
cal_streak_end: BlobField = FieldDesc(
BlobField,
mask_field='cal_streak_mask',
colour_field='cal_streak_end_colour',
colour_override_field='cal_streak_end_colour_override'
)
cal_streak_middle_colour: ColourField = '#1B3343'
cal_streak_middle_colour_override: ColourField = None
cal_streak_middle: BlobField = FieldDesc(
BlobField,
mask_field='cal_streak_mask',
colour_field='cal_streak_middle_colour',
colour_override_field='cal_streak_middle_colour_override'
)
cal_size: ComputedField = lambda skin: (
7 * skin.cal_number_size[0] + 6 * skin.cal_column_sep + skin.cal_streak_end.width // 2,
5 * skin.cal_number_size[1] + 4 * skin.cal_number_gap
+ skin.cal_weekday_height + skin.cal_weekday_gap
+ skin.cal_streak_end.height // 2
)
alt_cal_size: ComputedField = lambda skin: (
7 * skin.cal_number_size[0] + 6 * skin.cal_column_sep + skin.cal_streak_end.width // 2,
6 * skin.cal_number_size[1] + 5 * skin.alt_cal_number_gap
+ skin.cal_weekday_height + skin.cal_weekday_gap
+ skin.cal_streak_end.height // 2
)
col2_size: ComputedField = lambda skin: (
skin.cal_size[0],
skin.header_height + skin.header_gap
+ skin.col2_subheader_height + skin.col2_date_gap
+ skin.cal_size[1]
)
alt_col2_size: ComputedField = lambda skin: (
skin.alt_cal_size[0],
skin.header_height + skin.header_gap
+ skin.col2_subheader_height + skin.col2_date_gap
+ skin.alt_cal_size[1]
)
class StatsLayout(Layout):
def __init__(self, skin, lb_data, time_data, workouts, streak_data, date=None, draft=False, **kwargs):
self.draft = draft
self.skin = skin
self.data_lb_time = lb_data[0] # Position on time leaderboard, or None
self.data_lb_lc = lb_data[1] # Position on coin leaderboard, or None
self.data_time_daily = int(time_data[0]) # Daily time in seconds
self.data_time_weekly = int(time_data[1]) # Weekly time in seconds
self.data_time_monthly = int(time_data[2]) # Monthly time in seconds
self.data_time_all = int(time_data[3]) # All time in seconds
self.data_workouts = workouts # Number of workout sessions
self.data_streaks = streak_data # List of streak day ranges to show
# Extract date info
date = date if date else datetime.today() # Date to show for month/year
month_first_day = date.replace(day=1)
month_days = (month_first_day.replace(month=(month_first_day.month % 12) + 1) - timedelta(days=1)).day
self.date = date
self.month = date.strftime('%B').upper()
self.first_weekday = month_first_day.weekday() # Which weekday the month starts on
self.month_days = month_days
self.alt_layout = (month_days + self.first_weekday + 1) > 35 # Whether to use the alternate layout
if self.alt_layout:
self.skin.fields['cal_number_gap'].value = self.skin.alt_cal_number_gap
self.skin.fields['cal_size'].value = self.skin.alt_cal_size
self.skin.fields['col2_size'].value = self.skin.alt_col2_size
self.image: Image = None # Final Image
def draw(self):
# Load/copy background
image = self.skin.background
# Draw inner container
inner_container = self.draw_inner_container()
# Paste inner container on background
image.alpha_composite(inner_container, self.skin.container_position)
self.image = image
return image
def draw_inner_container(self):
container = Image.new('RGBA', self.skin.container_size)
col1 = self.draw_column_1()
col2 = self.draw_column_2()
container.alpha_composite(col1)
container.alpha_composite(col2, (container.width - col2.width, 0))
if self.draft:
draw = ImageDraw.Draw(container)
draw.rectangle(((0, 0), (self.skin.container_size[0]-1, self.skin.container_size[1]-1)))
return container
def draw_column_1(self) -> Image:
# Create new image for column 1
col1 = Image.new('RGBA', self.skin.col1_size)
draw = ImageDraw.Draw(col1)
if self.draft:
draw.rectangle(((0, 0), (self.skin.col1_size[0]-1, self.skin.col1_size[1]-1)))
# Tracking current drawing height
position = 0
# Write header
draw.text(
(0, position),
self.skin.col1_header,
font=self.skin.header_font,
fill=self.skin.header_colour
)
position += self.skin.header_height + self.skin.header_gap
# Leaderboard
draw.text(
(0, position),
'LEADERBOARD POSITION',
font=self.skin.stats_subheader_font,
fill=self.skin.stats_subheader_colour
)
position += self.skin.col2_subheader_height + self.skin.stats_text_gap
draw.text(
(0, position),
f"TIME: {format_lb(self.data_lb_time)}",
font=self.skin.stats_text_font,
fill=self.skin.stats_text_colour
)
position += self.skin.stats_text_height + self.skin.stats_text_gap
draw.text(
(0, position),
"ANKI: COMING SOON",
font=self.skin.stats_text_font,
fill=self.skin.stats_text_colour
)
position += self.skin.stats_text_height + self.skin.stats_text_gap
position += self.skin.stats_subheader_pregap
# Study time
draw.text(
(0, position),
'STUDY TIME',
font=self.skin.stats_subheader_font,
fill=self.skin.stats_subheader_colour
)
position += self.skin.col2_subheader_height + self.skin.stats_text_gap
draw.text(
(0, position),
f'DAILY: {format_time(self.data_time_daily)}',
font=self.skin.stats_text_font,
fill=self.skin.stats_text_colour
)
position += self.skin.stats_text_height + self.skin.stats_text_gap
draw.text(
(0, position),
f'MONTHLY: {format_time(self.data_time_monthly)}',
font=self.skin.stats_text_font,
fill=self.skin.stats_text_colour
)
position += self.skin.stats_text_height + self.skin.stats_text_gap
draw.text(
(0, position),
f'WEEKLY: {format_time(self.data_time_weekly)}',
font=self.skin.stats_text_font,
fill=self.skin.stats_text_colour
)
position += self.skin.stats_text_height + self.skin.stats_text_gap
draw.text(
(0, position),
f'ALL TIME: {format_time(self.data_time_all)}',
font=self.skin.stats_text_font,
fill=self.skin.stats_text_colour
)
position += self.skin.stats_text_height + self.skin.stats_text_gap
position += self.skin.stats_subheader_size[1] // 2
position += self.skin.stats_subheader_pregap
# Workouts
workout_text = "WORKOUTS: "
draw.text(
(0, position),
workout_text,
font=self.skin.stats_subheader_font,
fill=self.skin.stats_subheader_colour,
anchor='lm'
)
xposition = self.skin.stats_subheader_font.getlength(workout_text)
draw.text(
(xposition, position),
str(self.data_workouts),
font=self.skin.stats_text_font,
fill=self.skin.stats_subheader_colour,
anchor='lm'
)
return col1
def draw_column_2(self) -> Image:
# Create new image for column 1
col2 = Image.new('RGBA', self.skin.col2_size)
draw = ImageDraw.Draw(col2)
if self.draft:
draw.rectangle(((0, 0), (self.skin.col2_size[0]-1, self.skin.col2_size[1]-1)))
# Tracking current drawing height
position = 0
# Write header
draw.text(
(0, position),
self.skin.col2_header,
font=self.skin.header_font,
fill=self.skin.header_colour
)
position += self.skin.header_height + self.skin.header_gap
# Draw date line
month_text = "{}: ".format(self.month)
draw.text(
(0, position),
month_text,
font=self.skin.col2_date_font,
fill=self.skin.col2_date_colour
)
xposition = self.skin.col2_date_font.getlength(month_text)
draw.text(
(xposition, position),
f"{self.data_time_monthly // 3600} HRS",
font=self.skin.col2_date_font,
fill=self.skin.col2_hours_colour
)
year_text = str(self.date.year)
xposition = col2.width - self.skin.col2_date_font.getlength(year_text)
draw.text(
(xposition, position),
year_text,
font=self.skin.col2_date_font,
fill=self.skin.col2_date_colour
)
position += self.skin.col2_subheader_height + self.skin.col2_date_gap
# Draw calendar
cal = self.draw_calendar()
col2.alpha_composite(cal, (0, position))
return col2
def draw_calendar(self) -> Image:
cal = Image.new('RGBA', self.skin.cal_size)
draw = ImageDraw.Draw(cal)
if self.draft:
draw.rectangle(((0, 0), (self.skin.cal_size[0]-1, self.skin.cal_size[1]-1)))
xpos, ypos = (0, 0) # Approximate position of top left corner to draw on
# Constant offset to mid basepoint of text
xoffset = self.skin.cal_streak_end.width // 2
yoffset = self.skin.cal_number_size[1] // 2
# Draw the weekdays
for i, l in enumerate(self.skin.cal_weekday_text):
draw.text(
(xpos + xoffset, ypos + yoffset),
l,
font=self.skin.cal_weekday_font,
fill=self.skin.cal_weekday_colour,
anchor='mm'
)
xpos += self.skin.cal_number_size[0] + self.skin.cal_column_sep
ypos += self.skin.cal_weekday_height + self.skin.cal_weekday_gap
xpos = 0
streak_starts = list(itertools.chain(*self.data_streaks))
streak_middles = list(itertools.chain(*(range(i+1, j) for i, j in self.data_streaks)))
streak_pairs = set(i for i, j in self.data_streaks if j-i == 1)
# Draw the days of the month
num_diff_x = self.skin.cal_number_size[0] + self.skin.cal_column_sep
num_diff_y = self.skin.cal_number_size[1] + self.skin.cal_number_gap
offset = (self.first_weekday + 1) % 7
centres = [
(xpos + xoffset + (i + offset) % 7 * num_diff_x,
ypos + yoffset + (i + offset) // 7 * num_diff_y)
for i in range(0, self.month_days)
]
for day in streak_middles:
if day < 1:
continue
i = day - 1
if i >= len(centres):
# Shouldn't happen, but ignore
continue
x, y = centres[i]
week_day = (i + offset) % 7
top = y - self.skin.cal_streak_end.height // 2
bottom = y + self.skin.cal_streak_end.height // 2 - 1
# Middle of streak on edges
if week_day == 0 or i == 0:
# Draw end bobble
cal.paste(
self.skin.cal_streak_middle,
(x - self.skin.cal_streak_end.width // 2, top)
)
if week_day != 6:
# Draw rectangle forwards
draw.rectangle(
((x, top), (x + num_diff_x, bottom)),
fill=self.skin.cal_streak_middle_colour,
width=0
)
elif week_day == 6 or i == self.month_days - 1:
# Draw end bobble
cal.paste(
self.skin.cal_streak_middle,
(x - self.skin.cal_streak_end.width // 2, top)
)
if week_day != 0:
# Draw rectangle backwards
draw.rectangle(
((x - num_diff_x, top), (x, bottom)),
fill=self.skin.cal_streak_middle_colour,
width=0
)
else:
# Draw rectangle on either side
draw.rectangle(
((x - num_diff_x, top), (x + num_diff_x, bottom)),
fill=self.skin.cal_streak_middle_colour,
width=0
)
for i, (x, y) in enumerate(centres):
# Streak endpoint
if i + 1 in streak_starts:
if i + 1 in streak_pairs and (i + offset) % 7 != 6:
# Draw rectangle forwards
top = y - self.skin.cal_streak_end.height // 2
bottom = y + self.skin.cal_streak_end.height // 2 - 1
draw.rectangle(
((x, top), (x + num_diff_x, bottom)),
fill=self.skin.cal_streak_middle_colour,
width=0
)
cal.alpha_composite(
self.skin.cal_streak_end,
(x - self.skin.cal_streak_end.width // 2, y - self.skin.cal_streak_end.height // 2)
)
for i, (x, y) in enumerate(centres):
numstr = str(i + 1)
draw.text(
(x, y),
numstr,
font=self.skin.cal_number_font,
fill=self.skin.cal_number_end_colour if (i+1 in streak_starts) else self.skin.cal_number_colour,
anchor='mm'
)
return cal
class StatsCard(Card):
route = 'stats_card'
card_id = 'stats'
layout = StatsLayout
skin = StatsSkin
display_name = "User Stats"
@classmethod
async def sample_args(cls, ctx, **kwargs):
return {
'lb_data': (21, 123),
'time_data': (3600, 5 * 24 * 3600, 1.5 * 24 * 3600, 100 * 24 * 3600),
'workouts': 50,
'streak_data': [(1, 3), (7, 8), (10, 10), (12, 16), (18, 25), (27, 31)],
'date': datetime(2022, 2, 1)
}

View File

@@ -0,0 +1,389 @@
from io import BytesIO
import pickle
from PIL import Image, ImageDraw
from ..base import Card, Layout, fielded, Skin, FieldDesc
from ..base.Avatars import avatar_manager
from ..base.Skin import (
AssetField, StringField, NumberField,
FontField, ColourField, PointField, ComputedField
)
from .mixins import MiniProfileLayout
@fielded
class TasklistSkin(Skin):
_env = {
'scale': 2 # General size scale to match background resolution
}
# First page
first_page_bg: AssetField = "tasklist/first_page_background.png"
first_page_frame: AssetField = "tasklist/first_page_frame.png"
title_pre_gap: NumberField = 40
title_text: StringField = "TO DO LIST"
title_font: FontField = ('ExtraBold', 76)
title_size: ComputedField = lambda skin: skin.title_font.getsize(skin.title_text)
title_colour: ColourField = '#DDB21D'
title_underline_gap: NumberField = 10
title_underline_width: NumberField = 5
title_gap: NumberField = 50
# Profile section
mini_profile_indent: NumberField = 125
mini_profile_size: ComputedField = lambda skin: (
skin.first_page_bg.width - 2 * skin.mini_profile_indent,
int(skin._env['scale'] * 200)
)
mini_profile_avatar_mask: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_mask.png', convert=None)
mini_profile_avatar_frame: AssetField = FieldDesc(AssetField, 'mini-profile/avatar_frame.png', convert='RGBA')
mini_profile_avatar_sep: NumberField = 50
mini_profile_name_font: FontField = ('BoldItalic', 55)
mini_profile_name_colour: ColourField = '#DDB21D'
mini_profile_discrim_font: FontField = mini_profile_name_font
mini_profile_discrim_colour: ColourField = '#BABABA'
mini_profile_name_gap: NumberField = 20
mini_profile_badge_end: AssetField = "mini-profile/badge_end.png"
mini_profile_badge_font: FontField = ('Black', 30)
mini_profile_badge_colour: ColourField = '#FFFFFF'
mini_profile_badge_text_colour: ColourField = '#051822'
mini_profile_badge_gap: NumberField = 20
mini_profile_badge_min_sep: NumberField = 10
# Other pages
other_page_bg: AssetField = "tasklist/other_page_background.png"
other_page_frame: AssetField = "tasklist/other_page_frame.png"
# Help frame
help_frame: AssetField = "tasklist/help_frame.png"
# Tasks
task_start_position: PointField = (100, 75)
task_done_number_bg: AssetField = "tasklist/task_done_bg.png"
task_done_number_font: FontField = ('Regular', 45)
task_done_number_colour: ColourField = '#292828'
task_done_text_font: FontField = ('Regular', 55)
task_done_text_colour: ColourField = '#686868'
task_done_line_width: NumberField = 3.5
task_undone_number_bg: AssetField = "tasklist/task_undone_bg.png"
task_undone_number_font: FontField = ('Regular', 45)
task_undone_number_colour: ColourField = '#FFFFFF'
task_undone_text_font: FontField = ('Regular', 55)
task_undone_text_colour: ColourField = '#FFFFFF'
task_text_height: ComputedField = lambda skin: skin.task_done_text_font.getsize('TASK')[1]
task_num_sep: NumberField = 30
task_inter_gap: NumberField = 32
task_intra_gap: NumberField = 25
# Date text
footer_pre_gap: NumberField = 50
footer_font: FontField = ('Bold', 28)
footer_colour: ColourField = '#686868'
footer_gap: NumberField = 50
class TasklistLayout(Layout, MiniProfileLayout):
def __init__(self, skin, name, discrim, tasks, date, avatar, badges=()):
self.skin = skin
self.data_name = name
self.data_discrim = discrim
self.data_avatar = avatar
self.data_tasks = tasks
self.data_date = date
self.data_badges = badges
self.tasks_drawn = 0
self.images = []
def _execute_draw(self):
image_data = []
for image in self.draw():
with BytesIO() as data:
image.save(data, format='PNG')
data.seek(0)
image_data.append(data.getvalue())
return pickle.dumps(image_data)
def draw(self):
self.images = []
self.images.append(self._draw_first_page())
while self.tasks_drawn < len(self.data_tasks):
self.images.append(self._draw_another_page())
return self.images
def close(self):
if self.images:
for image in self.images:
image.close()
def _draw_first_page(self) -> Image:
image = self.skin.first_page_bg
draw = ImageDraw.Draw(image)
xpos, ypos = 0, 0
# Draw header text
xpos = (image.width - self.skin.title_size[0]) // 2
ypos += self.skin.title_pre_gap
draw.text(
(xpos, ypos),
self.skin.title_text,
fill=self.skin.title_colour,
font=self.skin.title_font
)
# Underline it
ypos += self.skin.title_size[1] + self.skin.title_underline_gap
# draw.line(
# (xpos, ypos, xpos + self.skin.title_size[0], ypos),
# fill=self.skin.title_colour,
# width=self.skin.title_underline_width
# )
ypos += self.skin.title_underline_width + self.skin.title_gap
# Draw the profile
xpos = self.skin.mini_profile_indent
profile = self._draw_profile()
image.alpha_composite(
profile,
(xpos, ypos)
)
# Start from the bottom
ypos = image.height
if self.data_tasks:
# Draw the date text
ypos -= self.skin.footer_gap
date_text = self.data_date.strftime("As of %d %b")
size = self.skin.footer_font.getsize(date_text)
ypos -= size[1]
draw.text(
((image.width - size[0]) // 2, ypos),
date_text,
font=self.skin.footer_font,
fill=self.skin.footer_colour
)
ypos -= self.skin.footer_pre_gap
# Draw the tasks
task_image = self._draw_tasks_into(self.skin.first_page_frame.copy())
ypos -= task_image.height
image.alpha_composite(
task_image,
((image.width - task_image.width) // 2, ypos)
)
else:
# Draw the help frame
ypos -= self.skin.footer_gap
image.alpha_composite(
self.skin.help_frame,
((image.width - self.skin.help_frame.width) // 2, ypos - self.skin.help_frame.height)
)
return image
def _draw_another_page(self) -> Image:
image = self.skin.other_page_bg.copy()
draw = ImageDraw.Draw(image)
# Start from the bottom
ypos = image.height
# Draw the date text
ypos -= self.skin.footer_gap
date_text = self.data_date.strftime("As of %d %b • {} {}".format(self.data_name, self.data_discrim))
size = self.skin.footer_font.getsize(date_text)
ypos -= size[1]
draw.text(
((image.width - size[0]) // 2, ypos),
date_text,
font=self.skin.footer_font,
fill=self.skin.footer_colour
)
ypos -= self.skin.footer_pre_gap
# Draw the tasks
task_image = self._draw_tasks_into(self.skin.other_page_frame.copy())
ypos -= task_image.height
image.alpha_composite(
task_image,
((image.width - task_image.width) // 2, ypos)
)
return image
def _draw_tasks_into(self, image) -> Image:
"""
Draw as many tasks as possible into the given image background.
"""
draw = ImageDraw.Draw(image)
xpos, ypos = self.skin.task_start_position
for n, task, done in self.data_tasks[self.tasks_drawn:]:
# Draw task first to check if it fits on the page
task_image = self._draw_text(
task,
image.width - xpos - self.skin.task_done_number_bg.width - self.skin.task_num_sep,
done
)
if task_image.height + ypos + self.skin.task_inter_gap > image.height:
break
# Draw number background
bg = self.skin.task_done_number_bg if done else self.skin.task_undone_number_bg
image.alpha_composite(
bg,
(xpos, ypos)
)
# Draw number
font = self.skin.task_done_number_font if done else self.skin.task_undone_number_font
colour = self.skin.task_done_number_colour if done else self.skin.task_undone_number_colour
draw.text(
(xpos + bg.width // 2, ypos + bg.height // 2),
str(n),
fill=colour,
font=font,
anchor='mm'
)
# Draw text
image.alpha_composite(
task_image,
(xpos + bg.width + self.skin.task_num_sep, ypos - (bg.height - self.skin.task_text_height) // 2)
)
ypos += task_image.height + self.skin.task_inter_gap
self.tasks_drawn += 1
return image
def _draw_text(self, task, maxwidth, done) -> Image:
"""
Draw the text of a given task.
"""
font = self.skin.task_done_text_font if done else self.skin.task_undone_text_font
colour = self.skin.task_done_text_colour if done else self.skin.task_undone_text_colour
# Handle empty tasks
if not task.strip():
task = '~'
# First breakup the text
lines = []
line = []
width = 0
for word in task.split():
length = font.getlength(word + ' ')
if width + length > maxwidth:
if line:
lines.append(' '.join(line))
line = []
width = 0
line.append(word)
width += length
if line:
lines.append(' '.join(line))
# Then draw it
bboxes = [font.getbbox(line) for line in lines]
heights = [font.getsize(line)[1] for line in lines]
height = sum(height for height in heights) + (len(lines) - 1) * self.skin.task_intra_gap
image = Image.new('RGBA', (maxwidth, height))
draw = ImageDraw.Draw(image)
x, y = 0, 0
for line, (x1, y1, x2, y2), height in zip(lines, bboxes, heights):
draw.text(
(x, y),
line,
fill=colour,
font=font
)
if done:
# Also strikethrough
draw.line(
(x1, y + y1 + (y2 - y1) // 2, x2, y + y1 + (y2 - y1) // 2),
fill=self.skin.task_done_text_colour,
width=self.skin.task_done_line_width
)
y += height + self.skin.task_intra_gap
return image
class TasklistCard(Card):
route = 'tasklist_card'
card_id = 'tasklist'
layout = TasklistLayout
skin = TasklistSkin
display_name = "Tasklist"
@classmethod
async def request(cls, *args, **kwargs):
data = await super().request(*args, **kwargs)
return pickle.loads(data)
@classmethod
async def card_route(cls, runner, args, kwargs):
kwargs['avatar'] = await avatar_manager().get_avatar(*kwargs['avatar'], 256)
return await super().card_route(runner, args, kwargs)
@classmethod
def _execute(cls, *args, **kwargs):
with BytesIO(kwargs['avatar']) as image_data:
with Image.open(image_data).convert('RGBA') as avatar_image:
kwargs['avatar'] = avatar_image
return super()._execute(*args, **kwargs)
@classmethod
async def generate_sample(cls, ctx=None, **kwargs):
from ..utils import image_as_file
sample_kwargs = await cls.sample_args(ctx)
cards = await cls.request(**{**sample_kwargs, **kwargs})
return image_as_file(cards[0], "sample.png")
@classmethod
async def sample_args(cls, ctx, **kwargs):
import datetime
from ..utils import get_avatar_key
return {
'name': ctx.author.name if ctx else "John Doe",
'discrim': '#' + ctx.author.discriminator if ctx else "#0000",
'tasks': [
(0, 'Run 50km', True),
(1, 'Read 5 books', False),
(2, 'Renovate bedroom', True),
(3, 'Learn a new language', False),
(4, 'Upload a vlog', False),
(5, 'Bibendum arcu vitae elementum curabitur vitae nunc sed velit', False),
(6, 'Dictum fusce ut placerat orci', True),
(7, 'Pharetra vel turpis nunc eget lorem dolor', True)
],
'date': datetime.datetime.now().replace(hour=0, minute=0, second=0),
'avatar': get_avatar_key(ctx.client, ctx.author.id) if ctx else (0, None),
'badges': (
'STUDYING: MEDICINE',
'HOBBY: MATHS',
'CAREER: STUDENT',
'FROM: EUROPE',
'LOVES CATS <3'
),
}

View File

@@ -0,0 +1,38 @@
from io import BytesIO
from PIL import Image, ImageDraw
from .Card import Card
from .Avatars import avatar_manager
class TestCard(Card):
server_route = "testing"
def __init__(self, text, avatar):
self.text = text
self.avatar = avatar
self.image = None
def draw(self):
bg = Image.new('RGBA', (100, 100))
draw = ImageDraw.Draw(bg)
draw.text(
(0, 0),
self.text,
fill='#FF0000'
)
bg.alpha_composite(self.avatar, (0, 30))
return bg
@classmethod
async def card_route(cls, executor, args, kwargs):
kwargs['avatar'] = (await avatar_manager().get_avatars(kwargs['avatar']))[0]
return await super().card_route(executor, args, kwargs)
@classmethod
def _execute(cls, *args, **kwargs):
with BytesIO(kwargs['avatar']) as image_data:
with Image.open(image_data).convert('RGBA') as avatar_image:
kwargs['avatar'] = avatar_image
return super()._execute(*args, **kwargs)

View File

@@ -0,0 +1,449 @@
import math
from io import BytesIO
from PIL import Image, ImageDraw, ImageOps
from ..base import Card, Layout, fielded, Skin
from ..base.Avatars import avatar_manager
from ..base.Skin import (
AssetField, StringField, NumberField,
FontField, ColourField, PointField, ComputedField
)
@fielded
class _TimerSkin(Skin):
_env = {
'scale': 2 # General size scale to match background resolution
}
background: AssetField = "timer/background.png"
main_colour: ColourField
header_field_height: NumberField = 171.5
header_font: FontField = ('ExtraBold', 76)
inner_margin: NumberField = 40
inner_sep: NumberField = 7.5
# Timer section
# Outer progress bar
progress_end: AssetField
progress_start: AssetField
progress_bg: AssetField = "timer/break_timer.png"
progress_mask: ComputedField = lambda skin: ImageOps.invert(skin.progress_bg.split()[-1].convert('L'))
timer_bg: AssetField = "timer/timer_bg.png"
# Inner timer text
countdown_font: FontField = ('Light', 112)
countdown_gap: NumberField = 10
stage_font: FontField = ('Light', 43.65)
stage_colour: ColourField = '#FFFFFF'
mic_icon: AssetField
stage_text: StringField
# Members
user_bg: AssetField = "timer/break_user.png"
user_mask: AssetField = "timer/avatar_mask.png"
time_font: FontField = ('Black', 26)
time_colour: ColourField = '#FFFFFF'
tag_gap: NumberField = 5.5
tag: AssetField
tag_font: FontField = ('SemiBold', 25)
# grid_x = (background.width - progress_mask.width - 2 * progress_end.width - grid_start[0] - user_bg.width) // 4
grid: PointField = (344, 246)
# Date text
date_font: FontField = ('Bold', 28)
date_colour: ColourField = '#6f6e6f'
date_gap: NumberField = 50
@fielded
class FocusTimerSkin(_TimerSkin):
main_colour: ColourField = '#DDB21D'
user_bg: AssetField = "timer/focus_user.png"
mic_icon: AssetField = "timer/mute.png"
progress_end: AssetField = "timer/progress_end_focus.png"
progress_start: AssetField = "timer/progress_start_focus.png"
stage_text: StringField = "FOCUS"
tag: AssetField = "timer/focus_tag.png"
@fielded
class BreakTimerSkin(_TimerSkin):
main_colour: ColourField = '#78B7EF'
user_bg: AssetField = "timer/break_user.png"
mic_icon: AssetField = "timer/unmute.png"
progress_end: AssetField = "timer/progress_end_break.png"
progress_start: AssetField = "timer/progress_start_break.png"
stage_text: StringField = "BREAK"
tag: AssetField = "timer/break_tag.png"
class TimerLayout(Layout):
def __init__(self, skin, name, remaining, duration, users):
self.skin = skin
self.data_name = name
self.data_remaining = 5 * math.ceil(remaining / 5)
self.data_duration = duration
self.data_amount = 1 - remaining / duration
self.data_users = sorted(users, key=lambda user: user[1], reverse=True) # (avatar, time)
@staticmethod
def format_time(time, hours=True):
if hours:
return "{:02}:{:02}".format(int(time // 3600), int((time // 60) % 60))
else:
return "{:02}:{:02}".format(int(time // 60), int(time % 60))
def draw(self):
image = self.skin.background
draw = ImageDraw.Draw(image)
# Draw header
text = self.data_name
length = self.skin.header_font.getlength(text)
draw.text(
(image.width // 2, self.skin.header_field_height // 2),
text,
fill=self.skin.main_colour,
font=self.skin.header_font,
anchor='mm'
)
# Draw timer
timer_image = self._draw_progress_bar(self.data_amount)
ypos = timer_y = (
self.skin.header_field_height
+ (image.height - self.skin.header_field_height - timer_image.height) // 2
- self.skin.progress_end.height // 2
)
xpos = timer_x = image.width - self.skin.inner_margin - timer_image.width
image.alpha_composite(
timer_image,
(xpos, ypos)
)
# Draw timer text
stage_size = self.skin.stage_font.getsize(' ' + self.skin.stage_text)
ypos += timer_image.height // 2 - stage_size[1] // 2
xpos += timer_image.width // 2
draw.text(
(xpos, ypos),
(text := self.format_time(self.data_remaining)),
fill=self.skin.main_colour,
font=self.skin.countdown_font,
anchor='mm'
)
size = int(self.skin.countdown_font.getsize(text)[1])
ypos += size
self.skin.mic_icon.thumbnail((stage_size[1], stage_size[1]))
length = int(self.skin.mic_icon.width + self.skin.stage_font.getlength(' ' + self.skin.stage_text))
xpos -= length // 2
image.alpha_composite(
self.skin.mic_icon,
(xpos, ypos - self.skin.mic_icon.height)
)
draw.text(
(xpos + self.skin.mic_icon.width, ypos),
' ' + self.skin.stage_text,
fill=self.skin.stage_colour,
font=self.skin.stage_font,
anchor='ls'
)
# Draw user grid
if self.data_users:
grid_image = self.draw_user_grid()
# ypos = self.skin.header_field_height + (image.height - self.skin.header_field_height - grid_image.height) // 2
ypos = timer_y + (timer_image.height - grid_image.height) // 2 - stage_size[1] // 2
xpos = (
self.skin.inner_margin
+ (timer_x - self.skin.inner_sep - self.skin.inner_margin) // 2
- grid_image.width // 2
)
image.alpha_composite(
grid_image,
(xpos, ypos)
)
# Draw the footer
ypos = image.height
ypos -= self.skin.date_gap
date_text = "Use !now [text] to show what you are working on!"
size = self.skin.date_font.getsize(date_text)
ypos -= size[1]
draw.text(
((image.width - size[0]) // 2, ypos),
date_text,
font=self.skin.date_font,
fill=self.skin.date_colour
)
return image
def draw_user_grid(self) -> Image:
users = list(self.data_users)[:25]
# Set these to 5 and 5 to force top left corner
rows = math.ceil(len(users) / 5)
columns = 5
# columns = min(len(users), 5)
size = (
(columns - 1) * self.skin.grid[0] + self.skin.user_bg.width,
(rows - 1) * self.skin.grid[1] + self.skin.user_bg.height + self.skin.tag_gap + self.skin.tag.height
)
image = Image.new(
'RGBA',
size
)
for i, user in enumerate(users):
x = (i % 5) * self.skin.grid[0]
y = (i // 5) * self.skin.grid[1]
user_image = self.draw_user(user)
image.alpha_composite(
user_image,
(x, y)
)
return image
def draw_user(self, user):
width = self.skin.user_bg.width
height = self.skin.user_bg.height + self.skin.tag_gap + self.skin.tag.height
image = Image.new('RGBA', (width, height))
draw = ImageDraw.Draw(image)
image.alpha_composite(self.skin.user_bg)
avatar, time, tag = user
avatar = avatar
timestr = self.format_time(time, hours=True)
# Mask avatar
avatar.paste((0, 0, 0, 0), mask=self.skin.user_mask.convert('RGBA'))
# Resize avatar
avatar.thumbnail((self.skin.user_bg.height - 10, self.skin.user_bg.height - 10))
image.alpha_composite(
avatar,
(5, 5)
)
draw.text(
(120, self.skin.user_bg.height // 2),
timestr,
anchor='lm',
font=self.skin.time_font,
fill=self.skin.time_colour
)
if tag:
ypos = self.skin.user_bg.height + self.skin.tag_gap
image.alpha_composite(
self.skin.tag,
((image.width - self.skin.tag.width) // 2, ypos)
)
draw.text(
(image.width // 2, ypos + self.skin.tag.height // 2),
tag,
font=self.skin.tag_font,
fill='#FFFFFF',
anchor='mm'
)
return image
def _draw_progress_bar(self, amount):
amount = min(amount, 1)
amount = max(amount, 0)
bg = self.skin.timer_bg
end = self.skin.progress_start
mask = self.skin.progress_mask
center = (
bg.width // 2 + 1,
bg.height // 2
)
radius = 553
theta = amount * math.pi * 2 - math.pi / 2
x = int(center[0] + radius * math.cos(theta))
y = int(center[1] + radius * math.sin(theta))
canvas = Image.new('RGBA', size=(bg.width, bg.height))
draw = ImageDraw.Draw(canvas)
if amount >= 0.01:
canvas.alpha_composite(
end,
(
center[0] - end.width // 2,
26 - end.height // 2
)
)
sidelength = bg.width // 2
line_ends = (
int(center[0] + sidelength * math.cos(theta)),
int(center[1] + sidelength * math.sin(theta))
)
if amount <= 0.25:
path = [
center,
(center[0], center[1] - sidelength),
(bg.width, 0),
line_ends
]
elif amount <= 0.5:
path = [
center,
(center[0], center[1] - sidelength),
(bg.width, 0),
(bg.width, bg.height),
line_ends
]
elif amount <= 0.75:
path = [
center,
(center[0], center[1] - sidelength),
(bg.width, 0),
(bg.width, bg.height),
(0, bg.height),
line_ends
]
else:
path = [
center,
(center[0], center[1] - sidelength),
(bg.width, 0),
(bg.width, bg.height),
(0, bg.height),
(0, 0),
line_ends
]
draw.polygon(
path,
fill=self.skin.main_colour
)
canvas.paste((0, 0, 0, 0), mask=mask)
image = Image.new(
'RGBA',
size=(bg.width + self.skin.progress_end.width,
bg.height + self.skin.progress_end.height)
)
image.alpha_composite(
bg,
(self.skin.progress_end.width // 2,
self.skin.progress_end.height // 2)
)
image.alpha_composite(
canvas,
(self.skin.progress_end.width // 2,
self.skin.progress_end.height // 2)
)
image.alpha_composite(
self.skin.progress_end,
(
x,
y
)
)
return image
class _TimerCard(Card):
layout = TimerLayout
@classmethod
async def card_route(cls, runner, args, kwargs):
if kwargs['users']:
avatar_keys, times, tags = zip(*kwargs['users'])
avatars = await avatar_manager().get_avatars(*((*key, 512) for key in avatar_keys))
kwargs['users'] = tuple(zip(avatars, times, tags))
return await super().card_route(runner, args, kwargs)
@classmethod
def _execute(cls, *args, **kwargs):
if kwargs['users']:
avatar_data, times, tags = zip(*kwargs['users'])
avatars = []
for datum in avatar_data:
with BytesIO(datum) as buffer:
buffer.seek(0)
avatars.append(Image.open(buffer).convert('RGBA'))
kwargs['users'] = tuple(zip(avatars, times, tags))
return super()._execute(*args, **kwargs)
class FocusTimerCard(_TimerCard):
route = 'focus_timer_card'
card_id = 'focus_timer'
skin = FocusTimerSkin
display_name = "Focus Timer"
@classmethod
async def sample_args(cls, ctx, **kwargs):
from ..utils import get_avatar_key
return {
'name': 'Pomodoro Timer',
'remaining': 1658,
'duration': 3000,
'users': [
(get_avatar_key(ctx.client, ctx.author.id), 7055, "SkinShop"),
((0, None), 6543, "Never"),
((0, None), 5432, "Going"),
((0, None), 4321, "To"),
((0, None), 3210, "Give"),
((0, None), 2109, "You"),
((0, None), 1098, "Up"),
]
}
class BreakTimerCard(_TimerCard):
route = 'break_timer_card'
card_id = 'break_timer'
skin = BreakTimerSkin
display_name = "Break Timer"
@classmethod
async def sample_args(cls, ctx, **kwargs):
from ..utils import get_avatar_key
return {
'name': 'Pomodoro Timer',
'remaining': 1658,
'duration': 3000,
'users': [
(get_avatar_key(ctx.client, ctx.author.id), 7055, "SkinShop"),
((0, None), 6543, "Never"),
((0, None), 5432, "Going"),
((0, None), 4321, "To"),
((0, None), 3210, "Let"),
((0, None), 2109, "You"),
((0, None), 1098, "Down"),
]
}

View File

@@ -0,0 +1,713 @@
import os
import math
from PIL import Image, ImageDraw, ImageColor
from datetime import timedelta, datetime, timezone
from ..utils import resolve_asset_path
from ..base import Card, Layout, fielded, Skin
from ..base.Skin import (
AssetField, RGBAAssetField, AssetPathField, BlobField, StringField, NumberField, PointField, RawField,
FontField, ColourField, ComputedField, FieldDesc
)
@fielded
class WeeklyStatsSkin(Skin):
_env = {
'scale': 1 # General size scale to match background resolution
}
background: AssetField = 'weekly/background.png'
# Header
title_pre_gap: NumberField = 40
title_text: StringField = "STUDY HOURS"
title_font: FontField = ('ExtraBold', 76)
title_size: ComputedField = lambda skin: skin.title_font.getsize(skin.title_text)
title_colour: ColourField = '#DDB21D'
title_underline_gap: NumberField = 10
title_underline_width: NumberField = 5
title_gap: NumberField = 50
# Top
top_grid_x: NumberField = 150
top_grid_y: NumberField = 100
top_hours_font: FontField = ('Bold', 36.35)
top_hours_colour: ColourField = '#FFFFFF'
top_hours_bg_mask: AssetField = 'weekly/hours_bg_mask.png'
top_hours_bg_colour: ColourField = '#0B465E' # TODO: Check this
top_hours_bg_colour_override: ColourField = None
top_hours_bg: BlobField = FieldDesc(
BlobField,
mask_field='top_hours_bg_mask',
colour_field='top_hours_bg_colour',
colour_field_override='top_hours_bg_colour_override'
)
top_hours_sep: NumberField = 100
top_line_width: NumberField = 10
top_line_colour: ColourField = '#042231'
top_weekday_pre_gap: NumberField = 20
top_weekday_font: FontField = ('Bold', 36.35)
top_weekday_colour: ColourField = '#FFFFFF'
top_weekday_height: ComputedField = lambda skin: skin.top_weekday_font.getsize('MTWTFSS')[1]
top_weekday_gap: NumberField = 5
top_date_font: FontField = ('SemiBold', 30)
top_date_colour: ColourField = '#808080'
top_date_height: ComputedField = lambda skin: skin.top_date_font.getsize('8/8')[1]
top_bar_mask: RGBAAssetField = 'weekly/top_bar_mask.png'
top_this_colour: ColourField = '#DDB21D'
top_this_color_override: ColourField = None
top_last_colour: ColourField = '#377689CC'
top_last_color_override: ColourField = None
top_this_bar_full: BlobField = FieldDesc(
BlobField,
mask_field='top_bar_mask',
colour_field='top_this_colour',
colour_field_override='top_this_colour_override'
)
top_last_bar_full: BlobField = FieldDesc(
BlobField,
mask_field='top_bar_mask',
colour_field='top_last_colour',
colour_field_override='top_last_colour_override'
)
top_gap: NumberField = 80
weekdays: RawField = ('M', 'T', 'W', 'T', 'F', 'S', 'S')
# Bottom
btm_bar_horiz_colour: ColourField = "#052B3B93"
btm_bar_vert_colour: ColourField = "#042231B2"
btm_weekly_background_size: PointField = (66, 400)
btm_weekly_background_colour: ColourField = '#06324880'
btm_weekly_background: ComputedField = lambda skin: (
Image.new(
'RGBA',
skin.btm_weekly_background_size,
color=ImageColor.getrgb(skin.btm_weekly_background_colour)
)
)
btm_timeline_end_mask: RGBAAssetField = 'weekly/timeline_end_mask.png'
btm_this_colour: ColourField = '#DDB21D'
btm_this_colour_override: ColourField = None
btm_this_end: BlobField = FieldDesc(
BlobField,
mask_field='btm_timeline_end_mask',
colour_field='btm_this_colour',
colour_override_field='btm_this_colour_override'
)
btm_last_colour: ColourField = '#5E6C747F'
btm_last_colour_override: ColourField = None
btm_last_end: BlobField = FieldDesc(
BlobField,
mask_field='btm_timeline_end_mask',
colour_field='btm_last_colour',
colour_override_field='btm_last_colour_override'
)
btm_horiz_width: ComputedField = lambda skin: skin.btm_this_end.height
btm_sep: ComputedField = lambda skin: (skin.btm_weekly_background_size[1] - 7 * skin.btm_horiz_width) // 6
btm_vert_width: NumberField = 10
btm_grid_x: NumberField = 48
btm_grid_y: ComputedField = lambda skin: skin.btm_horiz_width + skin.btm_sep
btm_weekday_font: FontField = ('Bold', 36.35)
btm_weekday_colour: ColourField = '#FFFFFF'
btm_day_font: FontField = ('SemiBold', 31)
btm_day_colour: ColourField = '#FFFFFF'
btm_day_height: ComputedField = lambda skin: skin.btm_day_font.getsize('88')[1]
btm_day_gap: NumberField = 15
btm_emoji_path: StringField = "weekly/emojis"
btm_emojis: ComputedField = lambda skin: {
state: Image.open(
resolve_asset_path(
skin._env['PATH'],
os.path.join(skin.btm_emoji_path, f"{state}.png")
)
).convert('RGBA')
for state in ('very_happy', 'happy', 'neutral', 'sad', 'shocked')
}
# Summary
summary_pre_gap: NumberField = 50
summary_mask: AssetField = 'weekly/summary_mask.png'
this_week_font: FontField = ('Light', 23)
this_week_colour: ColourField = '#BABABA'
this_week_image: BlobField = FieldDesc(
BlobField,
mask_field='summary_mask',
colour_field='top_this_colour',
colour_field_override='top_this_colour_override'
)
summary_sep: NumberField = 300
last_week_font: FontField = ('Light', 23)
last_week_colour: ColourField = '#BABABA'
last_week_image: BlobField = FieldDesc(
BlobField,
mask_field='summary_mask',
colour_field='top_last_colour',
colour_field_override='top_last_colour_override'
)
# Date text
footer_font: FontField = ('Bold', 28)
footer_colour: ColourField = '#6f6e6f'
footer_gap: NumberField = 50
class WeeklyStatsPage(Layout):
def __init__(self, skin, name, discrim, sessions, date):
"""
`sessions` is a list of study sessions from the last two weeks.
"""
self.skin = skin
self.data_sessions = sessions
self.data_date = date
self.data_name = name
self.data_discrim = discrim
self.week_start = date - timedelta(days=date.weekday())
self.last_week_start = self.week_start - timedelta(days=7)
periods = []
times = []
day_start = self.last_week_start
day_time = 0
day_periods = []
current_period = []
i = 0
while i < len(sessions):
start, end = sessions[i]
i += 1
day_end = day_start + timedelta(hours=24)
if end < day_start:
continue
if start < day_start:
start = day_start
elif start >= day_end:
if current_period:
day_periods.append(current_period)
periods.append(day_periods)
times.append(day_time)
current_period = []
day_periods = []
day_time = 0
day_start = day_end
i -= 1
continue
if (ended_after := (end - day_end).total_seconds()) > 0:
if ended_after > 60 * 20:
end = day_end
else:
end = day_end - timedelta(minutes=1)
day_time += (end - start).total_seconds()
if not current_period:
current_period = [start, end]
elif (start - current_period[1]).total_seconds() < 60 * 60:
current_period[1] = end
else:
day_periods.append(current_period)
current_period = [start, end]
if ended_after > 0:
if current_period:
day_periods.append(current_period)
periods.append(day_periods)
times.append(day_time)
current_period = []
day_periods = []
day_time = 0
day_start = day_end
if ended_after > 60 * 20:
i -= 1
if current_period:
day_periods.append(current_period)
periods.append(day_periods)
times.append(day_time)
self.data_periods = periods
for i in range(len(periods), 14):
periods.append([])
self.data_hours = [time / 3600 for time in times]
for i in range(len(self.data_hours), 14):
self.data_hours.append(0)
self.date_labels = [
(self.week_start + timedelta(days=i)).strftime('%d/%m')
for i in range(0, 7)
]
self.max_hour_label = (4 * math.ceil(max(self.data_hours) / 4)) or 4
self.image = None
def draw(self) -> Image:
image = self.skin.background
self.image = image
draw = ImageDraw.Draw(image)
xpos, ypos = 0, 0
# Draw header text
xpos = (image.width - self.skin.title_size[0]) // 2
ypos += self.skin.title_pre_gap
draw.text(
(xpos, ypos),
self.skin.title_text,
fill=self.skin.title_colour,
font=self.skin.title_font
)
# Underline it
title_size = self.skin.title_font.getsize(self.skin.title_text)
ypos += title_size[1] + self.skin.title_gap
# ypos += title_size[1] + self.skin.title_underline_gap
# draw.line(
# (xpos, ypos, xpos + title_size[0], ypos),
# fill=self.skin.title_colour,
# width=self.skin.title_underline_width
# )
# ypos += self.skin.title_underline_width + self.skin.title_gap
# Draw the top box
top = self.draw_top()
image.alpha_composite(
top,
((image.width - top.width) // 2, ypos)
)
ypos += top.height + self.skin.top_gap
# Draw the bottom box
bottom = self.draw_bottom()
image.alpha_composite(
bottom,
((image.width - bottom.width) // 2, ypos)
)
ypos += bottom.height + self.skin.summary_pre_gap
# Draw the summaries
summary_image = self.draw_summaries()
image.alpha_composite(
summary_image,
((image.width - summary_image.width) // 2, ypos)
)
# Draw the footer
ypos = image.height
ypos -= self.skin.footer_gap
date_text = self.data_date.strftime(
"Weekly Statistics • As of %d %b • {} {}".format(self.data_name, self.data_discrim)
)
size = self.skin.footer_font.getsize(date_text)
ypos -= size[1]
draw.text(
((image.width - size[0]) // 2, ypos),
date_text,
font=self.skin.footer_font,
fill=self.skin.footer_colour
)
return image
def draw_summaries(self) -> Image:
this_week_text = " THIS WEEK: {} Hours".format(int(sum(self.data_hours[7:])))
this_week_length = int(self.skin.this_week_font.getlength(this_week_text))
last_week_text = " LAST WEEK: {} Hours".format(int(sum(self.data_hours[:7])))
last_week_length = int(self.skin.last_week_font.getlength(last_week_text))
image = Image.new(
'RGBA',
(
self.skin.this_week_image.width + this_week_length
+ self.skin.summary_sep
+ self.skin.last_week_image.width + last_week_length,
self.skin.this_week_image.height
)
)
draw = ImageDraw.Draw(image)
xpos = 0
ypos = image.height // 2
image.alpha_composite(
self.skin.this_week_image,
(0, 0)
)
xpos += self.skin.this_week_image.width
draw.text(
(xpos, ypos),
this_week_text,
fill=self.skin.this_week_colour,
font=self.skin.this_week_font,
anchor='lm'
)
xpos += self.skin.summary_sep + this_week_length
image.alpha_composite(
self.skin.last_week_image,
(xpos, 0)
)
xpos += self.skin.last_week_image.width
draw.text(
(xpos, ypos),
last_week_text,
fill=self.skin.last_week_colour,
font=self.skin.last_week_font,
anchor='lm'
)
return image
def draw_top(self) -> Image:
size_x = (
self.skin.top_hours_bg.width // 2 + self.skin.top_hours_sep
+ 6 * self.skin.top_grid_x + self.skin.top_bar_mask.width // 2
+ self.skin.top_hours_bg.width // 2
)
size_y = (
self.skin.top_hours_bg.height // 2 + 4 * self.skin.top_grid_y + self.skin.top_weekday_pre_gap
+ self.skin.top_weekday_height + self.skin.top_weekday_gap + self.skin.top_date_height
)
image = Image.new('RGBA', (size_x, size_y))
draw = ImageDraw.Draw(image)
x0 = self.skin.top_hours_bg.width // 2 + self.skin.top_hours_sep
y0 = self.skin.top_hours_bg.height // 2 + 4 * self.skin.top_grid_y
# Draw lines and numbers
labels = list(int(i * self.max_hour_label // 4) for i in range(0, 5))
xpos = x0 - self.skin.top_hours_sep
ypos = y0
for label in labels:
draw.line(
((xpos, ypos), (image.width, ypos)),
width=self.skin.top_line_width,
fill=self.skin.top_line_colour
)
image.alpha_composite(
self.skin.top_hours_bg,
(xpos - self.skin.top_hours_bg.width // 2, ypos - self.skin.top_hours_bg.height // 2)
)
text = str(label)
draw.text(
(xpos, ypos),
text,
fill=self.skin.top_hours_colour,
font=self.skin.top_hours_font,
anchor='mm'
)
ypos -= self.skin.top_grid_y
# Draw dates
xpos = x0
ypos = y0 + self.skin.top_weekday_pre_gap
for letter, datestr in zip(self.skin.weekdays, self.date_labels):
draw.text(
(xpos, ypos),
letter,
fill=self.skin.top_weekday_colour,
font=self.skin.top_weekday_font,
anchor='mt'
)
draw.text(
(xpos, ypos + self.skin.top_weekday_height + self.skin.top_weekday_gap),
datestr,
fill=self.skin.top_date_colour,
font=self.skin.top_date_font,
anchor='mt'
)
xpos += self.skin.top_grid_x
# Draw bars
for i, (last_hours, this_hours) in enumerate(zip(self.data_hours[:7], self.data_hours[7:])):
day = i % 7
xpos = x0 + day * self.skin.top_grid_x
for draw_last in (last_hours > this_hours, not last_hours > this_hours):
hours = last_hours if draw_last else this_hours
height = (4 * self.skin.top_grid_y) * (hours / self.max_hour_label)
height = int(height)
if height >= 2 * self.skin.top_bar_mask.width:
bar = self.draw_vertical_bar(
height,
self.skin.top_last_bar_full if draw_last else self.skin.top_this_bar_full,
self.skin.top_bar_mask
)
image.alpha_composite(
bar,
(xpos - bar.width // 2, y0 - bar.height)
)
return image
def draw_vertical_bar(self, height, full_bar, mask_bar, crop=False):
y_2 = mask_bar.height
y_1 = height
image = Image.new('RGBA', full_bar.size)
image.paste(mask_bar, (0, y_2 - y_1), mask=mask_bar)
image.paste(full_bar, mask=image)
if crop:
image = image.crop(
(0, y_2 - y_1), (image.width, y_2 - y_1),
(image.height, 0), (image.height, image.width)
)
return image
def draw_horizontal_bar(self, length, full_bar, mask_bar, crop=False):
x_2 = mask_bar.length
x_1 = length
image = Image.new('RGBA', full_bar.size)
image.paste(mask_bar, (x_2 - x_1, 0), mask=mask_bar)
image.paste(full_bar, mask=image)
if crop:
image = image.crop(
(x_2 - x_1, 0), (image.width, 0),
(x_2 - x_1, image.height), (image.width, image.height)
)
return image
def draw_bottom(self) -> Image:
size_x = int(
self.skin.btm_weekly_background_size[0]
+ self.skin.btm_grid_x * 25
+ self.skin.btm_day_font.getlength('24') // 2
+ self.skin.btm_vert_width // 2
)
size_y = int(
7 * self.skin.btm_horiz_width + 6 * self.skin.btm_sep
+ self.skin.btm_day_gap
+ self.skin.btm_day_height
)
image = Image.new('RGBA', (size_x, size_y))
draw = ImageDraw.Draw(image)
# Grid origin
x0 = self.skin.btm_weekly_background_size[0] + self.skin.btm_vert_width // 2 + self.skin.btm_grid_x
y0 = self.skin.btm_day_gap + self.skin.btm_day_height + self.skin.btm_horiz_width // 2
# Draw the hours
ypos = y0 - self.skin.btm_horiz_width // 2 - self.skin.btm_day_gap
for i in range(-1, 25):
xpos = x0 + i * self.skin.btm_grid_x
if i >= 0:
draw.text(
(xpos, ypos),
str(i),
fill=self.skin.btm_day_colour,
font=self.skin.btm_day_font,
anchor='ms'
)
draw.line(
(
(xpos, y0 - self.skin.btm_horiz_width // 2),
(xpos, image.height)
),
fill=self.skin.btm_bar_vert_colour,
width=self.skin.btm_vert_width
)
# Draw the day bars
bar_image = Image.new(
'RGBA',
(image.width, self.skin.btm_horiz_width),
self.skin.btm_bar_horiz_colour
)
for i in range(0, 7):
ypos = y0 + i * self.skin.btm_grid_y - self.skin.btm_horiz_width // 2
image.alpha_composite(
bar_image,
(0, ypos)
)
# Draw the weekday background
image.alpha_composite(
self.skin.btm_weekly_background,
(0, y0 - self.skin.btm_horiz_width // 2)
)
# Draw the weekdays
xpos = self.skin.btm_weekly_background_size[0] // 2
for i, l in enumerate(self.skin.weekdays):
ypos = y0 + i * self.skin.btm_grid_y
draw.text(
(xpos, ypos),
l,
font=self.skin.btm_weekday_font,
fill=self.skin.btm_weekday_colour,
anchor='mm'
)
# Draw the sessions
seconds_in_day = 60 * 60 * 24
day_width = 24 * self.skin.btm_grid_x
for i, day in enumerate(reversed(self.data_periods)):
last = (i // 7)
ypos = y0 + (6 - i % 7) * self.skin.btm_grid_y
for start, end in day:
if end <= start:
continue
day_start = start.replace(hour=0, minute=0, second=0, microsecond=0)
flat_start = (start == day_start)
duration = (end - start).total_seconds()
xpos = x0 + int((start - day_start).total_seconds() / seconds_in_day * day_width)
flat_end = (end == day_start + timedelta(days=1))
if flat_end:
width = image.width - xpos
else:
width = int(duration / seconds_in_day * day_width)
bar = self.draw_timeline_bar(
width,
last=last,
flat_start=flat_start,
flat_end=flat_end
)
image.alpha_composite(
bar,
(xpos, ypos - bar.height // 2)
)
# Draw the emojis
xpos = x0 - self.skin.btm_grid_x // 2
average_study = sum(self.data_hours[7:]) / 7
for i, hours in enumerate(self.data_hours[7:]):
if hours:
ypos = y0 + i * self.skin.btm_grid_y
relative = hours / average_study
if relative > 1:
state = 'very_happy'
elif relative > 0.75:
state = 'happy'
elif relative > 0.25:
state = 'neutral'
else:
state = 'sad'
emoji = self.skin.btm_emojis[state]
image.alpha_composite(
emoji,
(xpos - emoji.width // 2, ypos - emoji.height // 2)
)
return image
def draw_timeline_bar(self, width, last=False, flat_start=False, flat_end=False) -> Image:
if last:
end = self.skin.btm_last_end
colour = self.skin.btm_last_colour
else:
end = self.skin.btm_this_end
colour = self.skin.btm_this_colour
image = Image.new(
'RGBA',
(width, end.height)
)
draw = ImageDraw.Draw(image)
# Draw endpoints
if not flat_start:
image.alpha_composite(
end,
(0, 0)
)
if not flat_end:
image.alpha_composite(
end,
(width - end.width, 0)
)
# Draw the rectangle
rstart = (not flat_start) * (end.width // 2)
rend = width - (not flat_end) * (end.width // 2)
draw.rectangle(
((rstart, 0), (rend, image.height)),
fill=colour,
width=0
)
return image
class WeeklyStatsCard(Card):
route = 'weekly_stats_card'
card_id = 'weekly_stats'
layout = WeeklyStatsPage
skin = WeeklyStatsSkin
display_name = "Weekly Stats"
@classmethod
async def sample_args(cls, ctx, **kwargs):
import random
sessions = []
day_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
day_start -= timedelta(hours=24) * 14
for day in range(0, 14):
day_start += timedelta(hours=24)
# start of day
pointer = int(abs(random.normalvariate(6 * 60, 1 * 60)))
while pointer < 20 * 60:
session_duration = int(abs(random.normalvariate(4 * 60, 1 * 60)))
sessions.append((
day_start + timedelta(minutes=pointer),
day_start + timedelta(minutes=(pointer + session_duration)),
)
)
pointer += session_duration
pointer += int(abs(random.normalvariate(2.5 * 60, 1 * 60)))
return {
'name': ctx.author.name if ctx else "John Doe",
'discrim': ('#' + ctx.author.discriminator) if ctx else "#0000",
'sessions': sessions,
'date': datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
}

View File

@@ -0,0 +1,320 @@
from datetime import timedelta
import asyncio
from data.conditions import GEQ
from modules.stats import goals
from ..module import module, ratelimit
from ...cards import WeeklyGoalCard, MonthlyGoalCard
from ...cards import WeeklyStatsCard, MonthlyStatsCard
from ...utils import get_avatar_key, image_as_file
async def _get_weekly_goals(ctx):
# Fetch goal data
goal_row = ctx.client.data.weekly_goals.fetch_or_create(
(ctx.guild.id, ctx.author.id, ctx.alion.week_timestamp)
)
tasklist_rows = ctx.client.data.member_weekly_goal_tasks.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
weekid=ctx.alion.week_timestamp,
_extra="ORDER BY taskid ASC"
)
tasklist = [
(i, task['content'], task['completed'])
for i, task in enumerate(tasklist_rows)
]
day_start = ctx.alion.day_start
week_start = day_start - timedelta(days=day_start.weekday())
# Fetch study data
week_study_time = ctx.client.data.session_history.queries.study_time_since(
ctx.guild.id, ctx.author.id, week_start
)
study_hours = week_study_time // 3600
# Fetch task data
tasks_done = ctx.client.data.tasklist.select_one_where(
userid=ctx.author.id,
completed_at=GEQ(week_start),
select_columns=('COUNT(*)',)
)[0]
# Fetch accountability data
accountability = ctx.client.data.accountability_member_info.select_where(
userid=ctx.author.id,
start_at=GEQ(week_start),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
)
if len(accountability):
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = acc_attended / acc_total
else:
acc_rate = None
goalpage = await WeeklyGoalCard.request(
name=ctx.author.name,
discrim=f"#{ctx.author.discriminator}",
avatar=get_avatar_key(ctx.client, ctx.author.id),
badges=ctx.alion.profile_tags,
tasks_done=tasks_done,
studied_hours=study_hours,
attendance=acc_rate,
tasks_goal=goal_row.task_goal,
studied_goal=goal_row.study_goal,
goals=tasklist,
date=ctx.alion.day_start,
skin=WeeklyGoalCard.skin_args_for(ctx)
)
return goalpage
@ratelimit.ward()
async def show_weekly_goals(ctx):
image = await _get_weekly_goals(ctx)
await ctx.reply(file=image_as_file(image, 'weekly_stats_1.png'))
goals.display_weekly_goals_for = show_weekly_goals
@module.cmd(
"weekly",
group="Statistics",
desc="View your weekly study statistics!"
)
@ratelimit.ward()
async def cmd_weekly(ctx):
"""
Usage``:
{prefix}weekly
Description:
View your weekly study profile.
See `{prefix}weeklygoals` to edit your goals!
"""
day_start = ctx.alion.day_start
last_week_start = day_start - timedelta(days=7 + day_start.weekday())
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
start_time=GEQ(last_week_start - timedelta(days=1)),
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time ASC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
page_1_task = asyncio.create_task(_get_weekly_goals(ctx))
page_2_task = asyncio.create_task(
WeeklyStatsCard.request(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start,
skin=WeeklyStatsCard.skin_args_for(ctx)
)
)
await asyncio.gather(page_1_task, page_2_task)
page_1 = page_1_task.result()
page_2 = page_2_task.result()
await ctx.reply(
files=[
image_as_file(page_1, "weekly_stats_1.png"),
image_as_file(page_2, "weekly_stats_2.png")
]
)
async def _get_monthly_goals(ctx):
# Fetch goal data
goal_row = ctx.client.data.monthly_goals.fetch_or_create(
(ctx.guild.id, ctx.author.id, ctx.alion.month_timestamp)
)
tasklist_rows = ctx.client.data.member_monthly_goal_tasks.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
monthid=ctx.alion.month_timestamp,
_extra="ORDER BY taskid ASC"
)
tasklist = [
(i, task['content'], task['completed'])
for i, task in enumerate(tasklist_rows)
]
day_start = ctx.alion.day_start
month_start = day_start.replace(day=1)
# Fetch study data
month_study_time = ctx.client.data.session_history.queries.study_time_since(
ctx.guild.id, ctx.author.id, month_start
)
study_hours = month_study_time // 3600
# Fetch task data
tasks_done = ctx.client.data.tasklist.select_one_where(
userid=ctx.author.id,
completed_at=GEQ(month_start),
select_columns=('COUNT(*)',)
)[0]
# Fetch accountability data
accountability = ctx.client.data.accountability_member_info.select_where(
userid=ctx.author.id,
start_at=GEQ(month_start),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
)
if len(accountability):
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = acc_attended / acc_total
else:
acc_rate = None
goalpage = await MonthlyGoalCard.request(
name=ctx.author.name,
discrim=f"#{ctx.author.discriminator}",
avatar=get_avatar_key(ctx.client, ctx.author.id),
badges=ctx.alion.profile_tags,
tasks_done=tasks_done,
studied_hours=study_hours,
attendance=acc_rate,
tasks_goal=goal_row.task_goal,
studied_goal=goal_row.study_goal,
goals=tasklist,
date=ctx.alion.day_start,
skin=MonthlyGoalCard.skin_args_for(ctx)
)
return goalpage
@ratelimit.ward()
async def show_monthly_goals(ctx):
image = await _get_monthly_goals(ctx)
await ctx.reply(file=image_as_file(image, 'monthly_stats_1.png'))
goals.display_monthly_goals_for = show_monthly_goals
@module.cmd(
"monthly",
group="Statistics",
desc="View your monthly study statistics!"
)
async def cmd_monthly(ctx):
"""
Usage``:
{prefix}monthly
Description:
View your monthly study profile.
See `{prefix}monthlygoals` to edit your goals!
"""
day_start = ctx.alion.day_start
period_start = day_start - timedelta(days=31*4)
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
if not sessions:
return await ctx.error_reply(
"No statistics to show, because you have never studied in this server before!"
)
# Streak statistics
streak = 0
current_streak = None
max_streak = 0
day_attended = True if 'sessions' in ctx.client.objects and ctx.alion.session else None
date = day_start
daydiff = timedelta(days=1)
periods = sessions
i = 0
while i < len(periods):
row = periods[i]
i += 1
if row[1] > date:
# They attended this day
day_attended = True
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
day_attended = False
prev_date = date
date -= daydiff
i -= 1
# Special case, when the last session started in the previous day
# Then the day is already attended
if i > 1 and date < periods[i-2][0] <= prev_date:
day_attended = True
continue
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
first_session_start = sessions[-1][0]
sessions = [session for session in sessions if session[1] > period_start]
page_1_task = asyncio.create_task(_get_monthly_goals(ctx))
page_2_task = asyncio.create_task(MonthlyStatsCard.request(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start.date(),
current_streak or 0,
max_streak or 0,
first_session_start,
skin=MonthlyStatsCard.skin_args_for(ctx)
))
await asyncio.gather(page_1_task, page_2_task)
page_1 = page_1_task.result()
page_2 = page_2_task.result()
await ctx.reply(
files=[
image_as_file(page_1, "monthly_stats_1.png"),
image_as_file(page_2, "monthly_stats_2.png")
]
)

View File

@@ -0,0 +1,198 @@
import gc
import asyncio
import discord
from cmdClient.checks import in_guild
import data
from data import tables
from utils.interactive import discord_shield
from meta import conf
from ...cards import LeaderboardCard
from ...utils import image_as_file, edit_files, get_avatar_key
from ..module import module, ratelimit
next_emoji = conf.emojis.forward
my_emoji = conf.emojis.person
prev_emoji = conf.emojis.backward
@module.cmd(
"top",
desc="View the Study Time leaderboard.",
group="Statistics",
aliases=('ttop', 'toptime', 'top100')
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_top(ctx):
"""
Usage``:
{prefix}top
{prefix}top100
Description:
Display the study time leaderboard, or the top 100.
"""
# Handle args
if ctx.args and not ctx.args == "100":
return await ctx.error_reply(
"**Usage:**`{prefix}top` or `{prefix}top100`.".format(prefix=ctx.best_prefix)
)
top100 = (ctx.args == "100" or ctx.alias == "top100")
# Fetch the leaderboard
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
exclude.update(ctx.client.user_blacklist())
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
args = {
'guildid': ctx.guild.id,
'select_columns': ('userid', 'total_tracked_time::INTEGER', 'display_name'),
'_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "")
}
if exclude:
args['userid'] = data.NOT(list(exclude))
user_data = tables.members_totals.select_where(**args)
# Quit early if the leaderboard is empty
if not user_data:
return await ctx.reply("No leaderboard entries yet!")
# Extract entries
author_rank = None
entries = []
for i, (userid, time, display_name) in enumerate(user_data):
if (member := ctx.guild.get_member(userid)):
name = member.display_name
elif display_name:
name = display_name
else:
name = str(userid)
entries.append(
(userid, i + 1, time, name, get_avatar_key(ctx.client, userid))
)
if ctx.author.id == userid:
author_rank = i + 1
# Break into pages
entry_pages = [entries[i:i+10] for i in range(0, len(entries), 10)]
page_count = len(entry_pages)
author_page = (author_rank - 1) // 10 if author_rank is not None else None
if page_count == 1:
image = await LeaderboardCard.request(
ctx.guild.name,
entries=entry_pages[0],
highlight=author_rank,
skin=LeaderboardCard.skin_args_for(ctx)
)
_file = image_as_file(image, "leaderboard.png")
await ctx.reply(file=_file)
del image
else:
page_i = 0
page_futures = {}
def submit_page_request(i):
if (_existing := page_futures.get(i, None)) is not None:
# A future was already submitted
_future = _existing
else:
_future = asyncio.create_task(
LeaderboardCard.request(
ctx.guild.name,
entries=entry_pages[i % page_count],
highlight=author_rank,
skin=LeaderboardCard.skin_args_for(ctx)
)
)
page_futures[i] = _future
return _future
# Draw first page
out_msg = await ctx.reply(file=image_as_file(await submit_page_request(0), "leaderboard.png"))
# Prefetch likely next page
submit_page_request(author_page or 1)
# Add reactions
try:
await out_msg.add_reaction(prev_emoji)
if author_page is not None:
await out_msg.add_reaction(my_emoji)
await out_msg.add_reaction(next_emoji)
except discord.Forbidden:
perms = ctx.ch.permissions_for(ctx.guild.me)
if not perms.add_reactions:
await ctx.error_reply(
"Cannot page leaderboard because I do not have the `add_reactions` permission!"
)
elif not perms.read_message_history:
await ctx.error_reply(
"Cannot page leaderboard because I do not have the `read_message_history` permission!"
)
else:
await ctx.error_reply(
"Cannot page leaderboard due to insufficient permissions!"
)
return
def reaction_check(reaction, user):
result = reaction.message.id == out_msg.id
result = result and reaction.emoji in [next_emoji, my_emoji, prev_emoji]
result = result and not (user.id == ctx.client.user.id)
return result
while True:
try:
reaction, user = await ctx.client.wait_for('reaction_add', check=reaction_check, timeout=60)
except asyncio.TimeoutError:
break
asyncio.create_task(discord_shield(out_msg.remove_reaction(reaction.emoji, user)))
# Change the page number
if reaction.emoji == next_emoji:
page_i += 1
page_i %= page_count
elif reaction.emoji == prev_emoji:
page_i -= 1
page_i %= page_count
else:
page_i = author_page
# Edit the message
image = await submit_page_request(page_i)
image_file = image_as_file(image, f"leaderboard_{page_i}.png")
await edit_files(
ctx.client._connection.http,
ctx.ch.id,
out_msg.id,
files=[image_file]
)
# Prefetch surrounding pages
submit_page_request((page_i + 1) % page_count)
submit_page_request((page_i - 1) % page_count)
# Clean up reactions
try:
await out_msg.clear_reactions()
except discord.Forbidden:
try:
await out_msg.remove_reaction(next_emoji, ctx.client.user)
await out_msg.remove_reaction(prev_emoji, ctx.client.user)
except discord.NotFound:
pass
except discord.NotFound:
pass
# Delete the image cache and explicit garbage collect
del page_futures
gc.collect()

View File

@@ -0,0 +1,90 @@
import logging
import time
import traceback
import discord
from LionModule import LionModule
from meta import client
from utils.ratelimits import RateLimit
from ..client import EmptyResponse, request
class PluginModule(LionModule):
def cmd(self, name, **kwargs):
# Remove any existing command with this name
for module in client.modules:
for i, cmd in enumerate(module.cmds):
if cmd.name == name:
module.cmds.pop(i)
return super().cmd(name, **kwargs)
async def on_exception(self, ctx, exception):
try:
raise exception
except (ConnectionError, EmptyResponse) as e:
full_traceback = traceback.format_exc()
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
client.log(
("Caught a communication exception while "
"executing command '{cmdname}' from module '{module}' "
"from user '{message.author}' (uid:{message.author.id}) "
"in guild '{message.guild}' (gid:{guildid}) "
"in channel '{message.channel}' (cid:{message.channel.id}).\n"
"Message Content:\n"
"{content}\n"
"{traceback}\n\n"
"{flat_ctx}").format(
cmdname=ctx.cmd.name,
module=ctx.cmd.module.name,
message=ctx.msg,
guildid=ctx.guild.id if ctx.guild else None,
content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()),
traceback=full_traceback,
flat_ctx=ctx.flatten()
),
context="mid:{}".format(ctx.msg.id),
level=logging.ERROR
)
error_embed = discord.Embed(title="Sorry, something went wrong!")
error_embed.description = (
"An unexpected error occurred while communicating with our rendering server!\n"
"Our development team has been notified, and the issue should be fixed soon.\n"
)
if logging.getLogger().getEffectiveLevel() < logging.INFO:
error_embed.add_field(
name="Exception",
value="`{}`".format(only_error)
)
await ctx.reply(embed=error_embed)
except Exception:
await super().on_exception(ctx, exception)
module = PluginModule("GUI")
ratelimit = RateLimit(5, 30)
logging.getLogger('PIL').setLevel(logging.WARNING)
@module.launch_task
async def ping_server(client):
start = time.time()
try:
await request('ping')
except Exception:
logging.error(
"Failed to ping the rendering server!",
exc_info=True
)
else:
end = time.time()
client.log(
f"Rendering server responded in {end-start:.6f} seconds!",
context="GUI INIT",
)

View File

@@ -0,0 +1,24 @@
import importlib
from .. import drawing
from . import goals, leaderboard, stats, tasklist
from cmdClient import cmd, checks
@cmd("reloadgui",
desc="Reload all GUI drawing modules.")
@checks.is_owner()
async def cmd_reload_gui(ctx):
importlib.reload(drawing.goals)
importlib.reload(drawing.leaderboard)
importlib.reload(drawing.profile)
importlib.reload(drawing.stats)
importlib.reload(drawing.tasklist)
importlib.reload(drawing.weekly)
importlib.reload(drawing.monthly)
importlib.reload(goals)
importlib.reload(leaderboard)
importlib.reload(stats)
importlib.reload(tasklist)
await ctx.reply("GUI plugin reloaded.")

View File

@@ -0,0 +1,278 @@
import asyncio
import time
from datetime import datetime, timedelta
from cmdClient.checks import in_guild
from utils.lib import utc_now
from data import tables
from data.conditions import LEQ
from core import Lion
from LionContext import LionContext as Context
from modules.study.tracking.data import session_history
from modules.stats.achievements import get_achievements_for
from ...cards import StatsCard, ProfileCard
from ...utils import get_avatar_key, image_as_file
from ..module import module, ratelimit
async def get_stats_card_for(ctx: Context, target):
lion = Lion.fetch(ctx.guild.id, target.id)
history = session_history.select_where(
guildid=ctx.guild.id,
userid=target.id,
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
)
# Current economy balance (accounting for current session)
workout_total = lion.data.workout_count
# Leaderboard ranks
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
exclude.update(ctx.client.user_blacklist())
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
if target.bot or target.id in exclude:
time_rank = None
coin_rank = None
else:
time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0]))
# Study time
# First get the all/month/week/day timestamps
day_start = lion.day_start
month_start = day_start.replace(day=1)
period_timestamps = (
datetime(1970, 1, 1),
month_start,
day_start - timedelta(days=day_start.weekday()),
day_start
)
study_times = [0, 0, 0, 0]
for i, timestamp in enumerate(period_timestamps):
study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp)
if not study_time:
# So we don't make unecessary database calls
break
study_times[i] = study_time
# Streak data for the study run view
streaks = []
streak = 0
streak_end = None
date = day_start
daydiff = timedelta(days=1)
if 'sessions' in ctx.client.objects and lion.session:
day_attended = True
streak_end = day_start.day
else:
day_attended = None
periods = [(row['start_time'], row['end_time']) for row in history]
i = 0
while i < len(periods):
row = periods[i]
i += 1
if row[1] > date:
# They attended this day
day_attended = True
if streak_end is None:
streak_end = (date - month_start).days + 1
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
day_attended = False
prev_date = date
date -= daydiff
i -= 1
# Special case, when the last session started in the previous day
# Then the day is already attended
if i > 1 and date < periods[i-2][0] <= prev_date:
day_attended = True
if streak_end is None:
streak_end = (date - month_start).days + 1
continue
if streak_end:
streaks.append((streak_end - streak + 1, streak_end))
streak_end = None
streak = 0
if date.month != day_start.month:
break
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
streaks.append((streak_end - streak + 1, streak_end))
# We have all the data for the stats card
return await StatsCard.request(
(time_rank, coin_rank),
list(reversed(study_times)),
workout_total,
streaks,
skin=StatsCard.skin_args_for(ctx)
)
async def get_profile_card_for(ctx: Context, target):
lion = Lion.fetch(ctx.guild.id, target.id)
# Current economy balance (accounting for current session)
coins = lion.coins
season_time = lion.time
# Study League
guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id)
if lion.data.last_study_badgeid:
current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid)
else:
current_badge = None
next_badge = min(
(badge for badge in guild_badges
if badge.required_time > (current_badge.required_time if current_badge else 0)),
key=lambda badge: badge.required_time,
default=None
)
if current_badge:
current_rank = (
role.name if (role := ctx.guild.get_role(current_badge.roleid)) else str(current_badge.roleid),
current_badge.required_time // 3600,
next_badge.required_time // 3600 if next_badge else None
)
else:
current_rank = None
if next_badge:
next_next_badge = min(
(badge for badge in guild_badges if badge.required_time > next_badge.required_time),
key=lambda badge: badge.required_time,
default=None
)
next_rank = (
role.name if (role := ctx.guild.get_role(next_badge.roleid)) else str(next_badge.roleid),
next_badge.required_time // 3600,
next_next_badge.required_time // 3600 if next_next_badge else None
)
else:
next_rank = None
achievements = await get_achievements_for(target)
# We have all the data for the profile card
avatar = get_avatar_key(ctx.client, target.id)
return await ProfileCard.request(
target.name,
'#{}'.format(target.discriminator),
coins,
season_time,
avatar=avatar,
gems=ctx.client.data.gem_transactions.queries.get_gems_for(target.id),
gifts=ctx.client.data.gem_transactions.queries.get_gifts_for(target.id),
achievements=[i for i, ach in enumerate(achievements) if ach.level_id > 0],
current_rank=current_rank,
next_rank=next_rank,
badges=lion.profile_tags,
skin=ProfileCard.skin_args_for(ctx)
)
@module.cmd(
"stats",
group="Statistics",
desc="View your server study statistics!"
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_stats(ctx):
"""
Usage``:
{prefix}stats
{prefix}stats <mention>
Description:
View your study statistics in this server, or those of the mentioned member.
"""
# Identify the target
if ctx.args:
if not ctx.msg.mentions:
return await ctx.error_reply("Please mention a user to view their statistics!")
target = ctx.msg.mentions[0]
else:
target = ctx.author
# System sync
Lion.sync()
# Fetch the cards
futures = (
asyncio.create_task(get_profile_card_for(ctx, target)),
asyncio.create_task(get_stats_card_for(ctx, target))
)
await futures[0]
await futures[1]
profile_image = futures[0].result()
stats_image = futures[1].result()
profile_file = image_as_file(profile_image, f"profile_{target.id}.png")
stats_file = image_as_file(stats_image, f"stats_{target.id}.png")
await ctx.reply(files=[profile_file, stats_file])
@module.cmd(
"profile",
group="Statistics",
desc="View your personal study profile!"
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_profile(ctx):
"""
Usage``:
{prefix}profile
{prefix}profile <mention>
Description:
View your server study profile, or that of the mentioned user.
"""
# Identify the target
if ctx.args:
if not ctx.msg.mentions:
return await ctx.error_reply("Please mention a user to view their profile!")
target = ctx.msg.mentions[0]
else:
target = ctx.author
# System sync
Lion.sync()
# Fetch the cards
profile_image = await get_profile_card_for(ctx, target)
profile_file = image_as_file(profile_image, f"profile_{target.id}.png")
await ctx.reply(file=profile_file)

View File

@@ -0,0 +1,111 @@
import asyncio
import discord
from core import Lion
from meta import client
from modules.todo.Tasklist import Tasklist as TextTasklist
from ...cards import TasklistCard
from ...utils import get_avatar_key, image_as_file, edit_files
widget_help = """
Open your interactive tasklist with `{prefix}todo`, \
then use the following commands to update your tasks. \
The `<taskids>` may be given as comma separated numbers and ranges.
`<taskids>` Toggle the status (done/notdone) of the provided tasks.
`add/+ <task>` Add a new TODO `task`. Each line is added as a separate task.
`d/rm/- <taskids>` Remove the specified tasks.
`c/check <taskids>` Check (mark as done) the specified tasks.
`u/uncheck <taskids>` Uncheck (mark incomplete) the specified tasks.
`cancel` Cancel the interactive tasklist mode.
*You do not need to write `{prefix}todo` before each command when the list is visible.*
**Examples**
`add Read chapter 1` Add a new task `Read chapter 1`.
`e 0 Notes chapter 1` Edit task `0` to say `Notes chapter 1`.
`d 0, 5-7, 9` Delete tasks `0, 5, 6, 7, 9`.
`0, 2-5, 9` Toggle the completion status of tasks `0, 2, 3, 4, 5, 9`.
[Click here to jump back]({jump_link})
"""
class GUITasklist(TextTasklist):
async def _format_tasklist(self):
tasks = [
(i, task.content, bool(task.completed_at))
for (i, task) in enumerate(self.tasklist)
]
avatar = get_avatar_key(client, self.member.id)
lion = Lion.fetch(self.member.guild.id, self.member.id)
date = lion.day_start
self.pages = await TasklistCard.request(
self.member.name,
f"#{self.member.discriminator}",
tasks,
date,
avatar=avatar,
badges=lion.profile_tags,
skin=TasklistCard.skin_args_for(guildid=self.member.guild.id, userid=self.member.id)
)
return self.pages
async def _post(self):
pages = self.pages
message = await self.channel.send(file=image_as_file(pages[self.current_page], "tasklist.png"))
# Add the reactions
self.has_paging = len(pages) > 1
for emoji in (self.paged_reaction_order if self.has_paging else self.non_paged_reaction_order):
await message.add_reaction(emoji)
# Register
if self.message:
self.messages.pop(self.message.id, None)
self.message = message
self.messages[message.id] = self
async def _update(self):
if self.show_help:
embed = discord.Embed(
title="Tasklist widget guide",
description=widget_help.format(
prefix=client.prefix,
jump_link=self.message.jump_url
),
colour=discord.Colour.orange()
)
try:
await self.member.send(embed=embed)
except discord.Forbidden:
await self.channel.send("Could not send you the guide! Please open your DMs first.")
except discord.HTTPException:
pass
self.show_help = False
await edit_files(
self.message._state.http,
self.channel.id,
self.message.id,
files=[image_as_file(self.pages[self.current_page], "tasklist.png")]
)
# Monkey patch the Tasklist fetch method to conditionally point to the GUI tasklist
# TODO: Config setting for text/gui
@classmethod
def fetch_or_create(cls, ctx, flags, member, channel):
factory = TextTasklist if flags['text'] else GUITasklist
tasklist = GUITasklist.active.get((member.id, channel.id), None)
if type(tasklist) != factory:
tasklist = None
return tasklist if tasklist is not None else factory(member, channel)
TextTasklist.fetch_or_create = fetch_or_create

View File

@@ -0,0 +1,169 @@
import asyncio
import time
import logging
import traceback
from collections import defaultdict
import discord
from utils.lib import utc_now
from core import Lion
from meta import client
from modules.study.timers.Timer import Timer
from ...cards import FocusTimerCard, BreakTimerCard
from ...utils import get_avatar_key, image_as_file, edit_files, asset_path
async def status(self):
stage = self.current_stage
name = self.data.pretty_name
remaining = int((stage.end - utc_now()).total_seconds())
duration = int(stage.duration)
next_starts = int(stage.end.timestamp())
users = [
(get_avatar_key(client, member.id),
session.duration if (session := Lion.fetch(member.guild.id, member.id).session) else 0,
session.data.tag if session else None)
for member in self.members
]
if stage.name == 'FOCUS':
card_class = FocusTimerCard
content = f"**Focus!** Session ends <t:{next_starts}:R>."
else:
card_class = BreakTimerCard
content = f"**Have a rest!** Break finishes <t:{next_starts}:R>."
page = await card_class.request(
name,
remaining,
duration,
users=users,
skin=card_class.skin_args_for(guildid=self.data.guildid)
)
return {
'content': content,
'files': [image_as_file(page, name="timer.png")]
}
_guard_delay = 60
_guarded = {} # timer channel id -> (last_executed_time, currently_waiting)
async def guard_request(id):
if (result := _guarded.get(id, None)):
last, currently = result
if currently:
return False
else:
_guarded[id] = (last, True)
await asyncio.sleep(_guard_delay - (time.time() - last))
_guarded[id] = (time.time(), False)
return True
else:
_guarded[id] = (time.time(), False)
return True
async def update_last_status(self):
"""
Update the last posted status message, if it exists.
"""
old_message = self.reaction_message
if not await guard_request(self.channelid):
return
if old_message != self.reaction_message:
return
args = await self.status()
repost = True
if self.reaction_message:
try:
await edit_files(
client._connection.http,
self.reaction_message.channel.id,
self.reaction_message.id,
**args
)
except discord.HTTPException:
pass
else:
repost = False
if repost and self.text_channel:
try:
self.reaction_message = await self.text_channel.send(**args)
await self.reaction_message.add_reaction('')
except discord.HTTPException:
pass
return
guild_locks = defaultdict(asyncio.Lock)
async def play_alert(channel: discord.VoiceChannel, alert_file):
if not channel.members:
# Don't notify an empty channel
return
async with guild_locks[channel.guild.id]:
try:
vc = channel.guild.voice_client
if not vc:
vc = await asyncio.wait_for(
channel.connect(timeout=10, reconnect=False),
20
)
elif vc.channel != channel:
await vc.move_to(channel)
except asyncio.TimeoutError:
client.log(
f"Timed out while attempting to connect to '{channel.name}' (cid:{channel.id}) "
f"in '{channel.guild.name}' (gid:{channel.guild.id}).",
context="TIMER_ALERT",
level=logging.WARNING
)
vc = channel.guild.voice_client
if vc:
await vc.disconnect(force=True)
return
audio_stream = open(alert_file, 'rb')
try:
vc.play(discord.PCMAudio(audio_stream), after=lambda e: audio_stream.close())
except discord.HTTPException:
pass
count = 0
while vc.is_playing() and count < 10:
await asyncio.sleep(1)
count += 1
await vc.disconnect(force=True)
async def notify_hook(self, old_stage, new_stage):
try:
if new_stage.name == 'BREAK':
await play_alert(self.channel, asset_path('timer/voice/break_alert.wav'))
else:
await play_alert(self.channel, asset_path('timer/voice/focus_alert.wav'))
except Exception:
full_traceback = traceback.format_exc()
client.log(
f"Caught an unhandled exception while playing timer alert in '{self.channel.name}' (cid:{self.channel.id})"
f" in '{self.channel.guild.name}' (gid:{self.channel.guild.id}).\n"
f"{full_traceback}",
context="TIMER_ALERT",
level=logging.ERROR
)
Timer.status = status
Timer.update_last_status = update_last_status
Timer.notify_hook = notify_hook

View File

@@ -0,0 +1,43 @@
import importlib
from datetime import datetime, timedelta
from data.conditions import GEQ
from ..module import module
from .. import drawing
from ..utils import get_avatar, image_as_file
@module.cmd(
'tasktest'
)
async def cmd_tasktest(ctx):
importlib.reload(drawing.weekly)
WeeklyStatsPage = drawing.weekly.WeeklyStatsPage
day_start = ctx.alion.day_start
last_week_start = day_start - timedelta(days=7 + day_start.weekday())
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
start_time=GEQ(last_week_start - timedelta(days=1)),
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time ASC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
page = WeeklyStatsPage(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start
)
image = page.draw()
await ctx.reply(file=image_as_file(image, 'weekly_stats.png'))