Files
croccybot/src/modules/tasklist/ui.py

965 lines
35 KiB
Python

from typing import Optional
from collections import defaultdict
from enum import Enum
import asyncio
import re
from io import StringIO
import discord
from discord.ui.select import select, Select, SelectOption
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from meta import conf
from meta.logger import log_wrap
from meta.errors import UserInputError
from utils.lib import MessageArgs, utc_now
from utils.ui import LeoUI, LeoModal, FastModal, error_handler_for, ModalRetryUI
from utils.ui.pagers import BasePager, Pager
from babel.translator import ctx_translator
from . import babel, logger
from .tasklist import Tasklist
from .data import TasklistData
_p = babel._p
checkmark = ""
checked_emoji = conf.emojis.task_checked
unchecked_emoji = conf.emojis.task_unchecked
class TasklistCaller(LeoUI):
def __init__(self, bot, **kwargs):
kwargs.setdefault('timeout', None)
super().__init__(**kwargs)
self.bot = bot
self.tasklist_callback.label = bot.translator.t(_p(
'ui:tasklist_caller|button:tasklist|label',
"Open Tasklist"
))
@button(label='TASKLIST_PLACEHOLDER', custom_id='open_tasklist', style=ButtonStyle.blurple)
async def tasklist_callback(self, press: discord.Interaction, pressed: Button):
cog = self.bot.get_cog('TasklistCog')
await cog.call_tasklist(press)
class SingleEditor(FastModal):
task: TextInput = TextInput(
label='',
max_length=200,
required=True
)
def setup_task(self):
t = ctx_translator.get().t
self.task.label = t(_p('modal:tasklist_single_editor|field:task|label', "Task content"))
parent: TextInput = TextInput(
label='',
max_length=120,
required=False
)
def setup_parent(self):
t = ctx_translator.get().t
self.parent.label = t(_p(
'modal:tasklist_single_editor|field:parent|label',
"Parent Task"
))
self.parent.placeholder = t(_p(
'modal:tasklist_single_editor|field:parent|placeholder',
"Enter a task number, e.g. 2.1"
))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setup()
def setup(self):
self.setup_task()
self.setup_parent()
@error_handler_for(UserInputError)
async def rerequest(self, interaction: discord.Interaction, error: UserInputError):
await ModalRetryUI(self, error.msg).respond_to(interaction)
class BulkEditor(LeoModal):
"""
Error-handling modal for bulk-editing a tasklist.
"""
tasklist_editor: TextInput = TextInput(
label='',
style=TextStyle.long,
max_length=4000,
required=False
)
def setup_tasklist_editor(self):
t = ctx_translator.get().t
self.tasklist_editor.label = t(_p(
'modal:tasklist_bulk_editor|field:tasklist|label', "Tasklist"
))
self.tasklist_editor.placeholder = t(_p(
'modal:tasklist_bulk_editor|field:tasklist|placeholder',
"- [ ] This is task 1, unfinished.\n"
"- [x] This is task 2, finished.\n"
" - [ ] This is subtask 2.1."
))
def __init__(self, tasklist: Tasklist, **kwargs):
self.setup()
super().__init__(**kwargs)
self.tasklist = tasklist
self.bot = tasklist.bot
self.labelled = tasklist.labelled
self.userid = tasklist.userid
self.lines = tasklist.flatten()
self.tasklist_editor.default = '\n'.join(self.lines.values())
self._callbacks = []
def setup(self):
t = ctx_translator.get().t
self.title = t(_p(
'modal:tasklist_bulk_editor', "Tasklist Editor"
))
self.setup_tasklist_editor()
def add_callback(self, coro):
self._callbacks.append(coro)
return coro
async def on_submit(self, interaction: discord.Interaction):
try:
await self.parse_editor()
for coro in self._callbacks:
await coro(interaction)
await interaction.response.defer()
except UserInputError as error:
await ModalRetryUI(self, error.msg).respond_to(interaction)
@log_wrap(action="parse editor")
async def parse_editor(self):
# First parse each line
new_lines = self.tasklist_editor.value.splitlines()
taskinfo = self.tasklist.parse_tasklist(new_lines)
old_info = self.tasklist.parse_tasklist(self.lines.values())
same_layout = (
len(old_info) == len(taskinfo)
and all(info[:2] == oldinfo[:2] for (info, oldinfo) in zip(taskinfo, old_info))
)
# TODO: Incremental/diff editing
async with self.bot.db.connection() as conn:
self.bot.db.conn = conn
async with conn.transaction():
now = utc_now()
if same_layout:
# if the layout has not changed, just edit the tasks
for taskid, (oldinfo, newinfo) in zip(self.lines.keys(), zip(old_info, taskinfo)):
args = {}
if oldinfo[2] != newinfo[2]:
args['completed_at'] = now if newinfo[2] else None
if oldinfo[3] != newinfo[3]:
args['content'] = newinfo[3]
if args:
await self.tasklist.update_tasks(taskid, **args)
else:
# Naive implementation clearing entire tasklist
# Clear tasklist
await self.tasklist.update_tasklist(deleted_at=now)
# Create tasklist
await self.tasklist.write_taskinfo(taskinfo)
class UIMode(Enum):
TOGGLE = (
_p(
'ui:tasklist|menu:main|mode:toggle|placeholder',
"Select to Toggle"
),
_p(
'ui:tasklist|menu:sub|mode:toggle|placeholder',
"Toggle from {label}.*"
),
)
EDIT = (
_p(
'ui:tasklist|menu:main|mode:edit|placeholder',
"Select to Edit"
),
_p(
'ui:tasklist|menu:sub|mode:edit|placeholder',
"Edit from {label}.*"
),
)
DELETE = (
_p(
'ui:tasklist|menu:main|mode:delete|placeholder',
"Select to Delete"
),
_p(
'ui:tasklist|menu:sub|mode:delete|placeholder',
"Delete from {label}.*"
),
)
@property
def main_placeholder(self):
return self.value[0]
@property
def sub_placeholder(self):
return self.value[1]
class TasklistUI(BasePager):
"""
Paged UI panel for managing the tasklist.
"""
# Cache of live tasklist widgets
# userid -> channelid -> TasklistUI
_live_ = defaultdict(dict)
def __init__(self,
tasklist: Tasklist,
channel: discord.abc.Messageable,
guild: Optional[discord.Guild] = None,
caller: Optional[discord.User | discord.Member] = None,
**kwargs):
kwargs.setdefault('timeout', 600)
super().__init__(**kwargs)
self.bot = tasklist.bot
self.tasklist = tasklist
self.labelled = tasklist.labelled
self.caller = caller
# NOTE: This is now a profiled
self.userid = tasklist.userid
self.channel = channel
self.guild = guild
# List of lists of (label, task) pairs
self._pages = []
self.page_num = -1
self._channelid = channel.id
self.current_page = None
self.mode: UIMode = UIMode.TOGGLE
self._message: Optional[discord.Message] = None
self._last_parentid: Optional[int] = None
self._subtree_root: Optional[int] = None
self.set_active()
@property
def this_page(self):
return self._pages[self.page_num % len(self._pages)] if self._pages else []
# ----- UI API -----
@classmethod
def fetch(cls, tasklist, channel, *args, **kwargs):
userid = tasklist.userid
channelid = channel.id
if channelid not in cls._live_[userid]:
self = cls(tasklist, channel, *args, **kwargs)
cls._live_[userid][channelid] = self
return cls._live_[userid][channelid]
async def run(self, interaction: discord.Interaction):
await self.refresh()
await self.redraw(interaction)
async def summon(self, force=False):
"""
Delete, refresh, and redisplay the tasklist widget as a non-ephemeral message in the current channel.
May raise `discord.HTTPException` (from `redraw`) if something goes wrong with the send.
"""
await self.refresh()
resend = force or not await self._check_recent()
if resend and self._message:
# Delete our current message if possible
try:
await self._message.delete()
except discord.HTTPException:
# If we cannot delete, it has probably already been deleted
# Or we don't have permission somehow
pass
self._message = None
# Redraw
try:
await self.redraw()
except discord.HTTPException:
if self._message:
self._message = None
await self.redraw()
async def page_cmd(self, interaction: discord.Interaction, value: str):
return await Pager.page_cmd(self, interaction, value)
async def page_acmpl(self, interaction: discord.Interaction, partial: str):
return await Pager.page_acmpl(self, interaction, partial)
# ----- Utilities / Workers ------
async def _check_recent(self) -> bool:
"""
Check whether the tasklist message is a "recent" message in the channel.
"""
if self._message is not None:
height = 0
async for message in self.channel.history(limit=5):
if message.id == self._message.id:
return True
if message.id < self._message.id:
return False
if message.attachments or message.embeds or height > 20:
return False
height += message.content.count('\n')
return False
return False
def _format_page(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str:
"""
Format a single block of page data into the task codeblock.
"""
lines = []
numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page)
for label, task in page:
label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1)
number = f"**`{label_string}`**"
if len(label) > 1:
depth = sum(len(str(c)) + 1 for c in label[:-1]) * ' '
depth = f"`{depth}`"
else:
depth = ''
task_string = "{depth}{cross}{number} {content}{cross}".format(
depth=depth,
number=number,
emoji=unchecked_emoji if task.completed_at is None else checked_emoji,
content=task.content,
cross='~~' if task.completed_at is not None else ''
)
lines.append(task_string)
return '\n'.join(lines)
def _format_page_text(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str:
"""
Format a single block of page data into the task codeblock.
"""
lines = []
numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page)
for label, task in page:
box = '[ ]' if task.completed_at is None else f"[{checkmark}]"
task_string = "{prepad} {depth} {content}".format(
prepad=' ' * numpad,
depth=(len(label) - 1) * ' ',
content=task.content
)
label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1)
taskline = box + ' ' + label_string + task_string[len(label_string):]
lines.append(taskline)
return "```md\n{}```".format('\n'.join(lines))
def _format_options(self, task_block, make_default: Optional[int] = None) -> list[SelectOption]:
options = []
for lbl, task in task_block:
value = str(task.taskid)
lblstr = '.'.join(map(str, lbl)) + '.' * (len(lbl) == 1)
name = f"{lblstr} {task.content[:100 - len(lblstr) - 1]}"
emoji = unchecked_emoji if task.completed_at is None else checked_emoji
options.append(SelectOption(label=name, value=value, emoji=emoji, default=(task.taskid == make_default)))
return options
def _format_parent(self, parentid) -> str:
parentstr = ''
if parentid is not None:
pair = next(((label, task) for label, task in self.labelled.items() if task.taskid == parentid), None)
if pair is not None:
label, task = pair
parent_label = self.tasklist.format_label(label).strip('.')
parentstr = f"{parent_label}: {task.content}"
return parentstr
def _parse_parent(self, provided: str) -> Optional[int]:
"""
Parse a provided parent field.
May raise UserInputError if parsing fails.
"""
t = self.bot.translator.t
provided = provided.strip()
if provided.split(':', maxsplit=1)[0].replace('.', '').strip().isdigit():
# Assume task label
label, _, _ = provided.partition(':')
label = label.strip()
pid = self.tasklist.parse_label(label)
if pid is None:
raise UserInputError(
t(_p(
'ui:tasklist_single_editor|field:parent|error:parse_id',
"Could not find the given parent task number `{input}` in your tasklist."
)).format(input=label)
)
elif provided:
# Search for matching tasks
matching = [
task.taskid
for task in self.tasklist.tasklist.values()
if provided.lower() in task.content.lower()
]
if len(matching) > 1:
raise UserInputError(
t(_p(
'ui:tasklist_single_editor|field:parent|error:multiple_matching',
"Multiple tasks matching given parent task `{input}`. Please use a task number instead!"
)).format(input=provided)
)
elif not matching:
raise UserInputError(
t(_p(
'ui:tasklist_single_editor|field:parent|error:no_matching',
"No tasks matching given parent task `{input}`."
)).format(input=provided)
)
pid = matching[0]
else:
pid = None
return pid
# ----- Components -----
async def _toggle_menu(self, interaction: discord.Interaction, selected: Select, subtree: bool):
await interaction.response.defer()
taskids = list(map(int, selected.values))
tasks = await self.tasklist.fetch_tasks(*taskids)
to_complete = [task for task in tasks if task.completed_at is None]
to_uncomplete = [task for task in tasks if task.completed_at is not None]
if to_complete:
await self.tasklist.update_tasks(
*(t.taskid for t in to_complete),
cascade=True,
completed_at=utc_now()
)
# TODO: Removed economy integration
# if self.guild:
# if (member := self.guild.get_member(self.userid)):
# self.bot.dispatch('tasks_completed', member, *(t.taskid for t in to_complete))
if to_uncomplete:
await self.tasklist.update_tasks(
*(t.taskid for t in to_uncomplete),
completed_at=None
)
# If the selected tasks share a parent, and we are not in the subtree menu, change the subtree root
if taskids and not subtree:
labelled = self.labelled
mapper = {t.taskid: label for label, t in labelled.items()}
shared_root = None
for task in tasks:
pid = task.parentid
plabel = mapper[pid] if pid else ()
if shared_root:
shared_root = tuple(i for i, j in zip(shared_root, plabel) if i == j)
else:
shared_root = plabel
if not shared_root:
break
if shared_root:
self._subtree_root = labelled[shared_root].taskid
self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False)
async def _delete_menu(self, interaction: discord.Interaction, selected: Select, subtree: bool):
await interaction.response.defer()
taskids = list(map(int, selected.values))
if taskids:
await self.tasklist.update_tasks(
*taskids,
cascade=True,
deleted_at=utc_now()
)
self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False)
async def _edit_menu(self, interaction: discord.Interaction, selected: Select, subtree: bool):
if not selected.values:
await interaction.response.defer()
else:
t = self.bot.translator.t
taskid = int(selected.values[0])
task = self.tasklist.tasklist[taskid]
editor = SingleEditor(
title=t(_p('ui:tasklist|menu:edit|modal:title', "Edit task"))
)
editor.parent.default = self._format_parent(task.parentid)
editor.task.default = task.content
@editor.submit_callback()
async def create_task(interaction):
new_task = editor.task.value
new_parentid = self._parse_parent(editor.parent.value)
await interaction.response.defer()
if task.content != new_task or task.parentid != new_parentid:
await task.update(content=new_task, parentid=new_parentid)
self._last_parentid = new_parentid
if not subtree:
self._subtree_root = new_parentid
self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False)
await interaction.response.send_modal(editor)
@select(placeholder="MAIN_MENU_PLACEHOLDER")
async def main_menu(self, interaction: discord.Interaction, selected: Select):
if self.mode is UIMode.TOGGLE:
await self._toggle_menu(interaction, selected, False)
elif self.mode is UIMode.DELETE:
await self._delete_menu(interaction, selected, False)
elif self.mode is UIMode.EDIT:
await self._edit_menu(interaction, selected, False)
async def main_menu_refresh(self):
t = self.bot.translator.t
menu = self.main_menu
menu.placeholder = t(self.mode.main_placeholder)
block = self.this_page
options = self._format_options(block)
menu.options = options
menu.min_values = 0
menu.max_values = len(options) if self.mode is not UIMode.EDIT else 1
@select(placeholder="SUB_MENU_PLACEHOLDER")
async def sub_menu(self, interaction: discord.Interaction, selected: Select):
if self.mode is UIMode.TOGGLE:
await self._toggle_menu(interaction, selected, True)
elif self.mode is UIMode.DELETE:
await self._delete_menu(interaction, selected, True)
elif self.mode is UIMode.EDIT:
await self._edit_menu(interaction, selected, True)
async def sub_menu_refresh(self):
t = self.bot.translator.t
menu = self.sub_menu
options = []
if self._subtree_root:
labelled = self.labelled
mapper = {t.taskid: label for label, t in labelled.items()}
rootid = self._subtree_root
rootlabel = mapper.get(rootid, ())
if rootlabel:
menu.placeholder = t(self.mode.sub_placeholder).format(
label=self.tasklist.format_label(rootlabel).strip('.'),
)
children = {
label: task
for label, task in labelled.items()
if all(i == j for i, j in zip(label, rootlabel))
}
this_page = self.this_page
if len(children) <= 25:
# Show all the children even if they don't display on the page
block = list(children.items())
else:
# Only show the children which display
page_children = [
(label, task) for label, task in this_page if label in children and task.taskid != rootid
][:24]
if page_children:
# Always add the root task
block = [(rootlabel, self.tasklist.tasklist[rootid]), *page_children]
else:
# There are no subtree children on the current page
block = []
# Special case if the subtree is exactly the same as the page
if not (len(block) == len(this_page) and all(i[0] == j[0] for i, j in zip(block, this_page))):
options = self._format_options(block)
menu.options = options
menu.min_values = 0
menu.max_values = len(options) if self.mode is not UIMode.EDIT else 1
@button(label='NEW_BUTTON_PLACEHOLDER', style=ButtonStyle.green, emoji=conf.emojis.task_new)
async def new_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
editor = SingleEditor(
title=t(_p('ui:tasklist_single_editor|title', "Add task"))
)
editor.parent.default = self._format_parent(self._last_parentid)
@editor.submit_callback()
async def create_task(interaction):
new_task = editor.task.value
parent = editor.parent.value
pid = self._parse_parent(parent)
self._last_parentid = pid
self._subtree_root = pid
await interaction.response.defer()
await self.tasklist.create_task(new_task, parentid=pid)
self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False)
await press.response.send_modal(editor)
async def new_button_refresh(self):
self.new_button.label = ""
@button(label="EDIT_MODE_PLACEHOLDER", style=ButtonStyle.blurple)
async def edit_mode_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.mode = UIMode.EDIT
await self.redraw()
async def edit_mode_button_refresh(self):
t = self.bot.translator.t
button = self.edit_mode_button
button.style = ButtonStyle.blurple if (self.mode is UIMode.EDIT) else ButtonStyle.grey
button.label = t(_p(
'ui:tasklist|button:edit_mode|label',
"Edit"
))
@button(label="DELETE_MODE_PLACEHOLDER", style=ButtonStyle.blurple)
async def delete_mode_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.mode = UIMode.DELETE
await self.redraw()
async def delete_mode_button_refresh(self):
t = self.bot.translator.t
button = self.delete_mode_button
button.style = ButtonStyle.blurple if (self.mode is UIMode.DELETE) else ButtonStyle.grey
button.label = t(_p(
'ui:tasklist|button:delete_mode|label',
"Delete"
))
@button(label="TOGGLE_MODE_PLACEHOLDER", style=ButtonStyle.blurple)
async def toggle_mode_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
self.mode = UIMode.TOGGLE
await self.redraw()
async def toggle_mode_button_refresh(self):
t = self.bot.translator.t
button = self.toggle_mode_button
button.style = ButtonStyle.blurple if (self.mode is UIMode.TOGGLE) else ButtonStyle.grey
button.label = t(_p(
'ui:tasklist|button:toggle_mode|label',
"Toggle"
))
@button(label="EDIT_BULK_PLACEHOLDER", style=ButtonStyle.blurple)
async def edit_bulk_button(self, press: discord.Interaction, pressed: Button):
editor = BulkEditor(self.tasklist)
@editor.add_callback
async def editor_callback(interaction: discord.Interaction):
self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False)
if sum(len(line) for line in editor.lines.values()) + len(editor.lines) >= 4000:
await press.response.send_message(
embed=discord.Embed(
colour=discord.Colour.brand_red(),
description=self.bot.translator.t(_p(
'ui:tasklist|button:edit_bulk|error:too_long',
"Your tasklist is too long to be edited in a Discord text input! "
"Use the save button and {cmds[tasks upload]} instead."
)).format(cmds=self.bot.core.mention_cache)
),
ephemeral=True
)
else:
await press.response.send_modal(editor)
async def edit_bulk_button_refresh(self):
t = self.bot.translator.t
button = self.edit_bulk_button
button.label = t(_p(
'ui:tasklist|button:edit_bulk|label',
"Bulk Edit"
))
@button(label='CLEAR_PLACEHOLDER', style=ButtonStyle.red)
async def clear_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
await self.tasklist.update_tasklist(
deleted_at=utc_now(),
)
self.bot.dispatch('tasklist_update', profileid=self.userid, channel=self.channel, summon=False)
async def clear_button_refresh(self):
self.clear_button.label = self.bot.translator.t(_p(
'ui:tasklist|button:clear|label', "Clear Tasklist"
))
self.clear_button.disabled = (len(self.labelled) == 0)
@button(label="SAVE_PLACEHOLDER", style=ButtonStyle.grey, emoji=conf.emojis.task_save)
async def save_button(self, press: discord.Interaction, pressed: Button):
"""
Send the tasklist to the user as a markdown file.
"""
t = self.bot.translator.t
await press.response.defer(thinking=True, ephemeral=True)
# Build the tasklist file
contents = '\n'.join(self.tasklist.flatten().values())
with StringIO(contents) as fp:
fp.seek(0)
file = discord.File(fp, filename='tasklist.md')
contents = t(_p(
'ui:tasklist|button:save|dm:contents',
"Your tasklist as of {now} is attached. Click here to jump back: {jump}"
)).format(
now=discord.utils.format_dt(utc_now()),
jump=press.message.jump_url
)
try:
await press.user.send(contents, file=file, silent=True)
except discord.HTTPException:
fp.seek(0)
file = discord.File(fp, filename='tasklist.md')
await press.followup.send(
t(_p(
'ui:tasklist|button:save|error:dms',
"Could not DM you! Do you have me blocked? Tasklist attached below."
)),
file=file,
)
else:
fp.seek(0)
file = discord.File(fp, filename='tasklist.md')
await press.followup.send(file=file)
async def save_button_refresh(self):
self.save_button.disabled = (len(self.labelled) == 0)
self.save_button.label = ''
@button(label="REFRESH_PLACEHOLDER", style=ButtonStyle.grey, emoji=conf.emojis.refresh, custom_id='open_tasklist')
async def refresh_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
await self.refresh()
await self.redraw()
async def refresh_button_refresh(self):
self.refresh_button.label = ''
@button(label="QUIT_PLACEHOLDER", style=ButtonStyle.grey, emoji=conf.emojis.cancel)
async def quit_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
if self._message is not None:
try:
await self._message.delete()
except discord.HTTPException:
pass
await self.close()
async def quit_button_refresh(self):
self.quit_button.label = ''
# ----- UI Flow -----
def access_check(self, userid):
return userid in (self.userid, self.caller.id if self.caller else None)
async def interaction_check(self, interaction: discord.Interaction):
t = self.bot.translator.t
interaction_profile = await self.bot.profiles.fetch_profile_discord(interaction.user)
if not self.access_check(interaction_profile.profileid):
embed = discord.Embed(
description=t(_p(
'ui:tasklist|error:wrong_user',
"This is not your tasklist!"
)),
colour=discord.Colour.brand_red()
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return False
else:
return True
async def cleanup(self):
self.set_inactive()
self._live_[self.userid].pop(self.channel.id, None)
if self._message is not None:
try:
await self._message.edit(view=None)
except discord.HTTPException:
pass
self._message = None
try:
if self._message is not None:
await self._message.edit(view=None)
except discord.HTTPException:
pass
async def get_page(self, page_id) -> MessageArgs:
t = self.bot.translator.t
tasks = [t for t in self.tasklist.tasklist.values() if t.deleted_at is None]
total = len(tasks)
completed = sum(t.completed_at is not None for t in tasks)
user = self.caller
user_name = user.name if user else str(self.userid)
user_colour = user.colour if user else discord.Color.orange()
author = t(_p(
'ui:tasklist|embed|author',
"{name}'s tasklist ({completed}/{total} complete)"
)).format(
name=user_name,
completed=completed,
total=total
)
embed = discord.Embed(
colour=user_colour,
)
embed.set_author(
name=author,
icon_url=user.avatar if user else None
)
if self._pages:
page = self.this_page
block = self._format_page(page)
embed.description = "{task_block}".format(task_block=block)
else:
embed.description = t(_p(
'ui:tasklist|embed|description',
"**You have no tasks on your tasklist!**\n"
"Add a task with {cmds[tasks new]}, or by pressing the {new_button} button below."
)).format(
cmds=self.bot.core.mention_cache,
new_button=conf.emojis.task_new
)
page_args = MessageArgs(embed=embed)
return page_args
def refresh_pages(self):
labelled = list(self.labelled.items())
count = len(labelled)
pages = []
if count > 0:
# Break into pages
edges = [0]
line_ptr = 0
while line_ptr < count:
line_ptr += 20
if line_ptr < count:
# Seek backwards to find the best parent
i = line_ptr - 5
minlabel = (i, len(labelled[i][0]))
while i < line_ptr:
i += 1
ilen = len(labelled[i][0])
if ilen <= minlabel[1]:
minlabel = (i, ilen)
line_ptr = minlabel[0]
else:
line_ptr = count
edges.append(line_ptr)
pages = [labelled[edges[i]:edges[i+1]] for i in range(len(edges) - 1)]
self._pages = pages
return pages
async def refresh(self):
# Refresh data
await self.tasklist.refresh()
self.labelled = self.tasklist.labelled
self.refresh_pages()
async def refresh_components(self):
if not self.labelled:
self.mode = UIMode.TOGGLE
await asyncio.gather(
self.main_menu_refresh(),
self.sub_menu_refresh(),
self.new_button_refresh(),
self.edit_mode_button_refresh(),
self.delete_mode_button_refresh(),
self.toggle_mode_button_refresh(),
self.edit_bulk_button_refresh(),
self.clear_button_refresh(),
self.save_button_refresh(),
self.refresh_button_refresh(),
self.quit_button_refresh(),
)
action_row = [
self.new_button, self.toggle_mode_button, self.edit_mode_button, self.delete_mode_button,
]
if self.mode is UIMode.EDIT:
action_row.append(self.edit_bulk_button)
elif self.mode is UIMode.DELETE:
action_row.append(self.clear_button)
main_row = (self.main_menu,) if self.main_menu.options else ()
sub_row = (self.sub_menu,) if self.sub_menu.options else ()
if len(self._pages) > 1:
# Multi paged layout
self._layout = (
action_row,
main_row,
sub_row,
(self.prev_page_button, self.save_button,
self.refresh_button, self.quit_button, self.next_page_button)
)
elif len(self.tasklist.tasklist) > 0:
# Single page, but still at least one task
self._layout = (
action_row,
main_row,
sub_row,
(self.save_button, self.refresh_button, self.quit_button)
)
else:
# No tasks
self._layout = (
(self.new_button, self.edit_bulk_button, self.refresh_button, self.quit_button),
)
async def redraw(self, interaction: Optional[discord.Interaction] = None):
self.current_page = await self.get_page(self.page_num)
await self.refresh_components()
# Resend
if interaction is not None:
if self._message:
try:
await self._message.delete()
except discord.HTTPException:
pass
self._message = await interaction.followup.send(**self.current_page.send_args, view=self)
elif self._message:
await self._message.edit(**self.current_page.edit_args, view=self)
else:
self._message = await self.channel.send(**self.current_page.send_args, view=self)