diff --git a/.gitignore b/.gitignore index 5a75e60..4f47e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ config/reloadalas test.py test/ note.md +MCE/config.json # Created by .ignore support plugin (hsz.mobi) diff --git a/Input Helper.py b/Input Helper.py new file mode 100644 index 0000000..9505ab2 --- /dev/null +++ b/Input Helper.py @@ -0,0 +1,102 @@ +import customtkinter +import json +from MCE.custom_widgets.ctk_scrollable_dropdown import CTkScrollableDropdown +import os +from tkinter import END + +class InputHelper(customtkinter.CTk): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.create_tabview() + self.create_invite_student_widgets() + self.create_lessons_widgets() + + + def create_tabview(self): + self.tabview = customtkinter.CTkTabview(master=self) + self.tabview.grid(row=0, column=0) + + self.cafe_tab = self.tabview.add("Cafe") # add tab at the end + self.lessons_tab = self.tabview.add("Lessons") # add tab at the end + self.tabview.set("Cafe") # set currently visible + + def create_invite_student_widgets(self): + self.invitation_label = customtkinter.CTkLabel(master=self.cafe_tab, text="Copy and paste this in AAS Invitation Settings:") + self.invitation_label.grid(row=0, column=0, padx=60) + + self.invitation_entry = customtkinter.CTkEntry(master=self.cafe_tab, width=500) + self.invitation_entry.grid(row=1, column=0) + + self.invite_copy_button = customtkinter.CTkButton(master=self.cafe_tab, text="Copy", width=40, command=lambda : self.copy_entry(self.invitation_entry, self.invite_copy_button)) + self.invite_copy_button.grid(row=1, column=3, padx=5) + + self.invite_clear_button = customtkinter.CTkButton(master=self.cafe_tab, text="Clear", width=40, fg_color="crimson", command=lambda : self.invitation_entry.delete(0, END)) + self.invite_clear_button.grid(row=1, column=4, padx=5) + + self.invite_frame = customtkinter.CTkFrame(master=self.cafe_tab, fg_color="transparent") + self.invite_frame.grid(row=2, column=0, padx=20, pady=20) + + self.server_dropdown = customtkinter.CTkOptionMenu(master=self.invite_frame, values=self.find_json_files("MCE/student_list"), command=self.switch_server, width=40) + self.server_dropdown.grid(row=0, column=0) + + self.student_entry = customtkinter.CTkComboBox(master=self.invite_frame, width=300) + self.student_entry.grid(row=0, column=1, padx=(50,0)) + + self.student_dropdown = CTkScrollableDropdown(self.student_entry, width=300, height=550, autocomplete=True, command=lambda choice: self.insert(choice, self.invitation_entry), values=[""]) + self.server_dropdown.set("EN") + self.switch_server("EN") + + def create_lessons_widgets(self): + self.lessons_label = customtkinter.CTkLabel(master=self.lessons_tab, text="Copy and paste this in AAS Lessons Settings:") + self.lessons_label.grid(row=0, column=0, padx=60) + + self.lessons_entry = customtkinter.CTkEntry(master=self.lessons_tab, width=500) + self.lessons_entry.grid(row=1, column=0) + + self.lessons_copy_button = customtkinter.CTkButton(master=self.lessons_tab, text="Copy", width=40, command=lambda : self.copy_entry(self.lessons_entry, self.lessons_copy_button)) + self.lessons_copy_button.grid(row=1, column=1, padx=5) + + self.lessons_clear_button = customtkinter.CTkButton(master=self.lessons_tab, text="Clear", width=40, fg_color="crimson", command=lambda : self.lessons_entry.delete(0, END)) + self.lessons_clear_button.grid(row=1, column=2, padx=5) + + self.lessons_buttons_frame = customtkinter.CTkFrame(master=self.lessons_tab, fg_color="transparent") + self.lessons_buttons_frame.grid(row=2, column=0, padx=20, pady=20) + + for i in range(9): + self.lesson_button = customtkinter.CTkButton(master=self.lessons_buttons_frame, text=str(i+1), command=lambda choice=str(i+1): self.insert(choice, self.lessons_entry), width=40) + self.lesson_button.grid(row=0, column=i, padx=5) + + + def find_json_files(self,folder_path): + json_files = [] + for root, dirs, files in os.walk(folder_path): + for file in files: + if file.endswith(".json"): + json_files.append(os.path.splitext(file)[0]) + return json_files + + def switch_server(self, server): + with open(f"MCE/student_list/{server}.json", "r") as f: + student_list = json.load(f) + self.student_dropdown.configure(values=student_list) + + def insert(self, value, entry): + entry.insert(index=END, string=value + " > ") + + def copy_entry(self, entry, button): + text_to_copy = entry.get() + + # Check if there is text to copy + if text_to_copy: + # Clear the clipboard and set the new text + self.clipboard_clear() + self.clipboard_append(text_to_copy) + self.update() # This is necessary on some systems to update the clipboard + button_color = button.cget("fg_color") + button.configure(fg_color="green") + self.after(2000, lambda : button.configure(fg_color=['#3B8ED0', '#1F6AA5'])) + +if __name__ == "__main__": + app = InputHelper() + app.title("Input Helper") + app.mainloop() diff --git a/MCE Manager.py b/MCE Manager.py new file mode 100644 index 0000000..efec62e --- /dev/null +++ b/MCE Manager.py @@ -0,0 +1,437 @@ +import customtkinter +import tkinter as tk +import random +import re +from MCE.custom_widgets.ctkmessagebox import CTkMessagebox +from MCE.custom_widgets.ctk_tooltip import CTkToolTip +from MCE.custom_widgets.ctk_timeentry import CTkTimeEntry +from MCE.custom_widgets.ctk_integerspinbox import CTkIntegerSpinbox +from MCE.custom_widgets.ctk_templatedialog import CTkTemplateDialog +from MCE.custom_widgets.ctk_notification import CTkNotification +from MCE.utils import Linker, Config +from filelock import FileLock, Timeout +import threading +import time + +class MCE_Manager(customtkinter.CTk): + def __init__(self, linker, config, **kwargs): + super().__init__(**kwargs) + self.linker = linker + self.config = config + self.create_widgets() + # Load Template Data + self.load_template_data() + # Load queue Data + self.load_queue_data() + + def create_widgets(self): + + self.create_mission_commissions_widgets() + + + def create_mission_commissions_widgets(self): + # Create Mission/Commissions/Event Checkbox + self.create_mission_commissions_checkbox() + + # Create Reset Daily Widgets + self.create_reset_daily_widgets() + + # Create Recharge AP and Event Checkboxes + self.create_recharge_and_event_checkboxes() + + # Create Preferred Template Selection + self.create_preferred_template_selection() + + # Create Mission Tabview with Template and Queue Tabs + self.create_mission_tabview() + + # Create Top-Level Window for Template Editing + self.create_template_queue_editor() + + # Create Template Frame and Queue Frame + self.create_template_and_queue_frames() + + # Create Lists to Store Frame Widgets + self.create_frame_lists() + + # Initialize Preferred Template and Templates List + self.initialize_preferred_template() + + # Create OptionMenu for Selecting a Template + self.create_template_option_menu() + + # Create Delete Template Button + self.create_delete_template_button() + + # Helper method to create Mission/Commissions/Event Checkbox + def create_mission_commissions_checkbox(self): + self.mission_commissions_checkbox = customtkinter.CTkLabel(self, text="Mission/Commissions/Event", width=60, font=customtkinter.CTkFont(family="Inter", size=20, weight="bold")) + self.mission_commissions_checkbox.grid(row=11, column=0, sticky="nw", padx=20, pady=20) + self.notification = CTkNotification(master=self, text="Config saved") + self.notification.grid(row=11, column=1) + + # Helper method to create Reset Daily Widgets + def create_reset_daily_widgets(self): + self.reset_daily = customtkinter.CTkCheckBox(self, text="Reset Daily", font=customtkinter.CTkFont(family="Inter", size=16, underline=True), command=lambda x=["ResetDaily"]: self.config.save_to_json(x)) + self.reset_daily.grid(row=12, column=0, sticky="nw", padx=80) + self.reset_daily_tooltip = CTkToolTip(self.reset_daily, wraplength=400, + message="If enabled and if current time >= reset time,\ + the queue will automatically be cleared and repopulated with preferred template stages. Only activated once a day.") + + self.reset_daily_sub_label = customtkinter.CTkLabel(self, text="hh/mm/ss", font=customtkinter.CTkFont(family="Inter", size=12)) + self.reset_daily_sub_label.grid(row=13, column=0, padx=80) + + self.reset_time = CTkTimeEntry(self) + self.reset_time.grid(row=12, column=1) + self.reset_time.hour_entry.bind("", lambda event, x=["ResetTime"]: self.config.save_to_json(x)) + self.reset_time.minute_entry.bind("", lambda event, x=["ResetTime"]: self.config.save_to_json(x)) + self.reset_time.second_entry.bind("", lambda event, x=["ResetTime"]: self.config.save_to_json(x)) + + self.linker.widgets["ResetDaily"] = self.reset_daily + self.linker.widgets["ResetTime"] = self.reset_time + + # Helper method to create Recharge AP and Event Checkboxes + def create_recharge_and_event_checkboxes(self): + self.recharge_checkbox = customtkinter.CTkCheckBox(self, text="Recharge AP", command=lambda x=["RechargeAP"]: self.config.save_to_json(x), font=customtkinter.CTkFont(family="Inter", size=16, underline=True)) + self.recharge_checkbox.grid(row=14, column=0, sticky="nw", padx=80, pady=20) + self.linker.widgets["RechargeAP"] = self.recharge_checkbox + self.recharge_tooltip = CTkToolTip(self.recharge_checkbox, wraplength=400, + message="When enabled, recharge AP when low via cafe earnings, tasks, club and mailbox if they are enabled in their respective sections.") + self.event_checkbox = customtkinter.CTkCheckBox(self, text="Sweep Event Stages", command=lambda x=["Event"]: self.config.save_to_json(x), font=customtkinter.CTkFont(family="Inter", size=16, underline=True)) + self.event_tooltip = CTkToolTip(self.event_checkbox, wraplength=400, message="When enabled, the script will sweep event stages. Otherwise, it will ignore them.") + self.event_checkbox.grid(row=15, column=0, sticky="nw", padx=80) + self.linker.widgets["Event"] = self.event_checkbox + + # Helper method to create Preferred Template Selection + def create_preferred_template_selection(self): + self.templates = self.config.config_data["Templates"] + self.templates_list = list(self.templates.keys()) + + self.preferred_template_label = customtkinter.CTkLabel(self, text="Preferred Template:", font=customtkinter.CTkFont(family="Inter", size=16, underline=True)) + self.preferred_template_label.grid(row=16, column=0, pady=20) + self.preferred_template_tooltip = CTkToolTip(self.preferred_template_label, wraplength=400, + message="The template from which to repopulate the queue when it is empty or reset daily is activated") + self.preferred_template_optionmenu = customtkinter.CTkOptionMenu(self, values=self.templates_list, command=lambda x, y=["PreferredTemplate"]: self.config.save_to_json(y)) + self.preferred_template_optionmenu.grid(row=16, column=1, pady=20) + self.linker.widgets["PreferredTemplate"] = self.preferred_template_optionmenu + + # Helper method to create Mission Tabview with Template and Queue Tabs + def create_mission_tabview(self): + self.mission_tabview = customtkinter.CTkTabview(self, height=500) + self.mission_tabview.grid(row=17, column=0, columnspan=3, padx=20) + + self.tab_template = self.mission_tabview.add('Template') + self.tab_queue = self.mission_tabview.add('Queue') + + # Helper method to create Template Queue Editor + def create_template_queue_editor(self): + self.queue_buttons = [] + + for i in [self.tab_queue, self.tab_template]: + queue = True if i == self.tab_queue else False + + self.template_labels = customtkinter.CTkFrame(i) + self.template_labels.grid(row=0, column=0, sticky="ew") + + self.mode_label = customtkinter.CTkLabel(self.template_labels, text="Mode:", font=customtkinter.CTkFont(underline=True)) + self.mode_tooltip = CTkToolTip(self.mode_label, message="N:Mission Normal\nH:Mission Hard\nE:Event Quest\nBD:Commissions EXP\nIR:Commissions Credits\n") + self.mode_label.grid(row=1, column=0, padx=(130, 0), pady=5) + + self.stage_label = customtkinter.CTkLabel(self.template_labels, text="Stage:", font=customtkinter.CTkFont(underline=True)) + self.stage_tooltip = CTkToolTip(self.stage_label, message="Valid format for Mission: 1-1\nValid format for Commissions/Event: 01") + self.stage_label.grid(row=1, column=1, padx=(40, 20), pady=5) + + self.run_times_label = customtkinter.CTkLabel(self.template_labels, text="Number of Sweeps:", font=customtkinter.CTkFont(underline=True)) + self.run_times_tooltip = CTkToolTip(self.run_times_label, message="How many times do you want to sweep the stage?") + self.run_times_label.grid(row=1, column=2, pady=5) + + self.template_buttons_frame = customtkinter.CTkFrame(i) + self.template_buttons_frame.grid(row=3, column=0) + + self.highlight_label = customtkinter.CTkLabel(self.template_buttons_frame, text="*You can double click an entry and press up or down arrow to change its position", font=customtkinter.CTkFont(family="Inter", size=12)) + self.highlight_label.grid(row=0, column=0, columnspan=3) + + self.add_button = customtkinter.CTkButton(self.template_buttons_frame , text="Add", command=lambda queue=queue: self.add_frame(queue=queue)) + self.add_button.grid(row=1, column=0, padx=5, pady=5) + + # Clear button to clear all frames + self.clear_button = customtkinter.CTkButton(self.template_buttons_frame, text="Clear All", command=lambda queue=queue: self.clear_frames(queue=queue), fg_color="crimson") + self.clear_button.grid(row=1, column=1, padx=5, pady=5) + + # Save button to save data + self.save_button = customtkinter.CTkButton(self.template_buttons_frame, text="Save", command=lambda queue=queue: self.save_data(queue=queue), fg_color="#DC621D") + self.save_button.grid(row=1, column=2, padx=5, pady=5) + if queue: + self.queue_buttons = [self.add_button, self.clear_button, self.save_button] + + # Helper method to create Template Frame and Queue Frame + def create_template_and_queue_frames(self): + self.template_frame = customtkinter.CTkScrollableFrame(self.tab_template, width=400, height=350) + self.template_frame.grid(row=1, column=0, sticky="nsew") + + self.queue_frame = customtkinter.CTkScrollableFrame(self.tab_queue, width=400, height=350) + self.queue_frame.grid(row=1, column=0, sticky="nsew") + + # Helper method to create Lists to Store Frame Widgets + def create_frame_lists(self): + self.template_frames = [] + self.queue_frames = [] + self.highlighted_frame = None + + # Helper method to initialize Preferred Template and Templates List + def initialize_preferred_template(self): + self.preferred_template = self.config.config_data["PreferredTemplate"] + self.templates_list.append("Add New Template") + + # Helper method to create OptionMenu for Selecting a Template + def create_template_option_menu(self): + self.selected_template = tk.StringVar(self.template_frame) + self.selected_template.set(self.preferred_template) # Set the initial value to the preferred template + + self.template_optionmenu = customtkinter.CTkOptionMenu(self.template_labels, values=self.templates_list, variable=self.selected_template, command=lambda *args: self.load_template_data()) + self.template_optionmenu.grid(row=0, column=0, padx=5, pady=5) + + # Helper method to create Delete Template Button + def create_delete_template_button(self): + self.delete_template_button = customtkinter.CTkButton(self.template_labels, width=40, text="Delete", command=self.delete_template) + self.delete_template_button.grid(row=0, column=1) + + # Helper method to add frames from Configuration Data + def load_queue_data(self, state="normal"): + for entry in self.config.config_data['Queue']: + self.add_frame(entry, queue=True, state=state) + + # Function to load template data into frames + def load_template_data(self): + selected = self.selected_template.get() + if selected == "Add New Template": + dialog = CTkTemplateDialog(text="Type in new template name. Template name MUST be different from other templates!", title="Template Name", values=self.templates_list[:-1]) + template_name, template_import = dialog.get_input() + if template_name.replace(" ", "") == "": + self.template_optionmenu.set(self.previous_selected) + return + elif template_name in self.templates_list: + CTkMessagebox(title="Error", message="Name is invalid.", icon="cancel") + self.template_optionmenu.set(self.previous_selected) + return + else: + if template_import != "": + self.templates[template_name] = self.templates[template_import] + else: + self.templates[template_name] = [] + self.templates_list[-1] = template_name + self.preferred_template_optionmenu.configure(values=self.templates_list) + selected = template_name + self.template_optionmenu.set(selected) + self.templates_list.append("Add New Template") + self.template_optionmenu.configure(values=self.templates_list) + self.clear_frames() + for entry in self.templates[selected]: + self.add_frame(entry) + self.previous_selected = selected + + def delete_template(self): + msg = CTkMessagebox(title="Template Deletetion", message=f"Are you sure you want to delete Template {self.previous_selected}?", + icon="question", option_1="No", option_2="Yes") + response = msg.get() + if response=="Yes": + if len(self.templates) != 1: + del self.templates[self.previous_selected] + self.templates_list = list(self.templates.keys()) + self.preferred_template_optionmenu.configure(values=self.templates_list) + if self.preferred_template == self.previous_selected: + self.preferred_template = random.choice(self.templates_list) + self.config.config_data["PreferredTemplate"] = self.preferred_template + self.selected_template.set(self.preferred_template) # Set the initial value to the preferred template + self.preferred_template_optionmenu.set(self.preferred_template) + self.load_template_data() + self.config.save_file() + self.templates_list.append("Add New Template") + self.template_optionmenu.configure(values=self.templates_list) + self.template_optionmenu.set(self.preferred_template) + else: + CTkMessagebox(title="Error", message="At least one template must exist!!!", icon="cancel") + return + +# Function to add a frame with widgets + def add_frame(self, inner_list=None, queue=False, state="normal"): + frames = self.queue_frames if queue else self.template_frames + parent_frame = self.queue_frame if queue else self.template_frame + row_index = len(frames) + 1 # Calculate the row for the new frame + # Create a frame + frame = tk.Frame(parent_frame, bg="gray17") + frame.grid(row=row_index, column=0, columnspan=4, padx=10, pady=10, sticky="w") + frames.append(frame) + # "Up" button to move the frame up + up_button = customtkinter.CTkButton(frame, text="Up", width=5, command=lambda f=frame, queue=queue: self.move_frame_up(f, queue), state=state) + up_button.grid(row=0, column=0, padx=5, pady=5, sticky="w") + # "Down" button to move the frame down + down_button = customtkinter.CTkButton(frame, text="Down", width=5, command=lambda f=frame, queue=queue: self.move_frame_down(f, queue), state=state) + down_button.grid(row=0, column=1, padx=5, pady=5, sticky="w") + # Dropdown menu for mode + mode_optionmenu = customtkinter.CTkOptionMenu(frame, width=60, values=["N", "H", "E", "BD", "IR"], state=state) + mode_optionmenu.set(inner_list[0] if inner_list else "N") + mode_optionmenu.grid(row=0, column=2, padx=5, pady=5, sticky="w") + # Entry widget for stage + stage_var = tk.StringVar(value=inner_list[1] if inner_list else "") + stage_entry = customtkinter.CTkEntry(frame, width=60, textvariable=stage_var, state=state) + stage_entry.grid(row=0, column=3, padx=5, pady=5, sticky="w") + mode_optionmenu.configure(command=lambda choice, x=mode_optionmenu, y=stage_entry : self.check_entry(x,y)) + stage_entry.bind('', command=lambda event, x=mode_optionmenu, y=stage_entry : self.check_entry(x,y)) + self.check_entry(mode_optionmenu, stage_entry) + # Entry widget for run times (only accepts numbers) + run_times_spinbox = CTkIntegerSpinbox(frame, step_size=1, min_value=1) + run_times_spinbox.set(value=inner_list[2] if inner_list else 1) + run_times_spinbox.grid(row=0, column=4, padx=5, pady=5, sticky="w") + + # Delete button to delete the frame + delete_button = customtkinter.CTkButton(frame, text="Delete", width=5, command=lambda f=frame, queue=queue: self.delete_frame(f, queue), state=state) + delete_button.grid(row=0, column=5, padx=5, pady=5, sticky="w") + + frame.bind("", lambda event, f=frame: self.highlight_frame(f)) + + # Function to clear all frames + def clear_frames(self, queue=False): + frames = self.queue_frames if queue else self.template_frames + for frame in frames: + frame.destroy() + frames.clear() + + # Function to save frames as data + def save_data(self, queue=False): + entries = [] + frames = self.queue_frames if queue else self.template_frames + name = "Queue" if queue else "Template" + for frame in frames: + mode_optionmenu = frame.winfo_children()[2] + stage_entry = frame.winfo_children()[3] + if not self.check_entry(mode_optionmenu, stage_entry): + CTkMessagebox(title="Error", message="Configuration not saved. Some entries are incomplete or have incorect input.", icon="cancel") + return + mode = frame.winfo_children()[2].get() + stage = frame.winfo_children()[3].get().strip() + run_times = frame.winfo_children()[4].get() + entries.append([mode, stage, int(run_times)]) + if queue: + self.config.config_data['Queue'] = entries + else: + selected = self.selected_template.get() + self.templates[selected] = entries + self.config.save_file(name) + + def check_entry(self, mode_dropdown, stage_entry): + mode = mode_dropdown.get() + stage = stage_entry.get() + if mode in ["N", "H"]: + pattern = r'\d{1,2}-[0-9A-Z]' + else: + pattern = r"^\d{2}$" + if re.match(pattern, stage): + stage_entry.configure(border_color=['#979DA2', '#565B5E']) + return True + else: + stage_entry.configure(border_color='crimson') + return False + + # Function to move a frame up + def move_frame_up(self, frame, queue=False): + frames = self.queue_frames if queue else self.template_frames + index = frames.index(frame) + if index > 0: + frames[index], frames[index - 1] = frames[index - 1], frames[index] + self.update_frame_positions(queue=queue) + + # Function to move a frame down + def move_frame_down(self, frame, queue=False): + frames = self.queue_frames if queue else self.template_frames + index = frames.index(frame) + if index < len(frames) - 1: + frames[index], frames[index + 1] = frames[index + 1], frames[index] + self.update_frame_positions(queue=queue) + + # Function to update frame positions in the grid + def update_frame_positions(self, queue=False): + frames = self.queue_frames if queue else self.template_frames + for index, frame in enumerate(frames): + frame.grid(row=index, column=0, columnspan=4, padx=10, pady=10, sticky="w") + + # Function to delete a frame + def delete_frame(self, frame, queue=False): + if queue: + self.queue_frames.remove(frame) + else: + self.template_frames.remove(frame) + frame.destroy() + # Update the positions of remaining frames + self.update_frame_positions(queue=queue) + + def highlight_frame(self, frame): + try: + if self.highlighted_frame is not None: + self.highlighted_frame.unbind("") + self.highlighted_frame.unbind("") + self.highlighted_frame.config(bg="gray17") + except: + pass + + if self.highlighted_frame == frame: + self.highlighted_frame = None + + else: + up_button = frame.winfo_children()[0] + down_button = frame.winfo_children()[1] + frame.config(bg="yellow") + frame.bind("", lambda event: up_button.invoke()) + frame.bind("", lambda event: down_button.invoke()) + frame.focus_set() + self.highlighted_frame = frame + + def check_lock(self): + while 1: + try: + lock = FileLock("MCE\config.json.lock") + lock.acquire(timeout=1) + except Timeout: + if not self.config.locked: + self.after(10, lambda : (self.queue_changed(), self.update_queue(), self.switch_queue_state("disabled"))) + self.config.locked = True + elif self.config.locked and self.queue_changed(): + self.after(10, lambda : (self.update_queue(), self.switch_queue_state("disabled"))) + else: + lock.release() + if self.config.locked: + self.after(10, lambda : (self.queue_changed(), self.update_queue(), self.switch_queue_state("normal"))) + self.config.locked = False + finally: + time.sleep(2) + + def switch_queue_state(self, state): + for button in self.queue_buttons: + button.configure(state=state) + for frame in self.queue_frames: + for widget in frame.winfo_children(): + widget.configure(state=state) + + def update_queue(self): + self.clear_frames(queue=True) + for entry in self.config.config_data['Queue']: + self.add_frame(entry, queue=True) + + def queue_changed(self): + new_config_data = self.config.read() + changed = self.config.config_data["Queue"] != new_config_data["Queue"] or self.config.config_data["LastRun"] != new_config_data["LastRun"] + if changed: + self.config.config_data["LastRun"] = new_config_data["LastRun"] + self.config.config_data['Queue'] = new_config_data['Queue'] + return changed + +if __name__ == "__main__": + linker = Linker() + config = Config(linker, "MCE\config.json") + app = MCE_Manager(linker, config) + app.title("MCE Manager") + linker.sidebar = app + config.load_config() + daemon_thread = threading.Thread(target=app.check_lock, daemon=True) + daemon_thread.start() + app.mainloop() \ No newline at end of file diff --git a/MCE/custom_widgets/__init__.py b/MCE/custom_widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/MCE/custom_widgets/ctk_integerspinbox.py b/MCE/custom_widgets/ctk_integerspinbox.py new file mode 100644 index 0000000..d3a2c56 --- /dev/null +++ b/MCE/custom_widgets/ctk_integerspinbox.py @@ -0,0 +1,95 @@ +import customtkinter +import re +from typing import Callable + +class CTkIntegerSpinbox(customtkinter.CTkFrame): + def __init__(self, *args, + width: int = 100, + height: int = 32, + step_size: int = 1, + min_value: int = 0, + command: Callable = None, + **kwargs): + super().__init__(*args, width=width, height=height, **kwargs) + + self.step_size = step_size + self.min_value = min_value + self.command = command + self.after_id = None + + self.grid_columnconfigure((0, 2), weight=0) + self.grid_columnconfigure(1, weight=1) + + self.subtract_button = customtkinter.CTkButton(self, text="-", width=height-6, height=height-6) + self.subtract_button.grid(row=0, column=0, padx=(3, 0), pady=3) + self.subtract_button.bind('', self.start_decrementing) + self.subtract_button.bind('', self.stop_decrementing) + + self.entry = customtkinter.CTkEntry(self, width=width-(2*height), height=height-6, border_width=0) + self.entry.grid(row=0, column=1, columnspan=1, padx=3, pady=3, sticky="ew") + + self.add_button = customtkinter.CTkButton(self, text="+", width=height-6, height=height-6) + self.add_button.grid(row=0, column=2, padx=(0, 3), pady=3) + self.add_button.bind('', self.start_incrementing) + self.add_button.bind('', self.stop_incrementing) + + self.entry.insert(0, "0") + + # Configure validatecommand to allow only integers + vcmd = (self.entry.register(self.validate_input), '%P') + self.entry.configure(validate='key', validatecommand=vcmd) + + def start_incrementing(self, event): + self.increment() + self.after_id = self.after(150, self.start_incrementing, event) + + def stop_incrementing(self, event): + if self.after_id: + self.after_cancel(self.after_id) + self.after_id = None + + def start_decrementing(self, event): + self.decrement() + self.after_id = self.after(150, self.start_decrementing, event) + + def stop_decrementing(self, event): + if self.after_id: + self.after_cancel(self.after_id) + self.after_id = None + + def increment(self): + value = int(self.entry.get()) + self.step_size + self.entry.delete(0, "end") + self.entry.insert(0, max(self.min_value, value)) # Ensure the value is not less than 1 + if self.command is not None: + self.command() + + def decrement(self): + value = int(self.entry.get()) - self.step_size + self.entry.delete(0, "end") + self.entry.insert(0, max(self.min_value, value)) # Ensure the value is not less than 0 + if self.command is not None: + self.command() + + def validate_input(self, new_value): + # Validate that the input is a non-negative integer + return re.match(r'^\d*$', new_value) is not None + + def get(self) -> int: + try: + return int(self.entry.get()) + except ValueError: + return 0 + + def set(self, value: int): + self.entry.delete(0, "end") + self.entry.insert(0, max(self.min_value, value)) # Ensure the value is not less than 0 + + def configure(self, **kwargs): + state = kwargs.get("state", None) + if state is not None: + self.subtract_button.configure(state=state) + self.add_button.configure(state=state) + self.entry.configure(state=state) + kwargs.pop("state") + super().configure(**kwargs) \ No newline at end of file diff --git a/MCE/custom_widgets/ctk_notification.py b/MCE/custom_widgets/ctk_notification.py new file mode 100644 index 0000000..713ecfd --- /dev/null +++ b/MCE/custom_widgets/ctk_notification.py @@ -0,0 +1,27 @@ +import customtkinter +class CTkNotification(customtkinter.CTkFrame): + def __init__(self, text, master, **kwargs): + self.master_color = master.cget("fg_color") + super().__init__(master=master, **kwargs, fg_color=self.master_color) + self.label = customtkinter.CTkLabel(self, text=text, text_color=self.master_color, width=200, wraplength=200, font=("Inter", 16)) + self.label.grid(row=0, column=0, sticky="nsew") + self.close_button = customtkinter.CTkButton( + self, width=40, text="X", text_color_disabled=self.master_color, command=self.hide, fg_color="transparent", state="disabled") + self.close_button.grid(row=0, column=1) + self.progress_bar = customtkinter.CTkProgressBar(self, determinate_speed=0.4, fg_color=self.master_color, progress_color=self.master_color) + self.progress_bar.grid(row=1, column=0, columnspan=2, sticky="nsew") + + def hide(self): + self.configure(fg_color="transparent") + self.progress_bar.stop() + self.progress_bar.configure(progress_color=self.master_color) + self.close_button.configure(state="disabled") + self.label.configure(text_color=self.master_color) + + def show(self): + self.configure(fg_color="green") + self.progress_bar.configure(progress_color="white") + self.progress_bar.set(0) + self.progress_bar.start() + self.close_button.configure(state="normal") + self.label.configure(text_color="white") \ No newline at end of file diff --git a/MCE/custom_widgets/ctk_scrollable_dropdown.py b/MCE/custom_widgets/ctk_scrollable_dropdown.py new file mode 100644 index 0000000..212e969 --- /dev/null +++ b/MCE/custom_widgets/ctk_scrollable_dropdown.py @@ -0,0 +1,337 @@ +''' +Advanced Scrollable Dropdown class for customtkinter widgets +Author: Akash Bora +''' + +import customtkinter +import sys +import time +import difflib + +class CTkScrollableDropdown(customtkinter.CTkToplevel): + + def __init__(self, attach, x=None, y=None, button_color=None, height: int = 200, width: int = None, + fg_color=None, button_height: int = 20, justify="center", scrollbar_button_color=None, + scrollbar=True, scrollbar_button_hover_color=None, frame_border_width=2, values=[], + command=None, image_values=[], alpha: float = 0.97, frame_corner_radius=20, double_click=False, + resize=True, frame_border_color=None, text_color=None, autocomplete=False, **button_kwargs): + + super().__init__(takefocus=1) + + self.focus() + self.lift() + self.alpha = alpha + self.attach = attach + self.corner = frame_corner_radius + self.padding = 0 + self.focus_something = False + self.disable = True + self.update() + + if sys.platform.startswith("win"): + self.after(100, lambda: self.overrideredirect(True)) + self.transparent_color = self._apply_appearance_mode(self._fg_color) + self.attributes("-transparentcolor", self.transparent_color) + elif sys.platform.startswith("darwin"): + self.overrideredirect(True) + self.transparent_color = 'systemTransparent' + self.attributes("-transparent", True) + self.focus_something = True + else: + self.overrideredirect(True) + self.transparent_color = '#000001' + self.corner = 0 + self.padding = 18 + self.withdraw() + + self.hide = True + self.attach.bind('', lambda e: self._withdraw() if not self.disable else None, add="+") + self.attach.winfo_toplevel().bind('', lambda e: self._withdraw() if not self.disable else None, add="+") + self.attach.winfo_toplevel().bind("", lambda e: self._withdraw() if not self.disable else None, add="+") + + + self.attributes('-alpha', 0) + self.disable = False + self.fg_color = customtkinter.ThemeManager.theme["CTkFrame"]["fg_color"] if fg_color is None else fg_color + self.scroll_button_color = customtkinter.ThemeManager.theme["CTkScrollbar"]["button_color"] if scrollbar_button_color is None else scrollbar_button_color + self.scroll_hover_color = customtkinter.ThemeManager.theme["CTkScrollbar"]["button_hover_color"] if scrollbar_button_hover_color is None else scrollbar_button_hover_color + self.frame_border_color = customtkinter.ThemeManager.theme["CTkFrame"]["border_color"] if frame_border_color is None else frame_border_color + self.button_color = customtkinter.ThemeManager.theme["CTkFrame"]["top_fg_color"] if button_color is None else button_color + self.text_color = customtkinter.ThemeManager.theme["CTkLabel"]["text_color"] if text_color is None else text_color + + if scrollbar is False: + self.scroll_button_color = self.fg_color + self.scroll_hover_color = self.fg_color + + self.frame = customtkinter.CTkScrollableFrame(self, bg_color=self.transparent_color, fg_color=self.fg_color, + scrollbar_button_hover_color=self.scroll_hover_color, + corner_radius=self.corner, border_width=frame_border_width, + scrollbar_button_color=self.scroll_button_color, + border_color=self.frame_border_color) + self.frame._scrollbar.grid_configure(padx=3) + self.frame.pack(expand=True, fill="both") + self.dummy_entry = customtkinter.CTkEntry(self.frame, fg_color="transparent", border_width=0, height=1, width=1) + self.no_match = customtkinter.CTkLabel(self.frame, text="No Match") + self.height = height + self.height_new = height + self.width = width + self.command = command + self.fade = False + self.resize = resize + self.autocomplete = autocomplete + self.var_update = customtkinter.StringVar() + self.appear = False + + if justify.lower()=="left": + self.justify = "w" + elif justify.lower()=="right": + self.justify = "e" + else: + self.justify = "c" + + self.button_height = button_height + self.values = values + self.button_num = len(self.values) + self.image_values = None if len(image_values)!=len(self.values) else image_values + + self.resizable(width=False, height=False) + self.transient(self.master) + self._init_buttons(**button_kwargs) + + # Add binding for different ctk widgets + if double_click or self.attach.winfo_name().startswith("!ctkentry") or self.attach.winfo_name().startswith("!ctkcombobox"): + self.attach.bind('', lambda e: self._iconify(), add="+") + else: + self.attach.bind('', lambda e: self._iconify(), add="+") + + if self.attach.winfo_name().startswith("!ctkcombobox"): + self.attach._canvas.tag_bind("right_parts", "", lambda e: self._iconify()) + self.attach._canvas.tag_bind("dropdown_arrow", "", lambda e: self._iconify()) + if self.command is None: + self.command = self.attach.set + + if self.attach.winfo_name().startswith("!ctkoptionmenu"): + self.attach._canvas.bind("", lambda e: self._iconify()) + self.attach._text_label.bind("", lambda e: self._iconify()) + if self.command is None: + self.command = self.attach.set + + self.attach.bind("", lambda _: self._destroy(), add="+") + + self.update_idletasks() + self.x = x + self.y = y + + if self.autocomplete: + self.bind_autocomplete() + + self.deiconify() + self.withdraw() + + self.attributes("-alpha", self.alpha) + + def _destroy(self): + self.after(500, self.destroy_popup) + + def _withdraw(self): + if self.winfo_viewable() and self.hide: + self.withdraw() + + self.event_generate("<>") + self.hide = True + + def _update(self, a, b, c): + self.live_update(self.attach._entry.get()) + + def bind_autocomplete(self, ): + def appear(x): + self.appear = True + + if self.attach.winfo_name().startswith("!ctkcombobox"): + self.attach._entry.configure(textvariable=self.var_update) + self.attach._entry.bind("", appear) + self.attach.set(self.values[0]) + self.var_update.trace_add('write', self._update) + + if self.attach.winfo_name().startswith("!ctkentry"): + self.attach.configure(textvariable=self.var_update) + self.attach.bind("", appear) + self.var_update.trace_add('write', self._update) + + def fade_out(self): + for i in range(100,0,-10): + if not self.winfo_exists(): + break + self.attributes("-alpha", i/100) + self.update() + time.sleep(1/100) + + def fade_in(self): + for i in range(0,100,10): + if not self.winfo_exists(): + break + self.attributes("-alpha", i/100) + self.update() + time.sleep(1/100) + + def _init_buttons(self, **button_kwargs): + self.i = 0 + self.widgets = {} + for row in self.values: + self.widgets[self.i] = customtkinter.CTkButton(self.frame, + text=row, + height=self.button_height, + fg_color=self.button_color, + text_color=self.text_color, + image=self.image_values[i] if self.image_values is not None else None, + anchor=self.justify, + command=lambda k=row: self._attach_key_press(k), **button_kwargs) + self.widgets[self.i].pack(fill="x", pady=2, padx=(self.padding, 0)) + self.i+=1 + + self.hide = False + + def destroy_popup(self): + self.destroy() + self.disable = True + + def place_dropdown(self): + self.x_pos = self.attach.winfo_rootx() if self.x is None else self.x + self.attach.winfo_rootx() + self.y_pos = self.attach.winfo_rooty() + self.attach.winfo_reqheight() + 5 if self.y is None else self.y + self.attach.winfo_rooty() + self.width_new = self.attach.winfo_width() if self.width is None else self.width + + if self.resize: + if self.button_num<=5: + self.height_new = self.button_height * self.button_num + 55 + else: + self.height_new = self.button_height * self.button_num + 35 + if self.height_new>self.height: + self.height_new = self.height + + self.geometry('{}x{}+{}+{}'.format(self.width_new, self.height_new, + self.x_pos, self.y_pos)) + self.fade_in() + self.attributes('-alpha', self.alpha) + self.attach.focus() + + def _iconify(self): + if self.disable: return + if self.hide: + self.event_generate("<>") + self._deiconify() + self.focus() + self.hide = False + self.place_dropdown() + if self.focus_something: + self.dummy_entry.pack() + self.dummy_entry.focus_set() + self.after(100, self.dummy_entry.pack_forget) + else: + self.withdraw() + self.hide = True + + def _attach_key_press(self, k): + self.event_generate("<>") + self.fade = True + if self.command: + self.command(k) + self.fade = False + self.fade_out() + self.withdraw() + self.hide = True + + def live_update(self, string=None): + if not self.appear: return + if self.disable: return + if self.fade: return + if string: + string = string.lower() + self._deiconify() + i=1 + for key in self.widgets.keys(): + s = self.widgets[key].cget("text").lower() + text_similarity = difflib.SequenceMatcher(None, s[0:len(string)], string).ratio() + similar = s.startswith(string) or text_similarity > 0.75 + if not similar: + self.widgets[key].pack_forget() + else: + self.widgets[key].pack(fill="x", pady=2, padx=(self.padding, 0)) + i+=1 + + if i==1: + self.no_match.pack(fill="x", pady=2, padx=(self.padding, 0)) + else: + self.no_match.pack_forget() + self.button_num = i + self.place_dropdown() + + else: + self.no_match.pack_forget() + self.button_num = len(self.values) + for key in self.widgets.keys(): + self.widgets[key].destroy() + self._init_buttons() + self.place_dropdown() + + self.frame._parent_canvas.yview_moveto(0.0) + self.appear = False + + def insert(self, value, **kwargs): + self.widgets[self.i] = customtkinter.CTkButton(self.frame, + text=value, + height=self.button_height, + fg_color=self.button_color, + text_color=self.text_color, + anchor=self.justify, + command=lambda k=value: self._attach_key_press(k), **kwargs) + self.widgets[self.i].pack(fill="x", pady=2, padx=(self.padding, 0)) + self.i+=1 + self.values.append(value) + + def _deiconify(self): + if len(self.values)>0: + self.deiconify() + + def popup(self, x=None, y=None): + self.x = x + self.y = y + self.hide = True + self._iconify() + + def configure(self, **kwargs): + if "height" in kwargs: + self.height = kwargs.pop("height") + self.height_new = self.height + + if "alpha" in kwargs: + self.alpha = kwargs.pop("alpha") + + if "width" in kwargs: + self.width = kwargs.pop("width") + + if "fg_color" in kwargs: + self.frame.configure(fg_color=kwargs.pop("fg_color")) + + if "values" in kwargs: + self.values = kwargs.pop("values") + self.image_values = None + self.button_num = len(self.values) + for key in self.widgets.keys(): + self.widgets[key].destroy() + self._init_buttons() + + if "image_values" in kwargs: + self.image_values = kwargs.pop("image_values") + self.image_values = None if len(self.image_values)!=len(self.values) else self.image_values + if self.image_values is not None: + i=0 + for key in self.widgets.keys(): + self.widgets[key].configure(image=self.image_values[i]) + i+=1 + + if "button_color" in kwargs: + for key in self.widgets.keys(): + self.widgets[key].configure(fg_color=kwargs.pop("button_color")) + + for key in self.widgets.keys(): + self.widgets[key].configure(**kwargs) diff --git a/MCE/custom_widgets/ctk_scrollable_dropdown_frame.py b/MCE/custom_widgets/ctk_scrollable_dropdown_frame.py new file mode 100644 index 0000000..609cf0c --- /dev/null +++ b/MCE/custom_widgets/ctk_scrollable_dropdown_frame.py @@ -0,0 +1,291 @@ +''' +Advanced Scrollable Dropdown Frame class for customtkinter widgets +Author: Akash Bora +''' + +import customtkinter +import sys +import difflib + +class CTkScrollableDropdownFrame(customtkinter.CTkFrame): + + def __init__(self, attach, x=None, y=None, button_color=None, height: int = 200, width: int = None, + fg_color=None, button_height: int = 20, justify="center", scrollbar_button_color=None, + scrollbar=True, scrollbar_button_hover_color=None, frame_border_width=2, values=[], + command=None, image_values=[], double_click=False, frame_corner_radius=True, resize=True, frame_border_color=None, + text_color=None, autocomplete=False, **button_kwargs): + + super().__init__(master=attach.winfo_toplevel(), bg_color=attach.cget("bg_color")) + + self.attach = attach + self.corner = 11 if frame_corner_radius else 0 + self.padding = 0 + self.disable = True + + self.hide = True + self.attach.bind('', lambda e: self._withdraw() if not self.disable else None, add="+") + self.attach.winfo_toplevel().bind("", lambda e: self._withdraw() if not self.disable else None, add="+") + + self.disable = False + self.fg_color = customtkinter.ThemeManager.theme["CTkFrame"]["fg_color"] if fg_color is None else fg_color + self.scroll_button_color = customtkinter.ThemeManager.theme["CTkScrollbar"]["button_color"] if scrollbar_button_color is None else scrollbar_button_color + self.scroll_hover_color = customtkinter.ThemeManager.theme["CTkScrollbar"]["button_hover_color"] if scrollbar_button_hover_color is None else scrollbar_button_hover_color + self.frame_border_color = customtkinter.ThemeManager.theme["CTkFrame"]["border_color"] if frame_border_color is None else frame_border_color + self.button_color = customtkinter.ThemeManager.theme["CTkFrame"]["top_fg_color"] if button_color is None else button_color + self.text_color = customtkinter.ThemeManager.theme["CTkLabel"]["text_color"] if text_color is None else text_color + + if scrollbar is False: + self.scroll_button_color = self.fg_color + self.scroll_hover_color = self.fg_color + + self.frame = customtkinter.CTkScrollableFrame(self, fg_color=self.fg_color, bg_color=attach.cget("bg_color"), + scrollbar_button_hover_color=self.scroll_hover_color, + corner_radius=self.corner, border_width=frame_border_width, + scrollbar_button_color=self.scroll_button_color, + border_color=self.frame_border_color) + self.frame._scrollbar.grid_configure(padx=3) + self.frame.pack(expand=True, fill="both") + + if self.corner==0: + self.corner = 21 + + self.dummy_entry = customtkinter.CTkEntry(self.frame, fg_color="transparent", border_width=0, height=1, width=1) + self.no_match = customtkinter.CTkLabel(self.frame, text="No Match") + self.height = height + self.height_new = height + self.width = width + self.command = command + self.fade = False + self.resize = resize + self.autocomplete = autocomplete + self.var_update = customtkinter.StringVar() + self.appear = False + + if justify.lower()=="left": + self.justify = "w" + elif justify.lower()=="right": + self.justify = "e" + else: + self.justify = "c" + + self.button_height = button_height + self.values = values + self.button_num = len(self.values) + self.image_values = None if len(image_values)!=len(self.values) else image_values + + self._init_buttons(**button_kwargs) + + # Add binding for different ctk widgets + if double_click or self.attach.winfo_name().startswith("!ctkentry") or self.attach.winfo_name().startswith("!ctkcombobox"): + self.attach.bind('', lambda e: self._iconify(), add="+") + self.attach._entry.bind('', lambda e: self._withdraw() if not self.disable else None, add="+") + else: + self.attach.bind('', lambda e: self._iconify(), add="+") + + if self.attach.winfo_name().startswith("!ctkcombobox"): + self.attach._canvas.tag_bind("right_parts", "", lambda e: self._iconify()) + self.attach._canvas.tag_bind("dropdown_arrow", "", lambda e: self._iconify()) + + if self.command is None: + self.command = self.attach.set + + if self.attach.winfo_name().startswith("!ctkoptionmenu"): + self.attach._canvas.bind("", lambda e: self._iconify()) + self.attach._text_label.bind("", lambda e: self._iconify()) + if self.command is None: + self.command = self.attach.set + + self.x = x + self.y = y + + self.attach.bind("", lambda _: self._destroy(), add="+") + + if self.autocomplete: + self.bind_autocomplete() + + def _destroy(self): + self.after(500, self.destroy_popup) + + def _withdraw(self): + if self.winfo_viewable() and self.hide: + self.place_forget() + + self.event_generate("<>") + self.hide = True + + def _update(self, a, b, c): + self.live_update(self.attach._entry.get()) + + def bind_autocomplete(self, ): + def appear(x): + self.appear = True + + if self.attach.winfo_name().startswith("!ctkcombobox"): + self.attach._entry.configure(textvariable=self.var_update) + self.attach.set(self.values[0]) + self.attach._entry.bind("", appear) + self.var_update.trace_add('write', self._update) + + if self.attach.winfo_name().startswith("!ctkentry"): + self.attach.configure(textvariable=self.var_update) + self.attach.bind("", appear) + self.var_update.trace_add('write', self._update) + + def _init_buttons(self, **button_kwargs): + self.i = 0 + self.widgets = {} + for row in self.values: + self.widgets[self.i] = customtkinter.CTkButton(self.frame, + text=row, + height=self.button_height, + fg_color=self.button_color, + text_color=self.text_color, + image=self.image_values[i] if self.image_values is not None else None, + anchor=self.justify, + command=lambda k=row: self._attach_key_press(k), **button_kwargs) + self.widgets[self.i].pack(fill="x", pady=2, padx=(self.padding, 0)) + self.i+=1 + + self.hide = False + + def destroy_popup(self): + self.destroy() + self.disable = True + + def place_dropdown(self): + self.x_pos = self.attach.winfo_x() if self.x is None else self.x + self.attach.winfo_rootx() + self.y_pos = self.attach.winfo_y() + self.attach.winfo_reqheight() + 5 if self.y is None else self.y + self.attach.winfo_rooty() + self.width_new = self.attach.winfo_width()-45+self.corner if self.width is None else self.width + + if self.resize: + if self.button_num<=5: + self.height_new = self.button_height * self.button_num + 55 + else: + self.height_new = self.button_height * self.button_num + 35 + if self.height_new>self.height: + self.height_new = self.height + + self.frame.configure(width=self.width_new, height=self.height_new) + self.place(x=self.x_pos, y=self.y_pos) + + if sys.platform.startswith("darwin"): + self.dummy_entry.pack() + self.after(100, self.dummy_entry.pack_forget()) + + self.lift() + self.attach.focus() + + def _iconify(self): + if self.disable: return + if self.hide: + self.event_generate("<>") + self.hide = False + self.place_dropdown() + else: + self.place_forget() + self.hide = True + + def _attach_key_press(self, k): + self.event_generate("<>") + self.fade = True + if self.command: + self.command(k) + self.fade = False + self.place_forget() + self.hide = True + + def live_update(self, string=None): + if not self.appear: return + if self.disable: return + if self.fade: return + if string: + string = string.lower() + self._deiconify() + i=1 + for key in self.widgets.keys(): + s = self.widgets[key].cget("text").lower() + text_similarity = difflib.SequenceMatcher(None, s[0:len(string)], string).ratio() + similar = s.startswith(string) or text_similarity > 0.75 + if not similar: + self.widgets[key].pack_forget() + else: + self.widgets[key].pack(fill="x", pady=2, padx=(self.padding, 0)) + i+=1 + + if i==1: + self.no_match.pack(fill="x", pady=2, padx=(self.padding, 0)) + else: + self.no_match.pack_forget() + self.button_num = i + self.place_dropdown() + + else: + self.no_match.pack_forget() + self.button_num = len(self.values) + for key in self.widgets.keys(): + self.widgets[key].destroy() + self._init_buttons() + self.place_dropdown() + + self.frame._parent_canvas.yview_moveto(0.0) + self.appear = False + + def insert(self, value, **kwargs): + self.widgets[self.i] = customtkinter.CTkButton(self.frame, + text=value, + height=self.button_height, + fg_color=self.button_color, + text_color=self.text_color, + anchor=self.justify, + command=lambda k=value: self._attach_key_press(k), **kwargs) + self.widgets[self.i].pack(fill="x", pady=2, padx=(self.padding, 0)) + self.i+=1 + self.values.append(value) + + def _deiconify(self): + if len(self.values)>0: + self.pack_forget() + + def popup(self, x=None, y=None): + self.x = x + self.y = y + self.hide = True + self._iconify() + + def configure(self, **kwargs): + if "height" in kwargs: + self.height = kwargs.pop("height") + self.height_new = self.height + + if "alpha" in kwargs: + self.alpha = kwargs.pop("alpha") + + if "width" in kwargs: + self.width = kwargs.pop("width") + + if "fg_color" in kwargs: + self.frame.configure(fg_color=kwargs.pop("fg_color")) + + if "values" in kwargs: + self.values = kwargs.pop("values") + self.image_values = None + self.button_num = len(self.values) + for key in self.widgets.keys(): + self.widgets[key].destroy() + self._init_buttons() + + if "image_values" in kwargs: + self.image_values = kwargs.pop("image_values") + self.image_values = None if len(self.image_values)!=len(self.values) else self.image_values + if self.image_values is not None: + i=0 + for key in self.widgets.keys(): + self.widgets[key].configure(image=self.image_values[i]) + i+=1 + + if "button_color" in kwargs: + for key in self.widgets.keys(): + self.widgets[key].configure(fg_color=kwargs.pop("button_color")) + + for key in self.widgets.keys(): + self.widgets[key].configure(**kwargs) diff --git a/MCE/custom_widgets/ctk_templatedialog.py b/MCE/custom_widgets/ctk_templatedialog.py new file mode 100644 index 0000000..553bed0 --- /dev/null +++ b/MCE/custom_widgets/ctk_templatedialog.py @@ -0,0 +1,133 @@ +from typing import Union, Tuple, Optional +from customtkinter import CTkLabel, CTkEntry, CTkButton, ThemeManager, CTkToplevel, CTkFont, CTkOptionMenu + + +class CTkTemplateDialog(CTkToplevel): + """ + Dialog with extra window, message, entry widget, cancel and ok button. + For detailed information check out the documentation. + """ + + def __init__(self, + fg_color: Optional[Union[str, Tuple[str, str]]] = None, + text_color: Optional[Union[str, Tuple[str, str]]] = None, + button_fg_color: Optional[Union[str, Tuple[str, str]]] = None, + button_hover_color: Optional[Union[str, Tuple[str, str]]] = None, + button_text_color: Optional[Union[str, Tuple[str, str]]] = None, + entry_fg_color: Optional[Union[str, Tuple[str, str]]] = None, + entry_border_color: Optional[Union[str, Tuple[str, str]]] = None, + entry_text_color: Optional[Union[str, Tuple[str, str]]] = None, + + title: str = "CTkDialog", + font: Optional[Union[tuple, CTkFont]] = None, + text: str = "CTkDialog", + values = []): + + super().__init__(fg_color=fg_color) + + self._fg_color = ThemeManager.theme["CTkToplevel"]["fg_color"] if fg_color is None else self._check_color_type(fg_color) + self._text_color = ThemeManager.theme["CTkLabel"]["text_color"] if text_color is None else self._check_color_type(button_hover_color) + self._button_fg_color = ThemeManager.theme["CTkButton"]["fg_color"] if button_fg_color is None else self._check_color_type(button_fg_color) + self._button_hover_color = ThemeManager.theme["CTkButton"]["hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) + self._button_text_color = ThemeManager.theme["CTkButton"]["text_color"] if button_text_color is None else self._check_color_type(button_text_color) + self._entry_fg_color = ThemeManager.theme["CTkEntry"]["fg_color"] if entry_fg_color is None else self._check_color_type(entry_fg_color) + self._entry_border_color = ThemeManager.theme["CTkEntry"]["border_color"] if entry_border_color is None else self._check_color_type(entry_border_color) + self._entry_text_color = ThemeManager.theme["CTkEntry"]["text_color"] if entry_text_color is None else self._check_color_type(entry_text_color) + + self._user_input = ("", "") + self._running: bool = False + self._title = title + self._text = text + self._font = font + self._values = [""] + values + + self.title(self._title) + self.lift() # lift window on top + self.attributes("-topmost", True) # stay on top + self.protocol("WM_DELETE_WINDOW", self._on_closing) + self.after(10, self._create_widgets) # create widgets with slight delay, to avoid white flickering of background + self.resizable(False, False) + self.grab_set() # make other windows not clickable + + def _create_widgets(self): + self.grid_columnconfigure((0, 1), weight=1) + self.rowconfigure(0, weight=1) + + self._label = CTkLabel(master=self, + width=300, + wraplength=300, + fg_color="transparent", + text_color=self._text_color, + text=self._text, + font=self._font) + self._label.grid(row=0, column=0, columnspan=2, padx=20, pady=20, sticky="ew") + + self._entry = CTkEntry(master=self, + width=230, + fg_color=self._entry_fg_color, + border_color=self._entry_border_color, + text_color=self._entry_text_color, + font=self._font) + self._entry.grid(row=1, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="ew") + + self._label2 = CTkLabel(master=self, + width=100, + wraplength=100, + fg_color="transparent", + text_color=self._text_color, + text="Import stages from: ", + font=self._font) + self._label2.grid(row=2, column=0, columnspan=1, padx=(20, 10), pady=(0, 20), sticky="ew") + + self._template_optionmenu = CTkOptionMenu(master=self, + width=100, + fg_color=self._button_fg_color, + button_hover_color=self._button_hover_color, + text_color=self._button_text_color, + font=self._font, + values=self._values + ) + self._template_optionmenu.grid(row=2, column=1, columnspan=1, padx=(10, 20), pady=(0, 20), sticky="ew") + + + self._ok_button = CTkButton(master=self, + width=100, + border_width=0, + fg_color=self._button_fg_color, + hover_color=self._button_hover_color, + text_color=self._button_text_color, + text='Ok', + font=self._font, + command=self._ok_event) + self._ok_button.grid(row=3, column=0, columnspan=1, padx=(20, 10), pady=(0, 20), sticky="ew") + + self._cancel_button = CTkButton(master=self, + width=100, + border_width=0, + fg_color=self._button_fg_color, + hover_color=self._button_hover_color, + text_color=self._button_text_color, + text='Cancel', + font=self._font, + command=self._cancel_event) + self._cancel_button.grid(row=3, column=1, columnspan=1, padx=(10, 20), pady=(0, 20), sticky="ew") + + self.after(150, lambda: self._entry.focus()) # set focus to entry with slight delay, otherwise it won't work + self._entry.bind("", self._ok_event) + + def _ok_event(self, event=None): + self._user_input = self._entry.get(), self._template_optionmenu.get() + self.grab_release() + self.destroy() + + def _on_closing(self): + self.grab_release() + self.destroy() + + def _cancel_event(self): + self.grab_release() + self.destroy() + + def get_input(self): + self.master.wait_window(self) + return self._user_input diff --git a/MCE/custom_widgets/ctk_timeentry.py b/MCE/custom_widgets/ctk_timeentry.py new file mode 100644 index 0000000..ee0aea0 --- /dev/null +++ b/MCE/custom_widgets/ctk_timeentry.py @@ -0,0 +1,36 @@ +import customtkinter +import tkinter as tk + +class CTkTimeEntry(customtkinter.CTkFrame): + def __init__(self, master=None, **kwargs): + super().__init__(master, **kwargs) + self.hour = tk.StringVar() + self.minute = tk.StringVar() + self.second = tk.StringVar() + + self.hour_entry = customtkinter.CTkEntry(self, width=50, textvariable=self.hour, validate="key", validatecommand=(self.register(self.validate_hour), '%P')) + self.hour_entry.pack(side=tk.LEFT) + + self.minute_entry = customtkinter.CTkEntry(self,width=50, textvariable=self.minute, validate="key", validatecommand=(self.register(self.validate_min_sec), '%P')) + self.minute_entry.pack(side=tk.LEFT) + + self.second_entry = customtkinter.CTkEntry(self, width=50, textvariable=self.second, validate="key", validatecommand=(self.register(self.validate_min_sec), '%P')) + self.second_entry.pack(side=tk.LEFT) + + def validate_hour(self, P): + return len(P) <= 2 and (P.isdigit() and int(P) <= 23 or P == "") + + def validate_min_sec(self, P): + return len(P) <= 2 and (P.isdigit() and int(P) <= 59 or P == "") + + def set(self, time_str): + h, m, s = map(str, time_str.split(':')) + self.hour.set(h) + self.minute.set(m) + self.second.set(s) + + def get(self): + h = self.hour.get() if self.hour.get() else "00" + m = self.minute.get() if self.minute.get() else "00" + s = self.second.get() if self.second.get() else "00" + return f"{h.zfill(2)}:{m.zfill(2)}:{s.zfill(2)}" \ No newline at end of file diff --git a/MCE/custom_widgets/ctk_tooltip.py b/MCE/custom_widgets/ctk_tooltip.py new file mode 100644 index 0000000..7304965 --- /dev/null +++ b/MCE/custom_widgets/ctk_tooltip.py @@ -0,0 +1,212 @@ +""" +CTkToolTip Widget +version: 0.8 +""" + +import time +import sys +import customtkinter +from tkinter import Toplevel, Frame + + +class CTkToolTip(Toplevel): + """ + Creates a ToolTip (pop-up) widget for customtkinter. + """ + + def __init__( + self, + widget: any = None, + message: str = None, + delay: float = 0.2, + follow: bool = True, + x_offset: int = +20, + y_offset: int = +10, + bg_color: str = None, + corner_radius: int = 10, + border_width: int = 0, + border_color: str = None, + alpha: float = 0.95, + padding: tuple = (10, 2), + **message_kwargs): + + super().__init__() + + self.widget = widget + + self.withdraw() + + # Disable ToolTip's title bar + self.overrideredirect(True) + + if sys.platform.startswith("win"): + self.transparent_color = self.widget._apply_appearance_mode( + customtkinter.ThemeManager.theme["CTkToplevel"]["fg_color"]) + self.attributes("-transparentcolor", self.transparent_color) + self.transient() + elif sys.platform.startswith("darwin"): + self.transparent_color = 'systemTransparent' + self.attributes("-transparent", True) + self.transient(self.master) + else: + self.transparent_color = '#000001' + corner_radius = 0 + self.transient() + + self.resizable(width=True, height=True) + + # Make the background transparent + self.config(background=self.transparent_color) + + # StringVar instance for msg string + self.messageVar = customtkinter.StringVar() + self.message = message + self.messageVar.set(self.message) + + self.delay = delay + self.follow = follow + self.x_offset = x_offset + self.y_offset = y_offset + self.corner_radius = corner_radius + self.alpha = alpha + self.border_width = border_width + self.padding = padding + self.bg_color = customtkinter.ThemeManager.theme["CTkFrame"]["fg_color"] if bg_color is None else bg_color + self.border_color = border_color + self.disable = False + + # visibility status of the ToolTip inside|outside|visible + self.status = "outside" + self.last_moved = 0 + self.attributes('-alpha', self.alpha) + + if sys.platform.startswith("win"): + if self.widget._apply_appearance_mode(self.bg_color) == self.transparent_color: + self.transparent_color = "#000001" + self.config(background=self.transparent_color) + self.attributes("-transparentcolor", self.transparent_color) + + # Add the message widget inside the tooltip + self.transparent_frame = Frame(self, bg=self.transparent_color) + self.transparent_frame.pack(padx=0, pady=0, fill="both", expand=True) + + self.frame = customtkinter.CTkFrame(self.transparent_frame, bg_color=self.transparent_color, + corner_radius=self.corner_radius, + border_width=self.border_width, fg_color=self.bg_color, + border_color=self.border_color) + self.frame.pack(padx=0, pady=0, fill="both", expand=True) + + self.message_label = customtkinter.CTkLabel(self.frame, textvariable=self.messageVar, **message_kwargs) + self.message_label.pack(fill="both", padx=self.padding[0] + self.border_width, + pady=self.padding[1] + self.border_width, expand=True) + + if self.widget.winfo_name() != "tk": + if self.frame.cget("fg_color") == self.widget.cget("bg_color"): + if not bg_color: + self._top_fg_color = self.frame._apply_appearance_mode( + customtkinter.ThemeManager.theme["CTkFrame"]["top_fg_color"]) + if self._top_fg_color != self.transparent_color: + self.frame.configure(fg_color=self._top_fg_color) + + # Add bindings to the widget without overriding the existing ones + self.widget.bind("", self.on_enter, add="+") + self.widget.bind("", self.on_leave, add="+") + self.widget.bind("", self.on_enter, add="+") + self.widget.bind("", self.on_enter, add="+") + self.widget.bind("", lambda _: self.hide(), add="+") + + def show(self) -> None: + """ + Enable the widget. + """ + self.disable = False + + def on_enter(self, event) -> None: + """ + Processes motion within the widget including entering and moving. + """ + + if self.disable: + return + self.last_moved = time.time() + + # Set the status as inside for the very first time + if self.status == "outside": + self.status = "inside" + + # If the follow flag is not set, motion within the widget will make the ToolTip dissapear + if not self.follow: + self.status = "inside" + self.withdraw() + + # Calculate available space on the right side of the widget relative to the screen + root_width = self.winfo_screenwidth() + widget_x = event.x_root + space_on_right = root_width - widget_x + + # Calculate the width of the tooltip's text based on the length of the message string + text_width = self.message_label.winfo_reqwidth() + + # Calculate the offset based on available space and text width to avoid going off-screen on the right side + offset_x = self.x_offset + if space_on_right < text_width + 20: # Adjust the threshold as needed + offset_x = -text_width - 20 # Negative offset when space is limited on the right side + + # Offsets the ToolTip using the coordinates od an event as an origin + self.geometry(f"+{event.x_root + offset_x}+{event.y_root + self.y_offset}") + + # Time is in integer: milliseconds + self.after(int(self.delay * 1000), self._show) + + def on_leave(self, event=None) -> None: + """ + Hides the ToolTip temporarily. + """ + + if self.disable: return + self.status = "outside" + self.withdraw() + + def _show(self) -> None: + """ + Displays the ToolTip. + """ + + if not self.widget.winfo_exists(): + self.hide() + self.destroy() + + if self.status == "inside" and time.time() - self.last_moved >= self.delay: + self.status = "visible" + self.deiconify() + + def hide(self) -> None: + """ + Disable the widget from appearing. + """ + if not self.winfo_exists(): + return + self.withdraw() + self.disable = True + + def is_disabled(self) -> None: + """ + Return the window state + """ + return self.disable + + def get(self) -> None: + """ + Returns the text on the tooltip. + """ + return self.messageVar.get() + + def configure(self, message: str = None, delay: float = None, bg_color: str = None, **kwargs): + """ + Set new message or configure the label parameters. + """ + if delay: self.delay = delay + if bg_color: self.frame.configure(fg_color=bg_color) + + self.messageVar.set(message) + self.message_label.configure(**kwargs) diff --git a/MCE/custom_widgets/ctkmessagebox.py b/MCE/custom_widgets/ctkmessagebox.py new file mode 100644 index 0000000..1a7465c --- /dev/null +++ b/MCE/custom_widgets/ctkmessagebox.py @@ -0,0 +1,448 @@ +""" +CustomTkinter Messagebox +Author: Akash Bora +Version: 2.5 +""" + +import customtkinter +from PIL import Image, ImageTk +import os +import sys +import time +from typing import Literal + +class CTkMessagebox(customtkinter.CTkToplevel): + ICONS = { + "check": None, + "cancel": None, + "info": None, + "question": None, + "warning": None + } + ICON_BITMAP = {} + def __init__(self, + master: any = None, + width: int = 400, + height: int = 200, + title: str = "CTkMessagebox", + message: str = "This is a CTkMessagebox!", + option_1: str = "OK", + option_2: str = None, + option_3: str = None, + options: list = [], + border_width: int = 1, + border_color: str = "default", + button_color: str = "default", + bg_color: str = "default", + fg_color: str = "default", + text_color: str = "default", + title_color: str = "default", + button_text_color: str = "default", + button_width: int = None, + button_height: int = None, + cancel_button_color: str = None, + cancel_button: str = None, # types: circle, cross or none + button_hover_color: str = "default", + icon: str = "info", + icon_size: tuple = None, + corner_radius: int = 15, + justify: str = "right", + font: tuple = None, + header: bool = False, + topmost: bool = True, + fade_in_duration: int = 0, + sound: bool = False, + option_focus: Literal[1, 2, 3] = None): + + super().__init__() + + + self.master_window = master + + self.width = 250 if width<250 else width + self.height = 150 if height<150 else height + + if self.master_window is None: + self.spawn_x = int((self.winfo_screenwidth()-self.width)/2) + self.spawn_y = int((self.winfo_screenheight()-self.height)/2) + else: + self.spawn_x = int(self.master_window.winfo_width() * .5 + self.master_window.winfo_x() - .5 * self.width + 7) + self.spawn_y = int(self.master_window.winfo_height() * .5 + self.master_window.winfo_y() - .5 * self.height + 20) + + self.after(10) + self.geometry(f"{self.width}x{self.height}+{self.spawn_x}+{self.spawn_y}") + self.title(title) + self.resizable(width=False, height=False) + self.fade = fade_in_duration + + if self.fade: + self.fade = 20 if self.fade<20 else self.fade + self.attributes("-alpha", 0) + + if not header: + self.overrideredirect(1) + + if topmost: + self.attributes("-topmost", True) + else: + self.transient(self.master_window) + + if sys.platform.startswith("win"): + self.transparent_color = self._apply_appearance_mode(self.cget("fg_color")) + self.attributes("-transparentcolor", self.transparent_color) + default_cancel_button = "cross" + elif sys.platform.startswith("darwin"): + self.transparent_color = 'systemTransparent' + self.attributes("-transparent", True) + default_cancel_button = "circle" + else: + self.transparent_color = '#000001' + corner_radius = 0 + default_cancel_button = "cross" + + self.lift() + + self.config(background=self.transparent_color) + self.protocol("WM_DELETE_WINDOW", self.button_event) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + self.x = self.winfo_x() + self.y = self.winfo_y() + self._title = title + self.message = message + self.font = font + self.justify = justify + self.sound = sound + self.cancel_button = cancel_button if cancel_button else default_cancel_button + self.round_corners = corner_radius if corner_radius<=30 else 30 + self.button_width = button_width if button_width else self.width/4 + self.button_height = button_height if button_height else 28 + + if self.fade: self.attributes("-alpha", 0) + + if self.button_height>self.height/4: self.button_height = self.height/4 -20 + self.dot_color = cancel_button_color + self.border_width = border_width if border_width<6 else 5 + + if type(options) is list and len(options)>0: + try: + option_1 = options[-1] + option_2 = options[-2] + option_3 = options[-3] + except IndexError: None + + if bg_color=="default": + self.bg_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkFrame"]["fg_color"]) + else: + self.bg_color = bg_color + + if fg_color=="default": + self.fg_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkFrame"]["top_fg_color"]) + else: + self.fg_color = fg_color + + default_button_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkButton"]["fg_color"]) + + if sys.platform.startswith("win"): + if self.bg_color==self.transparent_color or self.fg_color==self.transparent_color: + self.configure(fg_color="#000001") + self.transparent_color = "#000001" + self.attributes("-transparentcolor", self.transparent_color) + + if button_color=="default": + self.button_color = (default_button_color, default_button_color, default_button_color) + else: + if type(button_color) is tuple: + if len(button_color)==2: + self.button_color = (button_color[0], button_color[1], default_button_color) + elif len(button_color)==1: + self.button_color = (button_color[0], default_button_color, default_button_color) + else: + self.button_color = button_color + else: + self.button_color = (button_color, button_color, button_color) + + if text_color=="default": + self.text_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkLabel"]["text_color"]) + else: + self.text_color = text_color + + if title_color=="default": + self.title_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkLabel"]["text_color"]) + else: + self.title_color = title_color + + if button_text_color=="default": + self.bt_text_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkButton"]["text_color"]) + else: + self.bt_text_color = button_text_color + + if button_hover_color=="default": + self.bt_hv_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkButton"]["hover_color"]) + else: + self.bt_hv_color = button_hover_color + + if border_color=="default": + self.border_color = self._apply_appearance_mode(customtkinter.ThemeManager.theme["CTkFrame"]["border_color"]) + else: + self.border_color = border_color + + if icon_size: + self.size_height = icon_size[1] if icon_size[1]<=self.height-100 else self.height-100 + self.size = (icon_size[0], self.size_height) + else: + self.size = (self.height/4, self.height/4) + + self.icon = self.load_icon(icon, icon_size) if icon else None + + self.frame_top = customtkinter.CTkFrame(self, corner_radius=self.round_corners, width=self.width, border_width=self.border_width, + bg_color=self.transparent_color, fg_color=self.bg_color, border_color=self.border_color) + self.frame_top.grid(sticky="nswe") + + if button_width: + self.frame_top.grid_columnconfigure(0, weight=1) + else: + self.frame_top.grid_columnconfigure((1,2,3), weight=1) + + if button_height: + self.frame_top.grid_rowconfigure((0,1,3), weight=1) + else: + self.frame_top.grid_rowconfigure((0,1,2), weight=1) + + self.frame_top.bind("", self.move_window) + self.frame_top.bind("", self.oldxyset) + + if self.cancel_button=="cross": + self.button_close = customtkinter.CTkButton(self.frame_top, corner_radius=10, width=0, height=0, hover=False, border_width=0, + text_color=self.dot_color if self.dot_color else self.title_color, + text="✕", fg_color="transparent", command=self.button_event) + self.button_close.grid(row=0, column=5, sticky="ne", padx=5+self.border_width, pady=5+self.border_width) + elif self.cancel_button=="circle": + self.button_close = customtkinter.CTkButton(self.frame_top, corner_radius=10, width=10, height=10, hover=False, border_width=0, + text="", fg_color=self.dot_color if self.dot_color else "#c42b1c", command=self.button_event) + self.button_close.grid(row=0, column=5, sticky="ne", padx=10, pady=10) + + self.title_label = customtkinter.CTkLabel(self.frame_top, width=1, text=self._title, text_color=self.title_color, font=self.font) + self.title_label.grid(row=0, column=0, columnspan=6, sticky="nw", padx=(15,30), pady=5) + self.title_label.bind("", self.move_window) + self.title_label.bind("", self.oldxyset) + + self.info = customtkinter.CTkButton(self.frame_top, width=1, height=self.height/2, corner_radius=0, text=self.message, font=self.font, + fg_color=self.fg_color, hover=False, text_color=self.text_color, image=self.icon) + self.info._text_label.configure(wraplength=self.width/2, justify="left") + self.info.grid(row=1, column=0, columnspan=6, sticky="nwes", padx=self.border_width) + + if self.info._text_label.winfo_reqheight()>self.height/2: + height_offset = int((self.info._text_label.winfo_reqheight())-(self.height/2) + self.height) + self.geometry(f"{self.width}x{height_offset}") + + + self.option_text_1 = option_1 + + self.button_1 = customtkinter.CTkButton(self.frame_top, text=self.option_text_1, fg_color=self.button_color[0], + width=self.button_width, font=self.font, text_color=self.bt_text_color, + hover_color=self.bt_hv_color, height=self.button_height, + command=lambda: self.button_event(self.option_text_1)) + + + self.option_text_2 = option_2 + if option_2: + self.button_2 = customtkinter.CTkButton(self.frame_top, text=self.option_text_2, fg_color=self.button_color[1], + width=self.button_width, font=self.font, text_color=self.bt_text_color, + hover_color=self.bt_hv_color, height=self.button_height, + command=lambda: self.button_event(self.option_text_2)) + + self.option_text_3 = option_3 + if option_3: + self.button_3 = customtkinter.CTkButton(self.frame_top, text=self.option_text_3, fg_color=self.button_color[2], + width=self.button_width, font=self.font, text_color=self.bt_text_color, + hover_color=self.bt_hv_color, height=self.button_height, + command=lambda: self.button_event(self.option_text_3)) + + if self.justify=="center": + if button_width: + columns = [4,3,2] + span = 1 + else: + columns = [4,2,0] + span = 2 + if option_3: + self.frame_top.columnconfigure((0,1,2,3,4,5), weight=1) + self.button_1.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(0,10), pady=10) + self.button_2.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=10, pady=10) + self.button_3.grid(row=2, column=columns[2], columnspan=span, sticky="news", padx=(10,0), pady=10) + elif option_2: + self.frame_top.columnconfigure((0,5), weight=1) + columns = [2,3] + self.button_1.grid(row=2, column=columns[0], sticky="news", padx=(0,5), pady=10) + self.button_2.grid(row=2, column=columns[1], sticky="news", padx=(5,0), pady=10) + else: + if button_width: + self.frame_top.columnconfigure((0,1,2,3,4,5), weight=1) + else: + self.frame_top.columnconfigure((0,2,4), weight=2) + self.button_1.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=(0,10), pady=10) + elif self.justify=="left": + self.frame_top.columnconfigure((0,1,2,3,4,5), weight=1) + if button_width: + columns = [0,1,2] + span = 1 + else: + columns = [0,2,4] + span = 2 + if option_3: + self.button_1.grid(row=2, column=columns[2], columnspan=span, sticky="news", padx=(0,10), pady=10) + self.button_2.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=10, pady=10) + self.button_3.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(10,0), pady=10) + elif option_2: + self.button_1.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=10, pady=10) + self.button_2.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(10,0), pady=10) + else: + self.button_1.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(10,0), pady=10) + else: + self.frame_top.columnconfigure((0,1,2,3,4,5), weight=1) + if button_width: + columns = [5,4,3] + span = 1 + else: + columns = [4,2,0] + span = 2 + self.button_1.grid(row=2, column=columns[0], columnspan=span, sticky="news", padx=(0,10), pady=10) + if option_2: + self.button_2.grid(row=2, column=columns[1], columnspan=span, sticky="news", padx=10, pady=10) + if option_3: + self.button_3.grid(row=2, column=columns[2], columnspan=span, sticky="news", padx=(10,0), pady=10) + + if header: + self.title_label.configure(text="") + self.title_label.grid_configure(pady=0) + self.button_close.configure(text_color=self.bg_color) + self.frame_top.configure(corner_radius=0) + + if self.winfo_exists(): + self.grab_set() + + if self.sound: + self.bell() + + if self.fade: + self.fade_in() + + if option_focus: + self.option_focus = option_focus + self.focus_button(self.option_focus) + else: + if not self.option_text_2 and not self.option_text_3: + self.button_1.focus() + self.button_1.bind("", lambda event: self.button_event(self.option_text_1)) + + self.bind("", lambda e: self.button_event()) + + def focus_button(self, option_focus): + try: + self.selected_button = getattr(self, "button_"+str(option_focus)) + self.selected_button.focus() + self.selected_button.configure(border_color=self.bt_hv_color, border_width=3) + self.selected_option = getattr(self, "option_text_"+str(option_focus)) + self.selected_button.bind("", lambda event: self.button_event(self.selected_option)) + except AttributeError: + return + + self.bind("", lambda e: self.change_left()) + self.bind("", lambda e: self.change_right()) + + def change_left(self): + if self.option_focus==3: + return + + self.selected_button.unbind("") + self.selected_button.configure(border_width=0) + + if self.option_focus==1: + if self.option_text_2: + self.option_focus = 2 + + elif self.option_focus==2: + if self.option_text_3: + self.option_focus = 3 + + self.focus_button(self.option_focus) + + def change_right(self): + if self.option_focus==1: + return + + self.selected_button.unbind("") + self.selected_button.configure(border_width=0) + + if self.option_focus==2: + self.option_focus = 1 + + elif self.option_focus==3: + self.option_focus = 2 + + self.focus_button(self.option_focus) + + def load_icon(self, icon, icon_size): + if icon not in self.ICONS or self.ICONS[icon] is None: + if icon in ["check", "cancel", "info", "question", "warning"]: + image_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'icons', icon + '.png') + else: + image_path = icon + if icon_size: + size_height = icon_size[1] if icon_size[1] <= self.height - 100 else self.height - 100 + size = (icon_size[0], size_height) + else: + size = (self.height / 4, self.height / 4) + self.ICONS[icon] = customtkinter.CTkImage(Image.open(image_path), size=size) + self.ICON_BITMAP[icon] = ImageTk.PhotoImage(file=image_path) + self.after(200, lambda: self.iconphoto(False, self.ICON_BITMAP[icon])) + return self.ICONS[icon] + + def fade_in(self): + for i in range(0,110,10): + if not self.winfo_exists(): + break + self.attributes("-alpha", i/100) + self.update() + time.sleep(1/self.fade) + + def fade_out(self): + for i in range(100,0,-10): + if not self.winfo_exists(): + break + self.attributes("-alpha", i/100) + self.update() + time.sleep(1/self.fade) + + def get(self): + if self.winfo_exists(): + self.master.wait_window(self) + return self.event + + def oldxyset(self, event): + self.oldx = event.x + self.oldy = event.y + + def move_window(self, event): + self.y = event.y_root - self.oldy + self.x = event.x_root - self.oldx + self.geometry(f'+{self.x}+{self.y}') + + def button_event(self, event=None): + try: + self.button_1.configure(state="disabled") + self.button_2.configure(state="disabled") + self.button_3.configure(state="disabled") + except AttributeError: + pass + + if self.fade: + self.fade_out() + self.grab_release() + self.destroy() + self.event = event + +if __name__ == "__main__": + app = CTkMessagebox() + app.mainloop() diff --git a/MCE/custom_widgets/icons/cancel.png b/MCE/custom_widgets/icons/cancel.png new file mode 100644 index 0000000..dcae66e Binary files /dev/null and b/MCE/custom_widgets/icons/cancel.png differ diff --git a/MCE/custom_widgets/icons/check.png b/MCE/custom_widgets/icons/check.png new file mode 100644 index 0000000..dc0892a Binary files /dev/null and b/MCE/custom_widgets/icons/check.png differ diff --git a/MCE/custom_widgets/icons/info.png b/MCE/custom_widgets/icons/info.png new file mode 100644 index 0000000..4b38288 Binary files /dev/null and b/MCE/custom_widgets/icons/info.png differ diff --git a/MCE/custom_widgets/icons/question.png b/MCE/custom_widgets/icons/question.png new file mode 100644 index 0000000..24f77c3 Binary files /dev/null and b/MCE/custom_widgets/icons/question.png differ diff --git a/MCE/custom_widgets/icons/warning.png b/MCE/custom_widgets/icons/warning.png new file mode 100644 index 0000000..bc6aea6 Binary files /dev/null and b/MCE/custom_widgets/icons/warning.png differ diff --git a/MCE/student_list/EN.json b/MCE/student_list/EN.json new file mode 100644 index 0000000..9facb11 --- /dev/null +++ b/MCE/student_list/EN.json @@ -0,0 +1,158 @@ +[ + "Airi", + "Akane", + "Akane (Bunny Girl)", + "Akari", + "Ako", + "Arisu", + "Arisu (Maid)", + "Aru", + "Aru (New Year)", + "Asuna", + "Asuna (Bunny Girl)", + "Atsuko", + "Ayane", + "Ayane (Swimsuit)", + "Azusa", + "Azusa (Swimsuit)", + "Cherino", + "Cherino (Hot Spring)", + "Chihiro", + "Chinatsu", + "Chinatsu (Hot Spring)", + "Chise", + "Chise (Swimsuit)", + "Eimi", + "Fubuki", + "Fuuka", + "Fuuka (New Year)", + "Hanae", + "Hanae (Christmas)", + "Hanako", + "Hanako (Swimsuit)", + "Hare", + "Haruka", + "Haruka (New Year)", + "Haruna", + "Haruna (New Year)", + "Haruna (Sportswear)", + "Hasumi", + "Hasumi (Sportswear)", + "Hatsune Miku", + "Hibiki", + "Hibiki (Cheerleader)", + "Hifumi", + "Hifumi (Swimsuit)", + "Himari", + "Hina", + "Hina (Swimsuit)", + "Hinata", + "Hinata (Swimsuit)", + "Hiyori", + "Hoshino", + "Hoshino (Swimsuit)", + "Iori", + "Iori (Swimsuit)", + "Iroha", + "Izumi", + "Izumi (Swimsuit)", + "Izuna", + "Izuna (Swimsuit)", + "Junko", + "Junko (New Year)", + "Juri", + "Kaede", + "Kaho", + "Kanna", + "Karin", + "Karin (Bunny Girl)", + "Kayoko", + "Kayoko (New Year)", + "Kazusa", + "Kirino", + "Koharu", + "Koharu (Swimsuit)", + "Kokona", + "Kotama", + "Kotori", + "Kotori (Cheerleader)", + "Koyuki", + "Maki", + "Mari", + "Mari (Sportswear)", + "Marina", + "Mashiro", + "Mashiro (Swimsuit)", + "Megu", + "Meru", + "Michiru", + "Midori", + "Mika", + "Mimori", + "Mimori (Swimsuit)", + "Mina", + "Mine", + "Minori", + "Misaki", + "Miyako", + "Miyako (Swimsuit)", + "Miyu", + "Miyu (Swimsuit)", + "Moe", + "Momiji", + "Momoi", + "Mutsuki", + "Mutsuki (New Year)", + "Nagisa", + "Natsu", + "Neru", + "Neru (Bunny Girl)", + "Noa", + "Nodoka", + "Nodoka (Hot Spring)", + "Nonomi", + "Nonomi (Swimsuit)", + "Pina", + "Reisa", + "Rumi", + "Saki", + "Saki (Swimsuit)", + "Sakurako", + "Saori", + "Saya", + "Saya (Casual)", + "Sena", + "Serika", + "Serika (New Year)", + "Serina", + "Serina (Christmas)", + "Shigure", + "Shimiko", + "Shiroko", + "Shiroko (Riding)", + "Shiroko (Swimsuit)", + "Shizuko", + "Shizuko (Swimsuit)", + "Shun", + "Shun (Kid)", + "Sumire", + "Suzumi", + "Toki", + "Toki (Bunny Girl)", + "Tomoe", + "Tsubaki", + "Tsukuyo", + "Tsurugi", + "Tsurugi (Swimsuit)", + "Ui", + "Ui (Swimsuit)", + "Utaha", + "Utaha (Cheerleader)", + "Wakamo", + "Wakamo (Swimsuit)", + "Yoshimi", + "Yuuka", + "Yuuka (Sportswear)", + "Yuzu", + "Yuzu (Maid)" +] \ No newline at end of file diff --git a/MCE/student_list/JP.json b/MCE/student_list/JP.json new file mode 100644 index 0000000..b0a03f3 --- /dev/null +++ b/MCE/student_list/JP.json @@ -0,0 +1,168 @@ +[ + "\u30a2\u30a4\u30ea", + "\u30a2\u30ab\u30cd", + "\u30a2\u30ab\u30cd\n\uff08\u30d0\u30cb\u30fc\u30ac\u30fc\u30eb\uff09", + "\u30a2\u30ab\u30ea", + "\u30a2\u30b3", + "\u30a2\u30b9\u30ca", + "\u30a2\u30b9\u30ca\n\uff08\u30d0\u30cb\u30fc\u30ac\u30fc\u30eb\uff09", + "\u30a2\u30ba\u30b5", + "\u30a2\u30ba\u30b5\uff08\u6c34\u7740\uff09", + "\u30a2\u30c4\u30b3", + "\u30a2\u30e4\u30cd", + "\u30a2\u30e4\u30cd\uff08\u6c34\u7740\uff09", + "\u30a2\u30ea\u30b9", + "\u30a2\u30ea\u30b9\uff08\u30e1\u30a4\u30c9\uff09", + "\u30a2\u30eb", + "\u30a2\u30eb\uff08\u6b63\u6708\uff09", + "\u30a4\u30aa\u30ea", + "\u30a4\u30aa\u30ea\uff08\u6c34\u7740\uff09", + "\u30a4\u30ba\u30ca", + "\u30a4\u30ba\u30ca\uff08\u6c34\u7740\uff09", + "\u30a4\u30ba\u30df", + "\u30a4\u30ba\u30df\uff08\u6c34\u7740\uff09", + "\u30a4\u30c1\u30ab", + "\u30a4\u30ed\u30cf", + "\u30a6\u30a4", + "\u30a6\u30a4\uff08\u6c34\u7740\uff09", + "\u30a6\u30bf\u30cf", + "\u30a6\u30bf\u30cf\uff08\u5fdc\u63f4\u56e3\uff09", + "\u30a8\u30a4\u30df", + "\u30a8\u30a4\u30df\uff08\u6c34\u7740\uff09", + "\u30ab\u30a8\u30c7", + "\u30ab\u30b9\u30df", + "\u30ab\u30ba\u30b5", + "\u30ab\u30db", + "\u30ab\u30e8\u30b3", + "\u30ab\u30e8\u30b3\uff08\u6b63\u6708\uff09", + "\u30ab\u30ea\u30f3", + "\u30ab\u30ea\u30f3\n\uff08\u30d0\u30cb\u30fc\u30ac\u30fc\u30eb\uff09", + "\u30ab\u30f3\u30ca", + "\u30ad\u30ad\u30e7\u30a6", + "\u30ad\u30ea\u30ce", + "\u30b3\u30b3\u30ca", + "\u30b3\u30bf\u30de", + "\u30b3\u30c8\u30ea", + "\u30b3\u30c8\u30ea\uff08\u5fdc\u63f4\u56e3\uff09", + "\u30b3\u30cf\u30eb", + "\u30b3\u30cf\u30eb\uff08\u6c34\u7740\uff09", + "\u30b3\u30e6\u30ad", + "\u30b5\u30aa\u30ea", + "\u30b5\u30ad", + "\u30b5\u30ad\uff08\u6c34\u7740\uff09", + "\u30b5\u30af\u30e9\u30b3", + "\u30b5\u30e4", + "\u30b5\u30e4\uff08\u79c1\u670d\uff09", + "\u30b7\u30b0\u30ec", + "\u30b7\u30b0\u30ec\uff08\u6e29\u6cc9\uff09", + "\u30b7\u30ba\u30b3", + "\u30b7\u30ba\u30b3\uff08\u6c34\u7740\uff09", + "\u30b7\u30df\u30b3", + "\u30b7\u30e5\u30f3", + "\u30b7\u30e5\u30f3\uff08\u5e7c\u5973\uff09", + "\u30b7\u30ed\u30b3", + "\u30b7\u30ed\u30b3\n\uff08\u30e9\u30a4\u30c7\u30a3\u30f3\u30b0\uff09", + "\u30b7\u30ed\u30b3\uff08\u6c34\u7740\uff09", + "\u30b8\u30e5\u30ea", + "\u30b8\u30e5\u30f3\u30b3", + "\u30b8\u30e5\u30f3\u30b3\uff08\u6b63\u6708\uff09", + "\u30b9\u30ba\u30df", + "\u30b9\u30df\u30ec", + "\u30bb\u30ca", + "\u30bb\u30ea\u30ab", + "\u30bb\u30ea\u30ab\uff08\u6b63\u6708\uff09", + "\u30bb\u30ea\u30ca", + "\u30bb\u30ea\u30ca\n\uff08\u30af\u30ea\u30b9\u30de\u30b9\uff09", + "\u30c1\u30a7\u30ea\u30ce", + "\u30c1\u30a7\u30ea\u30ce\uff08\u6e29\u6cc9\uff09", + "\u30c1\u30bb", + "\u30c1\u30bb\uff08\u6c34\u7740\uff09", + "\u30c1\u30ca\u30c4", + "\u30c1\u30ca\u30c4\uff08\u6e29\u6cc9\uff09", + "\u30c1\u30d2\u30ed", + "\u30c4\u30af\u30e8", + "\u30c4\u30d0\u30ad", + "\u30c4\u30eb\u30ae", + "\u30c4\u30eb\u30ae\uff08\u6c34\u7740\uff09", + "\u30c8\u30ad", + "\u30c8\u30ad\n\uff08\u30d0\u30cb\u30fc\u30ac\u30fc\u30eb\uff09", + "\u30c8\u30e2\u30a8", + "\u30ca\u30ae\u30b5", + "\u30ca\u30c4", + "\u30cd\u30eb", + "\u30cd\u30eb\n\uff08\u30d0\u30cb\u30fc\u30ac\u30fc\u30eb\uff09", + "\u30ce\u30a2", + "\u30ce\u30c9\u30ab", + "\u30ce\u30c9\u30ab\uff08\u6e29\u6cc9\uff09", + "\u30ce\u30ce\u30df", + "\u30ce\u30ce\u30df\uff08\u6c34\u7740\uff09", + "\u30cf\u30b9\u30df", + "\u30cf\u30b9\u30df\uff08\u4f53\u64cd\u670d\uff09", + "\u30cf\u30ca\u30a8", + "\u30cf\u30ca\u30a8\n\uff08\u30af\u30ea\u30b9\u30de\u30b9\uff09", + "\u30cf\u30ca\u30b3", + "\u30cf\u30ca\u30b3\uff08\u6c34\u7740\uff09", + "\u30cf\u30eb\u30ab", + "\u30cf\u30eb\u30ab\uff08\u6b63\u6708\uff09", + "\u30cf\u30eb\u30ca", + "\u30cf\u30eb\u30ca\uff08\u4f53\u64cd\u670d\uff09", + "\u30cf\u30eb\u30ca\uff08\u6b63\u6708\uff09", + "\u30cf\u30ec", + "\u30d2\u30ca", + "\u30d2\u30ca\u30bf", + "\u30d2\u30ca\u30bf\uff08\u6c34\u7740\uff09", + "\u30d2\u30ca\uff08\u6c34\u7740\uff09", + "\u30d2\u30d3\u30ad", + "\u30d2\u30d3\u30ad\uff08\u5fdc\u63f4\u56e3\uff09", + "\u30d2\u30d5\u30df", + "\u30d2\u30d5\u30df\uff08\u6c34\u7740\uff09", + "\u30d2\u30de\u30ea", + "\u30d2\u30e8\u30ea", + "\u30d5\u30a3\u30fc\u30ca", + "\u30d5\u30a6\u30ab", + "\u30d5\u30a6\u30ab\uff08\u6b63\u6708\uff09", + "\u30d5\u30d6\u30ad", + "\u30db\u30b7\u30ce", + "\u30db\u30b7\u30ce\uff08\u6c34\u7740\uff09", + "\u30de\u30ad", + "\u30de\u30b7\u30ed", + "\u30de\u30b7\u30ed\uff08\u6c34\u7740\uff09", + "\u30de\u30ea\u30ca", + "\u30de\u30ea\u30fc", + "\u30de\u30ea\u30fc\uff08\u4f53\u64cd\u670d\uff09", + "\u30df\u30ab", + "\u30df\u30b5\u30ad", + "\u30df\u30c1\u30eb", + "\u30df\u30c9\u30ea", + "\u30df\u30ca", + "\u30df\u30cd", + "\u30df\u30ce\u30ea", + "\u30df\u30e2\u30ea", + "\u30df\u30e2\u30ea\uff08\u6c34\u7740\uff09", + "\u30df\u30e4\u30b3", + "\u30df\u30e4\u30b3\uff08\u6c34\u7740\uff09", + "\u30df\u30e6", + "\u30df\u30e6\uff08\u6c34\u7740\uff09", + "\u30e0\u30c4\u30ad", + "\u30e0\u30c4\u30ad\uff08\u6b63\u6708\uff09", + "\u30e1\u30b0", + "\u30e1\u30eb", + "\u30e2\u30a8", + "\u30e2\u30df\u30b8", + "\u30e2\u30e2\u30a4", + "\u30e6\u30a6\u30ab", + "\u30e6\u30a6\u30ab\uff08\u4f53\u64cd\u670d\uff09", + "\u30e6\u30ab\u30ea", + "\u30e6\u30ba", + "\u30e6\u30ba\uff08\u30e1\u30a4\u30c9\uff09", + "\u30e8\u30b7\u30df", + "\u30eb\u30df", + "\u30ec\u30a4\u30b5", + "\u30ec\u30f3\u30b2", + "\u30ef\u30ab\u30e2", + "\u30ef\u30ab\u30e2\uff08\u6c34\u7740\uff09", + "\u4f50\u5929\u6d99\u5b50", + "\u521d\u97f3\u30df\u30af", + "\u5fa1\u5742\u7f8e\u7434", + "\u98df\u8702\u64cd\u7948" +] \ No newline at end of file diff --git a/MCE/utils.py b/MCE/utils.py new file mode 100644 index 0000000..139584d --- /dev/null +++ b/MCE/utils.py @@ -0,0 +1,109 @@ +import customtkinter +import json +import sys +import os + +class Config: + def __init__(self, linker, config_file): + self.default_config = { + "ResetDaily": False, + "LastRun": "2023-12-24 21:41:55", + "ResetTime": "11:21:30", + "RechargeAP": False, + "PreferredTemplate": "template1", + "Queue": [], + "Event": False, + "Templates": { + "template1": [] + } + } + self.linker = linker + self.config_file = config_file + + if not os.path.exists(self.config_file): + with open(self.config_file, "w") as f: + json.dump(self.default_config, f, indent=2) + + self.config_data = self.read() + self.linker.widgets = self.set_values_to_none(self.config_data) + self.locked = False + linker.config = self + + def read(self): + # Read the JSON file + try: + with open(self.config_file, 'r') as json_file: + config_data = json.load(json_file) + return config_data + except FileNotFoundError: + print(f"Config file '{self.config_file}' not found.") + sys.exit(1) + except json.JSONDecodeError: + print(f"Invalid JSON format in '{self.config_file}'.") + sys.exit(1) + + def set_values_to_none(self, input_dict): + result = {} + for key, value in input_dict.items(): + if isinstance(value, dict): + result[key] = self.set_values_to_none(value) + else: + result[key] = None + return result + + def load_config(self, widgets=None, config_data=None): + if widgets == None: + widgets = self.linker.widgets + config_data = self.config_data + for key in widgets: + if isinstance(widgets[key], dict) and isinstance(config_data[key], dict): + self.load_config(widgets[key], config_data[key]) + else: + if widgets[key] is not None: + if isinstance(widgets[key], customtkinter.CTkCheckBox): + if config_data[key] == True: + widgets[key].select() + else: + widgets[key].deselect() + elif isinstance(widgets[key], customtkinter.CTkEntry): + widgets[key].insert(0, config_data[key]) + else: + widgets[key].set(config_data[key]) + + def save_to_json(self, list_keys): + widget = self.linker.widgets + data = self.config_data + for i in list_keys[:-1]: + widget = widget[i] + data = data[i] + widget = widget[list_keys[-1]] + value = widget.get() + if isinstance(widget, customtkinter.CTkCheckBox): + value = True if value==1 else False + data[list_keys[-1]] = value + self.save_file("Configuration") + + def save_file(self, name=None): + if self.locked: + with open("MCE\config.json", "r") as config_file: + new_config = json.load(config_file) + self.config_data["Queue"] = new_config["Queue"] + self.config_data["LastRun"] = new_config["LastRun"] + with open("MCE\config.json", "w") as config_file: + json.dump(self.config_data, config_file, indent=2) + if name: + self.linker.show_notification(name) + +class Linker: + def __init__(self): + self.capitalise = lambda word: " ".join(x.title() for x in word.split("_")) + self.config = None + self.widgets = {} + self.sidebar = None + self.event_id = None + + def show_notification(self, text): + if self.event_id: + self.sidebar.after_cancel(self.event_id) + self.sidebar.notification.show() + self.event_id = self.sidebar.after(2500, self.sidebar.notification.hide) diff --git a/aas.py b/aas.py index 311a24c..e95cd3e 100644 --- a/aas.py +++ b/aas.py @@ -58,6 +58,10 @@ class ArisuAutoSweeper(AzurLaneAutoScript): from tasks.momotalk.momotalk import MomoTalk MomoTalk(config=self.config, device=self.device).run() + def mission(self): + from tasks.mission.mission import Mission + Mission(config=self.config, device=self.device).run() + def data_update(self): from tasks.item.data_update import DataUpdate DataUpdate(config=self.config, device=self.device).run() diff --git a/assets/en/base/page/EVENT_CHECK.png b/assets/en/base/page/EVENT_CHECK.png new file mode 100644 index 0000000..dde3a28 Binary files /dev/null and b/assets/en/base/page/EVENT_CHECK.png differ diff --git a/assets/en/base/page/WORK_GO_TO_EVENT.png b/assets/en/base/page/WORK_GO_TO_EVENT.png new file mode 100644 index 0000000..adb7552 Binary files /dev/null and b/assets/en/base/page/WORK_GO_TO_EVENT.png differ diff --git a/assets/en/base/page/WORK_GO_TO_TACTICAL_CHALLENGE.png b/assets/en/base/page/WORK_GO_TO_TACTICAL_CHALLENGE.png index 8700d5a..8b3a753 100644 Binary files a/assets/en/base/page/WORK_GO_TO_TACTICAL_CHALLENGE.png and b/assets/en/base/page/WORK_GO_TO_TACTICAL_CHALLENGE.png differ diff --git a/assets/en/mission/CHECK_BD.png b/assets/en/mission/CHECK_BD.png new file mode 100644 index 0000000..c3413b1 Binary files /dev/null and b/assets/en/mission/CHECK_BD.png differ diff --git a/assets/en/mission/CHECK_COMMISSIONS.png b/assets/en/mission/CHECK_COMMISSIONS.png new file mode 100644 index 0000000..a4e92e6 Binary files /dev/null and b/assets/en/mission/CHECK_COMMISSIONS.png differ diff --git a/assets/en/mission/CHECK_IR.png b/assets/en/mission/CHECK_IR.png new file mode 100644 index 0000000..6ba97cd Binary files /dev/null and b/assets/en/mission/CHECK_IR.png differ diff --git a/assets/en/mission/CHECK_MISSION_SWEEP.png b/assets/en/mission/CHECK_MISSION_SWEEP.png new file mode 100644 index 0000000..df4347e Binary files /dev/null and b/assets/en/mission/CHECK_MISSION_SWEEP.png differ diff --git a/assets/en/mission/HARD_OFF.png b/assets/en/mission/HARD_OFF.png new file mode 100644 index 0000000..e41bc2a Binary files /dev/null and b/assets/en/mission/HARD_OFF.png differ diff --git a/assets/en/mission/HARD_ON.png b/assets/en/mission/HARD_ON.png new file mode 100644 index 0000000..7f7dc90 Binary files /dev/null and b/assets/en/mission/HARD_ON.png differ diff --git a/assets/en/mission/LEFT.png b/assets/en/mission/LEFT.png new file mode 100644 index 0000000..f1bc365 Binary files /dev/null and b/assets/en/mission/LEFT.png differ diff --git a/assets/en/mission/NORMAL_OFF.png b/assets/en/mission/NORMAL_OFF.png new file mode 100644 index 0000000..2a5fab9 Binary files /dev/null and b/assets/en/mission/NORMAL_OFF.png differ diff --git a/assets/en/mission/NORMAL_ON.png b/assets/en/mission/NORMAL_ON.png new file mode 100644 index 0000000..39b0af3 Binary files /dev/null and b/assets/en/mission/NORMAL_ON.png differ diff --git a/assets/en/mission/OCR_AREA.png b/assets/en/mission/OCR_AREA.png new file mode 100644 index 0000000..7602382 Binary files /dev/null and b/assets/en/mission/OCR_AREA.png differ diff --git a/assets/en/mission/QUEST_OFF.png b/assets/en/mission/QUEST_OFF.png new file mode 100644 index 0000000..2461875 Binary files /dev/null and b/assets/en/mission/QUEST_OFF.png differ diff --git a/assets/en/mission/QUEST_ON.png b/assets/en/mission/QUEST_ON.png new file mode 100644 index 0000000..1c45adc Binary files /dev/null and b/assets/en/mission/QUEST_ON.png differ diff --git a/assets/en/mission/RIGHT.png b/assets/en/mission/RIGHT.png new file mode 100644 index 0000000..e4c0e74 Binary files /dev/null and b/assets/en/mission/RIGHT.png differ diff --git a/assets/en/mission/SELECT_BD.png b/assets/en/mission/SELECT_BD.png new file mode 100644 index 0000000..b09f052 Binary files /dev/null and b/assets/en/mission/SELECT_BD.png differ diff --git a/assets/en/mission/SELECT_IR.png b/assets/en/mission/SELECT_IR.png new file mode 100644 index 0000000..39c493d Binary files /dev/null and b/assets/en/mission/SELECT_IR.png differ diff --git a/assets/en/task/COMPLETE.png b/assets/en/task/COMPLETE.png new file mode 100644 index 0000000..3068a8d Binary files /dev/null and b/assets/en/task/COMPLETE.png differ diff --git a/config/template.json b/config/template.json index 887e481..32f08f8 100644 --- a/config/template.json +++ b/config/template.json @@ -172,12 +172,20 @@ "Enable": true, "NextRun": "2020-01-01 00:00:00", "Command": "TacticalChallenge", - "ServerUpdate": "15:00" + "ServerUpdate": "14:00" }, "TacticalChallenge": { "PlayerSelect": 0 } }, + "Mission": { + "Scheduler": { + "Enable": false, + "NextRun": "2020-01-01 00:00:00", + "Command": "Mission", + "ServerUpdate": "04:00" + } + }, "Circle": { "Scheduler": { "Enable": true, diff --git a/module/config/argument/args.json b/module/config/argument/args.json index bb8d15d..e3acdf9 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -730,7 +730,7 @@ }, "ServerUpdate": { "type": "input", - "value": "15:00", + "value": "14:00", "display": "hide" } }, @@ -747,6 +747,33 @@ } } }, + "Mission": { + "Scheduler": { + "Enable": { + "type": "checkbox", + "value": false, + "option": [ + true, + false + ] + }, + "NextRun": { + "type": "datetime", + "value": "2020-01-01 00:00:00", + "validate": "datetime" + }, + "Command": { + "type": "input", + "value": "Mission", + "display": "hide" + }, + "ServerUpdate": { + "type": "input", + "value": "04:00", + "display": "hide" + } + } + }, "Circle": { "Scheduler": { "Enable": { diff --git a/module/config/argument/menu.json b/module/config/argument/menu.json index e0e2e9a..82c45c1 100644 --- a/module/config/argument/menu.json +++ b/module/config/argument/menu.json @@ -22,7 +22,8 @@ "tasks": [ "Bounty", "Scrimmage", - "TacticalChallenge" + "TacticalChallenge", + "Mission" ] }, "Reward": { diff --git a/module/config/argument/task.yaml b/module/config/argument/task.yaml index 33dc2ea..12c9a0a 100644 --- a/module/config/argument/task.yaml +++ b/module/config/argument/task.yaml @@ -55,6 +55,8 @@ Farm: TacticalChallenge: - Scheduler - TacticalChallenge + Mission: + - Scheduler # ==================== Rewards ==================== diff --git a/module/config/config_manual.py b/module/config/config_manual.py index d1fe26f..e2b7a06 100644 --- a/module/config/config_manual.py +++ b/module/config/config_manual.py @@ -9,7 +9,7 @@ class ManualConfig: SCHEDULER_PRIORITY = """ Restart > Cafe > TacticalChallenge > Circle > Mail - > DataUpdate > Bounty > Scrimmage > Task > Shop > Momotalk + > DataUpdate > Bounty > Scrimmage > Task > Shop > Mission > Momotalk """ """ diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 258def7..a97c31c 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -50,6 +50,10 @@ "name": "Tactical Challenge", "help": "" }, + "Mission": { + "name": "Mission/Commissions/Event", + "help": "Open MCE Manager for additional settings. Must be opened if it's your first time!" + }, "Circle": { "name": "Club", "help": "" diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 07927ee..4ab9e1f 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -50,6 +50,10 @@ "name": "战术对抗赛", "help": "战术大赛 / 竞技场" }, + "Mission": { + "name": "Task.Mission.name", + "help": "Task.Mission.help" + }, "Circle": { "name": "公会", "help": "社团 / 小组" diff --git a/tasks/base/assets/assets_base_page.py b/tasks/base/assets/assets_base_page.py index 1d9bf47..a3dad32 100644 --- a/tasks/base/assets/assets_base_page.py +++ b/tasks/base/assets/assets_base_page.py @@ -115,6 +115,17 @@ CRAFTING_CHECK = ButtonWrapper( button=(103, 4, 226, 41), ), ) +EVENT_CHECK = ButtonWrapper( + name='EVENT_CHECK', + jp=None, + en=Button( + file='./assets/en/base/page/EVENT_CHECK.png', + area=(102, 6, 187, 40), + search=(82, 0, 207, 60), + color=(200, 209, 216), + button=(102, 6, 187, 40), + ), +) GACHA_CHECK = ButtonWrapper( name='GACHA_CHECK', jp=Button( @@ -476,6 +487,17 @@ WORK_GO_TO_COMMISSIONS = ButtonWrapper( button=(656, 494, 803, 517), ), ) +WORK_GO_TO_EVENT = ButtonWrapper( + name='WORK_GO_TO_EVENT', + jp=None, + en=Button( + file='./assets/en/base/page/WORK_GO_TO_EVENT.png', + area=(67, 131, 142, 201), + search=(47, 111, 162, 221), + color=(134, 153, 166), + button=(67, 131, 142, 201), + ), +) WORK_GO_TO_MISSION = ButtonWrapper( name='WORK_GO_TO_MISSION', jp=Button( @@ -538,9 +560,9 @@ WORK_GO_TO_TACTICAL_CHALLENGE = ButtonWrapper( ), en=Button( file='./assets/en/base/page/WORK_GO_TO_TACTICAL_CHALLENGE.png', - area=(1034, 435, 1162, 466), - search=(1014, 415, 1182, 486), - color=(179, 199, 221), - button=(1034, 435, 1162, 466), + area=(1012, 532, 1152, 591), + search=(992, 512, 1172, 611), + color=(199, 211, 227), + button=(1012, 532, 1152, 591), ), ) diff --git a/tasks/login/login.py b/tasks/login/login.py index a518cf3..f6eaf9c 100644 --- a/tasks/login/login.py +++ b/tasks/login/login.py @@ -5,6 +5,7 @@ from module.logger import logger from tasks.base.page import page_main from tasks.base.ui import UI from tasks.login.assets.assets_login import LOGIN_CONFIRM, LOGIN_LOADING, UPDATE, SURVEY +from tasks.base.assets.assets_base_page import MAIN_GO_TO_MAIL class Login(UI): @@ -93,6 +94,10 @@ class Login(UI): continue if self.ui_additional(): continue + # press emulator back button when random popup in main + if self.appear(MAIN_GO_TO_MAIL) and not self.match_color(MAIN_GO_TO_MAIL): + self.device.u2.press("back") + continue return True diff --git a/tasks/mission/assets/assets_mission.py b/tasks/mission/assets/assets_mission.py new file mode 100644 index 0000000..caecaed --- /dev/null +++ b/tasks/mission/assets/assets_mission.py @@ -0,0 +1,170 @@ +from module.base.button import Button, ButtonWrapper + +# This file was auto-generated, do not modify it manually. To generate: +# ``` python -m dev_tools.button_extract ``` + +CHECK_BD = ButtonWrapper( + name='CHECK_BD', + jp=None, + en=Button( + file='./assets/en/mission/CHECK_BD.png', + area=(94, 135, 325, 194), + search=(74, 115, 345, 214), + color=(208, 215, 220), + button=(94, 135, 325, 194), + ), +) +CHECK_COMMISSIONS = ButtonWrapper( + name='CHECK_COMMISSIONS', + jp=None, + en=Button( + file='./assets/en/mission/CHECK_COMMISSIONS.png', + area=(646, 78, 909, 135), + search=(626, 58, 929, 155), + color=(70, 96, 124), + button=(646, 78, 909, 135), + ), +) +CHECK_IR = ButtonWrapper( + name='CHECK_IR', + jp=None, + en=Button( + file='./assets/en/mission/CHECK_IR.png', + area=(97, 137, 340, 191), + search=(77, 117, 360, 211), + color=(213, 220, 223), + button=(97, 137, 340, 191), + ), +) +CHECK_MISSION_SWEEP = ButtonWrapper( + name='CHECK_MISSION_SWEEP', + jp=None, + en=Button( + file='./assets/en/mission/CHECK_MISSION_SWEEP.png', + area=(654, 184, 703, 209), + search=(634, 164, 723, 229), + color=(208, 213, 219), + button=(654, 184, 703, 209), + ), +) +HARD_OFF = ButtonWrapper( + name='HARD_OFF', + jp=None, + en=Button( + file='./assets/en/mission/HARD_OFF.png', + area=(947, 132, 1193, 182), + search=(927, 112, 1213, 202), + color=(242, 246, 248), + button=(947, 132, 1193, 182), + ), +) +HARD_ON = ButtonWrapper( + name='HARD_ON', + jp=None, + en=Button( + file='./assets/en/mission/HARD_ON.png', + area=(940, 133, 1189, 186), + search=(920, 113, 1209, 206), + color=(200, 71, 63), + button=(940, 133, 1189, 186), + ), +) +LEFT = ButtonWrapper( + name='LEFT', + jp=None, + en=Button( + file='./assets/en/mission/LEFT.png', + area=(0, 301, 89, 408), + search=(0, 281, 109, 428), + color=(193, 224, 241), + button=(0, 301, 89, 408), + ), +) +NORMAL_OFF = ButtonWrapper( + name='NORMAL_OFF', + jp=None, + en=Button( + file='./assets/en/mission/NORMAL_OFF.png', + area=(682, 135, 927, 182), + search=(662, 115, 947, 202), + color=(238, 243, 246), + button=(682, 135, 927, 182), + ), +) +NORMAL_ON = ButtonWrapper( + name='NORMAL_ON', + jp=None, + en=Button( + file='./assets/en/mission/NORMAL_ON.png', + area=(682, 137, 924, 185), + search=(662, 117, 944, 205), + color=(62, 81, 89), + button=(682, 137, 924, 185), + ), +) +OCR_AREA = ButtonWrapper( + name='OCR_AREA', + jp=None, + en=Button( + file='./assets/en/mission/OCR_AREA.png', + area=(108, 176, 176, 219), + search=(88, 156, 196, 239), + color=(237, 238, 240), + button=(108, 176, 176, 219), + ), +) +QUEST_OFF = ButtonWrapper( + name='QUEST_OFF', + jp=None, + en=Button( + file='./assets/en/mission/QUEST_OFF.png', + area=(859, 69, 1025, 125), + search=(839, 49, 1045, 145), + color=(231, 235, 235), + button=(859, 69, 1025, 125), + ), +) +QUEST_ON = ButtonWrapper( + name='QUEST_ON', + jp=None, + en=Button( + file='./assets/en/mission/QUEST_ON.png', + area=(861, 71, 1025, 124), + search=(841, 51, 1045, 144), + color=(49, 68, 76), + button=(861, 71, 1025, 124), + ), +) +RIGHT = ButtonWrapper( + name='RIGHT', + jp=None, + en=Button( + file='./assets/en/mission/RIGHT.png', + area=(1202, 311, 1280, 412), + search=(1182, 291, 1280, 432), + color=(193, 223, 241), + button=(1202, 311, 1280, 412), + ), +) +SELECT_BD = ButtonWrapper( + name='SELECT_BD', + jp=None, + en=Button( + file='./assets/en/mission/SELECT_BD.png', + area=(1016, 165, 1227, 211), + search=(996, 145, 1247, 231), + color=(205, 212, 220), + button=(1016, 165, 1227, 211), + ), +) +SELECT_IR = ButtonWrapper( + name='SELECT_IR', + jp=None, + en=Button( + file='./assets/en/mission/SELECT_IR.png', + area=(1004, 267, 1237, 321), + search=(984, 247, 1257, 341), + color=(214, 220, 227), + button=(1004, 267, 1237, 321), + ), +) diff --git a/tasks/mission/mission.py b/tasks/mission/mission.py new file mode 100644 index 0000000..5b6d103 --- /dev/null +++ b/tasks/mission/mission.py @@ -0,0 +1,258 @@ +from enum import Enum + +from module.base.timer import Timer +from module.exception import RequestHumanTakeover +from module.logger import logger +from tasks.mission.ui import MissionUI, CommissionsUI +from tasks.stage.ap import AP +from tasks.cafe.cafe import Cafe +from tasks.circle.circle import Circle +from tasks.task.task import Task +from tasks.mail.mail import Mail +from tasks.item.data_update import DataUpdate +import json +import math +from filelock import FileLock +from datetime import datetime + +class MissionStatus(Enum): + AP = 0 # Calculate AP and decide to terminate Mission module or not + NAVIGATE = 1 # Navigate to the stage page for example the commissions page or mission page + SELECT = 2 # Select the stage mode for example hard or normal in mission + ENTER = 3 # Search and for the stage in the stage list and enter + SWEEP = 4 # Sweep the stage + RECHARGE = 5 # Recharge AP from other taks if they are enabled + FINISH = -1 # Inidicate termination of Mission module + + +class Mission(MissionUI, CommissionsUI): + _stage_ap = [10, 15, 15, 15] + + @property + def stage_ap(self): + return self._stage_ap + + @property + def mission_info(self) -> list: + """ + Read the config from MCE/config.json and extract the queue, a list of list. + If queue is empty repopulate from preferred template. + + Format of each element in queue: [mode, stage, sweep_num] + e.g. ["N", "1-1", 3] + + Mode Acronyms: + "N" : Normal Mission + "H" : Hard Mission + "E" : Event Quest + "IR" : Item Retrieval / Commission where you get credit + "BD" : Base Defense / Commission where you get exp + + Returns: + list of list + """ + queue = [] + try: + with open("MCE/config.json") as json_file: + config_data = json.load(json_file) + queue = config_data["Queue"] + self.recharge_AP = config_data["RechargeAP"] + self.reset_daily = config_data["ResetDaily"] + self.reset_time = config_data["ResetTime"] + self.last_run = config_data["LastRun"] + self.event = config_data["Event"] + if self.check_reset_daily() or not queue: + preferred_template = config_data["PreferredTemplate"] + queue = config_data["Templates"][preferred_template] + if not self.event: + queue = [x for x in queue if x[0] != "E"] + except: + logger.error("Failed to read configuration file") + finally: + return queue + + def check_reset_daily(self): + # Check if it's time to reset the queue + if self.reset_daily: + current_datetime = datetime.now().replace(microsecond=0) # Round to the nearest second + current_date = current_datetime.date() + current_time = current_datetime.time() + last_run_datetime = datetime.strptime(self.last_run, "%Y-%m-%d %H:%M:%S") + reset_time = datetime.strptime(self.reset_time, "%H:%M:%S").time() + + if current_date != last_run_datetime.date() and current_time >= reset_time: + self.last_run = str(datetime.now().replace(microsecond=0)) + logger.info("Reset Daily activated.") + return True + return False + + @property + def valid_task(self) -> list: + task = self.mission_info + if not task: + logger.warning('Mission enabled but no task set') + #self.error_handler() + return task + + @property + def current_mode(self): + return self.task[0][0] + + @property + def current_stage(self): + return self.task[0][1] + + @property + def current_count(self): + if self.current_mode == "H" and self.task[0][2] > 3: + return 3 + return self.task[0][2] + + @current_count.setter + def current_count(self, value): + self.task[0][2] = value + + def select(self) -> bool: + """ + A wrapper method to select the current_mode + by calling the specific method based on its type. + + Return + True if selection happens without any problem, False otherwise. + """ + if self.current_mode in ["N", "H"]: + return self.select_mission(self.current_mode, self.current_stage) + elif self.current_mode in ["BD", "IR"]: + return self.select_commission(self.current_mode) + elif self.current_mode == "E": + return self.select_event() + else: + logger.error("Uknown mode") + return False + + def get_realistic_count(self) -> int: + """ + Calculate the possible number of sweeps based on the current AP + """ + ap_cost = 20 if self.current_mode == "H" else 10 + required_ap = ap_cost * self.current_count + return math.floor(min(required_ap, self.current_ap) / ap_cost) + + def update_task(self, failure=False): + """ + Update self.task and save the current state of the queue in + MCE/config.json. + """ + try: + if failure or self.current_count == self.realistic_count: + self.previous_mode = self.current_mode + self.task.pop(0) + else: + self.previous_mode = None + self.current_count -= self.realistic_count + with open("MCE/config.json", "r") as json_file: + config_data = json.load(json_file) + with open("MCE/config.json", "w") as json_file: + config_data["Queue"] = self.task + config_data["LastRun"] = self.last_run + json.dump(config_data, json_file, indent=2) + except: + logger.error("Failed to save configuration") + self.task = [] + + def update_ap(self): + ap_cost = 20 if self.current_mode == "H" else 10 + ap = self.config.stored.AP + ap_old = ap.value + ap_new = ap_old - ap_cost * self.realistic_count + ap.set(ap_new, ap.total) + logger.info(f'Set AP: {ap_old} -> {ap_new}') + + def recharge(self) -> bool: + """ + Check if AP related modules such as cafe, circle, task, mail are enabled and run them if they are. + task_call only works after the current task is finished so is not suitable. + """ + cafe_reward = self.config.cross_get(["Cafe", "Scheduler", "Enable"]) and self.config.cross_get(["Cafe", "Cafe", "Reward"]) + circle = self.config.cross_get(["Circle", "Scheduler", "Enable"]) + task = self.config.cross_get(["Task", "Scheduler", "Enable"]) + mail = self.config.cross_get(["Mail", "Scheduler", "Enable"]) + ap_tasks = [(cafe_reward,Cafe), (circle, Circle), (task, Task), (mail, Mail)] + modules = [x[1] for x in ap_tasks if x[0]] + if not modules: + logger.info("Recharge AP was enabled but no AP related modules were enabled") + return False + for module in modules: + module(config=self.config, device=self.device).run() + return True + + def handle_mission(self, status): + match status: + case MissionStatus.AP: + if not self.task: + return MissionStatus.FINISH + self.realistic_count = self.get_realistic_count() + if self.realistic_count == 0 and self.recharge_AP: + self.recharge_AP = False + return MissionStatus.RECHARGE + elif self.realistic_count == 0 and not self.recharge_AP: + return MissionStatus.FINISH + else: + return MissionStatus.NAVIGATE + case MissionStatus.NAVIGATE: + self.navigate(self.previous_mode, self.current_mode) + return MissionStatus.SELECT + case MissionStatus.SELECT: + if self.select(): + return MissionStatus.ENTER + self.update_task(failure=True) + return MissionStatus.AP + case MissionStatus.ENTER: + if self.enter_stage(self.current_stage): + return MissionStatus.SWEEP + self.update_task(failure=True) + return MissionStatus.AP + case MissionStatus.SWEEP: + if self.do_sweep(self.current_mode, self.realistic_count): + self.update_ap() + self.update_task() + else: + self.update_task(failure=True) + return MissionStatus.AP + case MissionStatus.RECHARGE: + if self.recharge(): + DataUpdate(config=self.config, device=self.device).run() + return MissionStatus.AP + return MissionStatus.FINISH + case MissionStatus.FINISH: + return status + case _: + logger.warning(f'Invalid status: {status}') + return status + + def run(self): + self.lock = FileLock("MCE/config.json.lock") + with self.lock.acquire(): + self.previous_mode = None + self.task = self.valid_task + action_timer = Timer(0.5, 1) + status = MissionStatus.AP + + """Update the dashboard to accurately calculate AP""" + DataUpdate(config=self.config, device=self.device).run() + + while 1: + self.device.screenshot() + + if self.ui_additional(): + continue + + if action_timer.reached_and_reset(): + logger.attr('Status', status) + status = self.handle_mission(status) + + if status == MissionStatus.FINISH: + break + + self.config.task_delay(server_update=True) + \ No newline at end of file diff --git a/tasks/mission/ui.py b/tasks/mission/ui.py new file mode 100644 index 0000000..c10e837 --- /dev/null +++ b/tasks/mission/ui.py @@ -0,0 +1,150 @@ +from module.base.timer import Timer +from module.logger import logger +from module.ui.switch import Switch +from module.ocr.ocr import Digit +from tasks.base.assets.assets_base_page import BACK, MISSION_CHECK, EVENT_CHECK +from tasks.base.page import page_mission, page_commissions #,page_event +from tasks.base.ui import UI +from tasks.mission.assets.assets_mission import * +from tasks.stage.ap import AP +from tasks.stage.mission_list import StageList +from tasks.stage.sweep import StageSweep + + +SHARED_LIST = StageList('SharedList') +MISSION_SWEEP = StageSweep('MissionSweep', 60) +MISSION_SWEEP.set_button(button_check=CHECK_MISSION_SWEEP) # Check sweep is different for mission +SHARED_SWEEP = StageSweep('SharedSweep', 60) + +SWITCH_NORMAL = Switch("Normal_switch") +SWITCH_NORMAL.add_state("on", NORMAL_ON) +SWITCH_NORMAL.add_state("off", NORMAL_OFF) + +SWITCH_HARD = Switch("HARD_switch") +SWITCH_HARD.add_state("on", HARD_ON) +SWITCH_HARD.add_state("off", HARD_OFF) + +SWITCH_QUEST = Switch("QUEST_switch") +SWITCH_QUEST.add_state("on",QUEST_ON) +SWITCH_QUEST.add_state("off",QUEST_OFF) + +""" +A dictionary that maps the mode to a tuple where +the first element is an argument to go_back and second is for ui_ensure +Missing for "E" because there are no event in Global and no page_event +""" +MODE_TO_PAGE = { + "N": (MISSION_CHECK, page_mission), + "H": (MISSION_CHECK, page_mission), + "BD": (CHECK_BD, page_commissions), + "IR": (CHECK_IR, page_commissions), + "E" : (EVENT_CHECK) #page_event +} + + +class MissionUI(UI, AP): + def select_mission(self, mode, stage): + area = int(stage.split("-")[0]) + if not self.select_area(area): + logger.warning("Area not found") + return False + + to_switch = { + "N": SWITCH_NORMAL, + "H": SWITCH_HARD + } + switch = to_switch[mode] + if not self.select_mode(switch) and not self.select_area(area): + return False + return True + + def select_area(self, num): + """" + May require further error handling for these cases. + 1. Fails to ocr area number + 2. May trigger too many click exception when clicking left or right too many times + 3. Area not unlocked. Simplest way if left or right button are still present + but problem is it is expensive to check every time and they always keep moving. + """ + tries = 0 + ocr_area = Digit(OCR_AREA) + while 1: + try: + self.device.screenshot() + current_area = int(ocr_area.ocr_single_line(self.device.image)) + if current_area == num: + return True + elif current_area > num: + [self.click_with_interval(LEFT, interval=1) for x in range(abs(current_area-num))] + elif current_area < num: + [self.click_with_interval(RIGHT, interval=1) for x in range(abs(current_area-num))] + except: + tries += 1 + if tries > 3: + return False + + def select_mode(self, switch): + """ + Set switch to on. + Returns: + True if switch is set, False if switch not found + """ + if not switch.appear(main=self): + logger.info(f'{switch.name} not found') + return False + switch.set('on', main=self) + return True + + def select_event(self): + return self.select_mode(SWITCH_QUEST) + + def enter_stage(self, index: int) -> bool: + if not index: + index = SHARED_LIST.insight_max_sweepable_index(self) + if SHARED_LIST.select_index_enter(self, index): + return True + return False + + def do_sweep(self, mode, num: int) -> bool: + if mode in ["N", "H", "E"]: + return MISSION_SWEEP.do_sweep(self, num=num) + else: + return SHARED_SWEEP.do_sweep(self, num=num) + + def navigate(self, prev, next): + """ + go_back is called when the previous stage and next stage are in + the same game mode. + For example, "N" and "H" are in Mission so we call go_back. + If different, ui_ensure is called for example, "N" and "IR". + """ + if prev==next or (prev in ["N", "H"] and next in ["N", "H"]): + self.go_back(MODE_TO_PAGE[next][0]) + elif prev in ["BD", "IR"] and next in ["BD", "IR"]: + self.go_back(CHECK_COMMISSIONS) + else: + self.ui_ensure(MODE_TO_PAGE[next][1]) + + def go_back(self, check): + while 1: + self.device.screenshot() + if self.match_color(check) and self.appear(check): + return True + self.click_with_interval(BACK, interval=2) + +class CommissionsUI(UI, AP): + """Works the same way as select_bounty""" + def select_commission(self, mode): + to_button = { + "IR": (SELECT_IR, CHECK_IR), + "BD": (SELECT_BD, CHECK_BD) + } + dest_enter, dest_check = to_button[mode] + timer = Timer(5, 10).start() + while 1: + self.device.screenshot() + self.appear_then_click(dest_enter, interval=1) + if self.appear(dest_check): + return True + if timer.reached(): + return False diff --git a/tasks/stage/mission_list.py b/tasks/stage/mission_list.py new file mode 100644 index 0000000..340b73b --- /dev/null +++ b/tasks/stage/mission_list.py @@ -0,0 +1,252 @@ +import re + +import numpy as np + +from module.base.base import ModuleBase +from module.base.timer import Timer +from module.base.utils import area_pad, area_size, area_offset +from module.logger import logger +from module.ocr.ocr import Ocr +from tasks.stage.assets.assets_stage_list import * + + +class StageList: + swipe_vector_range = (0.65, 0.70) + + def __init__( + self, + name, + button_list: ButtonWrapper = None, + button_index: ButtonWrapper = None, + button_item: ButtonWrapper = None, + button_enter: ButtonWrapper = None, + button_stars: ButtonWrapper = None, + swipe_direction: str = "down" + ): + self.name = name + self.stage = button_list if button_list else STAGE_LIST + self.index_ocr = Ocr(button_index if button_index else OCR_INDEX, lang='en') + self.stage_item = (button_item if button_item else STAGE_ITEM).button + self.enter = button_enter if button_enter else STAGE_ENTER + self.sweepable = button_stars if button_stars else STAGE_STARS + self.swipe_direction = swipe_direction + + self.current_index_min = 1 + self.current_index_max = 1 + self.current_indexes: list[tuple[str, tuple]] = [] + + def __str__(self): + return f'StageList({self.name})' + + __repr__ = __str__ + + def __eq__(self, other): + return str(self) == str(other) + + def __hash__(self): + return hash(self.name) + + @property + def _indexes(self) -> list[int]: + return [x[0] for x in self.current_indexes] + + def load_stage_indexes(self, main: ModuleBase): + self.current_indexes = list( + filter( + lambda x: re.match(r'^\d{1,2}-?[\dA-Z]$', x[0]) and x[0] != '00', + map(lambda x: (x.ocr_text, x.box), self.index_ocr.detect_and_ocr(main.device.image)) + ) + ) + if not self.current_indexes: + logger.warning(f'No valid index in {self.index_ocr.name}') + return + indexes = self._indexes + + self.current_index_min = min(indexes) + self.current_index_max = max(indexes) + logger.attr(self.index_ocr.name, f'Index range: {self.current_index_min} - {self.current_index_max}') + + def swipe_page(self, direction: str, main: ModuleBase, vector_range=None, reverse=False): + """ + Args: + direction: up, down + main: + vector_range (tuple[float, float]): + reverse (bool): + """ + if vector_range is None: + vector_range = self.swipe_vector_range + vector = np.random.uniform(*vector_range) + width, height = area_size(self.stage.button) + if direction == 'up': + vector = (0, vector * height) + elif direction == 'down': + vector = (0, -vector * height) + else: + logger.warning(f'Unknown swipe direction: {direction}') + return + + if reverse: + vector = (-vector[0], -vector[1]) + main.device.swipe_vector(vector, self.stage.button, name=f'{self.name}_SWIPE') + + def insight_index(self, index: int, main: ModuleBase, skip_first_screenshot=True) -> bool: + """ + Args: + index: + main: + skip_first_screenshot: + + Returns: + If success + """ + logger.info(f'Insight index: {index}') + last_indexes: set[int] = set() + + while 1: + if skip_first_screenshot: + skip_first_screenshot = False + else: + main.device.screenshot() + + self.load_stage_indexes(main=main) + + if self.current_index_min <= index <= self.current_index_max: + return True + + indexes = self._indexes + if indexes and last_indexes == set(indexes): + logger.warning(f'No more index {index}') + return False + last_indexes = set(indexes) + + if index < self.current_index_min: + self.swipe_page(self.swipe_direction, main, reverse=True) + elif index > self.current_index_max: + self.swipe_page(self.swipe_direction, main) + + main.wait_until_stable( + self.stage.button, + timer=Timer(0, 0), + timeout=Timer(1.5, 5) + ) + + def insight_max_sweepable_index(self, main: ModuleBase, skip_first_screenshot=True) -> int: + """ + Args: + main: + skip_first_screenshot: + + Returns: + Index of max sweepable stage + """ + logger.info('Insight sweepable index') + max_sweepable_index = 0 + last_max_sweepable_index = 0 + + while 1: + if skip_first_screenshot: + skip_first_screenshot = False + else: + main.device.screenshot() + + self.load_stage_indexes(main=main) + + sweepable_index = next( + filter( + lambda x: not self.is_sweepable(main, self.search_box(x[-1][:2])), + self.current_indexes + ), None + ) + + # all sweepable + if sweepable_index is None: + logger.info('All sweepable') + max_sweepable_index = self.current_index_max + self.swipe_page(self.swipe_direction, main) + if max_sweepable_index == last_max_sweepable_index: + logger.info(f'Max sweepable index: {max_sweepable_index}') + return max_sweepable_index + last_max_sweepable_index = max_sweepable_index + # all not sweepable + elif int(sweepable_index[0]) == self.current_index_min: + logger.info('All not sweepable') + if int(sweepable_index[0]) == 1: + logger.warning('No sweepable index') + return 0 + self.swipe_page(self.swipe_direction, main, reverse=True) + else: + logger.info(f'Sweepable index: {int(sweepable_index[0]) - 1}') + return int(sweepable_index[0]) - 1 + + main.wait_until_stable( + self.stage.button, + timer=Timer(0, 0), + timeout=Timer(1.5, 5) + ) + + def is_sweepable(self, main: ModuleBase, search_box) -> bool: + self.sweepable.load_search(search_box) + return main.appear(self.sweepable, similarity=0.8) + + def search_box( + self, + index_cord: tuple[int, int], + padding: tuple[int, int] = (-20, -15) + ) -> tuple[int, int, int, int]: + stage_item_box = area_pad((*padding, *area_size(self.stage_item))) + return area_offset(stage_item_box, index_cord) + + def select_index_enter( + self, + main: ModuleBase, + index: int, + insight: bool = True, + sweepable: bool = True, + padding: tuple[int, int] = (-20, -15), + skip_first_screenshot: bool = True, + interval: int = 1.5 + ) -> bool: + # insight index, if failed, return False + if insight and not self.insight_index(index, main, skip_first_screenshot): + return False + logger.info(f'Select index: {index}') + click_interval = Timer(interval) + load_index_interval = Timer(1) + timeout = Timer(15, 10).start() + while 1: + if skip_first_screenshot: + skip_first_screenshot = False + else: + main.device.screenshot() + + # load index if not insight + if load_index_interval.reached_and_reset(): + self.load_stage_indexes(main=main) + + # find box of index + index_box = next(filter(lambda x: x[0] == index, self.current_indexes), None) + + if index_box is None: + logger.warning(f'No index {index} in {self.index_ocr.name}') + continue + + search_box = self.search_box(index_box[-1][:2], padding) + if sweepable and not self.is_sweepable(main, search_box): + logger.warning(f'Index {index} is not sweepable') + return False + + self.enter.load_search(search_box) + click_button = self.enter.match_multi_template(main.device.image) + + if not click_button: + logger.warning(f'No clickable {self.enter.name}') + continue + + if click_interval.reached_and_reset(): + main.device.click(click_button[0]) + return True + + if timeout.reached(): + logger.warning(f'{self.enter.name} failed') + return False diff --git a/tasks/task/assets/assets_task.py b/tasks/task/assets/assets_task.py index d08f0bb..4bfb5bd 100644 --- a/tasks/task/assets/assets_task.py +++ b/tasks/task/assets/assets_task.py @@ -5,7 +5,13 @@ from module.base.button import Button, ButtonWrapper CLAIM = ButtonWrapper( name='CLAIM', - jp=None, + jp=Button( + file='./assets/jp/task/CLAIM.png', + area=(936, 641, 1010, 696), + search=(916, 621, 1030, 716), + color=(230, 210, 63), + button=(936, 641, 1010, 696), + ), en=Button( file='./assets/en/task/CLAIM.png', area=(935, 639, 1015, 698), @@ -16,7 +22,13 @@ CLAIM = ButtonWrapper( ) CLAIMED = ButtonWrapper( name='CLAIMED', - jp=None, + jp=Button( + file='./assets/jp/task/CLAIMED.png', + area=(935, 641, 1010, 696), + search=(915, 621, 1030, 716), + color=(211, 211, 210), + button=(935, 641, 1010, 696), + ), en=Button( file='./assets/en/task/CLAIMED.png', area=(937, 641, 1010, 696), @@ -27,7 +39,13 @@ CLAIMED = ButtonWrapper( ) CLAIMED_ALL = ButtonWrapper( name='CLAIMED_ALL', - jp=None, + jp=Button( + file='./assets/jp/task/CLAIMED_ALL.png', + area=(1057, 640, 1242, 701), + search=(1037, 620, 1262, 720), + color=(192, 193, 196), + button=(1057, 640, 1242, 701), + ), en=Button( file='./assets/en/task/CLAIMED_ALL.png', area=(1058, 641, 1240, 701), @@ -38,7 +56,13 @@ CLAIMED_ALL = ButtonWrapper( ) CLAIM_ALL = ButtonWrapper( name='CLAIM_ALL', - jp=None, + jp=Button( + file='./assets/jp/task/CLAIM_ALL.png', + area=(1058, 641, 1242, 701), + search=(1038, 621, 1262, 720), + color=(235, 218, 67), + button=(1058, 641, 1242, 701), + ), en=Button( file='./assets/en/task/CLAIM_ALL.png', area=(1054, 642, 1243, 700), @@ -47,3 +71,14 @@ CLAIM_ALL = ButtonWrapper( button=(1054, 642, 1243, 700), ), ) +COMPLETE = ButtonWrapper( + name='COMPLETE', + jp=None, + en=Button( + file='./assets/en/task/COMPLETE.png', + area=(921, 643, 1026, 692), + search=(901, 623, 1046, 712), + color=(127, 131, 135), + button=(921, 643, 1026, 692), + ), +) diff --git a/tasks/task/task.py b/tasks/task/task.py index 85693df..2e0a9e6 100644 --- a/tasks/task/task.py +++ b/tasks/task/task.py @@ -23,7 +23,7 @@ class Task(UI): self.device.click(CLAIM) logger.info("Click Claim") continue - if self.match_color(CLAIMED) and self.match_color(CLAIMED_ALL): + if (self.match_color(CLAIMED) or self.match_color(COMPLETE)) and self.match_color(CLAIMED_ALL): logger.info("All claimed") break