rewrite: Move GUI cards into main repo.
This commit is contained in:
8
src/modules/pending-rewrite/gui-cards/__init__.py
Normal file
8
src/modules/pending-rewrite/gui-cards/__init__.py
Normal 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
|
||||
752
src/modules/pending-rewrite/gui-cards/goals.py
Normal file
752
src/modules/pending-rewrite/gui-cards/goals.py
Normal 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()
|
||||
}
|
||||
504
src/modules/pending-rewrite/gui-cards/leaderboard.py
Normal file
504
src/modules/pending-rewrite/gui-cards/leaderboard.py
Normal 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
|
||||
}
|
||||
143
src/modules/pending-rewrite/gui-cards/mixins.py
Normal file
143
src/modules/pending-rewrite/gui-cards/mixins.py
Normal 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
|
||||
750
src/modules/pending-rewrite/gui-cards/monthly.py
Normal file
750
src/modules/pending-rewrite/gui-cards/monthly.py
Normal 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)
|
||||
}
|
||||
623
src/modules/pending-rewrite/gui-cards/profile.py
Normal file
623
src/modules/pending-rewrite/gui-cards/profile.py
Normal 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),
|
||||
}
|
||||
508
src/modules/pending-rewrite/gui-cards/stats.py
Normal file
508
src/modules/pending-rewrite/gui-cards/stats.py
Normal 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)
|
||||
}
|
||||
389
src/modules/pending-rewrite/gui-cards/tasklist.py
Normal file
389
src/modules/pending-rewrite/gui-cards/tasklist.py
Normal 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'
|
||||
),
|
||||
}
|
||||
38
src/modules/pending-rewrite/gui-cards/test.py
Normal file
38
src/modules/pending-rewrite/gui-cards/test.py
Normal 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)
|
||||
449
src/modules/pending-rewrite/gui-cards/timer.py
Normal file
449
src/modules/pending-rewrite/gui-cards/timer.py
Normal 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"),
|
||||
]
|
||||
}
|
||||
713
src/modules/pending-rewrite/gui-cards/weekly.py
Normal file
713
src/modules/pending-rewrite/gui-cards/weekly.py
Normal 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)
|
||||
}
|
||||
320
src/modules/pending-rewrite/gui-commands/goals.py
Normal file
320
src/modules/pending-rewrite/gui-commands/goals.py
Normal 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")
|
||||
]
|
||||
)
|
||||
198
src/modules/pending-rewrite/gui-commands/leaderboard.py
Normal file
198
src/modules/pending-rewrite/gui-commands/leaderboard.py
Normal 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()
|
||||
90
src/modules/pending-rewrite/gui-commands/module.py
Normal file
90
src/modules/pending-rewrite/gui-commands/module.py
Normal 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",
|
||||
)
|
||||
24
src/modules/pending-rewrite/gui-commands/reloadgui.py
Normal file
24
src/modules/pending-rewrite/gui-commands/reloadgui.py
Normal 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.")
|
||||
278
src/modules/pending-rewrite/gui-commands/stats.py
Normal file
278
src/modules/pending-rewrite/gui-commands/stats.py
Normal 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)
|
||||
111
src/modules/pending-rewrite/gui-commands/tasklist.py
Normal file
111
src/modules/pending-rewrite/gui-commands/tasklist.py
Normal 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
|
||||
169
src/modules/pending-rewrite/gui-commands/timer.py
Normal file
169
src/modules/pending-rewrite/gui-commands/timer.py
Normal 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
|
||||
43
src/modules/pending-rewrite/gui-commands/weekly_test.py
Normal file
43
src/modules/pending-rewrite/gui-commands/weekly_test.py
Normal 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'))
|
||||
Reference in New Issue
Block a user