From 8b639b57c525182527196db7d4b566ed981f89ef Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:57:14 +0100 Subject: [PATCH 1/5] Add default AFK settings to config --- utils/defaults.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/utils/defaults.py b/utils/defaults.py index 646ceff..b455170 100644 --- a/utils/defaults.py +++ b/utils/defaults.py @@ -13,6 +13,12 @@ "name": "Ghost" } +DEFAULT_AFK = { + "afk_response": "I'm away from the keyboard right now, I'll get back to you as soon as I can.", + "afk_times": [8, 18], + "enabled": False +} + DEFAULT_CONFIG = { "token": "", "prefix": ".", @@ -48,6 +54,7 @@ "gui_theme": "dark", "telemetry": True, "install_id": "", + "afk": DEFAULT_AFK } DEFAULT_THEME = { From 9dc70a88e1c14796a26dadd2d8388bbd65f5901a Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:57:35 +0100 Subject: [PATCH 2/5] Add auto afk reply cog --- bot/bot.py | 2 +- bot/events/__init__.py | 3 ++- bot/events/afk_reply.py | 49 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 bot/events/afk_reply.py diff --git a/bot/bot.py b/bot/bot.py index 93b6b91..e239117 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -201,7 +201,7 @@ async def load_cogs(self): ghost_commands.Account, ghost_commands.Fun, ghost_commands.General, ghost_commands.Img, ghost_commands.Info, ghost_commands.Mod, ghost_commands.Nsfw, ghost_commands.Text, ghost_commands.Theming, ghost_commands.Util, ghost_commands.Abuse, ghost_commands.Sniper, - ghost_events.NitroSniper, ghost_events.PrivnoteSniper + ghost_events.NitroSniper, ghost_events.PrivnoteSniper, ghost_events.AFKReply ] for cog in cogs: diff --git a/bot/events/__init__.py b/bot/events/__init__.py index 6900588..5b012db 100644 --- a/bot/events/__init__.py +++ b/bot/events/__init__.py @@ -1,2 +1,3 @@ from .nitro_sniper import NitroSniper -from .privnote_sniper import PrivnoteSniper \ No newline at end of file +from .privnote_sniper import PrivnoteSniper +from .afk_reply import AFKReply \ No newline at end of file diff --git a/bot/events/afk_reply.py b/bot/events/afk_reply.py new file mode 100644 index 0000000..475d082 --- /dev/null +++ b/bot/events/afk_reply.py @@ -0,0 +1,49 @@ +import discord +import datetime +import asyncio +import random + +from discord.ext import commands, tasks + +from utils import config +from utils import console + +class AFKReply(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.cfg = config.Config() + self.already_replied_users = set() + self.reset_already_replied_users.start() + + async def handle_afk_message(self, message: discord.Message): + afk_cfg = self.cfg.get("afk") if self.cfg.get("afk") else {"afk_response": "I'm away from the keyboard right now, I'll get back to you as soon as I can.", "afk_times": [8, 18], "enabled": False} + + current_hour = datetime.datetime.now().hour + afk_times = afk_cfg["afk_times"] + afk_response = afk_cfg["afk_response"] + in_afk_time = any(start <= current_hour < end for (start, end) in [(afk_times[0], afk_times[1]), (0, 6)]) + + if isinstance(message.channel, discord.DMChannel) and not message.author.bot and message.author.id != self.bot.user.id and in_afk_time: + if message.author.id not in self.already_replied_users: + self.already_replied_users.add(message.author.id) + console.info(f"DM from {message.author}") + console.info(f"'{message.content}'") + + await asyncio.sleep(random.randint(1, 3)) # Add a small delay before replying to simulate human behavior + async with message.channel.typing(): + await asyncio.sleep(len(afk_response) * 0.01) # Simulate typing delay + + await message.reply(afk_response) + console.info(f"Auto-replied to DM from {message.author}.") + + @tasks.loop(hours=24) + async def reset_already_replied_users(self): + self.already_replied_users.clear() + console.info("Reset the already_replied_users set.") + + @commands.Cog.listener() + async def on_message(self, message): + await self.handle_afk_message(message) + +def setup(bot): + bot.add_cog(AFKReply(bot)) \ No newline at end of file From 75011185c06c9bf790adb1f0d070ea30c9334b8f Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:38:45 +0100 Subject: [PATCH 3/5] Fix afk reply not using enabled value --- bot/events/afk_reply.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/events/afk_reply.py b/bot/events/afk_reply.py index 475d082..86c0825 100644 --- a/bot/events/afk_reply.py +++ b/bot/events/afk_reply.py @@ -18,6 +18,9 @@ def __init__(self, bot): async def handle_afk_message(self, message: discord.Message): afk_cfg = self.cfg.get("afk") if self.cfg.get("afk") else {"afk_response": "I'm away from the keyboard right now, I'll get back to you as soon as I can.", "afk_times": [8, 18], "enabled": False} + if not afk_cfg["enabled"]: + return + current_hour = datetime.datetime.now().hour afk_times = afk_cfg["afk_times"] afk_response = afk_cfg["afk_response"] From 80e32f7eeb35cc8da2cb5cdc0904bb441247042b Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:08:56 +0100 Subject: [PATCH 4/5] Add settings/tools page for afk settings --- gui/pages/tools/__init__.py | 3 +- gui/pages/tools/auto_afk_reply_page.py | 207 +++++++++++++++++++++++++ gui/pages/tools/tools.py | 16 ++ 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 gui/pages/tools/auto_afk_reply_page.py diff --git a/gui/pages/tools/__init__.py b/gui/pages/tools/__init__.py index dfd7526..94c44f9 100644 --- a/gui/pages/tools/__init__.py +++ b/gui/pages/tools/__init__.py @@ -2,4 +2,5 @@ from .tools import ToolsPage from .message_logger_page import MessageLoggerPage from .user_lookup_page import UserLookupPage -from .password_gen import PasswordGenPage \ No newline at end of file +from .password_gen import PasswordGenPage +from .auto_afk_reply_page import AutoAFKReplyPage \ No newline at end of file diff --git a/gui/pages/tools/auto_afk_reply_page.py b/gui/pages/tools/auto_afk_reply_page.py new file mode 100644 index 0000000..9f8dae7 --- /dev/null +++ b/gui/pages/tools/auto_afk_reply_page.py @@ -0,0 +1,207 @@ +import ttkbootstrap as ttk + +from gui.helpers.style import Style +from gui.components import RoundedFrame, ToolPage, RoundedButton, RoundedSwitch, DropdownMenu +from utils.config import Config + +class AutoAFKReplyPage(ToolPage): + def __init__(self, toolspage, root, bot_controller, images, layout): + super().__init__(toolspage, root, bot_controller, images, layout, title="Auto AFK Reply") + self.cfg = Config() + self.afk_cfg = self.cfg.get("afk") if self.cfg.get("afk") else {"afk_response": "I'm away from the keyboard right now, I'll get back to you as soon as I can.", "afk_times": [8, 18], "enabled": False} + self.afk_response_entry = None + self.afk_start_entry = None + self.afk_end_entry = None + self.afk_start_period_menu = None + self.afk_end_period_menu = None + self.auto_toggle_switch = None + + def _validate_afk_hour(self, value): + if value == "": + return True + + if not value.isdigit(): + return False + + return 1 <= int(value) <= 12 + + def _hour_to_display(self, hour): + hour = int(hour) % 24 + period = "AM" if hour < 12 else "PM" + display_hour = hour % 12 + + if display_hour == 0: + display_hour = 12 + + return display_hour, period + + def _display_to_hour(self, hour_value, period_value, fallback): + try: + hour = int(hour_value) + except (TypeError, ValueError): + return fallback + + if hour < 1 or hour > 12: + return fallback + + if period_value == "AM": + return 0 if hour == 12 else hour + + if period_value == "PM": + return 12 if hour == 12 else hour + 12 + + return fallback + + def _get_afk_times(self): + afk_times = self.afk_cfg.get("afk_times", [8, 18]) + + if isinstance(afk_times, dict): + return afk_times.get("start", 8), afk_times.get("end", 18) + + if isinstance(afk_times, (list, tuple)) and len(afk_times) >= 2: + return afk_times[0], afk_times[1] + + return 8, 18 + + def _draw_time_selector(self, parent, selected_hour, selected_period): + wrapper = RoundedFrame(parent, radius=(10, 10, 10, 10), background=Style.SETTINGS_PILL_HOVER.value, parent_background=self.root.style.colors.get("secondary")) + wrapper.grid_columnconfigure(1, weight=1, minsize=82) + validate_command = (self.root.register(self._validate_afk_hour), "%P") + + entry = ttk.Entry(wrapper, width=3, font=("Host Grotesk", 12)) + entry.configure(foreground=self.root.style.colors.get("fg"), validate="key", validatecommand=validate_command) + entry.insert(0, str(selected_hour)) + entry.grid(row=0, column=0, sticky=ttk.W, padx=(5, 0), pady=5) + entry.bind("", lambda event: self._save_cfg()) + entry.bind("", lambda event: self._save_cfg()) + + menu = DropdownMenu(wrapper, options=["AM", "PM"], command=lambda _option: self._save_cfg()) + menu.set_selected(selected_period) + menu.draw().grid(row=0, column=1, sticky=ttk.EW, padx=(0, 5), pady=5) + + return wrapper, entry, menu + + def draw_navigation(self, parent): + wrapper = ttk.Frame(parent) + + tools_label = ttk.Label(wrapper, text="Tools", font=("Host Grotesk", 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=(10, 10)) + + page_name = ttk.Label(wrapper, text=self.title, font=("Host Grotesk", 24, "bold")) + page_name.grid(row=0, column=2, sticky=ttk.W) + page_name.bind("", lambda e: self.go_back()) + + wrapper.grid_columnconfigure(2, weight=1) + + return wrapper + + def _save_cfg(self): + self.afk_cfg["enabled"] = self.auto_toggle_switch.variable.get() + self.afk_cfg["afk_response"] = self.afk_response_entry.get() + + start_fallback, end_fallback = self._get_afk_times() + self.afk_cfg["afk_times"] = [ + self._display_to_hour(self.afk_start_entry.get(), self.afk_start_period_menu.value(), start_fallback), + self._display_to_hour(self.afk_end_entry.get(), self.afk_end_period_menu.value(), end_fallback), + ] + + self.cfg.set("afk", self.afk_cfg, save=False) + self.cfg.save() + + def draw_content(self, wrapper): + content_wrapper = RoundedFrame(wrapper, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") + content_wrapper.pack(fill=ttk.BOTH, expand=True, padx=20, pady=20) + + # indefinite_toggle_wrapper = RoundedFrame(content_wrapper, radius=(10, 10, 10, 10), bootstyle="secondary.TFrame") + # indefinite_toggle_wrapper.pack(fill=ttk.X, pady=(0, 10)) + # indefinite_toggle_wrapper.bind("", lambda e: self.indefinite_toggle_switch.invoke()) + # indefinite_toggle_wrapper.grid_columnconfigure(0, weight=1) + + # indefinite_toggle_label = ttk.Label(indefinite_toggle_wrapper, text="Enable AFK replies indefinitely", font=("Host Grotesk", 16, "bold")) + # indefinite_toggle_label.configure(background=self.root.style.colors.get("secondary")) + # indefinite_toggle_label.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=10) + # indefinite_toggle_label.bind("", lambda e: self.indefinite_toggle_switch.invoke()) + + # self.indefinite_toggle_switch = RoundedSwitch(indefinite_toggle_wrapper, variable=ttk.BooleanVar(value=self.afk_cfg.get("enabled", False))) + # self.indefinite_toggle_switch.grid(row=0, column=1, sticky=ttk.E, padx=(0, 10), pady=10) + # self.indefinite_toggle_switch.configure(command=self._save_cfg) + + auto_toggle_wrapper = RoundedFrame(content_wrapper, radius=(10, 10, 10, 10), bootstyle="secondary.TFrame") + auto_toggle_wrapper.pack(fill=ttk.X, pady=(0, 10)) + auto_toggle_wrapper.bind("", lambda e: self.auto_toggle_switch.invoke()) + auto_toggle_wrapper.grid_columnconfigure(0, weight=1) + + auto_toggle_label = ttk.Label(auto_toggle_wrapper, text="Enable automatic AFK replies", font=("Host Grotesk", 16, "bold")) + auto_toggle_label.configure(background=self.root.style.colors.get("secondary")) + auto_toggle_label.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=10) + auto_toggle_label.bind("", lambda e: self.auto_toggle_switch.invoke()) + + self.auto_toggle_switch = RoundedSwitch(auto_toggle_wrapper, variable=ttk.BooleanVar(value=self.afk_cfg.get("enabled", False))) + self.auto_toggle_switch.grid(row=0, column=1, sticky=ttk.E, padx=(0, 10), pady=10) + self.auto_toggle_switch.configure(command=self._save_cfg) + + if self.afk_cfg["enabled"]: + self.auto_toggle_switch.state(["!alternate", "selected"]) + else: + self.auto_toggle_switch.state(["!alternate", "!selected"]) + + afk_response_wrapper = RoundedFrame(content_wrapper, style="secondary.TFrame", radius=(10, 10, 10, 10)) + afk_response_wrapper.pack(fill=ttk.X, pady=(0, 10)) + afk_response_wrapper.grid_columnconfigure(0, weight=1) + + response_label = ttk.Label(afk_response_wrapper, text="AFK Response Message", font=("Host Grotesk", 16, "bold")) + response_label.configure(background=self.root.style.colors.get("secondary")) + response_label.grid(row=0, column=0, sticky=ttk.W, padx=10, pady=(10, 5)) + + response_desc = ttk.Label(afk_response_wrapper, text="This message will be sent automatically when someone DMs you while you're away.") + response_desc.configure(background=self.root.style.colors.get("secondary"), foreground=Style.LIGHT_GREY.value) + response_desc.grid(row=1, column=0, sticky=ttk.W, padx=10, pady=(0, 5)) + + self.afk_response_entry = ttk.Entry(afk_response_wrapper, font=("Host Grotesk", 12)) + self.afk_response_entry.configure(foreground=self.root.style.colors.get("fg")) + self.afk_response_entry.insert(0, self.afk_cfg["afk_response"]) + self.afk_response_entry.grid(row=2, column=0, sticky="we", padx=10, pady=(5, 10)) + self.afk_response_entry.bind("", lambda event: self._save_cfg()) + + + afk_time_wrapper = RoundedFrame(content_wrapper, style="secondary.TFrame", radius=(10, 10, 10, 10)) + afk_time_wrapper.pack(fill=ttk.X, pady=(0, 10)) + afk_time_wrapper.grid_columnconfigure(0, weight=1) + afk_time_wrapper.grid_columnconfigure(2, weight=1) + + afk_time_label = ttk.Label(afk_time_wrapper, text="AFK Time Range", font=("Host Grotesk", 16, "bold")) + afk_time_label.configure(background=self.root.style.colors.get("secondary")) + afk_time_label.grid(row=0, column=0, columnspan=3, sticky=ttk.W, padx=10, pady=(10, 5)) + + afk_time_desc = ttk.Label(afk_time_wrapper, text="Set your start and end times for when you're usually AFK.") + afk_time_desc.configure(background=self.root.style.colors.get("secondary"), foreground=Style.LIGHT_GREY.value) + afk_time_desc.grid(row=1, column=0, columnspan=3, sticky=ttk.W, padx=10, pady=(0, 5)) + + start_hour, end_hour = self._get_afk_times() + start_display_hour, start_period = self._hour_to_display(start_hour) + end_display_hour, end_period = self._hour_to_display(end_hour) + + start_wrapper, self.afk_start_entry, self.afk_start_period_menu = self._draw_time_selector( + afk_time_wrapper, + start_display_hour, + start_period, + ) + start_wrapper.grid(row=2, column=0, sticky=ttk.W, padx=10, pady=10) + + to_label = ttk.Label(afk_time_wrapper, text="to") + to_label.configure(background=self.root.style.colors.get("secondary")) + to_label.grid(row=2, column=1, sticky=ttk.NS, pady=10) + + end_wrapper, self.afk_end_entry, self.afk_end_period_menu = self._draw_time_selector( + afk_time_wrapper, + end_display_hour, + end_period, + ) + end_wrapper.grid(row=2, column=2, sticky=ttk.E, padx=10, pady=10) + + \ No newline at end of file diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py index ae74649..3c8cb1e 100644 --- a/gui/pages/tools/tools.py +++ b/gui/pages/tools/tools.py @@ -6,6 +6,7 @@ from gui.pages.tools.message_logger_page import MessageLoggerPage from gui.pages.tools.user_lookup_page import UserLookupPage from gui.pages.tools.password_gen import PasswordGenPage +from gui.pages.tools.auto_afk_reply_page import AutoAFKReplyPage from gui.helpers.style import Style class ToolsPage: @@ -21,6 +22,7 @@ def __init__(self, root, bot_controller, images, layout, position_resize_grips): self.message_logger_page = MessageLoggerPage(self, root, bot_controller, images, layout) self.user_lookup_page = UserLookupPage(self, root, bot_controller, images, layout) self.password_gen_page = PasswordGenPage(self, root, bot_controller, images, layout) + self.auto_afk_reply_page = AutoAFKReplyPage(self, root, bot_controller, images, layout) self.pages = [ { @@ -40,6 +42,12 @@ def __init__(self, root, bot_controller, images, layout, position_resize_grips): "description": "Generate strong, random passwords with customizable options", "page": self.password_gen_page, "command": self.draw_password_gen + }, + { + "name": "Auto AFK Reply", + "description": "Automatically reply to DMs when you're away from the keyboard", + "page": self.auto_afk_reply_page, + "command": self.draw_auto_afk_reply } # { # "name": "User Lookup", @@ -81,6 +89,14 @@ def draw_user_lookup(self): self.layout.sidebar.set_button_command("tools", self.draw_user_lookup) self.position_resize_grips() + def draw_auto_afk_reply(self): + self.layout.sidebar.set_current_page("tools") + self.layout.clear() + main = self.layout.main() + self.auto_afk_reply_page.draw(main) + self.layout.sidebar.set_button_command("tools", self.draw_auto_afk_reply) + self.position_resize_grips() + def _bind_hover_effects(self, widget, targets, hover_bg, normal_bg): def on_enter(_): for target in targets: From 364ae9431deb7f5252fb2e7baa194d5339bcb03f Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:09:30 +0100 Subject: [PATCH 5/5] Improve dropdown menu --- gui/components/dropdown_menu.py | 222 ++++++++++++++++++++++++++------ 1 file changed, 180 insertions(+), 42 deletions(-) diff --git a/gui/components/dropdown_menu.py b/gui/components/dropdown_menu.py index 18e6ab4..d798f5c 100644 --- a/gui/components/dropdown_menu.py +++ b/gui/components/dropdown_menu.py @@ -1,4 +1,6 @@ +import tkinter as tk import ttkbootstrap as ttk + from gui.components import RoundedFrame from gui.helpers.style import Style @@ -9,6 +11,13 @@ def __init__(self, parent, options, command=None): self.selected_option = ttk.StringVar(value=options[0] if options else "") self.command = command self.style = None + self.overlay = None + self.overlay_canvas = None + self.overlay_items_frame = None + self.overlay_canvas_window = None + self._reposition_after_id = None + self._mousewheel_bound = False + self._outside_click_bound = False # see if parent has root attribute, if so use it to get the style, otherwise get the style from the parent itself if hasattr(parent, "root") and parent.root and hasattr(parent.root, "style"): @@ -28,49 +37,182 @@ def _hover_leave(self, wrapper, label): 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(): + if selected in self.options: + self.options.remove(selected) + self.options.insert(0, selected) + + def _close_overlay(self): + if self._reposition_after_id is not None: + try: + self.parent.winfo_toplevel().after_cancel(self._reposition_after_id) + except Exception: + pass + self._reposition_after_id = None + + if self.overlay is not None and self.overlay.winfo_exists(): + self.overlay.destroy() + + self.overlay = None + self.overlay_canvas = None + self.overlay_items_frame = None + self.overlay_canvas_window = None + + def _reposition_overlay(self): + if self.overlay is None or not self.overlay.winfo_exists() or not self._alive(): + self._reposition_after_id = None return - + + root = self.parent.winfo_toplevel() + popup_width = max(self.frame.winfo_width(), 96) + x = self.frame.winfo_rootx() - root.winfo_rootx() + y = self.frame.winfo_rooty() - root.winfo_rooty() + self.frame.winfo_height() + + self.overlay.place_configure(x=x, y=y, width=popup_width) + self._reposition_after_id = root.after(16, self._reposition_overlay) + + def _scroll_overlay(self, delta): + if self.overlay_canvas is None or not self.overlay_canvas.winfo_exists(): + return + + self.overlay_canvas.yview_scroll(delta, "units") + + def _event_in_overlay(self, widget): + current = widget + while current is not None: + if current is self.overlay: + return True + current = getattr(current, "master", None) + return False + + def _on_mousewheel(self, event): + if self.overlay is None or not self.overlay.winfo_exists(): + return + + if not self._event_in_overlay(event.widget): + return + + if event.num == 4: + delta = -1 + elif event.num == 5: + delta = 1 + else: + delta = -1 if event.delta > 0 else 1 + + self._scroll_overlay(delta) + + def _bind_overlay_scroll(self): + if self._mousewheel_bound: + return + + root = self.parent.winfo_toplevel() + root.bind_all("", self._on_mousewheel, add="+") + root.bind_all("", self._on_mousewheel, add="+") + root.bind_all("", self._on_mousewheel, add="+") + self._mousewheel_bound = True + + def _render_closed_menu(self): 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)) + + 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._toggle_menu) + self.frame.bind("", self._toggle_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._toggle_menu) + + def _build_overlay(self): + self._close_overlay() + + root = self.parent.winfo_toplevel() + self.overlay = RoundedFrame( + root, + radius=8, + background=self.parent.style.colors.get("secondary"), + parent_background=self.parent.style.colors.get("secondary"), + ) + + self.overlay_canvas = tk.Canvas(self.overlay, highlightthickness=0, bd=0, background=self.parent.style.colors.get("secondary")) + self.overlay_canvas.pack(fill=ttk.BOTH, expand=True) + + scrollbar = ttk.Scrollbar(self.overlay, orient="vertical", command=self.overlay_canvas.yview) + scrollbar.place(relx=1.0, rely=0, relheight=1.0, x=-2, y=0, anchor="ne") + self.overlay_canvas.configure(yscrollcommand=scrollbar.set) + + self.overlay_items_frame = ttk.Frame(self.overlay_canvas, style="TFrame") + self.overlay_canvas_window = self.overlay_canvas.create_window((0, 0), window=self.overlay_items_frame, anchor="nw") + + def _sync_scrollregion(_event=None): + self.overlay_canvas.configure(scrollregion=self.overlay_canvas.bbox("all")) + + def _sync_width(event): + self.overlay_canvas.itemconfigure(self.overlay_canvas_window, width=event.width) + + self.overlay_items_frame.bind("", _sync_scrollregion) + self.overlay_canvas.bind("", _sync_width) + + max_visible_height = 220 + popup_width = max(self.frame.winfo_width(), 96) + + for index, option in enumerate(self.options): + wrapper = RoundedFrame(self.overlay_items_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) - 1 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)) + + self.overlay.update_idletasks() + content_height = self.overlay_items_frame.winfo_reqheight() + popup_height = min(content_height, max_visible_height) + self.overlay_canvas.configure(height=popup_height) + + x = self.frame.winfo_rootx() - root.winfo_rootx() + y = self.frame.winfo_rooty() - root.winfo_rooty() + self.frame.winfo_height() + self.overlay.place(x=x, y=y, width=popup_width, height=popup_height) + self.overlay.lift() + self._reposition_overlay() + self._bind_overlay_scroll() + + def _bind_outside_click(self): + if self._outside_click_bound: + return + + self.parent.winfo_toplevel().bind_all("", self._outside_click, add="+") + self._outside_click_bound = True - def _close_menu(self): + def _open_menu(self, event): 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._rearrange_options() + self._build_overlay() + self._bind_outside_click() + + def _toggle_menu(self, event): + if not self._alive(): + return + + if self.overlay is not None and self.overlay.winfo_exists(): + self._close_menu() + return + + self._open_menu(event) - 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 _close_menu(self): + if not self._alive(): + return + + self._close_overlay() + self._render_closed_menu() def _on_option_selected(self, option): if not self._alive(): @@ -80,27 +222,22 @@ def _on_option_selected(self, option): if self.command: self.command(option) - if self._alive(): - self._close_menu() + self._close_menu() def _outside_click(self, event): - if not self._alive(): + if not self._alive() or self.overlay is None or not self.overlay.winfo_exists(): return - if not self.frame.winfo_containing(event.x_root, event.y_root): - self._close_menu() + if self.overlay.winfo_containing(event.x_root, event.y_root): + return + + if self.frame.winfo_containing(event.x_root, event.y_root): + return + + 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.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.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) + self._render_closed_menu() return self.frame @@ -114,6 +251,7 @@ def set_selected(self, option): self._close_menu() def destroy(self): + self._close_overlay() if self._alive(): self.frame.destroy()