diff --git a/bot/bot.py b/bot/bot.py index b043bb7..523b303 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -156,7 +156,7 @@ async def on_ready(self): await self._setup_scripts() await self.controller.setup_webhooks() - self.controller.spypet.set_bot(self) + self.controller.surveillance.set_bot(self) except Exception as e: console.print_error(str(e)) diff --git a/bot/commands/util.py b/bot/commands/util.py index ab9ba82..385c905 100644 --- a/bot/commands/util.py +++ b/bot/commands/util.py @@ -345,157 +345,164 @@ async def commandhistory(self, ctx): description=description ), delete_after=cfg.get("message_settings")["auto_delete_delay"]) - @commands.command(name="spypet", description="Get a list of every message a member has sent in mutual servers.", usage="[member]") - async def spypet(self, ctx, member_id: int): - mutual_guilds = [guild for guild in self.bot.guilds if guild.get_member(member_id)] - data = {} - tasks = [] - sem = asyncio.Semaphore(10) - stop_event = asyncio.Event() - last_saved_count = 0 - - def _count_messages(): - return sum(len(channels) for guilds in data.values() for channels in guilds.values()) - - def _save_data(): - nonlocal last_saved_count - current_count = _count_messages() - if current_count == last_saved_count: - return - last_saved_count = current_count + @commands.command(name="surveillance", description="Get a list of every message a member has sent in mutual servers.", usage="[member]") + async def surveillance(self, ctx, member_id: int = None): + await cmdhelper.send_message(ctx, { + "title": "Surveillance", + "description": "This is a GUI only feature, please open the GUI to use it.", + "colour": "#ff0000" + }) + return + + # mutual_guilds = [guild for guild in self.bot.guilds if guild.get_member(member_id)] + # data = {} + # tasks = [] + # sem = asyncio.Semaphore(10) + # stop_event = asyncio.Event() + # last_saved_count = 0 + + # def _count_messages(): + # return sum(len(channels) for guilds in data.values() for channels in guilds.values()) + + # def _save_data(): + # nonlocal last_saved_count + # current_count = _count_messages() + # if current_count == last_saved_count: + # return + # last_saved_count = current_count - with open(files.get_application_support() + "/data/spypet.json", "w") as f: - json.dump(data, f, indent=4) - console.print_info(f"Auto-saved {current_count} messages.") - - async def _autosave(interval=5): - while not stop_event.is_set(): - await asyncio.sleep(interval) - _save_data() + # with open(files.get_application_support() + "/data/surveillance.json", "w") as f: + # json.dump(data, f, indent=4) + # console.print_info(f"Auto-saved {current_count} messages.") + + # async def _autosave(interval=5): + # while not stop_event.is_set(): + # await asyncio.sleep(interval) + # _save_data() - console.print_info("Auto-saving stopped. Spypet complete!") + # console.print_info("Auto-saving stopped. Surveillance complete!") - def _add_message(guild, channel, message): - if guild.name not in data: data[guild.name] = {} - if channel.name not in data[guild.name]: data[guild.name][channel.name] = [] + # def _add_message(guild, channel, message): + # if guild.name not in data: data[guild.name] = {} + # if channel.name not in data[guild.name]: data[guild.name][channel.name] = [] - data[guild.name][channel.name].append(message) - # _save_data() + # data[guild.name][channel.name].append(message) + # # _save_data() - def _get_permissions(channel): - member = channel.guild.get_member(member_id) - member_role = member.top_role - bot_role = channel.guild.me.top_role + # def _get_permissions(channel): + # member = channel.guild.get_member(member_id) + # member_role = member.top_role + # bot_role = channel.guild.me.top_role - return (channel.permissions_for(member).read_messages and - channel.permissions_for(channel.guild.me).read_messages or - channel.overwrites_for(member_role).read_messages and - channel.overwrites_for(bot_role).read_messages) + # return (channel.permissions_for(member).read_messages and + # channel.permissions_for(channel.guild.me).read_messages or + # channel.overwrites_for(member_role).read_messages and + # channel.overwrites_for(bot_role).read_messages) - async def _fetch_context_channel(channel): - if channel.id == ctx.channel.id: - return ctx.channel - try: - latest_msg = [msg async for msg in channel.history(limit=1)][0] - context = await self.bot.get_context(latest_msg) + # async def _fetch_context_channel(channel): + # if channel.id == ctx.channel.id: + # return ctx.channel + # try: + # latest_msg = [msg async for msg in channel.history(limit=1)][0] + # context = await self.bot.get_context(latest_msg) - console.success(f"Got context for {channel.guild.name} - {channel.name}") + # console.success(f"Got context for {channel.guild.name} - {channel.name}") - return context.channel - except Exception as e: - if "429" in str(e): - console.error(f"Rate limited while fetching context for {channel.guild.name} - {channel.name}") - await asyncio.sleep(5) - return await _fetch_context_channel(channel) + # return context.channel + # except Exception as e: + # if "429" in str(e): + # console.error(f"Rate limited while fetching context for {channel.guild.name} - {channel.name}") + # await asyncio.sleep(5) + # return await _fetch_context_channel(channel) - return None - - async def _get_messages(channel, delay=0.25, oldest_first=False): - async with sem: - try: - await asyncio.sleep(delay) - console.print_info(f"Finding messages in {channel.guild.name} - {channel.name}") - - channel = await _fetch_context_channel(channel) or channel - guild = channel.guild - messages = [] - - try: - async for msg in channel.history(limit=None, oldest_first=oldest_first): - print(channel.name, msg.author.id, msg.content) - if msg.author.id == member_id: - if len(msg.attachments) > 0: - attachments = "\n".join([f"Attachment: {attachment.url}" for attachment in msg.attachments]) - msg_string = f"[{msg.created_at.strftime('%Y-%m-%d %H:%M:%S')}] {msg.content}\n{attachments}" - else: - msg_string = f"[{msg.created_at.strftime('%Y-%m-%d %H:%M:%S')}] {msg.content}" - messages.append(msg_string) - _add_message(guild, channel, msg_string) - console.print_info(f"Found message in {channel.guild.name} - {channel.name}") + # return None + + # async def _get_messages(channel, delay=0.25, oldest_first=False): + # async with sem: + # try: + # await asyncio.sleep(delay) + # console.print_info(f"Finding messages in {channel.guild.name} - {channel.name}") + + # channel = await _fetch_context_channel(channel) or channel + # guild = channel.guild + # messages = [] + + # try: + # async for msg in channel.history(limit=None, oldest_first=oldest_first): + # print(channel.name, msg.author.id, msg.content) + # if msg.author.id == member_id: + # if len(msg.attachments) > 0: + # attachments = "\n".join([f"Attachment: {attachment.url}" for attachment in msg.attachments]) + # msg_string = f"[{msg.created_at.strftime('%Y-%m-%d %H:%M:%S')}] {msg.content}\n{attachments}" + # else: + # msg_string = f"[{msg.created_at.strftime('%Y-%m-%d %H:%M:%S')}] {msg.content}" + # messages.append(msg_string) + # _add_message(guild, channel, msg_string) + # console.print_info(f"Found message in {channel.guild.name} - {channel.name}") - if len(messages) > 0: - console.print_success(f"Found messages in {channel.guild.name} - {channel.name}") - else: - console.print_error(f"Found no messages in {channel.guild.name} - {channel.name}") - except Exception as e: - if "429" in str(e).lower(): - console.print_error("Rate limited! Waiting for 5 seconds...") - await asyncio.sleep(5) - return await _get_messages(channel, delay) - else: - console.print_error(f"Error in {channel.guild.name} - {channel.name}: {e}") - return - - _save_data() - except asyncio.CancelledError: - console.print_warning("Process was cancelled! Saving progress...") - stop_event.set() - _save_data() - raise - except Exception as e: - console.print_error(f"Error in {channel.guild.name} - {channel.name}: {e}") - finally: - _save_data() - - async def _attempt_scrape(guild, delay): - tasks = [] - console.info(f"Attempting to scrape {guild.name} - {guild.id}") + # if len(messages) > 0: + # console.print_success(f"Found messages in {channel.guild.name} - {channel.name}") + # else: + # console.print_error(f"Found no messages in {channel.guild.name} - {channel.name}") + # except Exception as e: + # if "429" in str(e).lower(): + # console.print_error("Rate limited! Waiting for 5 seconds...") + # await asyncio.sleep(5) + # return await _get_messages(channel, delay) + # else: + # console.print_error(f"Error in {channel.guild.name} - {channel.name}: {e}") + # return + + # _save_data() + # except asyncio.CancelledError: + # console.print_warning("Process was cancelled! Saving progress...") + # stop_event.set() + # _save_data() + # raise + # except Exception as e: + # console.print_error(f"Error in {channel.guild.name} - {channel.name}: {e}") + # finally: + # _save_data() + + # async def _attempt_scrape(guild, delay): + # tasks = [] + # console.info(f"Attempting to scrape {guild.name} - {guild.id}") - for channel in guild.channels: - if isinstance(channel, discord.TextChannel) and _get_permissions(channel): - tasks.append(_get_messages(channel, delay, oldest_first=True)) - delay += 2 + # for channel in guild.channels: + # if isinstance(channel, discord.TextChannel) and _get_permissions(channel): + # tasks.append(_get_messages(channel, delay, oldest_first=True)) + # delay += 2 - if len(tasks) == 0: - console.print_error(f"No valid channels in {guild.name} - {guild.id}") - return + # if len(tasks) == 0: + # console.print_error(f"No valid channels in {guild.name} - {guild.id}") + # return - try: - await asyncio.gather(*tasks) - except asyncio.CancelledError: - console.print_warning("Process was cancelled! Saving progress...") - stop_event.set() - _save_data() - raise - - delay = 1 - autosave_task = asyncio.create_task(_autosave(5)) + # try: + # await asyncio.gather(*tasks) + # except asyncio.CancelledError: + # console.print_warning("Process was cancelled! Saving progress...") + # stop_event.set() + # _save_data() + # raise + + # delay = 1 + # autosave_task = asyncio.create_task(_autosave(5)) - for guild in mutual_guilds: - tasks.append(_attempt_scrape(guild, delay)) - delay += 1.5 - - await asyncio.gather(*tasks) - stop_event.set() - await autosave_task - _save_data() + # for guild in mutual_guilds: + # tasks.append(_attempt_scrape(guild, delay)) + # delay += 1.5 + + # await asyncio.gather(*tasks) + # stop_event.set() + # await autosave_task + # _save_data() - console.print_success("Spypet complete! Data saved to data/spypet.json.") - console.print_info(f"Total messages: {_count_messages()}") - console.print_info(f"Total guilds: {len(data)}") - console.print_info(f"Total channels: {sum(len(channels) for channels in data.values())}") - await ctx.send(file=discord.File(files.get_application_support() + "/data/spypet.json"), delete_after=self.cfg.get("message_settings")["auto_delete_delay"]) + # console.print_success("Spypet complete! Data saved to data/surveillance.json.") + # console.print_info(f"Total messages: {_count_messages()}") + # console.print_info(f"Total guilds: {len(data)}") + # console.print_info(f"Total channels: {sum(len(channels) for channels in data.values())}") + # await ctx.send(file=discord.File(files.get_application_support() + "/data/surveillance.json"), delete_after=self.cfg.get("message_settings")["auto_delete_delay"]) @commands.command(name="latency", description="Check the bot's latency", usage="") async def latency(self, ctx): diff --git a/bot/controller.py b/bot/controller.py index e5189db..ea34aa5 100644 --- a/bot/controller.py +++ b/bot/controller.py @@ -15,13 +15,13 @@ from bot.helpers import cmdhelper, imgembed import utils.webhook as webhook_client from gui.helpers.images import resize_and_sharpen -from bot.tools import SpyPet +from bot.tools import Surveillance if getattr(sys, 'frozen', False): os.chdir(os.path.dirname(sys.executable)) class BotController: - spypet = None + surveillance = None def __init__(self): self.cfg = Config() @@ -33,34 +33,37 @@ def __init__(self): self.bot_running = False self.startup_scripts = [] self.presence = self.cfg.get_rich_presence() - self.spypet = SpyPet(self) + self.surveillance = Surveillance(self) + self._avatar_cache = {} # (url, size, radius) -> PhotoImage + self._avatar_bytes_cache = {} # url -> raw bytes + self._avatar_lock = threading.Lock() - def start_spypet(self): - if not self.spypet.bot: - self.spypet.set_bot(self.bot) - console.success("SpyPet bot set successfully.") + def start_surveillance(self): + if not self.surveillance.bot: + self.surveillance.set_bot(self.bot) + console.success("Surveillance bot set successfully.") else: - console.warning("SpyPet bot is already set.") + console.warning("Surveillance bot is already set.") - if not self.spypet.member_id: - console.error("SpyPet member ID is not set. Please set it in the settings.") + if not self.surveillance.member_id: + console.error("Surveillance member ID is not set. Please set it in the settings.") return - asyncio.run_coroutine_threadsafe(self.spypet.start(), self.loop) - console.success("SpyPet started successfully!") + asyncio.run_coroutine_threadsafe(self.surveillance.start(), self.loop) + console.success("Surveillance started successfully!") - def stop_spypet(self): - if self.spypet.running: - asyncio.run_coroutine_threadsafe(self.spypet.stop(), self.loop) - console.success("SpyPet stopped successfully!") + def stop_surveillance(self): + if self.surveillance.running: + asyncio.run_coroutine_threadsafe(self.surveillance.stop(), self.loop) + console.success("Surveillance stopped successfully!") else: - console.warning("SpyPet is not running.") + console.warning("Surveillance is not running.") - def get_mutual_guilds_spypet(self): - if self.spypet.running: - return asyncio.run_coroutine_threadsafe(self.spypet.get_mutual_guilds(), self.loop).result() + def get_mutual_guilds_surveillance(self): + if self.surveillance.running: + return asyncio.run_coroutine_threadsafe(self.surveillance.get_mutual_guilds(), self.loop).result() else: - console.warning("SpyPet is not running. Cannot get mutual guilds.") + console.warning("Surveillance is not running. Cannot get mutual guilds.") return [] def add_startup_script(self, script): @@ -68,7 +71,7 @@ def add_startup_script(self, script): def set_gui(self, gui): self.gui = gui - self.spypet.set_gui(gui.tools_page.spypet_page) + self.surveillance.set_gui(gui.tools_page.surveillance_page) def check_token(self): resp = requests.get("https://discord.com/api/v9/users/@me", headers={"Authorization": self.cfg.get("token")}) @@ -220,20 +223,45 @@ def get_user_from_id(self, user_id): def get_avatar_from_url(self, url, size=50, radius=5): try: + if not url: + return None + url = url.split("?")[0] if url.endswith(".gif"): url = url.replace(".gif", ".png") - response = requests.get(url, timeout=10) - if response.status_code == 200: - image = Image.open(BytesIO(response.content)) - # image = image.resize((size, size)) - image = resize_and_sharpen(image, (size, size)) + + cache_key = (url, size, radius) + + with self._avatar_lock: + if cache_key in self._avatar_cache: + return self._avatar_cache[cache_key] + + # reuse downloaded bytes if available + with self._avatar_lock: + if url in self._avatar_bytes_cache: + content = self._avatar_bytes_cache[url] + else: + response = requests.get(url, timeout=5) + response.raise_for_status() + content = response.content + self._avatar_bytes_cache[url] = content + + image = Image.open(BytesIO(content)).convert("RGBA") + image = resize_and_sharpen(image, (size, size)) + + if radius > 0: image = imgembed.add_corners(image, radius) - return ImageTk.PhotoImage(image) + + photo = ImageTk.PhotoImage(image) + + with self._avatar_lock: + self._avatar_cache[cache_key] = photo + + return photo + except Exception as e: print(f"Error processing avatar from URL {url}: {e}") - - return None + return None def get_avatar(self, size=50, radius=5): try: @@ -263,5 +291,15 @@ def get_user_from_id(self, user_id): get_user = lambda self: self.bot.user if self.bot else None get_friends = lambda self: self.bot.friends if self.bot else None get_guilds = lambda self: self.bot.guilds if self.bot else None - get_uptime = lambda self: cmdhelper.format_time(time.time() - self.bot.start_time, short_form=True) if self.bot else "0:00:00" - get_latency = lambda self: f"{round(self.bot.latency * 1000)}ms" if self.bot else "0ms" \ No newline at end of file + + def get_latency(self): + try: + return f"{round(self.bot.latency * 1000)}ms" + except: + return "0ms" + + def get_uptime(self): + try: + return cmdhelper.format_time(time.time() - self.bot.start_time, short_form=True) + except: + return "0s" \ No newline at end of file diff --git a/bot/helpers/cmdhelper.py b/bot/helpers/cmdhelper.py index 7b6c218..6012eae 100644 --- a/bot/helpers/cmdhelper.py +++ b/bot/helpers/cmdhelper.py @@ -149,7 +149,7 @@ async def send_message(ctx, embed_obj: dict, extra_title="", extra_message="", d msg_style = "codeblock" if msg_style == "codeblock": - description = re.sub(r"[*_~`]", "", codeblock_desc).lower() + description = re.sub(r"[*_~`]", "", codeblock_desc) if title == theme.title: title = f"{theme.emoji} {title}" @@ -159,30 +159,30 @@ async def send_message(ctx, embed_obj: dict, extra_title="", extra_message="", d msg = await ctx.send( str(codeblock.Codeblock( - title=title.lower(), - description=description.lower(), - extra_title=extra_title.lower(), - # footer=footer.lower() + title=title, + description=description, + extra_title=extra_title, + # footer=footer )), delete_after=delete_after ) elif msg_style == "image": title = remove_emojis(title.replace(theme.emoji, "").lstrip()) - embed2 = imgembed.Embed(title=title.lower(), description=description.lower(), colour=colour) + embed2 = imgembed.Embed(title=title, description=description, colour=colour) embed2.set_footer(text=footer) embed2.set_thumbnail(url=thumbnail) embed_file = embed2.save() - msg = await ctx.send(file=discord.File(embed_file, filename="embed.png"), delete_after=delete_after) + msg = await ctx.send(file=discord.File(embed_file, filename=embed_file.split("/")[-1]), delete_after=delete_after) os.remove(embed_file) elif msg_style == "embed" and cfg.get("rich_embed_webhook"): if title == theme.title: title = f"{theme.emoji} {title}" embed = discord.Embed( - title=title.lower(), - description=description.lower(), + title=title, + description=description, colour=discord.Color.from_str(colour) ) embed.set_footer(text=footer) diff --git a/bot/helpers/imgembed.py b/bot/helpers/imgembed.py index bb116e2..007e667 100644 --- a/bot/helpers/imgembed.py +++ b/bot/helpers/imgembed.py @@ -3,23 +3,40 @@ import re from io import BytesIO -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw, ImageFont, ImageChops from utils import files from utils import console +def crop_center_square(im: Image.Image) -> Image.Image: + if im.mode != "RGBA": + im = im.convert("RGBA") + + w, h = im.size + side = min(w, h) + + left = (w - side) // 2 + top = (h - side) // 2 + right = left + side + bottom = top + side + + return im.crop((left, top, right, bottom)) + def add_corners(im, rad): - # src: https://stackoverflow.com/questions/11287402/how-to-round-corner-a-logo-without-white-backgroundtransparent-on-it-using-pi - circle = Image.new('L', (rad * 2, rad * 2), 0) - draw = ImageDraw.Draw(circle) - draw.ellipse((0, 0, rad * 2 - 1, rad * 2 - 1), fill=255) - alpha = Image.new('L', im.size, 255) + if im.mode != "RGBA": + im = im.convert("RGBA") + w, h = im.size - alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0)) - alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad)) - alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0)) - alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad)) - im.putalpha(alpha) + # Create rounded rectangle mask + mask = Image.new("L", (w, h), 0) + draw = ImageDraw.Draw(mask) + draw.rounded_rectangle([(0, 0), (w, h)], radius=rad, fill=255) + + # Combine with existing alpha (if present) + existing_alpha = im.split()[3] + combined_alpha = ImageChops.multiply(existing_alpha, mask) + + im.putalpha(combined_alpha) return im @@ -96,8 +113,14 @@ def __init__(self, title="", description="", colour="#AE00E5", color=""): self.height = 50 self.width = 1500 self.wrap_width = self.width - 450 - - def set_thumbnail(self, url = ""): self.thumbnail = url + + self.thumbnail_resp = None + self.thumbnail_gif = False + self.waves = Image.open(files.resource_path("data/waves.png")).convert("RGBA") + + def set_thumbnail(self, url = ""): + self.thumbnail = url + self.thumbnail_resp = requests.get(url) def set_image(self, url = ""): self.image = url def set_footer(self, text = "", icon_url = ""): self.footer = text def set_author(self, name = "", icon_url = "", url = ""): pass @@ -151,12 +174,14 @@ def draw_title(self, template, draw): def draw_thumbnail(self, template, draw): if self.thumbnail != "": try: - logo = Image.open(BytesIO(requests.get(self.thumbnail).content)).convert("RGBA") - logo = logo.resize((300, 300)) + logo = Image.open(BytesIO(self.thumbnail_resp.content)) + logo = crop_center_square(logo) + logo = logo.resize((300, 300), Image.LANCZOS) logo = add_corners(logo, 20) + template.alpha_composite(logo, (self.width - 300 - 45, 40)) - except Exception as e: - console.print_error(f"Failed to load thumbnail from theme.") + except Exception: + console.print_error("Failed to load thumbnail from theme.") def draw_description(self, template, draw): if self.description != "": @@ -253,10 +278,9 @@ def draw_background(self, template, draw): # draw.rounded_rectangle([(0, 0), (self.width - 15, self.height)], 25, fill=hex_to_rgb(self.colour)) # draw.rounded_rectangle([(10, 0), (self.width - 10, self.height)], 25, fill=(30, 30, 30, 255)) - waves = Image.open(files.resource_path("data/waves.png")).convert("RGBA") - template.paste(waves, - (int(self.width / 2) - int(waves.width / 2), int(self.height / 2) - int(waves.height / 1.5)), - waves) + template.paste(self.waves, + (int(self.width / 2) - int(self.waves.width / 2), int(self.height / 2) - int(self.waves.height / 1.5)), + self.waves) # draw background image # background = Image.open("data/background.png").convert("RGBA") @@ -282,13 +306,62 @@ def draw(self): return template + def build_static_base(self): + template = Image.new("RGBA", (self.width, self.height), (30, 30, 30, 255)) + draw = ImageDraw.Draw(template) + + self.draw_background(template, draw) + self.draw_title(template, draw) + self.draw_description(template, draw) + self.draw_fields(template, draw) + self.draw_footer(template, draw) + + return template + + def draw_animated(self): + self.setup_dimensions() + + thumbnail = Image.open(BytesIO(self.thumbnail_resp.content)) + + base = self.build_static_base() + + frames = [] + duration = thumbnail.info.get("duration", 100) + + # Limit frames for speed + max_frames = 40 + step = max(1, thumbnail.n_frames // max_frames) + + for i in range(0, thumbnail.n_frames, step): + thumbnail.seek(i) + + frame_image = thumbnail.convert("RGBA") + frame_image = crop_center_square(frame_image) + frame_image = frame_image.resize((300, 300), Image.LANCZOS) + frame_image = add_corners(frame_image, 20) + + frame = base.copy() # cheap copy + frame.alpha_composite(frame_image, (self.width - 300 - 45, 40)) + + frames.append(frame) + + return frames, duration * 1.5 + def save(self): - path = f"embed-{random.randint(1000, 9999)}.png" # comment this out if youre running the script directly + thumbnail_is_gif = getattr(Image.open(BytesIO(self.thumbnail_resp.content)), "is_animated", False) + extension = ".gif" if thumbnail_is_gif else ".png" + path = f"embed-{random.randint(1000, 9999)}" + extension # comment this out if youre running the script directly # path = "embed.png" # uncomment this if youre running the script directly - final = self.draw() - final.thumbnail((self.width // 2, self.height // 2), Image.LANCZOS) - # final = final.resize((self.width // 2, self.height // 2), Image.LANCZOS) - final.save(path, optimize=True, quality=20) + + if thumbnail_is_gif: + frames, duration = self.draw_animated() + frames[0].save(path, save_all=True, append_images=frames[1:], optimize=True, duration=duration, loop=0) + else: + final = self.draw() + final.thumbnail((self.width // 2, self.height // 2), Image.LANCZOS) + # final = final.resize((self.width // 2, self.height // 2), Image.LANCZOS) + final.save(path, optimize=True, quality=20) + return path diff --git a/bot/helpers/spypet.py b/bot/helpers/spypet.py deleted file mode 100644 index 91f219f..0000000 --- a/bot/helpers/spypet.py +++ /dev/null @@ -1,17 +0,0 @@ -class Spypet: - def __init__(self): - self.bot = None - self.messages = {} - - def set_bot(self, bot): - self.bot = bot - - def add_message(self, guild, channel, message): - if guild.name not in self.messages: - self.messages[guild.name] = {} - if channel.name not in self.messages[guild.name]: - self.messages[guild.name][channel.name] = [] - self.messages[guild.name][channel.name].append(message) - - def clear_messages(self): - self.messages = {} \ No newline at end of file diff --git a/bot/tools/__init__.py b/bot/tools/__init__.py index b9bfd27..c895371 100644 --- a/bot/tools/__init__.py +++ b/bot/tools/__init__.py @@ -1 +1 @@ -from .spypet import SpyPet \ No newline at end of file +from .surveillance import Surveillance \ No newline at end of file diff --git a/bot/tools/spypet.py b/bot/tools/surveillance.py similarity index 94% rename from bot/tools/spypet.py rename to bot/tools/surveillance.py index e41b9d4..a828221 100644 --- a/bot/tools/spypet.py +++ b/bot/tools/surveillance.py @@ -8,7 +8,7 @@ from utils import files from utils.config import Config -class SpyPetConsole: +class SurveillanceConsole: def __init__(self, controller): self.controller = controller self.gui = None @@ -31,7 +31,7 @@ def info(self, message): def warning(self, message): self.add_log("warning", message) -class SpyPet: +class Surveillance: def __init__(self, controller): self.bot = None self.gui = None @@ -43,7 +43,7 @@ def __init__(self, controller): self.semaphore = asyncio.Semaphore(5) self.member_id = None self.cache = set() - self.console = SpyPetConsole(controller) + self.console = SurveillanceConsole(controller) self.tasks = [] self.total_messages = 0 self.user_total_messages = 0 @@ -51,15 +51,15 @@ def __init__(self, controller): def set_gui(self, gui): self.gui = gui self.console.set_gui(gui) - self.console.success("SpyPet GUI set successfully.") + self.console.success("Surveillance GUI set successfully.") def set_bot(self, bot): self.bot = bot self.load_cache() - self.console.success("SpyPet bot set successfully.") + self.console.success("Surveillance bot set successfully.") def get_data_path(self): - return files.get_application_support() + f"/data/spypet/{self.member_id}.json" + return files.get_application_support() + f"/data/surveillance/{self.member_id}.json" def save_data(self): if not self.data: @@ -249,9 +249,9 @@ async def start(self): return self.running = True - self.console.info("SpyPet started.") + self.console.info("Surveillance started.") self.mutual_guilds = await self.get_mutual_guilds() - self.gui._check_spypet_running() + self.gui._check_surveillance_running() self.gui.mutual_guilds = self.mutual_guilds if not self.tasks: @@ -297,8 +297,8 @@ async def stop(self): self.console.info("Saving data...") self.save_data() - self.console.success("SpyPet stopped and data saved.") - self.gui._check_spypet_running() + self.console.success("Surveillance stopped and data saved.") + self.gui._check_surveillance_running() def reset(self): self.running = False @@ -308,8 +308,8 @@ def reset(self): self.tasks.clear() self.total_messages = 0 self.user_total_messages = 0 - self.console.info("SpyPet reset. All data cleared and tasks cancelled.") - self.gui._check_spypet_running() + self.console.info("Surveillance reset. All data cleared and tasks cancelled.") + self.gui._check_surveillance_running() def set_member_id(self, member_id): self.member_id = member_id diff --git a/compile.py b/compile.py index 1ef34d5..a72599a 100644 --- a/compile.py +++ b/compile.py @@ -1,19 +1,43 @@ import os -import sys import platform import subprocess +import plistlib + +from utils.config import VERSION + + +def patch_macos_plist(app_name): + plist_path = os.path.join("dist", f"{app_name}.app", "Contents", "Info.plist") + + if not os.path.exists(plist_path): + print("Info.plist not found, skipping version patch") + return + + with open(plist_path, "rb") as f: + plist = plistlib.load(f) + + plist["CFBundleShortVersionString"] = VERSION # About menu + plist["CFBundleVersion"] = VERSION # build number + + with open(plist_path, "wb") as f: + plistlib.dump(plist, f) + + print(f"Patched Info.plist with version {VERSION}") + def build(): system = platform.system() - + name = "Ghost" entry_script = "ghost.py" icon = "data/icon-win.png" if system == "Windows" else "data/icon.png" - common_args = [ + args = [ "pyinstaller", - "--name=" + name, + f"--name={name}", "--onefile", + "--clean", + "--noconfirm", "--windowed", "--noconsole", f"--icon={icon}", @@ -25,32 +49,28 @@ def build(): entry_script ] - # Add paths to site-packages if needed if system == "Windows": - site_packages = ".venv\\Lib\\site-packages" - add_data = [ + args += [ + "--paths=.venv\\Lib\\site-packages", "--add-data=data\\*;data", "--add-data=data\\fonts\\*;data/fonts", "--add-data=data\\icons\\*;data/icons" ] else: - site_packages = ".venv/lib/python3.10/site-packages" - add_data = [ + args += [ + "--paths=.venv/lib/python3.10/site-packages", "--add-data=data/*:data", "--add-data=data/fonts/*:data/fonts", - "--add-data=data/icons/*:data/icons" + "--add-data=data/icons/*:data/icons", + "--osx-bundle-identifier=fun.benny.ghost" ] - - common_args.append(f"--paths={site_packages}") - common_args += add_data - # macOS-specific option + print(f"🔨 Building Ghost {VERSION} for {system}...") + subprocess.run(args, check=True) + if system == "Darwin": - common_args.append("--osx-bundle-identifier=fun.benny.ghost") + patch_macos_plist(name) - # Run the command - print(f"Building for {system}...") - subprocess.run(common_args) if __name__ == "__main__": build() diff --git a/data/gui_theme.json b/data/gui_theme.json index 5f8fc60..e503441 100644 --- a/data/gui_theme.json +++ b/data/gui_theme.json @@ -5,21 +5,21 @@ "type": "dark", "colors": { "primary": "#433dfb", - "secondary": "#222324", + "secondary": "#171726", "success": "#0abf34", "info": "#2b6eff", "warning": "#f39c12", "danger": "#ff341f", "light": "#ADB5BD", - "dark": "#1a1c1c", - "bg": "#121111", + "dark": "#12121c", + "bg": "#111017", "fg": "#ffffff", "selectbg": "#555555", "selectfg": "#ffffff", "border": "#121111", "inputfg": "#ffffff", "inputbg": "#2f2f2f", - "active": "#1F1F1F" + "active": "#171726" } } } diff --git a/gui/components/__init__.py b/gui/components/__init__.py index 0bd6d45..62ff3c8 100644 --- a/gui/components/__init__.py +++ b/gui/components/__init__.py @@ -5,4 +5,5 @@ from .settings_frame import SettingsFrame from .settings_panel import SettingsPanel from .titlebar import Titlebar -from .tool_page import ToolPage \ No newline at end of file +from .tool_page import ToolPage +from .dropdown_menu import DropdownMenu \ No newline at end of file diff --git a/gui/components/console.py b/gui/components/console.py index a577dd5..5dbed5a 100644 --- a/gui/components/console.py +++ b/gui/components/console.py @@ -2,6 +2,7 @@ import ttkbootstrap as ttk from gui.components.rounded_frame import RoundedFrame from gui.helpers.images import Images +from gui.helpers.style import Style from utils.console import get_formatted_time class Console: @@ -61,8 +62,8 @@ def add_log(self, prefix, text): self.update() def _load_tags(self): - self.textarea.tag_config("timestamp", foreground="gray") - self.textarea.tag_config("log_text", foreground="lightgrey") + self.textarea.tag_config("timestamp", foreground=Style.DARK_GREY.value, font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("log_text", foreground=Style.LIGHT_GREY.value, font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) self.textarea.tag_config("prefix_sniper", foreground="red", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) self.textarea.tag_config("sniper_key", foreground="#eceb18", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) @@ -113,10 +114,13 @@ def _draw_main(self, parent): state="normal" ) - self.textarea.bind_all( - "" if sys.platform != "darwin" else "", - lambda _: self.textarea.event_generate("<>") - ) + try: + self.textarea.bind_all( + "" if sys.platform != "darwin" else "", + lambda _: self.textarea.event_generate("<>") + ) + except: + pass self.textarea.pack(fill="both", expand=True, padx=5, pady=5) self._load_tags() diff --git a/gui/components/dropdown_menu.py b/gui/components/dropdown_menu.py new file mode 100644 index 0000000..7acd0ac --- /dev/null +++ b/gui/components/dropdown_menu.py @@ -0,0 +1,112 @@ +import ttkbootstrap as ttk +from gui.components import RoundedFrame +from gui.helpers.style import Style + +class DropdownMenu: + def __init__(self, parent, options, command=None): + self.parent = parent + self.options = options + self.selected_option = ttk.StringVar(value=options[0] if options else "") + self.command = command + + def _hover_enter(self, wrapper, label): + wrapper.set_background(Style.DROPDOWN_OPTION_HOVER.value) + label.configure(background=Style.DROPDOWN_OPTION_HOVER.value) + + def _hover_leave(self, wrapper, label): + wrapper.set_background(self.parent.style.colors.get("secondary")) + label.configure(background=self.parent.style.colors.get("secondary")) + + def _rearrange_options(self): + selected = self.selected_option.get() + self.options.remove(selected) + self.options.insert(0, selected) + + def _open_menu(self, event): + if not self._alive(): + return + + for widget in self.frame.winfo_children(): + widget.destroy() + + self._rearrange_options() + index = 0 + for option in self.options: + index += 1 + + wrapper = RoundedFrame(self.frame, radius=8, background=self.parent.style.colors.get("secondary")) + wrapper.pack(fill=ttk.X, padx=5, pady=(4, 5 if index == len(self.options) else 0)) + wrapper.bind("", lambda e, opt=option: self._on_option_selected(opt)) + + label = ttk.Label(wrapper, text=option, background=self.parent.style.colors.get("secondary"), anchor="w") + label.pack(fill=ttk.X, padx=5, pady=2) + label.bind("", lambda e, opt=option: self._on_option_selected(opt)) + + label.bind("", lambda e, w=wrapper, l=label: self._hover_enter(w, l)) + label.bind("", lambda e, w=wrapper, l=label: self._hover_leave(w, l)) + wrapper.bind("", lambda e, w=wrapper, l=label: self._hover_enter(w, l)) + wrapper.bind("", lambda e, w=wrapper, l=label: self._hover_leave(w, l)) + + def _close_menu(self): + if not self._alive(): + return + + for widget in self.frame.winfo_children(): + widget.destroy() + + label = ttk.Label(self.frame, textvariable=self.selected_option, anchor="w", background=self.parent.style.colors.get("secondary")) + label.pack(fill=ttk.X, padx=10, pady=5) + label.bind("", self._open_menu) + self.frame.bind("", self._open_menu) + + self.down_arrow = ttk.Label(self.frame, text="▼", background=self.parent.style.colors.get("secondary"), font=("Host Grotesk", 10)) + self.down_arrow.place(relx=1.0, rely=0.5, x=-10, y=0, anchor="e") + self.down_arrow.bind("", self._open_menu) + + def _on_option_selected(self, option): + if not self._alive(): + return + + self.selected_option.set(option) + if self.command: + self.command(option) + + if self._alive(): + self._close_menu() + + def _outside_click(self, event): + if not self._alive(): + return + if not self.frame.winfo_containing(event.x_root, event.y_root): + self._close_menu() + + def draw(self): + self.parent.bind("", self._outside_click, add="+") + self.frame = RoundedFrame(self.parent, radius=5, bootstyle="secondary.TFrame") + + label = ttk.Label(self.frame, textvariable=self.selected_option, anchor="w", background=self.parent.style.colors.get("secondary")) + label.pack(fill=ttk.X, padx=10, pady=5) + label.bind("", self._open_menu) + self.frame.bind("", self._open_menu) + + self.down_arrow = ttk.Label(self.frame, text="▼", background=self.parent.style.colors.get("secondary"), font=("Host Grotesk", 10)) + self.down_arrow.place(relx=1.0, rely=0.5, x=-10, y=0, anchor="e") + self.down_arrow.bind("", self._open_menu) + + return self.frame + + def value(self): + return self.selected_option.get() + + def set_selected(self, option): + if option in self.options: + self.selected_option.set(option) + if self._alive(): + self._close_menu() + + def destroy(self): + if self._alive(): + self.frame.destroy() + + def _alive(self): + return hasattr(self, "frame") and self.frame.winfo_exists() diff --git a/gui/components/rounded_button.py b/gui/components/rounded_button.py index 1f05e8f..51e6539 100644 --- a/gui/components/rounded_button.py +++ b/gui/components/rounded_button.py @@ -17,6 +17,8 @@ def __init__(self, parent, radius=(8, 8, 8, 8), text=None, image=None, command=N self.style = self.root.style self.padx = kwargs.get("padx", 2) self.pady = kwargs.get("pady", 0 if sys.platform != "darwin" else 1) + self.state = "normal" + self.command = command self.configure(background=self._get_parent_background()) @@ -66,11 +68,36 @@ def _darken_color(self, hex_color, factor=0.9): def _hover_enter(self, event=None): """ Apply hover effect """ + if self.state == "disabled": + return hover_color = self._darken_color(self.original_bg, 0.9) self.frame.set_background(hover_color) self.button.configure(background=hover_color) def _hover_leave(self, event=None): """ Reset to original color """ + if self.state == "disabled": + return self.frame.set_background(self.original_bg) self.button.configure(background=self.original_bg) + + def set_state(self, state): + if state == "disabled": + self.state = "disabled" + self.button.state(["disabled"]) + self.frame.set_background(self.style.colors.get("secondary")) + self.button.configure(background=self.style.colors.get("secondary")) + if self.command: + self.button.unbind("") + self.frame.unbind("") + else: + self.state = "normal" + self.button.state(["!disabled"]) + self.frame.set_background(self.original_bg) + self.button.configure(background=self.original_bg) + if self.command: + self.button.bind("", self.command) + self.frame.bind("", self.command) + + def set_text(self, text): + self.button.configure(text=text) \ No newline at end of file diff --git a/gui/components/rounded_frame.py b/gui/components/rounded_frame.py index cd3f36a..0d2c902 100644 --- a/gui/components/rounded_frame.py +++ b/gui/components/rounded_frame.py @@ -105,4 +105,7 @@ def set_width(self, width): def bind(self, sequence=None, func=None, add=None): if sequence == "": return super().bind(sequence, self.on_resize, add=add) - return super().bind(sequence, func, add=add) \ No newline at end of file + return super().bind(sequence, func, add=add) + + def lift(self): + self.master.tk.call("raise", self._w) \ No newline at end of file diff --git a/gui/components/settings/apis.py b/gui/components/settings/apis.py index c845486..cb424e1 100644 --- a/gui/components/settings/apis.py +++ b/gui/components/settings/apis.py @@ -3,12 +3,12 @@ from gui.components import SettingsPanel class APIsPanel(SettingsPanel): - def __init__(self, root, parent, images, config): - super().__init__(root, parent, "APIs", images.get("apis")) + def __init__(self, root, parent, images, config, width=None): + super().__init__(root, parent, "APIs", images.get("apis"), width=width, collapsed=False) self.cfg = config self.api_keys_tk_entries = {} self.api_keys_entries = { - "serpapi": "SerpAPI" + "serpapi": "SerpAPI Key" } def _save_api_keys(self): @@ -25,7 +25,7 @@ def draw(self): for index, (key, value) in enumerate(self.api_keys_entries.items()): cfg_value = self.cfg.get(f"apis.{key}") - entry = ttk.Entry(wrapper, bootstyle="secondary", show="*", font=("Host Grotesk",)) + entry = ttk.Entry(wrapper, show="*", font=("Host Grotesk",)) entry.insert(0, cfg_value) entry.bind("", lambda event: self._save_api_keys()) entry.bind("", lambda event: self._save_api_keys()) diff --git a/gui/components/settings/general.py b/gui/components/settings/general.py index 76a6d94..39d7e3e 100644 --- a/gui/components/settings/general.py +++ b/gui/components/settings/general.py @@ -1,10 +1,10 @@ import ttkbootstrap as ttk import utils.console as console -from gui.components import SettingsPanel +from gui.components import SettingsPanel, DropdownMenu, RoundedButton class GeneralPanel(SettingsPanel): - def __init__(self, root, parent, bot_controller, images, config): - super().__init__(root, parent, "General", images.get("settings"), collapsed=False) + def __init__(self, root, parent, bot_controller, images, config, width=None): + super().__init__(root, parent, "General", images.get("settings"), collapsed=False, width=width) self.bot_controller = bot_controller self.cfg = config self.config_tk_entries = {} @@ -14,6 +14,7 @@ def __init__(self, root, parent, bot_controller, images, config): "message_settings.auto_delete_delay": "Auto delete delay", "rich_embed_webhook": "Rich embed webhook", } + self.message_style_entry = None def _save_cfg(self): for index, (key, value) in enumerate(self.config_entries.items()): @@ -32,17 +33,26 @@ def _save_cfg(self): self.cfg.set(key, tkinter_entry.get(), save=False) + try: + self.cfg.set("message_settings.style", self.message_style_entry.value(), save=False) + except Exception as e: + console.error(f"Failed to set message style: {e}") + self.cfg.save(notify=False) def _only_numeric(self, event): if not event.char.isnumeric() and event.char != "" and event.keysym != "BackSpace": return "break" + def _set_message_style(self, style): + # self.message_style_entry.configure(text=style) + self._save_cfg() + def draw(self): for index, (key, value) in enumerate(self.config_entries.items()): padding = (10, 2) cfg_value = self.cfg.get(key) - entry = ttk.Entry(self.body, bootstyle="secondary", font=("Host Grotesk",)) if key != "token" else ttk.Entry(self.body, bootstyle="secondary", show="*", font=("Host Grotesk",)) + entry = ttk.Entry(self.body, font=("Host Grotesk",)) if key != "token" else ttk.Entry(self.body, show="*", font=("Host Grotesk",)) entry.insert(0, cfg_value) entry.bind("", lambda event: self._save_cfg()) entry.bind("", lambda event: self._save_cfg()) @@ -52,8 +62,6 @@ def draw(self): if index == 0: padding = (padding[0], (10, 2)) - elif index == len(self.config_entries) - 1: - padding = (padding[0], (2, 10)) label = ttk.Label(self.body, text=value) label.configure(background=self.root.style.colors.get("dark")) @@ -64,6 +72,14 @@ def draw(self): self.body.grid_columnconfigure(1, weight=1) self.config_tk_entries[key] = entry + message_style_label = ttk.Label(self.body, text="Message style") + message_style_label.configure(background=self.root.style.colors.get("dark")) + message_style_label.grid(row=len(self.config_entries) + 1, column=0, sticky=ttk.NW, padx=(10, 0), pady=(5, 10)) + + self.message_style_entry = DropdownMenu(self.body, options=["codeblock", "image", "embed"], command=self._set_message_style) + self.message_style_entry.set_selected(self.cfg.get("message_settings.style")) + self.message_style_entry.draw().grid(row=len(self.config_entries) + 1, column=1, sticky="we", padx=(10, 10), pady=(2, 10), columnspan=3) + return self.wrapper def save(self): diff --git a/gui/components/settings/rich_presence.py b/gui/components/settings/rich_presence.py index 668f7b5..f9befe5 100644 --- a/gui/components/settings/rich_presence.py +++ b/gui/components/settings/rich_presence.py @@ -1,10 +1,11 @@ +import sys import ttkbootstrap as ttk import utils.console as console -from gui.components import SettingsPanel, RoundedButton +from gui.components import SettingsPanel, RoundedButton, RoundedFrame class RichPresencePanel(SettingsPanel): - def __init__(self, root, parent, images, config): - super().__init__(root, parent, "Rich Presence", images.get("rich_presence")) + def __init__(self, root, parent, images, config, width=None, bot_controller=None): + super().__init__(root, parent, "Rich Presence", images.get("rich_presence"), width=width, collapsed=False) self.cfg = config self.rpc = self.cfg.get_rich_presence() self.rpc_tk_entries = {} @@ -36,6 +37,28 @@ def __init__(self, root, parent, images, config): "large_url": self.rpc.large_url, "small_url": self.rpc.small_url } + self.bot_controller = bot_controller + self.images = images + self.user_banner_colour = None + self.user_avatar = None + self.user = None + self.preview_vars = { + "name": ttk.StringVar(value=self.rpc.name), + "details": ttk.StringVar(value=self.rpc.details), + "state": ttk.StringVar(value=self.rpc.state), + "large_image": ttk.StringVar(value=self.rpc.large_image) + } + + self.large_image_label = None + self.name_label = None + self.details_label = None + self.state_label = None + + self._preview_after_id = None + self.PREVIEW_DEBOUNCE_MS = 500 # adjust if needed + + for var in self.preview_vars.values(): + var.trace_add("write", self._schedule_preview_update) def _save_rpc(self): for index, (key, value) in enumerate(self.rpc_entries.items()): @@ -50,17 +73,157 @@ def _save_rpc(self): def _reset_rpc(self): self.rpc.reset_defaults() + def _schedule_preview_update(self, *_): + if self._preview_after_id is not None: + self.root.after_cancel(self._preview_after_id) + + self._preview_after_id = self.root.after( + self.PREVIEW_DEBOUNCE_MS, + self._update_preview + ) + + def _update_preview(self): + try: + name = self.preview_vars["name"].get() or "Ghost" + details = self.preview_vars["details"].get().strip() + state = self.preview_vars["state"].get().strip() + large_image_url = self.preview_vars["large_image"].get().strip() + if large_image_url == "" or large_image_url.lower() == "none": + large_image_url = "https://www.ghostt.cc/assets/ghost.png" + + self.name_label.configure(text=name) + + row = 1 # start under the name + + if details: + self.details_label.configure(text=details) + self.details_label.grid(row=row, column=0, sticky=ttk.W) + row += 1 + else: + self.details_label.grid_remove() + + if state: + self.state_label.configure(text=state) + self.state_label.grid(row=row, column=0, sticky=ttk.W) + row += 1 + else: + self.state_label.grid_remove() + + if large_image_url: + large_img = self.images.load_image_from_url(large_image_url, (64, 64), 5) + if large_img: + self.large_image_label.configure(image=large_img) + self.large_image_label.image = large_img + + except Exception as e: + print(f"Error updating RPC preview: {e}") + + def _draw_preview(self, parent): + wrapper = RoundedFrame(parent, radius=15, background=self.root.style.colors.get("secondary")) + wrapper.pack(fill=ttk.X, expand=False, pady=(10, 0), padx=10) + + playing_label = ttk.Label(wrapper, text="Playing", font=("Host Grotesk", 10 if sys.platform != "darwin" else 12)) + playing_label.configure(background=self.root.style.colors.get("secondary")) + playing_label.grid(row=0, column=0, sticky=ttk.W, padx=(5, 5), pady=(5, 0)) + + large_image = self.images.load_image_from_url(self.rpc.large_image if self.rpc.large_image else "https://www.ghostt.cc/assets/ghost.png", (64, 64), 5) + self.large_image_label = ttk.Label(wrapper, image=large_image, background=self.root.style.colors.get("secondary")) + self.large_image_label.image = large_image + self.large_image_label.grid(row=1, column=0, padx=(5, 5), pady=5) + + # small_image = self.images.load_image_from_url(self.rpc.small_image if self.rpc.small_image else "https://www.ghostt.cc/assets/ghost.png", (28, 28), 12) + # self.small_image_label = ttk.Label(wrapper, image=small_image, background=self.root.style.colors.get("secondary")) + # self.small_image_label.image = small_image + # self.small_image_label.place(x=50, y=70, width=28, height=28) + # self.small_image_label.lift() + + details_wrapper = RoundedFrame(wrapper, radius=0, background=self.root.style.colors.get("secondary")) + details_wrapper.grid(row=1, column=1, sticky=ttk.W, padx=(0, 5), pady=5) + + self.name_label = ttk.Label(details_wrapper, text=self.rpc.name or "Ghost", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold")) + self.name_label.configure(background=self.root.style.colors.get("secondary")) + self.name_label.grid(row=0, column=0, sticky=ttk.W) + + self.details_label = ttk.Label(details_wrapper, text=self.rpc.details or "", font=("Host Grotesk", 10 if sys.platform != "darwin" else 12)) + self.details_label.configure(background=self.root.style.colors.get("secondary")) + self.details_label.grid(row=1, column=0, sticky=ttk.W) + + self.state_label = ttk.Label(details_wrapper, text=self.rpc.state or "", font=("Host Grotesk", 10 if sys.platform != "darwin" else 12)) + self.state_label.configure(background=self.root.style.colors.get("secondary")) + self.state_label.grid(row=2, column=0, sticky=ttk.W) + + time_elapsed_label = ttk.Label(details_wrapper, text="00:15", font=("Host Grotesk", 10 if sys.platform != "darwin" else 12)) + time_elapsed_label.configure(foreground="#68ae7c", background=self.root.style.colors.get("secondary")) + time_elapsed_label.grid(row=3, column=0, sticky=ttk.W) + + def _draw_user_wrapper(self, parent): + wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame", custom_size=True) + wrapper.set_width(225) + wrapper.grid(row=1, column=1, sticky=ttk.NSEW, padx=(10, 0)) + + if self.user_avatar: + accent_colour_banner = RoundedFrame(wrapper, radius=(15, 15, 0, 0), background=self.user_banner_colour, parent_background=self.root.style.colors.get("bg")) + accent_colour_banner.set_height(85) + accent_colour_banner.pack(side=ttk.TOP, fill=ttk.X) + accent_colour_banner.columnconfigure(0, weight=1) + + avatar_label = ttk.Canvas(wrapper, width=100, height=200, background=self.user_banner_colour, highlightthickness=0) + + avatar_label.create_rectangle(0, 0, 100, 50, fill=self.user_banner_colour, outline="") + avatar_label.create_rectangle(0, 50, 100, 200, fill=self.root.style.colors.get("dark"), outline="") + + # create an ovel the same size of the avatar but an extra 5px on each side and use the dark background color, this is to create a border + avatar_label.create_oval(9, 8, 85, 85, fill=self.root.style.colors.get("dark"), outline="") + avatar_label.create_image(65//2 + 15, 65//2 + 15, image=self.user_avatar, anchor="center") + + avatar_label.place(x=0, y=85-50, width=100, height=200) + + if self.user: + user_info_wrapper = ttk.Frame(wrapper, style="dark.TFrame") + user_info_wrapper.pack(side=ttk.TOP, fill=ttk.X, pady=(35, 0), padx=(10, 10)) + user_info_wrapper.configure(height=50) + + display_name = ttk.Label(user_info_wrapper, text=self.user.display_name, font=("Host Grotesk", 16 if sys.platform != "darwin" else 18, "bold")) + display_name.configure(background=self.root.style.colors.get("dark")) + display_name.place(relx=0, rely=0) + + username = ttk.Label(user_info_wrapper, text=f"{self.user.name}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + username.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") + username.place(relx=0, rely=0.42 if sys.platform == "darwin" else 0.45) + + self._draw_preview(wrapper) + + return wrapper + def draw(self): - toggle_checkbox = ttk.Checkbutton(self.body, text="Enable Rich Presence", style="success.TCheckbutton") - toggle_checkbox.grid(row=0, column=0, columnspan=2, sticky=ttk.W, padx=(13, 0), pady=(10, 10)) - toggle_checkbox.configure(command=self._save_rpc) + toggle_wrapper = RoundedFrame(self.wrapper, radius=(10, 10, 10, 10), bootstyle="dark.TFrame") + toggle_wrapper.grid(row=0, column=0, columnspan=4, sticky="we", pady=(0, 10)) + toggle_wrapper.bind("", lambda e: self.toggle_checkbox.invoke()) + + toggle_label = ttk.Label(toggle_wrapper, text="Enable Rich Presence") + toggle_label.configure(background=self.root.style.colors.get("dark")) + toggle_label.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=10) + toggle_label.bind("", lambda e: self.toggle_checkbox.invoke()) + + self.toggle_checkbox = ttk.Checkbutton(toggle_wrapper, text="", style="success-round-toggle") + self.toggle_checkbox.grid(row=0, column=1, sticky=ttk.E, padx=(0, 10), pady=10) + self.toggle_checkbox.configure(command=self._save_rpc) + + toggle_wrapper.grid_columnconfigure(0, weight=1) + + self.user = self.bot_controller.get_user() + if self.user: + avatar_url = self.user.avatar.url if self.user and self.user.avatar else "https://ia600305.us.archive.org/31/items/discordprofilepictures/discordblue.png" + self.user_avatar = self.bot_controller.get_avatar_from_url(avatar_url, size=65, radius=65//2) + self.user_banner_colour = self.images.get_majority_color_from_url(avatar_url) + self._draw_user_wrapper(self.wrapper) if self.rpc.enabled: - toggle_checkbox.state(["!alternate", "selected"]) + self.toggle_checkbox.state(["!alternate", "selected"]) else: - toggle_checkbox.state(["!alternate", "!selected"]) + self.toggle_checkbox.state(["!alternate", "!selected"]) - self.rpc_tk_entries["enabled"] = toggle_checkbox + self.rpc_tk_entries["enabled"] = self.toggle_checkbox padding = (10, 2) for index, (key, value) in enumerate(self.rpc_entries.items()): @@ -68,16 +231,27 @@ def draw(self): continue rpc_value = self.rpc.get(key) - entry = ttk.Entry(self.body, bootstyle="secondary", font=("Host Grotesk",)) - entry.insert(0, rpc_value) + if key in self.preview_vars: + entry = ttk.Entry( + self.body, + textvariable=self.preview_vars[key], + font=("Host Grotesk",) + ) + else: + entry = ttk.Entry( + self.body, + font=("Host Grotesk",) + ) + entry.insert(0, rpc_value) + entry.bind("", lambda event: self._save_rpc()) entry.bind("", lambda event: self._save_rpc()) label = ttk.Label(self.body, text=value) label.configure(background=self.root.style.colors.get("dark")) - label.grid(row=index + 1, column=0, sticky=ttk.W, padx=padding[0], pady=padding[1]) - entry.grid(row=index + 1, column=1, sticky="we", padx=padding[0], pady=padding[1], columnspan=3) + label.grid(row=index + 1, column=0, sticky=ttk.W, padx=padding[0], pady=(padding[1] + 8 if index == 1 else padding[1], padding[1])) + entry.grid(row=index + 1, column=1, sticky="we", padx=padding[0], pady=(padding[1] + 8 if index == 1 else padding[1], padding[1]), columnspan=3) self.body.grid_columnconfigure(1, weight=1) self.rpc_tk_entries[key] = entry @@ -93,4 +267,5 @@ def draw(self): reset_rpc_button = RoundedButton(self.body, text="Reset", style="danger.TButton", command=self._reset_rpc) reset_rpc_button.grid(row=len(self.rpc_entries) + 1, column=3, sticky=ttk.E, padx=(5, 11), pady=10) + self._update_preview() return self.wrapper \ No newline at end of file diff --git a/gui/components/settings/session_spoofing.py b/gui/components/settings/session_spoofing.py index b54194c..1b39401 100644 --- a/gui/components/settings/session_spoofing.py +++ b/gui/components/settings/session_spoofing.py @@ -1,10 +1,11 @@ +import sys import ttkbootstrap as ttk import utils.console as console -from gui.components import SettingsPanel +from gui.components import SettingsPanel, RoundedFrame, DropdownMenu class SessionSpoofingPanel(SettingsPanel): - def __init__(self, root, parent, images, config): - super().__init__(root, parent, "Session Spoofing", images.get("session_spoofing")) + def __init__(self, root, parent, images, config, width=None): + super().__init__(root, parent, "Session Spoofing", images.get("session_spoofing"), width=width, collapsed=False) self.cfg = config self.selected_device = ttk.StringVar(value=self.cfg.get("session_spoofing.device")) self.last_saved_state = { @@ -30,10 +31,18 @@ def _select_and_save_device(self, device): self._save_session_spoofing() def draw(self): - self.checkbox = ttk.Checkbutton(self.body, text="Enable session spoofing", style="success.TCheckbutton") - self.checkbox.grid(row=0, column=0, columnspan=2, sticky=ttk.W, padx=(13, 0), pady=(10, 0)) + toggle_label = ttk.Label(self.body, text="Enable Session Spoofing") + toggle_label.configure(background=self.root.style.colors.get("dark")) + toggle_label.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=(15, 5)) + toggle_label.bind("", lambda e: self.checkbox.invoke()) + + self.checkbox = ttk.Checkbutton(self.body, text="", style="success-round-toggle") + # self.checkbox.grid(row=0, column=0, columnspan=2, sticky=ttk.W, padx=(13, 0), pady=(15, 0)) + self.checkbox.grid(row=0, column=1, sticky=ttk.E, padx=(0, 10), pady=(10, 5)) self.checkbox.configure(command=self._save_session_spoofing) + self.body.grid_columnconfigure(0, weight=1) + if self.cfg.get("session_spoofing.enabled"): self.checkbox.state(["!alternate", "selected"]) else: @@ -41,23 +50,25 @@ def draw(self): device_label = ttk.Label(self.body, text="Session spoofing device") device_label.configure(background=self.root.style.colors.get("dark")) - device_label.grid(row=1, column=0, sticky=ttk.W, padx=(10, 0), pady=(5, 0)) + device_label.grid(row=1, column=0, sticky=ttk.NW, padx=(10, 0), pady=(10, 10)) - device_select_menu = ttk.Menubutton(self.body, textvariable=self.selected_device, bootstyle="secondary") - device_select_menu.menu = ttk.Menu(device_select_menu, tearoff=0) - device_select_menu["menu"] = device_select_menu.menu + # device_select_menu = ttk.Menubutton(self.body, textvariable=self.selected_device, bootstyle="secondary") + # device_select_menu.menu = ttk.Menu(device_select_menu, tearoff=0) + # device_select_menu["menu"] = device_select_menu.menu - for device in ["mobile", "desktop", "web", "embedded"]: - device_select_menu.menu.add_command(label=device, command=lambda device=device: self._select_and_save_device(device)) + # for device in ["mobile", "desktop", "web", "embedded"]: + # device_select_menu.menu.add_command(label=device, command=lambda device=device: self._select_and_save_device(device)) - device_select_menu.grid(row=1, column=1, sticky="we", padx=(10, 10), pady=(5, 0)) + device_select_menu = DropdownMenu(self.body, options=["mobile", "desktop", "web", "embedded"], command=self._select_and_save_device) + device_select_menu.set_selected(self.selected_device.get()) + device_select_menu.draw().grid(row=1, column=1, sticky="we", padx=(10, 10), pady=(5, 10)) # save_button = ttk.Button(self.body, text="Save", style="success.TButton", command=self._save_session_spoofing) # save_button.grid(row=2, column=1, sticky=ttk.E, padx=(0, 11), pady=10) - restart_required_label = ttk.Label(self.body, text="A restart is required to apply changes!", font=("Host Grotesk", 12, "italic")) - restart_required_label.configure(background=self.root.style.colors.get("dark"), foreground="#cccccc") - restart_required_label.grid(row=2, column=0, sticky=ttk.W, padx=(10, 0), pady=10) + # restart_required_label = ttk.Label(self.body, text="A restart is required to apply changes!", font=("Host Grotesk", 12, "italic")) + # restart_required_label.configure(background=self.root.style.colors.get("dark"), foreground="#cccccc") + # restart_required_label.grid(row=2, column=0, sticky=ttk.W, padx=(10, 0), pady=10) self.body.grid_columnconfigure(1, weight=1) diff --git a/gui/components/settings/snipers.py b/gui/components/settings/snipers.py index a68d1e5..d7ca005 100644 --- a/gui/components/settings/snipers.py +++ b/gui/components/settings/snipers.py @@ -3,8 +3,8 @@ from gui.components import SettingsPanel, RoundedFrame class SnipersPanel(SettingsPanel): - def __init__(self, root, parent, images, config): - super().__init__(root, parent, "Snipers", images.get("snipers")) + def __init__(self, root, parent, images, config, width=None): + super().__init__(root, parent, "Snipers", images.get("snipers"), width=width, collapsed=False) self.cfg = config self.images = images self.snipers = None @@ -19,30 +19,32 @@ def _save_sniper(self, sniper_name): sniper.enabled = self.snipers_tk_entries[sniper_name]["enabled"].get() sniper.ignore_invalid = self.snipers_tk_entries[sniper_name]["ignore_invalid"].get() - sniper.webhook = self.snipers_tk_entries[sniper_name]["webhook"].get() + + value = self.snipers_tk_entries[sniper_name]["webhook"].get() + sniper.webhook = "" if value == self.placeholder else value sniper.save(notify=False) def _draw_card(self, sniper): - card = RoundedFrame(self.body, radius=(10, 10, 10, 10), bootstyle="secondary.TFrame") + card = RoundedFrame(self.wrapper, radius=(10, 10, 10, 10), bootstyle="dark.TFrame") self.snipers_tk_entries[sniper.name] = {} - header = ttk.Frame(card, style="secondary.TFrame") - header.grid(row=0, column=0, sticky=ttk.NSEW, pady=(10, 5), padx=10) + header = ttk.Frame(card, style="dark.TFrame") + header.grid(row=0, column=0, sticky=ttk.NSEW, pady=(10, 10), padx=10) - title = ttk.Label(header, text=sniper.name.capitalize() + " Sniper", font=("Host Grotesk", 16, "bold")) - title.configure(background=self.root.style.colors.get("secondary")) + title = ttk.Label(header, text=sniper.name.capitalize() + " Sniper", font=("Host Grotesk", 18, "bold")) + title.configure(background=self.root.style.colors.get("dark")) title.grid(row=0, column=0, sticky=ttk.NSEW) entries = [ { - "label": "Enabled", + "label": "Enable sniper", "type": "checkbox", "value": sniper.enabled, "config_key": "enabled" }, { - "label": "Ignore Invalid", + "label": "Ignore invalid codes", "type": "checkbox", "value": sniper.ignore_invalid, "config_key": "ignore_invalid" @@ -57,44 +59,77 @@ def _draw_card(self, sniper): for i, entry in enumerate(entries): if entry["type"] == "checkbox": - checkbox_wrapper = ttk.Frame(card, style="secondary.TFrame") - checkbox_wrapper.grid(row=i + 1, column=0, sticky=ttk.NSEW, pady=(2, 0), padx=10) + checkbox_wrapper = RoundedFrame(card, radius=8, bootstyle="secondary.TFrame") + checkbox_wrapper.grid(row=i + 1, column=0, sticky=ttk.NSEW, padx=10, pady=(0, 5)) + + label = ttk.Label(checkbox_wrapper, text=" " + entry["label"]) + label.configure(background=self.root.style.colors.get("secondary")) + label.grid(row=0, column=0, sticky=ttk.W, pady=10, padx=(8, 0)) - # ✅ Use BooleanVar to properly track state var = ttk.BooleanVar(value=entry["value"]) checkbox = ttk.Checkbutton( checkbox_wrapper, - style="success.TCheckbutton", - variable=var, # ✅ Bind to BooleanVar + style="success-round-toggle", + variable=var, command=lambda sniper_name=sniper.name: self._save_sniper(sniper_name), tristatevalue=None ) - checkbox.grid(row=0, column=0, sticky=ttk.W) - - # ✅ Force checkbox state update - if entry["value"]: - checkbox.state(["selected", "!alternate"]) # Remove alternate state - else: - checkbox.state(["!selected", "!alternate"]) # Ensure it's not in an indeterminate state + checkbox.grid(row=0, column=1, sticky=ttk.E, pady=10, padx=(0, 10)) + checkbox_wrapper.grid_columnconfigure(0, weight=1) self.snipers_tk_entries[sniper.name][entry["config_key"]] = var + + def toggle_checkbox(event, var_obj=var, sniper_name=sniper.name): + var_obj.set(not var_obj.get()) + self._save_sniper(sniper_name) - label = ttk.Label(checkbox_wrapper, text=" " + entry["label"]) - label.configure(background=self.root.style.colors.get("secondary")) - label.grid(row=0, column=0, sticky=ttk.W, padx=(15, 10), ipadx=10) + label.bind("", toggle_checkbox) + checkbox_wrapper.bind("", toggle_checkbox) else: - label = ttk.Label(card, text=entry["label"]) - label.configure(background=self.root.style.colors.get("secondary")) - label.grid(row=i + 1, column=0, sticky=ttk.W, pady=(10, 5), padx=10) + wrapper = RoundedFrame(card, radius=8, bootstyle="dark.TFrame") + wrapper.grid(row=i + 1, column=0, sticky=ttk.NSEW, padx=10, pady=(10, 10)) + + label = ttk.Label(wrapper, text=entry["label"]) + label.configure(background=self.root.style.colors.get("dark")) + label.grid(row=0, column=0, sticky=ttk.W, pady=(0, 5)) - textbox = ttk.Entry(card, bootstyle="secondary", font=("Host Grotesk",)) - textbox.insert(0, entry["value"]) - textbox.grid(row=i + 2, column=0, sticky=ttk.EW, pady=(0, 10), padx=10, columnspan=2) + textbox = ttk.Entry(wrapper, font=("Host Grotesk",)) + textbox.grid(row=1, column=0, sticky=ttk.EW) + wrapper.grid_columnconfigure(0, weight=1) - textbox.bind("", lambda event, sniper_name=sniper.name: self._save_sniper(sniper_name)) - textbox.bind("", lambda event, sniper_name=sniper.name: self._save_sniper(sniper_name)) + placeholder_color = "#6c757d" # subtle grey + normal_color = self.root.style.colors.get("fg") + + def set_placeholder(entry_widget): + entry_widget.delete(0, ttk.END) + entry_widget.insert(0, self.placeholder) + entry_widget.configure(foreground=placeholder_color) + + def clear_placeholder(entry_widget): + if entry_widget.get() == self.placeholder: + entry_widget.delete(0, ttk.END) + entry_widget.configure(foreground=normal_color) + + def on_focus_in(event, entry_widget=textbox): + clear_placeholder(entry_widget) + + def on_focus_out(event, entry_widget=textbox, sniper_name=sniper.name): + if not entry_widget.get(): + set_placeholder(entry_widget) + self._save_sniper(sniper_name) + + # Initial state + if entry["value"]: + textbox.insert(0, entry["value"]) + textbox.configure(foreground=normal_color) + else: + set_placeholder(textbox) + + textbox.bind("", on_focus_in) + textbox.bind("", on_focus_out) + textbox.bind("", lambda e, sniper_name=sniper.name: self._save_sniper(sniper_name)) self.snipers_tk_entries[sniper.name][entry["config_key"]] = textbox @@ -104,29 +139,20 @@ def _draw_card(self, sniper): return card def draw(self): + self.body.grid_remove() self.snipers = self.cfg.get_snipers() if not self.snipers: console.log_to_gui("error", "No snipers found.") return - row, column = 0, 0 + row = 0 for sniper in self.snipers: card = self._draw_card(sniper) - card.grid(row=row, column=column, sticky=ttk.NSEW, padx=(10, 5) if column == 0 else (5, 10), pady=10) - - column += 1 + card.grid(row=row, column=0, sticky=ttk.NSEW, padx=0, pady=(0, 10)) - if column > 1: - column = 0 - row += 1 - - self.body.grid_rowconfigure(row, weight=1) - - self.body.grid_columnconfigure(0, weight=1) - self.body.grid_columnconfigure(1, weight=1) - self.body.grid_rowconfigure(row, weight=1) + row += 1 return self.wrapper \ No newline at end of file diff --git a/gui/components/settings/theming.py b/gui/components/settings/theming.py index 141f1c1..fd74149 100644 --- a/gui/components/settings/theming.py +++ b/gui/components/settings/theming.py @@ -1,16 +1,18 @@ import ttkbootstrap as ttk import utils.console as console from utils.files import open_path_in_explorer, get_themes_path -from gui.components import SettingsPanel, RoundedButton, RoundedFrame +from gui.components import SettingsPanel, RoundedButton, RoundedFrame, DropdownMenu +from gui.helpers.style import Style class ThemingPanel(SettingsPanel): - def __init__(self, root, parent, images, config): - super().__init__(root, parent, "Theming", images.get("theming")) + def __init__(self, root, parent, images, config, width=None): + super().__init__(root, parent, "Theming", images.get("theming"), width=width) self.cfg = config self.root = root self.images = images self.theme_tk_entries = [] self.themes = self.cfg.get_themes() + self.menu_themes = [str(theme) for theme in self.themes] self.theme_dict = self.cfg.theme.to_dict() def _save_theme(self, _=None): @@ -18,14 +20,16 @@ def _save_theme(self, _=None): self.cfg.theme.set(key, self.theme_tk_entries[index].get()) self.cfg.theme.save(notify=False) - - self.cfg.set("message_settings.style", self.message_style_entry.cget("text"), save=False) self.cfg.save(notify=False) def _set_theme(self, theme): - self.select_menu.configure(text=theme) + print(f"Setting theme to: {theme}") + # self.select_menu.set_selected(theme) self.cfg.set_theme(theme, save=False) self.cfg.save(notify=True) + # self.select_menu.configure(text=theme) + # self.cfg.set_theme(theme, save=False) + # self.cfg.save(notify=True) # self.create_entry.delete(0, "end") # self.select_menu.configure(text=theme) @@ -71,8 +75,8 @@ def _set_message_style(self, style): def _draw_open_folder_button(self, parent): def _hover_enter(_): - wrapper.set_background(background="#202021") - open_folder_button.configure(background="#202021") + wrapper.set_background(background=Style.SETTINGS_PILL_HOVER.value) + open_folder_button.configure(background=Style.SETTINGS_PILL_HOVER.value) def _hover_leave(_): wrapper.set_background(background=self.root.style.colors.get("secondary")) @@ -92,52 +96,61 @@ def _hover_leave(_): return wrapper + def toggle_create_theme_button(self, state): + if state: + self.create_button.set_state("normal") + else: + self.create_button.set_state("disabled") + def draw(self): self.themes = self.cfg.get_themes() self.theme_dict = self.cfg.theme.to_dict() #------- - create_label = ttk.Label(self.body, text="Create a new theme") + create_label = ttk.Label(self.body, text="New theme name") create_label.configure(background=self.root.style.colors.get("dark")) create_label.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=(10, 0)) - self.create_entry = ttk.Entry(self.body, bootstyle="secondary", font=("Host Grotesk",)) - self.create_entry.config(style="secondary.TEntry") + self.create_entry = ttk.Entry(self.body, font=("Host Grotesk",)) self.create_entry.grid(row=0, column=1, sticky="we", padx=(10, 10), pady=(10, 0)) + self.create_entry.bind("", lambda e: self.toggle_create_theme_button(self.create_entry.get().strip() != "")) - create_button = RoundedButton(self.body, text="Create", command=lambda _: self._create_theme(self.create_entry.get()), style="success.TButton") - create_button.grid(row=0, column=2, sticky=ttk.E, padx=(0, 11), pady=(10, 0)) + self.create_button = RoundedButton(self.body, text="Create", command=lambda _: self._create_theme(self.create_entry.get()), style="success.TButton") + self.create_button.grid(row=0, column=2, sticky=ttk.E, padx=(0, 11), pady=(10, 0)) + self.create_button.set_state("disabled") #------- - select_label = ttk.Label(self.body, text="Select a theme") + select_label = ttk.Label(self.body, text="Select theme") select_label.configure(background=self.root.style.colors.get("dark")) - select_label.grid(row=1, column=0, sticky=ttk.W, padx=(10, 0), pady=(10, 0)) + select_label.grid(row=1, column=0, sticky=ttk.NW, padx=(10, 0), pady=(15, 0)) - self.select_menu = ttk.Menubutton(self.body, text=self.cfg.theme.name, bootstyle="secondary") - self.select_menu.menu = ttk.Menu(self.select_menu, tearoff=0) - self.select_menu["menu"] = self.select_menu.menu + # self.select_menu = ttk.Menubutton(self.body, text=self.cfg.theme.name, bootstyle="secondary") + # self.select_menu.menu = ttk.Menu(self.select_menu, tearoff=0) + # self.select_menu["menu"] = self.select_menu.menu - for theme in self.themes: - self.select_menu.menu.add_command(label=str(theme), command=lambda theme=theme.name: self._set_theme(theme)) + # for theme in self.themes: + # self.select_menu.menu.add_command(label=str(theme), command=lambda theme=theme.name: self._set_theme(theme)) - self.select_menu.grid(row=1, column=1, columnspan=2, sticky="we", padx=(10, 10), pady=(10, 0)) + self.select_menu = DropdownMenu(self.body, options=self.menu_themes, command=self._set_theme) + self.select_menu.set_selected(self.cfg.theme.name) + self.select_menu.draw().grid(row=1, column=1, columnspan=2, sticky="we", padx=(10, 10), pady=(10, 0)) #------- - message_style_label = ttk.Label(self.body, text="Global message style") - message_style_label.configure(background=self.root.style.colors.get("dark")) - message_style_label.grid(row=2, column=0, sticky=ttk.W, padx=(10, 0), pady=(10, 0)) + # message_style_label = ttk.Label(self.body, text="Message style") + # message_style_label.configure(background=self.root.style.colors.get("dark")) + # message_style_label.grid(row=2, column=0, sticky=ttk.W, padx=(10, 0), pady=(10, 0)) - self.message_style_entry = ttk.Menubutton(self.body, text=self.cfg.config["message_settings"]["style"], bootstyle="secondary") - self.message_style_entry.menu = ttk.Menu(self.message_style_entry, tearoff=0) - self.message_style_entry["menu"] = self.message_style_entry.menu + # self.message_style_entry = ttk.Menubutton(self.body, text=self.cfg.config["message_settings"]["style"], bootstyle="secondary") + # self.message_style_entry.menu = ttk.Menu(self.message_style_entry, tearoff=0) + # self.message_style_entry["menu"] = self.message_style_entry.menu - for style in ["codeblock", "image", "embed"]: - self.message_style_entry.menu.add_command(label=style, command=lambda style=style: self._set_message_style(style)) + # for style in ["codeblock", "image", "embed"]: + # self.message_style_entry.menu.add_command(label=style, command=lambda style=style: self._set_message_style(style)) - self.message_style_entry.grid(row=2, column=1, columnspan=2, sticky="we", padx=(10, 10), pady=(10, 0)) + # self.message_style_entry.grid(row=2, column=1, columnspan=2, sticky="we", padx=(10, 10), pady=(10, 0)) #------- @@ -147,7 +160,7 @@ def draw(self): for index, (key, value) in enumerate(self.theme_dict.items()): padding = (10, 2) - entry = ttk.Entry(self.body, bootstyle="secondary", font=("Host Grotesk",)) + entry = ttk.Entry(self.body, font=("Host Grotesk",)) entry.insert(0, value) if index == 0: diff --git a/gui/components/settings_frame.py b/gui/components/settings_frame.py index 9eff226..5aaf136 100644 --- a/gui/components/settings_frame.py +++ b/gui/components/settings_frame.py @@ -4,12 +4,13 @@ from utils.config import Config class SettingsFrame: - def __init__(self, parent, header_text, header_icon, collapsed=False, collapsible=False): + def __init__(self, parent, header_text, header_icon, collapsed=False, collapsible=False, width=None): self.parent = parent self.root = parent.winfo_toplevel() self.hover_colour = "#282a2a" self.header_text = header_text self.header_icon = header_icon + self.width = width self.config = Config() self.collapsible = collapsible @@ -27,7 +28,7 @@ def _toggle_collapsed(self): self.body.pack_forget() else: self.header.set_corner_radius((15, 15, 0, 0)) - self.body.pack(fill=ttk.BOTH, expand=True) + self.body.pack(fill=ttk.BOTH, expand=False) def _hover_enter(self, _): self.header.set_background(background=self.hover_colour) @@ -43,13 +44,13 @@ def _draw_header(self, parent): self.header = RoundedFrame(parent, radius=(15, 15, 0, 0), bootstyle="secondary.TFrame") self.header.pack(fill=ttk.BOTH, expand=False) - self.title = ttk.Label(self.header, text=self.header_text, font=("Host Grotesk", 14 if sys.platform != "darwin" else 20, "bold")) + self.title = ttk.Label(self.header, text=self.header_text, font=("Host Grotesk", 14 if sys.platform != "darwin" else 18, "bold")) self.title.configure(background=self.root.style.colors.get("secondary")) - self.title.grid(row=0, column=0, sticky=ttk.NSEW, padx=15, pady=15) + self.title.grid(row=0, column=0, sticky=ttk.NSEW, padx=15, pady=10) self.icon = ttk.Label(self.header, image=self.header_icon) self.icon.configure(background=self.root.style.colors.get("secondary")) - self.icon.grid(row=0, column=2, sticky=ttk.E, padx=(0, 15), pady=15) + self.icon.grid(row=0, column=2, sticky=ttk.E, padx=(0, 15), pady=10) self.header.grid_columnconfigure(1, weight=1) @@ -60,21 +61,27 @@ def _draw_header(self, parent): component.bind("", lambda e: self._toggle_collapsed()) def _draw_body(self, parent): - frame = RoundedFrame(parent, radius=(0, 0, 15, 15), bootstyle="dark.TFrame") - frame.pack(fill=ttk.BOTH, expand=True) + frame = RoundedFrame(parent, radius=15, bootstyle="dark.TFrame") + # frame.pack(fill=ttk.BOTH, expand=False) + frame.grid(column=0, row=1, sticky="nsew") + parent.grid_columnconfigure(0, weight=1) return frame def draw(self): - wrapper = ttk.Frame(self.parent, takefocus=True) + if self.width: + wrapper = ttk.Frame(self.parent, takefocus=True, width=self.width) + else: + wrapper = ttk.Frame(self.parent, takefocus=True) wrapper.configure(style="default.TLabel") - wrapper.pack(fill=ttk.BOTH, expand=True) + # wrapper.pack(fill=ttk.BOTH, expand=True) + # wrapper.grid(column=0, row=0, sticky="nsew") - self._draw_header(wrapper) + # self._draw_header(wrapper) self.body = self._draw_body(wrapper) if self.is_collapsed: - self.header.set_corner_radius((15, 15, 15, 15)) + # self.header.set_corner_radius((15, 15, 15, 15)) self.body.pack_forget() return self.body, wrapper \ No newline at end of file diff --git a/gui/components/settings_panel.py b/gui/components/settings_panel.py index 0309734..4641031 100644 --- a/gui/components/settings_panel.py +++ b/gui/components/settings_panel.py @@ -2,21 +2,21 @@ from gui.components import SettingsFrame class SettingsPanel: - def __init__(self, root, parent, title, icon, collapsible=True, collapsed=True): + def __init__(self, root, parent, title, icon, collapsible=False, collapsed=False, width=None): self.root = root self.parent = parent self.title = title self.icon = icon self.collapsible = collapsible self.collapsed = collapsed - + self.width = width # Create the body and wrapper upfront self.body, self.wrapper = self._create_body_and_wrapper() self.root.bind("", self._remove_focus) def _create_body_and_wrapper(self): # You can use your existing logic for creating the body and wrapper - body, wrapper = SettingsFrame(self.parent, self.title, self.icon, collapsible=self.collapsible, collapsed=self.collapsed).draw() + body, wrapper = SettingsFrame(self.parent, self.title, self.icon, collapsible=self.collapsible, collapsed=self.collapsed, width=self.width).draw() return body, wrapper def _remove_focus(self, event): diff --git a/gui/components/sidebar.py b/gui/components/sidebar.py index 22edcc6..59d856d 100644 --- a/gui/components/sidebar.py +++ b/gui/components/sidebar.py @@ -2,6 +2,8 @@ import ttkbootstrap as ttk from ttkbootstrap.dialogs import Messagebox from gui.helpers import Images +from gui.components import RoundedFrame, RoundedButton +from gui.helpers.style import Style class Sidebar: def __init__(self, root): @@ -14,6 +16,8 @@ def __init__(self, root): root_width = 600 self.width = root_width // (root_width // 65) + if sys.platform != "darwin": + self.width += 5 def add_button(self, page_name, command): self.button_cmds[page_name] = command @@ -21,23 +25,44 @@ def add_button(self, page_name, command): def set_current_page(self, page_name): self.current_page = page_name - def _hover_enter(self, button, page_name): - background = "#242424" if self.current_page != page_name else self.root.style.colors.get("secondary") + def _hover_enter(self, button_wrapper, button, page_name): + if self.current_page == page_name: + return + background = self.root.style.colors.get("secondary") if self.current_page != page_name else Style.WINDOW_BORDER.value button.configure(background=background) + button_wrapper.set_background(background) - def _hover_leave(self, button, page_name): - background = self.root.style.colors.get("dark") if self.current_page != page_name else self.root.style.colors.get("secondary") + def _hover_leave(self, button_wrapper, button, page_name): + if self.current_page == page_name: + return + background = Style.WINDOW_BORDER.value if self.current_page != page_name else self.root.style.colors.get("secondary") button.configure(background=background) + button_wrapper.set_background(background) def _create_button(self, image, page_name, command, row): is_selected = self.current_page == page_name - bg_color = self.root.style.colors.get("secondary") if is_selected else self.root.style.colors.get("dark") + bg_color = self.root.style.colors.get("secondary") if is_selected else Style.WINDOW_BORDER.value + + button_wrapper = RoundedFrame( + self.sidebar, + radius=20, + background=bg_color + ) + + button_wrapper.grid(row=row, column=0, sticky=ttk.NSEW, pady=(10 if sys.platform == "darwin" else 25, 2) if row == 0 else 2, ipady=8, padx=10) + + button = ttk.Label(button_wrapper, image=image, anchor="center", background=bg_color) + + button_wrapper.bind("", lambda e: self._update_page(command, page_name)) + button_wrapper.bind("", lambda e: self._hover_enter(button_wrapper, button, page_name)) + button_wrapper.bind("", lambda e: self._hover_leave(button_wrapper, button, page_name)) - button = ttk.Label(self.sidebar, image=image, background=bg_color, anchor="center") button.bind("", lambda e: self._update_page(command, page_name)) - button.bind("", lambda e: self._hover_enter(button, page_name)) - button.bind("", lambda e: self._hover_leave(button, page_name)) - button.grid(row=row, column=0, sticky=ttk.NSEW, pady=(10, 2) if row == 0 else 2, ipady=12) + button.bind("", lambda e: self._hover_enter(button_wrapper, button, page_name)) + button.bind("", lambda e: self._hover_leave(button_wrapper, button, page_name)) + + # button.grid(row=row, column=0, sticky=ttk.NSEW, pady=(10, 2) if row == 0 else 2, ipady=12) + button.pack(fill=ttk.BOTH, expand=True, padx=5, pady=5) self.tk_buttons.append(button) @@ -67,23 +92,26 @@ def disable(self): button.unbind("") def draw(self): - self.sidebar = ttk.Frame(self.root, width=self.width, height=self.root.winfo_height(), style="dark.TFrame") + # self.sidebar = ttk.Frame(self.root, width=self.width, height=self.root.winfo_height(), style="dark.TFrame") + self.sidebar = RoundedFrame(self.root, radius=(0, 0, 0, 25), background=Style.WINDOW_BORDER.value) + self.sidebar.set_height(self.root.winfo_height()) + self.sidebar.set_width(self.width + 7) # self.sidebar.pack(side=ttk.LEFT, fill=ttk.BOTH) self.sidebar.grid_propagate(False) self.buttons = { "home": self._create_button(self.images.get("home"), "home", self.button_cmds["home"], 0), - "console": self._create_button(self.images.get("console"), "console", self.button_cmds["console"], 1), - "settings": self._create_button(self.images.get("settings"), "settings", self.button_cmds["settings"], 2), + # "console": self._create_button(self.images.get("console"), "console", self.button_cmds["console"], 1), + "settings": self._create_button(self.images.get("settings"), "settings", self.button_cmds["settings"], 1), + "tools": self._create_button(self.images.get("tools"), "tools", self.button_cmds["tools"], 2), "scripts": self._create_button(self.images.get("scripts"), "scripts", self.button_cmds["scripts"], 3), - "tools": self._create_button(self.images.get("tools"), "tools", self.button_cmds["tools"], 4), } - logout_btn = ttk.Label(self.sidebar, image=self.images.get("logout"), background=self.root.style.colors.get("dark"), anchor="center") + logout_btn = ttk.Label(self.sidebar, image=self.images.get("logout"), background=Style.WINDOW_BORDER.value, anchor="center") logout_btn.bind("", lambda e: self._quit()) logout_btn.bind("", lambda e: self._hover_enter(logout_btn, "logout")) logout_btn.bind("", lambda e: self._hover_leave(logout_btn, "logout")) - logout_btn.grid(row=len(self.buttons) + 2, column=0, sticky=ttk.NSEW, pady=10, ipady=12) + # logout_btn.grid(row=len(self.buttons) + 2, column=0, sticky=ttk.NSEW, pady=10, ipady=12) self.sidebar.grid_rowconfigure(len(self.buttons) + 1, weight=1) self.sidebar.grid_columnconfigure(0, weight=1) diff --git a/gui/components/titlebar.py b/gui/components/titlebar.py index 2568279..280e737 100644 --- a/gui/components/titlebar.py +++ b/gui/components/titlebar.py @@ -1,40 +1,147 @@ +from logging import root +import sys import ttkbootstrap as ttk +from gui.components import RoundedFrame +from gui.helpers.style import Style class Titlebar: - def __init__(self, root): + def __init__(self, root, images): self.root = root + self.images = images self._offset_x = 0 self._offset_y = 0 + self._dragging = False def _on_press(self, event): + self._dragging = True self._offset_x = event.x_root - self.root.winfo_x() self._offset_y = event.y_root - self.root.winfo_y() - - event.widget.grab_set() - - self.root.bind("", self._move_window) - self.root.bind("", self._on_release) - def _move_window(self, event): + def _on_motion(self, event): + if not self._dragging: + return + x = event.x_root - self._offset_x y = event.y_root - self._offset_y self.root.geometry(f"+{x}+{y}") def _on_release(self, event): - self.root.unbind("") - self.root.unbind("") - self.root.grab_release() - - def draw(self): - titlebar = ttk.Frame(self.root, style="dark.TFrame") - inner_wrapper = ttk.Frame(titlebar, style="dark.TFrame") - inner_wrapper.pack(fill=ttk.BOTH, expand=True, pady=5, padx=10) + self._dragging = False + + def _reset_hover_state(self): + x, y = self.root.winfo_pointerxy() + self.root.event_generate("", warp=True, x=x+1, y=y) + self.root.event_generate("", warp=True, x=x, y=y) + + def _close(self): + if sys.platform == "darwin": + self.root.update_idletasks() + self.root.overrideredirect(False) + self.root.withdraw() + else: + self.root.quit() + + def _minimize(self): + self.root.update_idletasks() + self.root.overrideredirect(False) - titlebar.bind("", self._on_press) - inner_wrapper.bind("", self._on_press) + if sys.platform == "darwin": + self.root.iconify() + elif sys.platform == "win32": + self.root.iconify() + def restore_override(): + if not self.root.state() == 'iconic': + self.root.overrideredirect(True) + else: + self.root.after(100, restore_override) + self.root.after(100, restore_override) + + def _restore_once(self, event=None): + self.root.unbind("") + self.root.deiconify() + + self.root.after(10, lambda: self.root.overrideredirect(True)) + self.root.after(20, self._reset_hover_state) + + def _maximize(self): + screen_height = self.root.winfo_screenheight() + screen_width = self.root.winfo_screenwidth() - title = ttk.Label(inner_wrapper, text="Ghost") - title.configure(background=self.root.style.colors.get("dark")) - title.pack(side=ttk.LEFT) + window_geometry = self.root.winfo_geometry() + window_size = window_geometry.split("+")[0] + window_width = int(window_size.split("x")[0]) + window_height = int(window_size.split("x")[1]) + if window_width > self.root.size[0] or window_height > self.root.size[1]: + self.root.geometry(f"{self.root.size[0]}x{self.root.size[1]}") + self.root.update_idletasks() + self.root.after(10, lambda: self.root.overrideredirect(True)) + self.root.after(20, lambda: self.root.geometry(f"+{(screen_width - self.root.size[0]) // 2}+{(screen_height - self.root.size[1]) // 2}")) + else: + self.root.geometry(f"{screen_width}x{screen_height - 40}+0+0") + self.root.update_idletasks() + self.root.after(10, lambda: self.root.overrideredirect(True)) + + def draw(self): + titlebar = RoundedFrame( + self.root, + radius=(25, 25, 0, 0), + background=Style.WINDOW_BORDER.value + ) + + inner_wrapper = RoundedFrame(titlebar, background=Style.WINDOW_BORDER.value, radius=0) + padx = 8 + pady = 8 + + # Bind to all titlebar surfaces + for widget in (titlebar, inner_wrapper): + widget.bind("", self._on_press) + widget.bind("", self._on_motion) + widget.bind("", self._on_release) + + if sys.platform == "darwin": + pady = 0 + + close_btn = ttk.Label(inner_wrapper, text="●", foreground="#FF5F57", font=("Arial", 25)) + close_btn.configure(background=Style.WINDOW_BORDER.value) + close_btn.pack(side=ttk.LEFT, padx=(0, 0)) + close_btn.bind("", lambda e: self._close()) + close_btn.bind("", lambda e: close_btn.configure(foreground="#CC4940")) + close_btn.bind("", lambda e: close_btn.configure(foreground="#FF5F57")) + + minimize_btn = ttk.Label(inner_wrapper, text="●", foreground=Style.MAC_TITLEBAR_INACTIVE.value, font=("Arial", 28)) + minimize_btn.configure(background=Style.WINDOW_BORDER.value) + minimize_btn.pack(side=ttk.LEFT, padx=(0, 0)) + # minimize_btn.bind("", lambda e: self._minimize()) + # minimize_btn.bind("", lambda e: minimize_btn.configure(foreground="#CC9A26")) + # minimize_btn.bind("", lambda e: minimize_btn.configure(foreground="#FFBD2E")) + + maximize_btn = ttk.Label(inner_wrapper, text="●", foreground=Style.MAC_TITLEBAR_INACTIVE.value, font=("Arial", 28)) + maximize_btn.configure(background=Style.WINDOW_BORDER.value) + maximize_btn.pack(side=ttk.LEFT, padx=(0, 5)) + # maximize_btn.bind("", lambda e: maximize_btn.configure(foreground="#20A833")) + # maximize_btn.bind("", lambda e: maximize_btn.configure(foreground="#28C940")) + # maximize_btn.bind("", lambda e: self._maximize()) + + else: + ico = ttk.Label(inner_wrapper, image=self.images.images["titlebar-ico"]) + ico.configure(background=Style.WINDOW_BORDER.value) + ico.pack(side=ttk.LEFT, padx=(5, 0)) + + title = ttk.Label(inner_wrapper, text="Ghost", font=("Host Grotesk", 10)) + title.configure(background=Style.WINDOW_BORDER.value) + title.pack(side=ttk.LEFT, padx=(5, 0)) + + close_btn = ttk.Label(inner_wrapper, text="✕", font=("Host Grotesk", 10)) + close_btn.configure(background=Style.WINDOW_BORDER.value) + close_btn.pack(side=ttk.RIGHT, padx=(0, 5)) + close_btn.bind("", lambda e: self.root.quit()) + + minimize_btn = ttk.Label(inner_wrapper, text="—", font=("Host Grotesk", 10)) + minimize_btn.configure(background=Style.WINDOW_BORDER.value) + minimize_btn.pack(side=ttk.RIGHT, padx=(0, 7)) + minimize_btn.bind("", lambda e: self._minimize()) + + + inner_wrapper.pack(fill=ttk.BOTH, expand=True, pady=pady, padx=padx) return titlebar \ No newline at end of file diff --git a/gui/components/tool_page.py b/gui/components/tool_page.py index 34dd2bf..64be12c 100644 --- a/gui/components/tool_page.py +++ b/gui/components/tool_page.py @@ -1,8 +1,10 @@ import abc +import sys import ttkbootstrap as ttk from gui.components import RoundedFrame +from gui.helpers.style import Style -class ToolPage(abc.ABC): +class ToolPage: def __init__(self, toolspage, root, bot_controller, images, layout, title, frame=True): self.toolspage = toolspage self.root = root @@ -22,12 +24,17 @@ def go_back(self): def draw_navigation(self, parent): wrapper = ttk.Frame(parent) - back_button = ttk.Label(wrapper, image=self.images.get("left-chevron")) + tools_label = ttk.Label(wrapper, text="Tools", font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold"), foreground=Style.LIGHT_GREY.value) + tools_label.grid(row=0, column=0, sticky=ttk.W) + tools_label.bind("", lambda e: self.go_back()) + + back_button = ttk.Label(wrapper, image=self.images.get("right-chevron-small")) back_button.bind("", lambda e: self.go_back()) - back_button.grid(row=0, column=1, sticky=ttk.W, padx=(0, 10)) + back_button.grid(row=0, column=1, sticky=ttk.W, padx=(10, 10)) - page_name = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 16, "bold")) + page_name = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold")) page_name.grid(row=0, column=2, sticky=ttk.W) + page_name.bind("", lambda e: self.go_back()) return wrapper diff --git a/gui/helpers/__init__.py b/gui/helpers/__init__.py index 3f24a63..de20785 100644 --- a/gui/helpers/__init__.py +++ b/gui/helpers/__init__.py @@ -1,2 +1,3 @@ from .images import Images -from .layout import Layout \ No newline at end of file +from .layout import Layout +from .style import Style \ No newline at end of file diff --git a/gui/helpers/images.py b/gui/helpers/images.py index a2fdc3a..0c44c60 100644 --- a/gui/helpers/images.py +++ b/gui/helpers/images.py @@ -1,10 +1,12 @@ from PIL import Image, ImageTk, ImageFilter, ImageEnhance -import os +import os, sys import threading +import requests from utils.files import resource_path import requests from io import BytesIO from collections import Counter +from bot.helpers import imgembed def resize_and_sharpen(image, size): try: @@ -41,9 +43,15 @@ def __new__(cls): cls._instance._init_images() # Initialize images return cls._instance - def _init_images(self): + def _init_images(self): self.images = {} self.original_images = {} + + self._url_image_cache = {} # (url, size, radius) -> PhotoImage + self._url_color_cache = {} # url -> hex colour + self._url_bytes_cache = {} # url -> raw bytes + self._url_lock = threading.Lock() # thread safety + self._load_images() self._load_webhooks_template() @@ -64,8 +72,8 @@ def _load_webhooks_template(self): def _load_images(self): SIZES = { "bigger": (23, 23), - "icon": (20, 20), - "small": (15, 15), + "icon": (20, 20) if sys.platform == "darwin" else (23, 23), + "small": (15, 15) if sys.platform == "darwin" else (18, 18), "smaller": (12, 12), "tiny": (10, 10), "logo": (50, 50), @@ -73,9 +81,9 @@ def _load_images(self): ICON_CONFIG = { "bigger": ["scripts"], - "small": ["trash", "github", "restart", "checkmark", "left-chevron", "file-signature", "trash-white", "right-chevron"], - "tiny": ["submit", "max", "min", "search"], - "smaller": ["folder-open", "plus", "reset", "play", "stop"], + "small": ["trash", "github", "restart", "checkmark", "left-chevron", "file-signature", "trash-white", "titlebar-ico", "right-chevron-small"], + "tiny": ["submit", "max", "min", "search", "right-chevron-tiny"], + "smaller": ["folder-open", "plus", "reset", "play", "stop", "right-chevron"], "logo": ["ghost-logo"], } @@ -105,10 +113,13 @@ def _load_images(self): "file-signature": "data/icons/file-signature-solid.png", "left-chevron": "data/icons/chevron-left-solid.png", "right-chevron": "data/icons/chevron-right-solid.png", + "right-chevron-tiny": "data/icons/chevron-right-solid.png", + "right-chevron-small": "data/icons/chevron-right-solid.png", "tools": "data/icons/screwdriver-wrench-solid.png", "reset": "data/icons/rotate-left-solid.png", "play": "data/icons/play-solid.png", "stop": "data/icons/stop-solid.png", + "titlebar-ico": "data/icon-win.png" } for key, path in ICON_PATHS.items(): @@ -158,33 +169,81 @@ def get(self, key, hover_colour=None): return self.images.get(key) def get_majority_color_from_url(self, image_url: str) -> str: - response = requests.get(image_url) - image = Image.open(BytesIO(response.content)).convert("RGB") - - # Crop center to focus on subject - w, h = image.size - crop_margin = 0.2 - image = image.crop(( - int(w * crop_margin), - int(h * crop_margin), - int(w * (1 - crop_margin)), - int(h * (1 - crop_margin)) - )) - - # Slight blur to reduce texture noise - image = image.filter(ImageFilter.GaussianBlur(radius=1)) - image = image.resize((50, 50)) # reduce size for performance - - pixels = list(image.getdata()) - - # Filter out very dark pixels (likely shadows) - filtered_pixels = [rgb for rgb in pixels if sum(rgb) > 60] # brightness threshold - - if not filtered_pixels: - # fallback to original pixels if all were filtered - filtered_pixels = pixels - - counter = Counter(filtered_pixels) - most_common = counter.most_common(1)[0][0] - - return '#{:02x}{:02x}{:02x}'.format(*most_common) \ No newline at end of file + with self._url_lock: + if image_url in self._url_color_cache: + return self._url_color_cache[image_url] + + try: + # reuse downloaded bytes if available + with self._url_lock: + if image_url in self._url_bytes_cache: + content = self._url_bytes_cache[image_url] + else: + response = requests.get(image_url, timeout=5) + response.raise_for_status() + content = response.content + self._url_bytes_cache[image_url] = content + + image = Image.open(BytesIO(content)).convert("RGB") + + w, h = image.size + crop_margin = 0.2 + image = image.crop(( + int(w * crop_margin), + int(h * crop_margin), + int(w * (1 - crop_margin)), + int(h * (1 - crop_margin)) + )) + + image = image.filter(ImageFilter.GaussianBlur(radius=1)) + image = image.resize((50, 50)) + + pixels = list(image.getdata()) + filtered_pixels = [rgb for rgb in pixels if sum(rgb) > 60] or pixels + + most_common = Counter(filtered_pixels).most_common(1)[0][0] + hex_colour = '#{:02x}{:02x}{:02x}'.format(*most_common) + + with self._url_lock: + self._url_color_cache[image_url] = hex_colour + + return hex_colour + + except Exception as e: + print("Error getting majority colour:", e) + return "#2b2d31" # safe fallback + + def load_image_from_url(self, image_url: str, size: tuple, radius=10) -> ImageTk.PhotoImage: + cache_key = (image_url, size, radius) + + with self._url_lock: + if cache_key in self._url_image_cache: + return self._url_image_cache[cache_key] + + try: + # reuse raw bytes if already downloaded + with self._url_lock: + if image_url in self._url_bytes_cache: + content = self._url_bytes_cache[image_url] + else: + response = requests.get(image_url, timeout=5) + response.raise_for_status() + content = response.content + self._url_bytes_cache[image_url] = content + + image = Image.open(BytesIO(content)).convert("RGBA") + image = resize_and_sharpen(image, size) + + if radius > 0: + image = imgembed.add_corners(image, radius) + + photo = ImageTk.PhotoImage(image) + + with self._url_lock: + self._url_image_cache[cache_key] = photo + + return photo + + except Exception as e: + print("Error loading image from URL:", e) + return None diff --git a/gui/helpers/layout.py b/gui/helpers/layout.py index 5254ae1..9437b84 100644 --- a/gui/helpers/layout.py +++ b/gui/helpers/layout.py @@ -1,6 +1,8 @@ import sys import ttkbootstrap as ttk from ttkbootstrap.scrolled import ScrolledFrame +from gui.components import RoundedFrame +from gui.helpers.style import Style def resize(root, width, height): root.minsize(width, height) @@ -18,25 +20,82 @@ def center_window(root, width, height): root.focus_force() class Layout: - def __init__(self, root, sidebar): + def __init__(self, root, sidebar, titlebar, resize_grips): self.root = root self.width = root.winfo_width() self.height = root.winfo_height() self.sidebar = sidebar + self.titlebar = titlebar + self.resize_grips = resize_grips - def main(self, scrollable=False, padx=25, pady=25): + def main(self, scrollable=False, padx=10, pady=10): width = self.width - (self.width // 100) - wrapper = self.root - - if scrollable: - wrapper = ScrolledFrame(self.root, width=width, height=self.height) - wrapper.pack(fill=ttk.BOTH, expand=True) + main = None + + if sys.platform == "darwin": + border = RoundedFrame( + self.root, + radius=(0, 0, 25, 0), # ALL corners here + background=Style.WINDOW_BORDER.value + ) + border.pack(fill=ttk.BOTH, expand=True) + + outer = RoundedFrame( + border, + radius=(25, 25, 25, 25), # only internal shaping + background=self.root.style.colors.get("bg") + ) + outer.pack( + fill=ttk.BOTH, + expand=True, + padx=(0, 8), + pady=(0, 8) + ) + + # SAFE ZONE: keeps native widgets away from rounded corners + safe = ttk.Frame( + outer, + style="TFrame" + ) + safe.pack(fill=ttk.BOTH, expand=True, padx=15, pady=15) + + # INNER: scrolling container (optional) + if scrollable: + inner = ScrolledFrame( + safe, + width=width, + height=self.height + ) + inner.pack(fill=ttk.BOTH, expand=True) + content_parent = inner + else: + content_parent = safe + + # CONTENT FRAME + main = ttk.Frame( + content_parent, + width=width, + height=self.height + ) + main.pack( + fill=ttk.BOTH, + expand=True, + padx=(8, 22) if scrollable else padx, + pady=8 if scrollable else pady + ) + + else: + wrapper = self.root - # main = ttk.Frame(wrapper) - # main.pack(fill=ttk.BOTH, expand=True, padx=23, pady=23) + if scrollable: + wrapper = ScrolledFrame(self.root, width=width, height=self.height) + wrapper.pack(fill=ttk.BOTH, expand=True) + + # main = ttk.Frame(wrapper) + # main.pack(fill=ttk.BOTH, expand=True, padx=23, pady=23) - main = ttk.Frame(wrapper, width=width, height=self.height) - main.pack(fill=ttk.BOTH, expand=True, padx=(23, 32) if scrollable else padx, pady=23 if scrollable else pady) + main = ttk.Frame(wrapper, width=width, height=self.height) + main.pack(fill=ttk.BOTH, expand=True, padx=(23, 32) if scrollable else 25, pady=23 if scrollable else 25) return main @@ -46,9 +105,16 @@ def clear_everything(self): def clear(self): for widget in self.root.winfo_children(): - if isinstance(widget, ttk.Frame): + # ignore resize grips + if widget in self.resize_grips.values(): + continue + if isinstance(widget, ttk.Frame) or isinstance(widget, ScrolledFrame) or isinstance(widget, ttk.Canvas) or isinstance(widget, RoundedFrame): widget.destroy() + if sys.platform == "darwin": + titlebar = self.titlebar.draw() + titlebar.pack(fill=ttk.X, side=ttk.TOP) + sidebar = self.sidebar.draw() sidebar.pack(side=ttk.LEFT, fill=ttk.BOTH) diff --git a/gui/helpers/style.py b/gui/helpers/style.py new file mode 100644 index 0000000..6acdf8d --- /dev/null +++ b/gui/helpers/style.py @@ -0,0 +1,15 @@ +import json +from enum import Enum + +class Style(Enum): + WINDOW_BORDER = "#12121c" + SIDEBAR_SELECTED = "#181722" + ENTRY_BG = "#1b1b2b" + SETTINGS_PILL_HOVER = "#252534" + SETTINGS_PILL_SELECTED = "#2d2d41" + DROPDOWN_OPTION_HOVER = "#20202f" + DARK_GREY = "#7f7f92" + LIGHT_GREY = "#cbcbd2" + PRIMARY_BTN_HOVER = "#322bef" + MAC_TITLEBAR_INACTIVE = "#454256" + TOOL_HOVER = "#1c1c2e" \ No newline at end of file diff --git a/gui/main.py b/gui/main.py index df5f80f..e2384a9 100644 --- a/gui/main.py +++ b/gui/main.py @@ -3,106 +3,211 @@ os.environ["SSL_CERT_FILE"] = certifi.where() import ttkbootstrap as ttk -from ttkbootstrap.dialogs import Messagebox -from ttkbootstrap.utility import enable_high_dpi_awareness from utils.notifier import Notifier from utils.config import Config import utils.console as logging from utils.files import resource_path -from utils import uninstall_fonts from gui.pages import HomePage, LoadingPage, SettingsPage, OnboardingPage, ScriptsPage, ToolsPage -from gui.components import Sidebar, Console -from gui.helpers import Images, Layout +from gui.components import Sidebar, Console, Titlebar, RoundedFrame +from gui.helpers import Images, Layout, Style class GhostGUI: def __init__(self, bot_controller): - self.size = (600, 530) + self.resize_grip_size = 5 + self.size = (700 if sys.platform == "darwin" else 800, 530) self.bot_controller = bot_controller - - enable_high_dpi_awareness() + self.resize_grips = {} self.root = ttk.tk.Tk() + self.root.size = self.size self.root.title("Ghost") - # self.root.resizable(False, False) + + if sys.platform == "darwin": + self.root.overrideredirect(False) + self.root.withdraw() + if os.name == "nt": self.root.iconbitmap(resource_path("data/icon.ico")) - self.root.geometry(f"{self.size[0]}x{self.size[1]}") + self.root.minsize(self.size[0], self.size[1]) self.root.protocol("WM_DELETE_WINDOW", self.quit) - self.root.createcommand('::tk::mac::ReopenApplication', self._show_window) self.root.style = ttk.Style() # self.root.style.theme_use("darkly") self.root.style.load_user_themes(resource_path("data/gui_theme.json")) self.root.style.theme_use("ghost") - self.root.style.configure("TEntry", background=self.root.style.colors.get("dark"), fieldbackground=self.root.style.colors.get("secondary")) - self.root.style.configure("TCheckbutton", background=self.root.style.colors.get("dark")) - self.root.style.configure("TMenubutton", font=("Host Grotesk",)) - self.root.style.configure("TCheckbutton", font=("Host Grotesk",)) - self.root.style.configure("TEntry", font=("Host Grotesk",)) - self.root.style.configure("TLabel", font=("Host Grotesk",)) - self.root.style.configure("TButton", font=("Host Grotesk",)) + self.root.style.configure("TEntry", background=self.root.style.colors.get("dark"), fieldbackground=Style.ENTRY_BG.value, font=("Host Grotesk",), bordercolor=Style.ENTRY_BG.value, foreground="#ffffff", borderstyle="flat", borderwidth=0) + self.root.style.configure("TCheckbutton", background=self.root.style.colors.get("dark"), font=("Host Grotesk",)) + self.root.style.configure("TMenubutton", font=("Host Grotesk",)) + self.root.style.configure("TLabel", font=("Host Grotesk",)) + self.root.style.configure("TButton", font=("Host Grotesk",)) + + if sys.platform == "darwin": + self.root.attributes("-transparent", True) + self.root.configure(bg="systemTransparent") + # elif sys.platform == "win32": + # self.root.configure(bg="#ff00ff") + # self.root.attributes("-transparentcolor", "#ff00ff") + # else: + # self.root.attributes("-alpha", 1) self.cfg = Config() self.notifier = Notifier() self.images = Images() self.sidebar = Sidebar(self.root) - self.sidebar.add_button("home", self.draw_home) - self.sidebar.add_button("console", self.draw_console) + self.sidebar.add_button("home", self.draw_home) + # self.sidebar.add_button("console", self.draw_console) self.sidebar.add_button("settings", self.draw_settings) - self.sidebar.add_button("scripts", self.draw_scripts) - self.sidebar.add_button("tools", self.draw_tools) - self.sidebar.add_button("logout", self.quit) + self.sidebar.add_button("scripts", self.draw_scripts) + self.sidebar.add_button("tools", self.draw_tools) + self.sidebar.add_button("logout", self.quit) + + if self.cfg.get("token") != "": + self._create_resize_grips() - self.layout = Layout(self.root, self.sidebar) + self.titlebar = Titlebar(self.root, self.images) + self.layout = Layout(self.root, self.sidebar, self.titlebar, self.resize_grips) self.loading_page = LoadingPage(self.root) self.onboarding_page = OnboardingPage(self.root, self.run, self.bot_controller) self.console = Console(self.root, self.bot_controller) - self.home_page = HomePage(self.root, self.bot_controller, self._restart_bot) + self.home_page = HomePage(self.root, self.bot_controller, self._restart_bot, self.console) self.settings_page = SettingsPage(self.root, self.bot_controller) self.scripts_page = ScriptsPage(self, self.bot_controller, self.images) - self.tools_page = ToolsPage(self.root, self.bot_controller, self.images, self.layout) + self.tools_page = ToolsPage(self.root, self.bot_controller, self.images, self.layout, self._position_resize_grips) logging.set_gui(self) if bot_controller: self.bot_controller.set_gui(self) + + if sys.platform == "darwin": + self.layout.center_window(self.size[0], self.size[1]) + self.root.update_idletasks() + + self.root.update_idletasks() + self.root.createcommand('::tk::mac::ReopenApplication', self._show_window) + self.root.bind("", lambda _: self._window_mapped()) + + self.root.after(450, self._show_window) + self.root.after(500, self._window_mapped) + + def _pre_load_images(self, user): + print("Pre-loading images...") + avatar_url = user.avatar.url if user and user.avatar else "https://ia600305.us.archive.org/31/items/discordprofilepictures/discordblue.png" + self.bot_controller.get_avatar_from_url(avatar_url, size=65, radius=65//2) + self.images.get_majority_color_from_url(avatar_url) + + rpc = self.cfg.get_rich_presence() + if rpc and rpc.large_image: + self.images.load_image_from_url(rpc.large_image if rpc.large_image else "https://www.ghostt.cc/assets/ghost.png", (64, 64), 5) + + print("Finished pre-loading images.") + def _window_mapped(self): + self.root.update_idletasks() + self.root.overrideredirect(True) + self.root.state("normal") + def _show_window(self): + self.root.update_idletasks() self.root.deiconify() + self.root.overrideredirect(True) + + def _create_resize_grips(self): + if sys.platform != "darwin": + return + + self.resize_grips = {} + + # Bottom grip + bottom = RoundedFrame( + self.root, + radius=(0, 0, 25, 25), + background=Style.WINDOW_BORDER.value + ) + bottom.bind("", self._resize_window) + bottom.bind("", lambda e: self.root.config(cursor="sb_v_double_arrow")) + bottom.bind("", lambda e: self.root.config(cursor="")) + self.resize_grips["bottom"] = bottom + + # Right grip + right = RoundedFrame( + self.root, + radius=(0, 25, 25, 0), + background=Style.WINDOW_BORDER.value + ) + right.bind("", self._resize_window) + right.bind("", lambda e: self.root.config(cursor="sb_h_double_arrow")) + right.bind("", lambda e: self.root.config(cursor="")) + self.resize_grips["right"] = right + + self._position_resize_grips() + + def _position_resize_grips(self): + if sys.platform != "darwin": + return + + w = self.root.winfo_width() + h = self.root.winfo_height() + s = self.resize_grip_size + + self.resize_grips["bottom"].place( + x=0, y=h - s, width=w, height=s + ) + + self.resize_grips["right"].place( + x=w - s, y=0, width=s, height=h + ) + + for grip in self.resize_grips.values(): + ttk.tk.Misc.lift(grip) + + def _resize_window(self, event): + # resize the window based on mouse position, save the new positions so resizing continues smoothly + x = self.root.winfo_pointerx() + y = self.root.winfo_pointery() + self.root.geometry(f"{x - self.root.winfo_x()}x{y - self.root.winfo_y()}") + self.root.update_idletasks() + + self.size = (self.root.winfo_width(), self.root.winfo_height()) + self._position_resize_grips() def draw_home(self, restart=False, start=False): self.sidebar.set_current_page("home") self.layout.clear() main = self.layout.main() self.home_page.draw(main, restart=restart, start=start) + self._position_resize_grips() - def draw_console(self): - self.sidebar.set_current_page("console") - self.layout.clear() - main = self.layout.main() - self.console.draw(main) + # def draw_console(self): + # self.sidebar.set_current_page("console") + # self.layout.clear() + # main = self.layout.main() + # self.console.draw(main) def draw_settings(self): self.sidebar.set_current_page("settings") self.layout.clear() main = self.layout.main(scrollable=True) self.settings_page.draw(main) + self._position_resize_grips() def draw_scripts(self): self.sidebar.set_current_page("scripts") self.layout.clear() - main = self.layout.main(padx=(23, 24)) + main = self.layout.main() self.scripts_page.draw(main) + self._position_resize_grips() def draw_tools(self): self.sidebar.set_current_page("tools") self.layout.clear() - main = self.layout.main(scrollable=True) + main = self.layout.main(scrollable=False) self.tools_page.draw(main) + self._position_resize_grips() # def draw_loading(self): # self.layout.hide_titlebar() @@ -144,6 +249,8 @@ def _on_bot_ready(self): self.layout.resize(600, 530) self.layout.center_window(600, 530) + user = self.bot_controller.get_user() + self._pre_load_images(user) self.root.after(50, lambda: self.notifier.send("Ghost", "Ghost has successfully started!")) self.root.after(50, lambda: self.draw_home()) diff --git a/gui/pages/home.py b/gui/pages/home.py index 4109f6d..c64b505 100644 --- a/gui/pages/home.py +++ b/gui/pages/home.py @@ -4,13 +4,15 @@ from ttkbootstrap.scrolled import ScrolledFrame from gui.components import RoundedFrame, RoundedButton from gui.helpers import Images +from gui.helpers.style import Style from utils.config import VERSION, CHANGELOG, MOTD, Config class HomePage: - def __init__(self, root, bot_controller, _restart_bot): + def __init__(self, root, bot_controller, _restart_bot, console): self.root = root self.bot_controller = bot_controller self._restart_bot = _restart_bot + self.console = console self.width = root.winfo_width() self.height = root.winfo_height() self.restart = False @@ -64,8 +66,8 @@ def _update_bot_details(self): def _draw_restart_button(self, parent, disabled=False): def _hover_enter(_): - frame.set_background(background="#322bef") - restart_label.configure(background="#322bef") + frame.set_background(background=Style.PRIMARY_BTN_HOVER.value) + restart_label.configure(background=Style.PRIMARY_BTN_HOVER.value) def _hover_leave(_): frame.set_background(background=self.root.style.colors.get("primary")) @@ -90,7 +92,7 @@ def _hover_leave(_): def _draw_header(self, parent): wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="secondary.TFrame") - wrapper.pack(fill=ttk.BOTH, expand=False) + wrapper.pack(fill=ttk.BOTH, expand=False, pady=(0, 10)) if self.avatar and not self.restart: avatar = ttk.Label(wrapper, image=self.avatar) @@ -98,22 +100,22 @@ def _draw_header(self, parent): avatar.grid(row=0, column=0, sticky=ttk.W, padx=(15, 10), pady=15, rowspan=2) if not self.restart: - display_name = ttk.Label(wrapper, text=self.bot_controller.get_user().display_name, font=("Host Grotesk", 16 if sys.platform != "darwin" else 20, "bold")) + display_name = ttk.Label(wrapper, text=self.bot_controller.get_user().display_name, font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold")) display_name.configure(background=self.root.style.colors.get("secondary")) display_name.grid(row=0, column=1, sticky=ttk.W, pady=(15, 0)) - username = ttk.Label(wrapper, text=self.bot_controller.get_user().name, font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "italic")) - username.configure(background=self.root.style.colors.get("secondary"), foreground="lightgrey") + username = ttk.Label(wrapper, text=self.bot_controller.get_user().name, font=("Host Grotesk", 14 if sys.platform != "darwin" else 16, "italic")) + username.configure(background=self.root.style.colors.get("secondary"), foreground=Style.LIGHT_GREY.value) username.grid(row=1, column=1, sticky=ttk.W, pady=(0, 15)) # restart_btn = self._draw_restart_button(wrapper) # restart_btn.grid(row=0, column=3, rowspan=2, sticky=ttk.EW, padx=(10, 16), pady=(10, 10)) - restart_btn = RoundedButton(wrapper, radius=8, bootstyle="primary.TButton", command=lambda _: self._restart_bot(), image=self.images.get("restart"), padx=15, pady=6) + restart_btn = RoundedButton(wrapper, radius=8, bootstyle="primary.TButton", command=lambda _: self._restart_bot(), image=self.images.get("restart"), padx=15 if sys.platform == "darwin" else 20, pady=6 if sys.platform == "darwin" else 10) restart_btn.grid(row=0, column=3, rowspan=2, sticky=ttk.EW, padx=(10, 16), pady=(10, 10)) wrapper.grid_columnconfigure(2, weight=1) else: - self.restart_title = ttk.Label(wrapper, text=f"{self.restart_title_text}...", font=("Host Grotesk", 14 if sys.platform != "darwin" else 20, "bold"), anchor="center") + self.restart_title = ttk.Label(wrapper, text=f"{self.restart_title_text}...", font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold"), anchor="center") self.restart_title.configure(background=self.root.style.colors.get("secondary")) self.restart_title.grid(row=0, column=0, sticky=ttk.NSEW, pady=26, padx=15, columnspan=2) wrapper.grid_columnconfigure(0, weight=1) @@ -145,11 +147,11 @@ def _draw_account_details(self, parent): wrapper.grid_columnconfigure(1, weight=1) self.friends_label = ttk.Label(wrapper, text=f"Friends: {len(self.bot_controller.get_friends())}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) - self.friends_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else "lightgrey") + self.friends_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else Style.LIGHT_GREY.value) self.friends_label.grid(row=2, column=0, sticky=ttk.W, padx=10, pady=(5, 0)) self.guilds_label = ttk.Label(wrapper, text=f"Guilds: {len(self.bot_controller.get_guilds())}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) - self.guilds_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else "lightgrey") + self.guilds_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else Style.LIGHT_GREY.value) self.guilds_label.grid(row=3, column=0, sticky=ttk.W, padx=10, pady=(0, 10)) def _draw_bot_details(self, parent): @@ -171,27 +173,48 @@ def _draw_bot_details(self, parent): version.grid(row=2, column=0, sticky=ttk.W, padx=(10, 0), pady=(5, 0)) self.uptime_label = ttk.Label(wrapper, text=f"Uptime: {self.bot_controller.get_uptime()}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) - self.uptime_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else "lightgrey") + self.uptime_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else Style.LIGHT_GREY.value) self.uptime_label.grid(row=3, column=0, sticky=ttk.W, padx=(10, 0)) self.latency_label = ttk.Label(wrapper, text=f"Latency: {self.bot_controller.get_latency()}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) - self.latency_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else "lightgrey") + self.latency_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else Style.LIGHT_GREY.value) self.latency_label.grid(row=4, column=0, sticky=ttk.W, padx=(10, 0), pady=(0, 10)) + def _draw_details(self, parent): + wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") + wrapper.pack(fill=ttk.BOTH, expand=False, pady=(0, 10)) + + version = ttk.Label(wrapper, text=f"Ghost v{VERSION}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + version.configure(background=self.root.style.colors.get("dark")) + version.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=10) + + self.uptime_label = ttk.Label(wrapper, text=f"Uptime: {self.bot_controller.get_uptime()}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + self.uptime_label.configure(background=self.root.style.colors.get("dark")) + self.uptime_label.grid(row=0, column=1, sticky=ttk.E, padx=(10, 0), pady=10) + + self.latency_label = ttk.Label(wrapper, text=f"Latency: {self.bot_controller.get_latency()}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + self.latency_label.configure(background=self.root.style.colors.get("dark")) + self.latency_label.grid(row=0, column=2, sticky=ttk.E, padx=10, pady=10) + + wrapper.grid_columnconfigure(1, weight=1) + def _update_wraplength(self, event=None): # This method can be used for future wraplength updates if needed pass - def draw(self, parent, restart=False, start=False): self.restart = restart or start self.restart_title_text = "Ghost is starting" if start else "Ghost is restarting" - self.avatar = self.bot_controller.get_avatar() + self.avatar = self.bot_controller.get_avatar(size=55 if sys.platform == "darwin" else 65) self._draw_header(parent) - self.details_wrapper = self._draw_details_wrapper(parent) - self._draw_account_details(self.details_wrapper) - self._draw_bot_details(self.details_wrapper) + self._draw_details(parent) + + # self.details_wrapper = self._draw_details_wrapper(parent) + # self._draw_account_details(self.details_wrapper) + # self._draw_bot_details(self.details_wrapper) self._update_bot_details() - self._update_account_details() \ No newline at end of file + # self._update_account_details() + + self.console.draw(parent) \ No newline at end of file diff --git a/gui/pages/onboarding.py b/gui/pages/onboarding.py index 6630491..194fee0 100644 --- a/gui/pages/onboarding.py +++ b/gui/pages/onboarding.py @@ -172,7 +172,7 @@ def _draw_webhook_setup(self, parent): return wrapper def draw(self): - wrapper = ttk.Frame(self.root) + wrapper = RoundedFrame(self.root, radius=(25, 25, 25, 25), background=self.root.style.colors.get("bg")) wrapper.place(relx=0.5, rely=0.5, anchor="center") token_entry = self._draw_token_entry(wrapper) diff --git a/gui/pages/scripts.py b/gui/pages/scripts.py index b48ce5c..28bff81 100644 --- a/gui/pages/scripts.py +++ b/gui/pages/scripts.py @@ -4,6 +4,7 @@ from ttkbootstrap.scrolled import ScrolledFrame from ttkbootstrap.dialogs import Messagebox from gui.components import RoundedFrame +from gui.helpers.style import Style # Uncomment the below to enable the dedicated script page. # Please be aware this is a work in progress and the current state of the page is laggy and sometimes unresponsive. @@ -60,7 +61,7 @@ def _new_scripts_listener(self): if current_scripts != previous_scripts: try: if not self.restart_warning.winfo_ismapped(): - self.restart_warning.pack(fill=ttk.X, padx=2, side=ttk.TOP, pady=(10, 0)) + self.restart_warning.pack(fill=ttk.X, side=ttk.TOP, pady=(10, 0)) except: pass else: @@ -171,8 +172,8 @@ def on_focus_out(event): def _draw_open_folder_button(self, parent): def _hover_enter(_): - wrapper.set_background(background="#202021") - open_folder_button.configure(background="#202021") + wrapper.set_background(background=Style.SETTINGS_PILL_HOVER.value) + open_folder_button.configure(background=Style.SETTINGS_PILL_HOVER.value) def _hover_leave(_): wrapper.set_background(background=self.root.style.colors.get("secondary")) @@ -194,8 +195,8 @@ def _hover_leave(_): def _draw_plus_button(self, parent): def _hover_enter(_): - wrapper.set_background(background="#322bef") - plus_button.configure(background="#322bef") + wrapper.set_background(background=Style.PRIMARY_BTN_HOVER.value) + plus_button.configure(background=Style.PRIMARY_BTN_HOVER.value) def _hover_leave(_): wrapper.set_background(background=self.root.style.colors.get("primary")) @@ -314,11 +315,16 @@ def _draw_restart_warning(self, parent): def draw(self, parent): self.restart_warning = self._draw_restart_warning(parent) - self.restart_warning.pack(fill=ttk.X, padx=2, side=ttk.TOP, pady=(10, 0)) + self.restart_warning.pack(fill=ttk.X, side=ttk.TOP, pady=(10, 0)) self.restart_warning.pack_forget() + title = ttk.Label(parent, text="Scripts", font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold")) + title.configure(background=self.root.style.colors.get("bg")) + title.pack(pady=(0, 15), anchor=ttk.W) + # title.grid(row=0, column=0, sticky=ttk.W, pady=(0, 15)) + header = self._draw_header(parent) - header.pack(fill=ttk.X, padx=2) + header.pack(fill=ttk.X) ttk.Separator(parent, orient="horizontal").pack(fill=ttk.X, pady=(20, 16), padx=4) diff --git a/gui/pages/settings.py b/gui/pages/settings.py index dbcd7d1..2693fe9 100644 --- a/gui/pages/settings.py +++ b/gui/pages/settings.py @@ -1,6 +1,8 @@ +import sys import ttkbootstrap as ttk from gui.components.settings import GeneralPanel, ThemingPanel, APIsPanel, SessionSpoofingPanel, RichPresencePanel, SnipersPanel -from gui.helpers import Images +from gui.components import RoundedFrame, DropdownMenu +from gui.helpers import Images, Style from utils.config import Config class SettingsPage: @@ -13,6 +15,19 @@ def __init__(self, root, bot_controller): self.images = Images() self.cfg = Config() self.cfg.subscribe(self) + + self.current_page = "general" + self.pages = { + "general": None, + "theming": None, + # "apis": None, + # "session_spoofing": None, + "rich_presence": None, + "snipers": None, + } + self.pills = {} + self.selected_colour = Style.SETTINGS_PILL_SELECTED.value + self.hover_colour = Style.SETTINGS_PILL_HOVER.value def refresh_config(self): if not self.parent: @@ -24,26 +39,92 @@ def refresh_config(self): if self.parent: self.parent.update_idletasks() self.draw(self.parent) - except: - pass - - def draw(self, parent): - self.parent = parent + if self.parent: + self.root.after_idle(lambda: self.root.focus_force()) + except Exception as e: + print(f"Error refreshing config: {e}") + + def _create_sections(self, wrapper): + general_wrapper = ttk.Frame(wrapper) + + self.general = GeneralPanel(self.root, general_wrapper, self.bot_controller, self.images, self.cfg).draw() + self.session_spoofing = SessionSpoofingPanel(self.root, general_wrapper, self.images, self.cfg).draw() + self.snipers = SnipersPanel(self.root, wrapper, self.images, self.cfg).draw() + self.rpc = RichPresencePanel(self.root, wrapper, self.images, self.cfg, bot_controller=self.bot_controller).draw() + self.apis = APIsPanel(self.root, general_wrapper, self.images, self.cfg).draw() + self.theming = ThemingPanel(self.root, wrapper, self.images, self.cfg).draw() + + self.general.pack(fill=ttk.BOTH, expand=True, pady=(0, 10)) + self.session_spoofing.pack(fill=ttk.BOTH, expand=True, pady=(0, 10)) + self.apis.pack(fill=ttk.BOTH, expand=True, pady=(0, 10)) + + self.pages["general"] = general_wrapper + self.pages["theming"] = self.theming + # self.pages["apis"] = self.apis + # self.pages["session_spoofing"] = self.session_spoofing + self.pages["rich_presence"] = self.rpc + self.pages["snipers"] = self.snipers + + def _create_pill(self, parent, text, row, command): + def _hover_enter(_, pill, label): + if pill == self.pills[self.current_page]["pill"]: + return + pill.set_background(self.hover_colour) + label.configure(background=self.hover_colour) + def _hover_leave(_, pill, label): + if pill == self.pills[self.current_page]["pill"]: + return + pill.set_background(self.root.style.colors.get("secondary")) + label.configure(background=self.root.style.colors.get("secondary")) - general = GeneralPanel(self.root, parent, self.bot_controller, self.images, self.cfg).draw() - general.pack(fill=ttk.BOTH, expand=True, pady=(0, 15)) + pill = RoundedFrame(parent, radius=10, bootstyle="secondary.TFrame") + pill.grid(row=0, column=row, sticky=ttk.W, padx=(5, 0 if text != "Snipers" else 5), pady=5) + label = ttk.Label(pill, text=text, font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) + label.configure(background=self.root.style.colors.get("secondary")) + label.grid(row=0, column=0, sticky=ttk.W, padx=5, pady=5) + label.bind("", lambda e: command()) + pill.bind("", lambda e: command()) + pill.bind("", lambda e: _hover_enter(e, pill, label)) + pill.bind("", lambda e: _hover_leave(e, pill, label)) + label.bind("", lambda e: _hover_enter(e, pill, label)) + label.bind("", lambda e: _hover_leave(e, pill, label)) + self.pills[text.lower().replace(" ", "_")] = {"pill": pill, "label": label} - theming = ThemingPanel(self.root, parent, self.images, self.cfg).draw() - theming.pack(fill=ttk.BOTH, expand=True, pady=(0, 15)) + def toggle(self, key): + if key != self.current_page: + for page in self.pages.values(): + page.pack_forget() + self.pages[key].pack(fill=ttk.BOTH, expand=True, pady=(0, 10)) + self.current_page = key - apis = APIsPanel(self.root, parent, self.images, self.cfg).draw() - apis.pack(fill=ttk.BOTH, expand=True, pady=(0, 15)) + for pill_key, pill_components in self.pills.items(): + is_selected = pill_key == key + bg_color = self.selected_colour if is_selected else self.root.style.colors.get("secondary") + pill_components["pill"].set_background(bg_color) + pill_components["label"].configure(background=bg_color, font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold" if is_selected else "normal")) + + def draw(self, parent): + self.parent = parent + + self.settings_wrapper = ttk.Frame(parent) + self._create_sections(self.settings_wrapper) + + self.title = ttk.Label(parent, text="Settings", font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold")) + self.title.configure(background=self.root.style.colors.get("bg")) + self.title.pack(pady=(0, 10), anchor=ttk.W) + + self.pages_wrapper = RoundedFrame(parent, radius=15, bootstyle="dark.TFrame") + self.pages_wrapper.pack(anchor=ttk.W) - session_spoofing = SessionSpoofingPanel(self.root, parent, self.images, self.cfg).draw() - session_spoofing.pack(fill=ttk.BOTH, expand=True, pady=(0, 15)) + self._create_pill(self.pages_wrapper, "General", 0, lambda: self.toggle("general")) + self._create_pill(self.pages_wrapper, "Theming", 1, lambda: self.toggle("theming")) + # self._create_pill(self.pages_wrapper, "APIs", 2, lambda: self.toggle("apis")) + # self._create_pill(self.pages_wrapper, "Session Spoofing", 3, lambda: self.toggle("session_spoofing")) + self._create_pill(self.pages_wrapper, "Rich Presence", 4, lambda: self.toggle("rich_presence")) + self._create_pill(self.pages_wrapper, "Snipers", 5, lambda: self.toggle("snipers")) - rpc = RichPresencePanel(self.root, parent, self.images, self.cfg).draw() - rpc.pack(fill=ttk.BOTH, expand=True, pady=(0, 15)) + # ------- - snipers = SnipersPanel(self.root, parent, self.images, self.cfg).draw() - snipers.pack(fill=ttk.BOTH, expand=True) \ No newline at end of file + self.settings_wrapper.pack(fill=ttk.BOTH, expand=True, pady=(10, 0)) + self.pages[self.current_page].pack(fill=ttk.BOTH, expand=True, pady=(0, 10)) + self.toggle(self.current_page) \ No newline at end of file diff --git a/gui/pages/tools/__init__.py b/gui/pages/tools/__init__.py index 5496ec9..8169670 100644 --- a/gui/pages/tools/__init__.py +++ b/gui/pages/tools/__init__.py @@ -1,4 +1,4 @@ -from .spypet_page import SpyPetPage +from .surveillance_page import SurveillancePage from .tools import ToolsPage from .message_logger_page import MessageLoggerPage from .user_lookup_page import UserLookupPage \ No newline at end of file diff --git a/gui/pages/tools/message_logger_page.py b/gui/pages/tools/message_logger_page.py index 04e4aca..37a6892 100644 --- a/gui/pages/tools/message_logger_page.py +++ b/gui/pages/tools/message_logger_page.py @@ -5,6 +5,7 @@ from gui.components import RoundedFrame, ToolPage from gui.components.tools.message_log_entry import MessageLogEntry from gui.helpers import Images +from gui.helpers.style import Style from utils.config import VERSION, CHANGELOG, MOTD, Config class MessageLoggerPage(ToolPage): @@ -23,12 +24,17 @@ def __init__(self, toolspage, root, bot_controller, images, layout): def draw_navigation(self, parent): wrapper = ttk.Frame(parent) - back_button = ttk.Label(wrapper, image=self.images.get("left-chevron")) + tools_label = ttk.Label(wrapper, text="Tools", font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold"), foreground=Style.LIGHT_GREY.value) + tools_label.grid(row=0, column=0, sticky=ttk.W) + tools_label.bind("", lambda e: self.go_back()) + + back_button = ttk.Label(wrapper, image=self.images.get("right-chevron-small")) back_button.bind("", lambda e: self.go_back()) - back_button.grid(row=0, column=1, sticky=ttk.W, padx=(0, 10)) + back_button.grid(row=0, column=1, sticky=ttk.W, padx=(10, 10)) - page_name = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 16, "bold")) + page_name = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold")) page_name.grid(row=0, column=2, sticky=ttk.W) + page_name.bind("", lambda e: self.go_back()) clear_btn = ttk.Label(wrapper, image=self.images.get("trash")) clear_btn.configure(foreground="white") diff --git a/gui/pages/tools/spypet_page.py b/gui/pages/tools/surveillance_page.py similarity index 88% rename from gui/pages/tools/spypet_page.py rename to gui/pages/tools/surveillance_page.py index 1f0a035..d4a6ef6 100644 --- a/gui/pages/tools/spypet_page.py +++ b/gui/pages/tools/surveillance_page.py @@ -4,10 +4,11 @@ from ttkbootstrap.tableview import Tableview from gui.components import ToolPage, RoundedFrame, RoundedButton from utils.console import get_formatted_time +from gui.helpers.style import Style -class SpyPetPage(ToolPage): +class SurveillancePage(ToolPage): def __init__(self, toolspage, root, bot_controller, images, layout): - super().__init__(toolspage, root, bot_controller, images, layout, title="ghetto spy.pet", frame=None) + super().__init__(toolspage, root, bot_controller, images, layout, title="Surveillance", frame=None) self.wrapper = None self.search_entry = None # Initialize search entry to None self.user_id = None @@ -15,7 +16,7 @@ def __init__(self, toolspage, root, bot_controller, images, layout): self.user_avatar = None self.user_wrapper = None self.mutual_guilds = [] - self.spypet = self.bot_controller.spypet + self.surveillance = self.bot_controller.surveillance self.log_wrapper = None self.search_placeholder_text = "Search a Discord user ID..." self.console = [] @@ -56,15 +57,15 @@ def _get_user(self, user_id): self.add_log("error", "Invalid user ID. Please enter a valid Discord user ID.") return - if user_id == self.user_id or self.spypet.member_id == user_id: + if user_id == self.user_id or self.surveillance.member_id == user_id: self.add_log("info", "You are already viewing this user.") return else: self.add_log("warning", f"Switching to user ID: {user_id}") - self._reset_spypet() + self._reset_surveillance() self.user_id = user_id - self.spypet.set_member_id(user_id) + self.surveillance.set_member_id(user_id) user = self.bot_controller.get_user_from_id(int(user_id)) self.user = user @@ -88,8 +89,8 @@ def _configure_start_stop_button(self, running): except Exception as e: print(f"Error configuring start/stop button: {e}") - def _check_spypet_running(self): - if self.spypet.running: + def _check_surveillance_running(self): + if self.surveillance.running: self.search_placeholder_text = "Search for a message..." self.messages_textarea_updating = True self.root.after(50, lambda: self._disable_reset_button()) @@ -106,11 +107,11 @@ def _check_spypet_running(self): if self.search_button.winfo_ismapped(): self.search_button.grid_forget() - if not self.spypet.running and len(self.messages_all) > 0: + if not self.surveillance.running and len(self.messages_all) > 0: self.search_placeholder_text = "Search for a message..." if not self.search_button.winfo_ismapped(): self.search_button.grid(row=0, column=2, sticky=ttk.E, padx=(0, 10), pady=10) - # elif self.spypet.running and len(self.messages_all) > 0: + # elif self.surveillance.running and len(self.messages_all) > 0: # self.search_placeholder_text = "Search for a message..." try: @@ -141,14 +142,14 @@ def _enable_reset_button(self): except Exception as e: print(f"Error enabling reset button: {e}") - def _toggle_spypet(self): + def _toggle_surveillance(self): search_text = self.search_entry.get().strip() - if not self.spypet.running: - if self.user and int(self.user.id) == int(self.spypet.member_id): - self.bot_controller.start_spypet() + if not self.surveillance.running: + if self.user and int(self.user.id) == int(self.surveillance.member_id): + self.bot_controller.start_surveillance() elif not search_text or search_text == self.search_placeholder_text: - self.add_log("error", "Please enter a valid Discord user ID to start SpyPet.") + self.add_log("error", "Please enter a valid Discord user ID to start Surveillance.") return else: self._get_user(search_text) @@ -156,53 +157,53 @@ def _toggle_spypet(self): self.add_log("error", "Failed to fetch user. Please check the user ID.") return - self.bot_controller.start_spypet() - self.add_log("info", f"SpyPet started for user ID: {self.user_id}") + self.bot_controller.start_surveillance() + self.add_log("info", f"Surveillance started for user ID: {self.user_id}") self.messages_textarea_updating = True self.root.after(50, lambda: self.update_messages()) else: - self.bot_controller.stop_spypet() - self.add_log("info", "SpyPet stopped.") + self.bot_controller.stop_surveillance() + self.add_log("info", "Surveillance stopped.") - self._check_spypet_running() + self._check_surveillance_running() - def _reset_spypet(self): - if self.spypet.running: - self.add_log("warning", "You cannot reset SpyPet while it is running. Please stop it first.") + def _reset_surveillance(self): + if self.surveillance.running: + self.add_log("warning", "You cannot reset Surveillance while it is running. Please stop it first.") return - print("Resetting SpyPet...") + print("Resetting Surveillance...") self.clear() - self.spypet.reset() + self.surveillance.reset() self.user_id = None self.user = None self.user_avatar = None self.mutual_guilds = [] self._redraw_user_wrapper() self.clear_messages() - self._check_spypet_running() + self._check_surveillance_running() self._update_progress_labels(0, 0) self.root.after(150, self._disable_reset_button) def _draw_start_stop_button(self, parent): def _hover_enter(_): - wrapper.set_background(background="#322bef" if not self.spypet.running else "#de2d1b") - self.start_stop_button.configure(background="#322bef" if not self.spypet.running else "#de2d1b") + wrapper.set_background(background=Style.PRIMARY_BTN_HOVER.value if not self.surveillance.running else "#de2d1b") + self.start_stop_button.configure(background=Style.PRIMARY_BTN_HOVER.value if not self.surveillance.running else "#de2d1b") def _hover_leave(_): - wrapper.set_background(background=self.root.style.colors.get("primary") if not self.spypet.running else self.root.style.colors.get("danger")) - self.start_stop_button.configure(background=self.root.style.colors.get("primary") if not self.spypet.running else self.root.style.colors.get("danger")) + wrapper.set_background(background=self.root.style.colors.get("primary") if not self.surveillance.running else self.root.style.colors.get("danger")) + self.start_stop_button.configure(background=self.root.style.colors.get("primary") if not self.surveillance.running else self.root.style.colors.get("danger")) - wrapper = RoundedFrame(parent, radius=(10, 10, 10, 10), bootstyle="primary" if not self.spypet.running else "danger") + wrapper = RoundedFrame(parent, radius=(10, 10, 10, 10), bootstyle="primary" if not self.surveillance.running else "danger") wrapper.bind("", _hover_enter) wrapper.bind("", _hover_leave) - wrapper.bind("", lambda e: self._toggle_spypet()) + wrapper.bind("", lambda e: self._toggle_surveillance()) self.start_stop_button = ttk.Label(wrapper, image=self.images.get("play"), style="primary") self.start_stop_button.configure(background=self.root.style.colors.get("primary")) self.start_stop_button.pack(side=ttk.LEFT, padx=15, pady=14) - self.start_stop_button.bind("", lambda e: self._toggle_spypet()) + self.start_stop_button.bind("", lambda e: self._toggle_surveillance()) self.start_stop_button.bind("", _hover_enter) self.start_stop_button.bind("", _hover_leave) @@ -211,7 +212,7 @@ def _hover_leave(_): def _draw_reset_button(self, parent): def _reset(_): if not self.reset_button_disabled: - self._reset_spypet() + self._reset_surveillance() def _hover_enter(_): self.reset_button_wrapper.set_background(background="#de2d1b" if not self.reset_button_disabled else self.root.style.colors.get("dark")) @@ -297,14 +298,14 @@ def update(self): self.textarea.yview_moveto(1) except: - print("SpyPet console tried to update without being drawn.") + print("Surveillance console tried to update without being drawn.") def clear(self): self.console = [] try: self.textarea.delete("1.0", "end") except: - print("SpyPet console tried to clear without being drawn.") + print("Surveillance console tried to clear without being drawn.") def add_log(self, prefix, text): time = get_formatted_time() @@ -312,8 +313,8 @@ def add_log(self, prefix, text): self.update() def _load_tags(self): - self.textarea.tag_config("timestamp", foreground="gray") - self.textarea.tag_config("log_text", foreground="lightgrey") + self.textarea.tag_config("timestamp", foreground=Style.DARK_GREY.value, font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) + self.textarea.tag_config("log_text", foreground=Style.LIGHT_GREY.value, font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) self.textarea.tag_config("prefix_sniper", foreground="red", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) self.textarea.tag_config("sniper_key", foreground="#eceb18", font=("JetBrainsMono NF Bold", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size)) @@ -334,7 +335,7 @@ def _draw_log_wrapper(self, parent): self.textarea.config( border=0, background=self.root.style.colors.get("dark"), - foreground="lightgrey", + foreground=Style.LIGHT_GREY.value, highlightcolor=self.root.style.colors.get("dark"), highlightbackground=self.root.style.colors.get("dark"), state="normal" @@ -376,8 +377,8 @@ def _draw_user_wrapper(self, parent): display_name.place(relx=0, rely=0) username = ttk.Label(user_info_wrapper, text=f"{self.user.name}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) - username.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") - username.place(relx=0, rely=0.42) + username.configure(background=self.root.style.colors.get("dark"), foreground=Style.LIGHT_GREY.value) + username.place(relx=0, rely=0.42 if sys.platform == "darwin" else 0.45) if self.mutual_guilds: mutual_guilds_subtitle = ttk.Label(wrapper, text="Mutual Guilds", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14, "bold")) @@ -402,7 +403,7 @@ def _draw_user_wrapper(self, parent): row += 1 else: mutual_guilds_subtitle = ttk.Label(wrapper, text="No Mutual Guilds", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) - mutual_guilds_subtitle.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") + mutual_guilds_subtitle.configure(background=self.root.style.colors.get("dark"), foreground=Style.LIGHT_GREY.value) mutual_guilds_subtitle.place(relx=.7, rely=0.65, relwidth=1, anchor="center") return wrapper @@ -417,7 +418,7 @@ def clear_messages(self): self.messages_textarea_updating = False # Reset placeholder for search bar - # self.search_placeholder_text = "Search a Discord user ID..." if not self.spypet.running else "Search for a message..." + # self.search_placeholder_text = "Search a Discord user ID..." if not self.surveillance.running else "Search for a message..." # if self.search_entry: # self.search_entry.configure(foreground="grey") # self.search_var.set("") # Resets the actual entry content @@ -459,7 +460,7 @@ def _apply_message_filter(self): def update_messages(self): if self.messages_textarea_updating and self.messages_textarea: try: - data = self.spypet.data + data = self.surveillance.data current_yview = self.messages_textarea.yview() at_bottom = current_yview[1] >= 0.999 @@ -506,7 +507,7 @@ def _draw_messages_wrapper(self, parent): self.messages_textarea.config( border=0, background=self.root.style.colors.get("dark"), - foreground="lightgrey", + foreground=Style.LIGHT_GREY.value, highlightcolor=self.root.style.colors.get("dark"), highlightbackground=self.root.style.colors.get("dark"), state="normal" @@ -528,12 +529,12 @@ def _draw_progress_wrapper(self, parent): wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame", custom_size=True) self.total_messages_label = ttk.Label(wrapper, text="Total: 0", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) - self.total_messages_label.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") + self.total_messages_label.configure(background=self.root.style.colors.get("dark"), foreground=Style.LIGHT_GREY.value) self.total_messages_label.place(relx=0.05, rely=0.13, relwidth=1, anchor="nw") self.total_user_messages_label = ttk.Label(wrapper, text="Sent by user: 0", font=("Host Grotesk", 12 if sys.platform != "darwin" else 14)) - self.total_user_messages_label.configure(background=self.root.style.colors.get("dark"), foreground="lightgrey") - self.total_user_messages_label.place(relx=0.05, rely=0.3 + 0.13, relwidth=1, anchor="nw") + self.total_user_messages_label.configure(background=self.root.style.colors.get("dark"), foreground=Style.LIGHT_GREY.value) + self.total_user_messages_label.place(relx=0.05, rely=0.43 if sys.platform == "darwin" else 0.45, relwidth=1, anchor="nw") return wrapper @@ -570,10 +571,10 @@ def draw_content(self, wrapper): self.update() - if self.spypet.running or len(self.messages_all) > 0: - print("SpyPet is running, updating messages...") - self._check_spypet_running() - self._update_progress_labels(self.spypet.total_messages, self.spypet.user_total_messages) + if self.surveillance.running or len(self.messages_all) > 0: + print("Surveillance is running, updating messages...") + self._check_surveillance_running() + self._update_progress_labels(self.surveillance.total_messages, self.surveillance.user_total_messages) if self._disable_reset_button: self._disable_reset_button() diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py index cd166fc..d264228 100644 --- a/gui/pages/tools/tools.py +++ b/gui/pages/tools/tools.py @@ -2,49 +2,52 @@ import ttkbootstrap as ttk from gui.components import RoundedFrame -from gui.pages.tools.spypet_page import SpyPetPage +from gui.pages.tools.surveillance_page import SurveillancePage from gui.pages.tools.message_logger_page import MessageLoggerPage from gui.pages.tools.user_lookup_page import UserLookupPage +from gui.helpers.style import Style class ToolsPage: - def __init__(self, root, bot_controller, images, layout): + def __init__(self, root, bot_controller, images, layout, position_resize_grips): self.root = root self.bot_controller = bot_controller self.images = images self.layout = layout - self.hover_colour = "#282a2a" + self.position_resize_grips = position_resize_grips + self.hover_colour = self.root.style.colors.get("secondary") - self.spypet_page = SpyPetPage(self, root, bot_controller, images, layout) + self.surveillance_page = SurveillancePage(self, root, bot_controller, images, layout) self.message_logger_page = MessageLoggerPage(self, root, bot_controller, images, layout) self.user_lookup_page = UserLookupPage(self, root, bot_controller, images, layout) self.pages = [ { - "name": "ghetto spy.pet", - "description": "A tool to look up every message sent by a user you share mutual servers with.", - "page": self.spypet_page, - "command": self.draw_spypet + "name": "Surveillance", + "description": "Search a user’s message history across mutual servers", + "page": self.surveillance_page, + "command": self.draw_surveillance }, { "name": "Message Logger", - "description": "A tool to log deleted messages from every server you're in.", + "description": "Logs every deleted message sent in your servers", "page": self.message_logger_page, "command": self.draw_message_logger }, - { - "name": "User Lookup", - "description": "A tool to look up a user's information.", - "page": self.user_lookup_page, - "command": self.draw_user_lookup - } + # { + # "name": "User Lookup", + # "description": "Look up information about a user by their ID", + # "page": self.user_lookup_page, + # "command": self.draw_user_lookup + # } ] - def draw_spypet(self): + def draw_surveillance(self): self.layout.sidebar.set_current_page("tools") self.layout.clear() main = self.layout.main() - self.spypet_page.draw(main) - self.layout.sidebar.set_button_command("tools", self.draw_spypet) + self.surveillance_page.draw(main) + self.layout.sidebar.set_button_command("tools", self.draw_surveillance) + self.position_resize_grips() def draw_message_logger(self): self.layout.sidebar.set_current_page("tools") @@ -52,6 +55,7 @@ def draw_message_logger(self): main = self.layout.main() self.message_logger_page.draw(main) self.layout.sidebar.set_button_command("tools", self.draw_message_logger) + self.position_resize_grips() def draw_user_lookup(self): self.layout.sidebar.set_current_page("tools") @@ -59,6 +63,7 @@ def draw_user_lookup(self): main = self.layout.main() self.user_lookup_page.draw(main) self.layout.sidebar.set_button_command("tools", self.draw_user_lookup) + self.position_resize_grips() def _bind_hover_effects(self, widget, targets, hover_bg, normal_bg): def on_enter(_): @@ -78,25 +83,51 @@ def on_leave(_): widget.bind("", on_enter) widget.bind("", on_leave) - def draw(self, parent): - for page in self.pages: - page_wrapper = RoundedFrame(parent, radius=15, bootstyle="secondary.TFrame") - page_wrapper.pack(fill="x", expand=True, pady=(0, 10)) - page_wrapper.bind("", lambda e, cmd=page["command"]: cmd()) + def _draw_page_card(self, parent, page): + page_wrapper = RoundedFrame(parent, radius=15, bootstyle="dark.TFrame") + page_wrapper.bind("", lambda e, cmd=page["command"]: cmd()) - page_title = ttk.Label(page_wrapper, text=page["name"], font=("Host Grotesk", 14 if sys.platform != "darwin" else 20, "bold")) - page_title.configure(background=self.root.style.colors.get("secondary")) - page_title.grid(row=0, column=0, sticky=ttk.NSEW, padx=15, pady=(15, 15)) - page_title.bind("", lambda e, cmd=page["command"]: cmd()) + page_title = ttk.Label(page_wrapper, text=page["name"], font=("Host Grotesk", 14 if sys.platform != "darwin" else 18, "bold"), justify=ttk.CENTER) + page_title.configure(background=self.root.style.colors.get("dark")) + page_title.grid(row=0, column=0, pady=(25, 5)) + page_title.bind("", lambda e, cmd=page["command"]: cmd()) - # page_description = ttk.Label(page_wrapper, text=page["description"], font=("Host Grotesk", 12 if sys.platform != "darwin" else 16), wraplength=450) - # page_description.configure(background=self.root.style.colors.get("secondary")) - # page_description.grid(row=1, column=0, sticky=ttk.NSEW, padx=15, pady=(0, 15)) - # page_description.bind("", lambda e, cmd=page["command"]: cmd()) - - page_icon = ttk.Label(page_wrapper, image=self.images.get("right-chevron")) - page_icon.configure(background=self.root.style.colors.get("secondary")) - page_icon.grid(row=0, column=1, sticky=ttk.E, padx=(0, 20), pady=15) - - page_wrapper.grid_columnconfigure(1, weight=1) - self._bind_hover_effects(page_wrapper, [page_title, page_wrapper, page_icon], self.hover_colour, self.root.style.colors.get("secondary")) \ No newline at end of file + page_description = ttk.Label(page_wrapper, text=page["description"], wraplength=175, justify=ttk.CENTER) + page_description.configure(background=self.root.style.colors.get("dark"), foreground=Style.LIGHT_GREY.value) + page_description.grid(row=1, column=0, pady=(0, 25)) + page_description.bind("", lambda e, cmd=page["command"]: cmd()) + + # page_icon = ttk.Label(page_wrapper, image=self.images.get("right-chevron")) + # page_icon.configure(background=self.root.style.colors.get("dark")) + # page_icon.grid(row=0, column=1, sticky=ttk.E, padx=(0, 20), pady=15) + + page_wrapper.grid_columnconfigure(0, weight=1) + page_wrapper.grid_rowconfigure(0, weight=1) + page_wrapper.grid_rowconfigure(1, weight=1) + self._bind_hover_effects(page_wrapper, [page_title, page_wrapper, page_description], self.hover_colour, self.root.style.colors.get("dark")) + self._bind_hover_effects(page_title, [page_title, page_wrapper, page_description], self.hover_colour, self.root.style.colors.get("dark")) + self._bind_hover_effects(page_description, [page_title, page_wrapper, page_description], self.hover_colour, self.root.style.colors.get("dark")) + # self._bind_hover_effects(page_icon, [page_title, page_wrapper, page_icon], self.hover_colour, self.root.style.colors.get("dark")) + + return page_wrapper + + def draw(self, parent): + title = ttk.Label(parent, text="Tools", font=("Host Grotesk", 20 if sys.platform != "darwin" else 24, "bold")) + title.configure(background=self.root.style.colors.get("bg")) + # title.pack(pady=(0, 15), anchor=ttk.W) + title.grid(row=0, column=0, sticky=ttk.W, pady=(0, 15)) + + parent.grid_columnconfigure(0, weight=1) + parent.grid_columnconfigure(1, weight=1) + + # create a grid for the page cards, two columns + row, col = 1, 0 + for page in self.pages: + card = self._draw_page_card(parent, page) + card.grid(row=row, column=col, sticky=ttk.NSEW, padx=(0, 5) if col == 0 else (5, 0), pady=5) + col += 1 + if col > 1: + col = 0 + row += 1 + + self.position_resize_grips() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2a2c056..aac4015 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ pillow pystyle psutil pycryptodome -ttkbootstrap +ttkbootstrap==1.14.2 pyinstaller # git+https://github.com/tomlin7/cupcake.git cupcake-editor diff --git a/utils/config/__init__.py b/utils/config/__init__.py index 7d99cf3..9b55f07 100644 --- a/utils/config/__init__.py +++ b/utils/config/__init__.py @@ -4,7 +4,7 @@ from .config import Config from .token import Token -VERSION = "4.1.0-dev" -PRODUCTION = False +VERSION = "4.1.0" +PRODUCTION = True MOTD = "gotta love tkinter ;)" CHANGELOG = """""" \ No newline at end of file diff --git a/utils/startup_check.py b/utils/startup_check.py index 6859ec6..52d02db 100644 --- a/utils/startup_check.py +++ b/utils/startup_check.py @@ -12,7 +12,7 @@ "data", "scripts", "themes", - "data/spypet" + "data/surveillance" ] REQUIRED_FILES = {