Exemplo n.º 1
0
class MasterFrame(Config):
    def __init__(self):
        # Check if application is already open
        self.title = 'MF run counter'

        # Create error logger
        lh = logging.FileHandler(filename='mf_timer.log', mode='w', delay=True)
        logging.basicConfig(handlers=[lh],
                            format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
                            datefmt='%H:%M:%S',
                            level=logging.WARNING)

        # Check OS
        self.os_platform = platform.system()
        self.os_release = platform.release()
        if not self.os_platform == 'Windows':
            raise SystemError("MF Run Counter only supports windows")

        # Create root
        self.root = tkd.Tk()

        # Ensure errors are handled with an exception pop-up if encountered
        self.root.report_callback_exception = self.report_callback_exception

        # Build/load config file
        self.cfg = self.load_config_file()
        if hasattr(logging, self.cfg['DEFAULT']['logging_level']):
            logging.getLogger().setLevel(getattr(logging, self.cfg['DEFAULT']['logging_level']))
        self.SP_game_path = self.cfg['DEFAULT']['SP_game_path']
        self.MP_game_path = self.cfg['DEFAULT']['MP_game_path']
        self.herokuapp_username = self.cfg['DEFAULT']['herokuapp_username']
        self.herokuapp_password = base64.b64decode(self.cfg['DEFAULT']['herokuapp_password']).decode('utf-8')
        self.webproxies = other_utils.safe_eval(self.cfg['DEFAULT']['webproxies'])
        self.automode = other_utils.safe_eval(self.cfg['AUTOMODE']['automode'])
        self.end_run_in_menu = other_utils.safe_eval(self.cfg['AUTOMODE']['end_run_in_menu'])
        self.pause_on_esc_menu = other_utils.safe_eval(self.cfg['AUTOMODE']['pause_on_esc_menu'])
        self.always_on_top = other_utils.safe_eval(self.cfg['OPTIONS']['always_on_top'])
        self.tab_switch_keys_global = other_utils.safe_eval(self.cfg['OPTIONS']['tab_switch_keys_global'])
        self.check_for_new_version = other_utils.safe_eval(self.cfg['OPTIONS']['check_for_new_version'])
        self.enable_sound_effects = other_utils.safe_eval(self.cfg['OPTIONS']['enable_sound_effects'])
        self.start_run_delay_seconds = other_utils.safe_eval(self.cfg['OPTIONS']['start_run_delay_seconds'])
        self.show_drops_tab_below = other_utils.safe_eval(self.cfg['OPTIONS']['show_drops_tab_below'])
        self.active_theme = self.cfg['OPTIONS']['active_theme'].lower()
        self.auto_upload_herokuapp = other_utils.safe_eval(self.cfg['OPTIONS']['auto_upload_herokuapp'])
        self.auto_archive_hours = other_utils.safe_eval(self.cfg['OPTIONS']['auto_archive_hours'])
        self.autocompletion_unids = other_utils.safe_eval(self.cfg['OPTIONS']['autocompletion_unids'])
        self.add_to_last_run = other_utils.safe_eval(self.cfg['OPTIONS']['add_to_last_run'])
        self.disable_scaling = other_utils.safe_eval(self.cfg['OPTIONS']['disable_dpi_scaling'])

        # UI config
        self.show_buttons = other_utils.safe_eval(self.cfg['UI']['show_buttons'])
        self.show_drops_section = other_utils.safe_eval(self.cfg['UI']['show_drops_section'])
        self.show_advanced_tracker = other_utils.safe_eval(self.cfg['UI']['show_advanced_tracker'])
        self.show_xp_tracker = other_utils.safe_eval(self.cfg['UI']['show_xp_tracker'])

        # Initiate variables for memory reading
        self.is_user_admin = reader_utils.is_user_admin()
        self.advanced_error_thrown = False
        self.d2_reader = None

        # Load theme
        if self.active_theme not in available_themes:
            self.active_theme = 'vista'
        self.theme = Theme(used_theme=self.active_theme)

        # Create hotkey queue and initiate process for monitoring the queue
        self.queue = queue.Queue(maxsize=1)
        self.process_queue()

        # Check for version update
        if self.check_for_new_version:
            self.dl_count = github_releases.check_newest_version(release_repo)
        else:
            self.dl_count = ''

        # Load profile info
        self.make_profile_folder()
        self.profiles = [x[:-5] for x in os.listdir('Profiles') if x.endswith('.json') and not x == 'grail.json']
        self.active_profile = self.cfg['DEFAULT']['active_profile']
        if len(self.profiles) == 0:
            self.active_profile = ''
        elif len(self.profiles) > 0 and self.active_profile not in self.profiles:
            self.active_profile = self.profiles[0]

        self.profiles = self.sorted_profiles()

        # Modify root window
        self.root.title(self.title)
        self.clickable = True
        self.root.resizable(False, False)
        self.root.geometry('+%d+%d' % other_utils.safe_eval(self.cfg['DEFAULT']['window_start_position']))
        self.root.config(borderwidth=2, height=365, width=240, relief='raised')
        # self.root.wm_attributes("-transparentcolor", "purple")
        self.root.wm_attributes("-topmost", self.always_on_top)
        self.root.focus_get()
        self.root.protocol("WM_DELETE_WINDOW", self.Quit)
        self.root.iconbitmap(media_path + 'icon.ico')
        self.root.pack_propagate(False)

        # Build banner image and make window draggable on the banner
        d2banner = media_path + 'd2icon.png'
        img = tk.PhotoImage(file=d2banner)
        self.img_panel = tkd.Label(self.root, image=img, borderwidth=0)
        self.img_panel.pack()
        self.img_panel.bind("<ButtonPress-1>", self.root.start_move)
        self.img_panel.bind("<ButtonRelease-1>", self.root.stop_move)
        self.img_panel.bind("<B1-Motion>", self.root.on_motion)
        self.root.bind("<Delete>", self.delete_selection)
        self.root.bind("<Left>", self.root.moveleft)
        self.root.bind("<Right>", self.root.moveright)
        self.root.bind("<Up>", self.root.moveup)
        self.root.bind("<Down>", self.root.movedown)

        # Add buttons to main widget
        self.btn_frame = tkd.Frame(self.root)
        tkd.Button(self.btn_frame, text='Delete selection', command=self.delete_selection).pack(side=tk.LEFT, expand=True, fill=tk.BOTH, padx=[2, 1], pady=1)
        tkd.Button(self.btn_frame, text='Archive session', command=self.ArchiveReset).pack(side=tk.LEFT, expand=True, fill=tk.BOTH, padx=[0, 1], pady=1)

        # Build tabs
        self.caret_frame = tkd.Frame(self.root)
        self.drops_frame = tkd.Frame(self.caret_frame)
        self.adv_stats_frame = tkd.Frame(self.caret_frame)

        self.tabcontrol = tkd.Notebook(self.root)
        self.tabcontrol.pack(expand=False, fill=tk.BOTH)
        self.profile_tab = Profile(self, parent=self.tabcontrol)
        self.timer_tab = MFRunTimer(self, parent=self.tabcontrol)
        self.drops_tab = Drops(self, parent=self.drops_frame)
        self.options_tab = Options(self, self.timer_tab, self.drops_tab, parent=self.tabcontrol)
        self.grail_tab = Grail(self, parent=self.tabcontrol)
        self.about_tab = About(self, parent=self.tabcontrol)

        self.tabcontrol.add(self.timer_tab, text='Timer')
        self.tabcontrol.add(self.options_tab, text='Options')
        self.tabcontrol.add(self.profile_tab, text='Profile')
        self.tabcontrol.add(self.grail_tab, text='Grail')
        self.tabcontrol.add(self.about_tab, text='About')

        self.root.bind("<<NotebookTabChanged>>", lambda _e: self.notebook_tab_change())
        self.profile_tab.update_descriptive_statistics()

        self.toggle_drops_frame(show=self.show_drops_tab_below)
        self.drops_caret = tkd.CaretButton(self.drops_frame, active=self.show_drops_tab_below, command=self.toggle_drops_frame, text='Drops', compound=tk.RIGHT, height=13)
        self.drops_caret.propagate(False)
        self.drops_caret.pack(side=tk.BOTTOM, fill=tk.X, expand=True, padx=[2, 1], pady=[0, 1])

        tracker_is_active = other_utils.safe_eval(self.cfg['AUTOMODE']['advanced_tracker_open']) and self.automode == 2 and self.is_user_admin
        self.advanced_stats_tracker = StatsTracker(self, self.adv_stats_frame)
        self.advanced_stats_caret = tkd.CaretButton(self.adv_stats_frame, active=tracker_is_active, text='Advanced stats', compound=tk.RIGHT, height=13, command=self.toggle_advanced_stats_frame)
        self.advanced_stats_caret.propagate(False)
        self.advanced_stats_caret.pack(side=tk.BOTTOM, fill=tk.X, expand=True, padx=[2, 1], pady=[0, 1])

        # Register binds for changing tabs
        if self.tab_switch_keys_global:
            self.options_tab.tab2.hk.register(['control', 'shift', 'next'], callback=lambda event: self.queue.put(self.tabcontrol.next_tab))
            self.options_tab.tab2.hk.register(['control', 'shift', 'prior'], callback=lambda event: self.queue.put(self.tabcontrol.prev_tab))
        else:
            self.root.bind_all('<Control-Shift-Next>', lambda event: self.tabcontrol.next_tab())
            self.root.bind_all('<Control-Shift-Prior>', lambda event: self.tabcontrol.prev_tab())

        # Load save state and start autosave process
        active_state = self.load_state_file()
        self.LoadActiveState(active_state)
        self.root.after(30000, self._autosave_state)

        # Apply styling options
        self.theme.apply_theme_style()
        self.theme.update_colors()

        # Automode and advanced stats loop
        self.am_lab = tk.Text(self.root, height=1, width=13, wrap=tk.NONE, bg="black", font=('Segoe UI', 9), cursor='', borderwidth=0)
        self.am_lab.tag_configure("am", foreground="white", background="black")
        self.am_lab.tag_configure("on", foreground="lawn green", background="black")
        self.am_lab.tag_configure("off", foreground="red", background="black")
        self.am_lab.place(x=1, y=0.4)
        self.toggle_automode()
        self.toggle_advanced_stats_frame(show=tracker_is_active)

        # A trick to disable windows DPI scaling - the app doesnt work well with scaling, unfortunately
        if self.os_release == '10' and self.disable_scaling:
            ctypes.windll.shcore.SetProcessDpiAwareness(2)

        # Used if "auto archive session" is activated
        self.profile_tab.auto_reset_session()

        # Pressing ALT_L paused UI updates when in focus, disable (probably hooked to opening menus)
        self.root.unbind_all('<Alt_L>')

        # Start the program
        self.root.mainloop()

    def delete_selection(self, event=None):
        if self.timer_tab.m.curselection():
            self.timer_tab.delete_selected_run()
        elif self.drops_tab.focus_get()._name == 'droplist':
            self.drops_tab.delete_selected_drops()

    def load_memory_reader(self, show_err=True):
        err = None
        d2_game_open = reader_utils.process_exists(reader.D2_GAME_EXE)
        d2_se_open = reader_utils.process_exists(reader.D2_SE_EXE)
        if not self.is_user_admin:
            err = ('Elevated access rights', 'You must run the app as ADMIN to initialize memory reader for advanced automode.\n\nDisabling advanced automode.')
            self.d2_reader = None
        elif self.automode != 2:
            err = ('Automode option', 'Automode has not been set to "Advanced" - Will not initiate memory reader')
            self.d2_reader = None
        elif reader_utils.number_of_processes_with_names([reader.D2_GAME_EXE, reader.D2_SE_EXE], logging_level=self.cfg['DEFAULT']['logging_level']) > 1:
            err = ('Number of processes', 'Several D2 processes have been opened, this bugs out the memory reader.\n\nDisabling advanced automode.')
            self.d2_reader = None
        elif not d2_game_open and not d2_se_open:
            self.d2_reader = None
        else:
            process_name = reader.D2_SE_EXE if d2_se_open else reader.D2_GAME_EXE
            try:
                self.d2_reader = reader.D2Reader(process_name=process_name)
                if self.d2_reader.dlls_loaded:
                    self.d2_reader.map_ptrs()
                    if not self.d2_reader.patch_supported:
                        err = ('D2 version error', 'Advanced automode currently only supports D2 patch versions 1.13c, 1.13d, 1.14b, 1.14c and 1.14d, your version is "%s".\n\nDisabling automode.' % self.d2_reader.d2_ver)
                else:
                    self.d2_reader = None

            except pymem.exception.CouldNotOpenProcess:
                err = ('Open process error', 'Memory reader failed to get handle of Game process, try restarting your computer.\n\nDisabling advanced automode')
                self.d2_reader = False
            except other_utils.pymem_err_list as e:
                logging.debug('Load reader error: %s' % e)
                self.d2_reader = None

        if err is not None and (not self.advanced_error_thrown or show_err):
            self.advanced_error_thrown = True
            messagebox.showerror(*err)
            logging.debug(err[1])
        else:
            self.advanced_error_thrown = False

    def sorted_profiles(self):
        def sort_key(x):
            file = 'Profiles/%s.json' % x
            if not os.path.isfile(file):
                return float('inf')
            else:
                state = other_utils.json_load_err(file)
                return state.get('extra_data', dict()).get('Last update', os.stat(file).st_mtime)
        return sorted(self.profiles, key=sort_key, reverse=True)

    def game_path(self):
        game_mode = self.profile_tab.game_mode.get()
        if game_mode == 'Single Player':
            return self.SP_game_path
        else:
            return self.MP_game_path

    def character_file_extension(self):
        game_mode = self.profile_tab.game_mode.get()
        if game_mode == 'Single Player':
            return '.d2s'
        else:
            return '.map'

    def toggle_automode(self, char_name=None):
        """
        Enables or disables automode. Shows a small label on top of the banner image with the text "Automode" when
        automode is activated. Takes an optional argument "char_name" which is used by the profile manager when changing
        between profiles, such that the automode loop listens to changes in the correct save file.
        """
        if char_name is None:
            char_name = self.profile_tab.char_name.get()
        if self.automode:
            self.am_lab.configure(state=tk.NORMAL)
            self.am_lab.delete(1.0, tk.END)
            self.am_lab.insert(tk.END, "Automode: ", "am")
            self.am_lab.insert(tk.END, "ON", "on")
            self.am_lab.config(width=14)
            self.am_lab.configure(state=tk.DISABLED)
        else:
            self.am_lab.configure(state=tk.NORMAL)
            self.am_lab.delete(1.0, tk.END)
            self.am_lab.insert(tk.END, "Automode: ", "am")
            self.am_lab.insert(tk.END, "OFF", "off")
            self.am_lab.config(width=15)
            self.am_lab.configure(state=tk.DISABLED)
        self.timer_tab.toggle_automode(char_name=char_name)

    def toggle_tab_keys_global(self):
        """
        Change whether tab switching keybind (ctrl-shift-pgup/pgdn) works only when the app has focus, or also when
        the app doesn't have focus. Added this feature, as some users might have this keybind natively bound to sth else
        """
        if self.tab_switch_keys_global:
            self.root.unbind_all('<Control-Shift-Next>')
            self.root.unbind_all('<Control-Shift-Prior>')
            self.options_tab.tab2.hk.register(['control', 'shift', 'next'], callback=lambda event: self.queue.put(self.tabcontrol.next_tab))
            self.options_tab.tab2.hk.register(['control', 'shift', 'prior'], callback=lambda event: self.queue.put(self.tabcontrol.prev_tab))
        else:
            self.options_tab.tab2.hk.unregister(['control', 'shift', 'next'])
            self.options_tab.tab2.hk.unregister(['control', 'shift', 'prior'])
            self.root.bind_all('<Control-Shift-Next>', lambda event: self.tabcontrol.next_tab())
            self.root.bind_all('<Control-Shift-Prior>', lambda event: self.tabcontrol.prev_tab())

    def toggle_drops_frame(self, show=None):
        if show is None:
            show = self.drops_caret.active
        drops_height = 175
        if show:
            self.root.update()
            self.root.config(height=self.root.winfo_height()+drops_height)
            self.drops_tab.pack(pady=[0, 2])
        else:
            if hasattr(self, 'drops_tab') and self.drops_tab.winfo_ismapped():
                self.drops_tab.forget()
                self.root.config(height=self.root.winfo_height()-drops_height)
        self.show_drops_tab_below = show
        self.img_panel.focus_force()

    def toggle_advanced_stats_frame(self, show=None):
        if show is None:
            show = self.advanced_stats_caret.active
        if self.automode != 2:
            if show:
                messagebox.showerror('Error', 'You need to activate "Advanced automode" in Options->Automode to see advanced stats')
            show = False
            self.advanced_stats_caret.toggle_image(active=False)
        if (self.show_xp_tracker and not hasattr(self.advanced_stats_tracker, 'after_updater')) or self.advanced_stats_tracker.xp_lf1.winfo_ismapped():
            tracker_height = 311
        else:
            tracker_height = 132

        if self.show_xp_tracker and not self.advanced_stats_tracker.xp_lf1.winfo_ismapped():
            self.advanced_stats_tracker.xp_lf1.pack(expand=False, fill=tk.X, padx=1, pady=8)
            self.advanced_stats_tracker.xp_lf2.pack(expand=False, fill=tk.X, padx=1)
        if not self.show_xp_tracker and self.advanced_stats_tracker.xp_lf1.winfo_ismapped():
            self.advanced_stats_tracker.xp_lf1.forget()
            self.advanced_stats_tracker.xp_lf2.forget()

        if show:
            self.root.update()
            self.root.config(height=self.root.winfo_height()+tracker_height)
            self.advanced_stats_tracker.pack()
            self.advanced_stats_tracker.update_loop()
        else:
            if hasattr(self.advanced_stats_tracker, 'after_updater'):
                self.advanced_stats_tracker.forget()
                self.root.config(height=self.root.winfo_height()-tracker_height)
                self.advanced_stats_tracker.after_cancel(self.advanced_stats_tracker.after_updater)
                delattr(self.advanced_stats_tracker, 'after_updater')

    def process_queue(self):
        """
        The system hotkeys are registered in child threads, and thus tkinter needs a queue to process hotkey calls to
        achieve a threadsafe system
        """
        try:
            self.queue.get(False)()
            self.root.after(50, lambda: self.process_queue())
        except queue.Empty:
            self.root.after(50, lambda: self.process_queue())

    def set_clickthrough(self):
        """
        Allow for making mouse clicks pass through the window, in case you want to use it as an overlay in your game
        Also makes the window transparent to visualize this effect.
        Calling the function again reverts the window to normal state.
        """
        hwnd = win32gui.FindWindow(None, self.title)
        if self.clickable:
            # Get window style and perform a 'bitwise or' operation to make the style layered and transparent, achieving
            # the clickthrough property
            l_ex_style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE)
            l_ex_style |= win32con.WS_EX_TRANSPARENT | win32con.WS_EX_LAYERED
            win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, l_ex_style)

            # Set the window to be transparent and appear always on top
            win32gui.SetLayeredWindowAttributes(hwnd, win32api.RGB(0, 0, 0), 190, win32con.LWA_ALPHA)  # transparent
            win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, self.root.winfo_x(), self.root.winfo_y(), 0, 0, 0)
            self.clickable = False
        else:
            # Calling the function again sets the extended style of the window to zero, reverting to a standard window
            win32api.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, 0)
            if not self.always_on_top:
                # Remove the always on top property again, in case always on top was set to false in options
                win32gui.SetWindowPos(hwnd, win32con.HWND_NOTOPMOST, self.root.winfo_x(), self.root.winfo_y(), 0, 0, 0)
            self.clickable = True

    def report_callback_exception(self, *args):
        """
        Handles errors occuring in the application, showing a messagebox with the occured error that user can send back
        for bug fixing
        """
        err = traceback.format_exception(*args)
        tk.messagebox.showerror('Exception occured', 'Progress since last autosave is lost...\n\n' + ''.join(err))
        logging.exception(args)
        os._exit(0)

    def notebook_tab_change(self):
        """
        When tab is switched to profile, the descriptive statistics are updated.
        """
        x = self.tabcontrol.select()
        if x.endswith('profile'):
            # self.SaveActiveState()
            # self.profile_tab.profile_dropdown['values'] = self.sorted_profiles()
            self.profile_tab.update_descriptive_statistics()
        # A 'hack' to ensure that dropdown menus don't take focus immediately when you switch tabs by focusing the
        # banner image instead :)
        self.img_panel.focus_force()

    @staticmethod
    def make_profile_folder():
        if not os.path.isdir('Profiles'):
            os.makedirs('Profiles')

    def load_state_file(self):
        """
        Loads the save file for the active profile. Ensures directory exists, and if not it is created. Ensures the
        file exists, and if not an empty dictionary is returned.
        """
        self.make_profile_folder()
        file = 'Profiles/%s.json' % self.active_profile
        if not os.path.isfile(file):
            state = dict()
        else:
            state = other_utils.json_load_err(file)
        return state

    def _autosave_state(self):
        """
        Function to run the autosave loop that saves the profile every 30 seconds
        """
        self.SaveActiveState()
        self.root.after(30000, self._autosave_state)

    def ResetSession(self):
        """
        Resets session for the timer module and drops from the drops module
        """
        self.timer_tab.reset_session()
        self.drops_tab.reset_session()
        self.advanced_stats_tracker.reset_session()

    def ArchiveReset(self, confirm_msg='Would you like to archive and reset session?', stamp_from_epoch=None):
        """
        If any laps or drops have been recorded, this function saves the current session to the profile archive, and
        resets all info in the active session. In case no runs/drops are recorded, the session timer is simply reset
        """
        if not self.timer_tab.laps and not self.drops_tab.drops:
            self.ResetSession()
            return

        xc = self.root.winfo_rootx() - self.root.winfo_width()//12
        yc = self.root.winfo_rooty() + self.root.winfo_height()//3
        if tk_utils.mbox(confirm_msg, b1='Yes', b2='No', coords=[xc, yc], master=self):
            # Stop any active run and load current session info from timer and drop module.
            self.timer_tab.stop()
            active = self.timer_tab.save_state()
            self.profile_tab.tot_laps += len(active['laps'])
            active.update(self.drops_tab.save_state())
            active.update(self.advanced_stats_tracker.save_state())

            # Update session dropdown for the profile
            if stamp_from_epoch is None:
                stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
            else:
                stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stamp_from_epoch))
            self.profile_tab.available_archive.insert(2, stamp)
            self.profile_tab.archive_dropdown['values'] = self.profile_tab.available_archive
            # self.profile_tab.update_descriptive_statistics()

            # Update profile .json with the session
            state = self.load_state_file()
            state['active_state'] = dict()
            state[stamp] = active
            state.setdefault('extra_data', dict())['Last update'] = time.time()

            file = 'Profiles/%s.json' % self.active_profile
            other_utils.atomic_json_dump(file, state)

            # When session has been successfully saved, the session is reset
            self.ResetSession()

    def LoadActiveState(self, state):
        """
        Loads the input state into the timer and drop modules. This is called at start-up when loading the profile .json
        and when you change the active profile in the profiles tab.
        """
        active_state = state.get('active_state', dict())
        self.timer_tab.load_from_state(active_state)
        self.drops_tab.load_from_state(active_state)
        self.advanced_stats_tracker.load_from_state(active_state)

    def SaveActiveState(self):
        """
        Loads the .json for the profile, and replaces the 'active_state' key with the current active state, whereafter
        it is saved to file.
        """
        logging.debug('Saved active state')
        cache = self.load_state_file()
        timer_state = self.timer_tab.save_state()
        drops_state = self.drops_tab.save_state()
        advanced_stats_state = self.advanced_stats_tracker.save_state()
        if cache.get('active_state', dict()).get('laps', []) != timer_state.get('laps', []) or cache.get('active_state', dict()).get('drops', dict()) != drops_state.get('drops', dict()):
            is_updated = True
        else:
            is_updated = False
        cache['active_state'] = {**timer_state, **drops_state, **advanced_stats_state}
        cache.setdefault('extra_data', dict())['Game mode'] = self.profile_tab.game_mode.get()
        if is_updated or 'Last update' not in cache['extra_data']:
            cache['extra_data']['Last update'] = time.time()
        file = 'Profiles/%s.json' % self.active_profile
        other_utils.atomic_json_dump(file, cache)
        self.grail_tab.save_grail()

    def Quit(self):
        """
        Stops the active run, updates config, saves current state to profile .json, and finally exits the application
        """
        if self.timer_tab.is_running and not (self.profile_tab.game_mode.get() == 'Single Player' and self.timer_tab.automode_active and self.automode == 1):
            self.timer_tab.stop()
        self.SaveActiveState()
        self.update_config(self)
        sys.exit()
Exemplo n.º 2
0
    def open_grail_controller(self):
        def rec_checkbox_add(master, frame, dct, rows=4, depth=None):
            # Default arguments cannot be mutable
            if depth is None:
                depth = []

            # Init count to determine number of rows before adding a new column
            cnt = 0
            for k, v in dct.items():
                # Bottom of tree nodes are either empty dicts or has the 'wasFound' key
                if v == dict() or 'wasFound' in v:
                    found = v.get('wasFound', False)

                    # Due to weird handling of rainbow facets in herokuapp, we utilise the saved stack of keys (in the
                    # 'depth' variable) to determine the appropriate item name
                    if k in ['Cold', 'Fire', 'Light', 'Poison']:
                        i_name = 'Rainbow Facet (%s %s)' % (k,
                                                            depth[-1].title())
                        var_name = 'grail_item_' + i_name.replace(
                            "'", "1").replace(' ', '_')
                    else:
                        # Replace characters that cannot be used in variable names
                        var_name = 'grail_item_' + k.replace("'", "1").replace(
                            ' ', '_')
                        i_name = k

                    # Define an IntVar as a class attribute that we can later call when needed. Then build the
                    # checkbutton, noting that the lambda needs to be passed a default argument, otherwise it will
                    # overwrite its own definition at each iteration
                    setattr(master, var_name, tk.IntVar(value=found))
                    tkd.Checkbutton(frame,
                                    text=k,
                                    variable=getattr(master, var_name),
                                    command=lambda _k=i_name: master.
                                    update_grail_from_name(_k)).pack(
                                        expand=True, anchor=tk.W)
                # We are not at the bottom of tree node, thus we will create a child node and call recursively
                else:
                    # When at top node, determine if a new column should be made or not, based on number of rows
                    if len(depth) == 0:
                        if cnt % rows == 0:
                            topframe = tkd.Frame(frame)
                            topframe.pack(side=tk.LEFT,
                                          expand=True,
                                          anchor=tk.NW)
                        new_frame = tkd.Frame(topframe)
                        new_frame.pack(side=tk.TOP,
                                       expand=True,
                                       anchor=tk.NW,
                                       fill=tk.Y,
                                       pady=[0, 30])
                        cnt += 1
                    else:
                        new_frame = tkd.Frame(frame)
                        new_frame.pack(side=tk.LEFT, expand=True, anchor=tk.N)

                    # .title() function bugs out with apostrophes. Handle the specific issues hardcoded here
                    # Also, there is a spelling mistake in herokuapp that we fix: Hsaru's -> Hsarus'
                    txt = k.title().replace("'S",
                                            "'s").replace("'A", "'a").replace(
                                                "Hsaru's", "Hsarus'")
                    tkd.Label(new_frame, text=txt,
                              font='Arial 15 bold').pack(expand=True,
                                                         anchor=tk.N)
                    rec_checkbox_add(master, new_frame, v, rows, depth + [k])

        # We dont allow more than one open window of the grail controller
        if win32gui.FindWindow(None, 'Grail controller'):
            return

        if self.show_eth_grail.get():
            messagebox.showinfo(
                'Eth grail',
                'Grail controller cannot be used to modify your eth grail as of right now.\nInstead you can do this on herokuapp, and then sync to the application afterwards'
            )
            self.update_statistics(eth=False)

        # Initialise the TopLevel window (important we use TopLevel instead of Tk, to pass over information between
        # the defined widgets and the main app)
        window = tkd.Toplevel()
        window.title('Grail controller')
        window.resizable(True, True)
        window.iconbitmap(media_path + 'icon.ico')

        # Build nested dict with information from the current grail
        upd_dict = {
            x['Item']: True
            for x in self.grail if x.get('Found', None) is True
        }
        nested_grail = herokuapp_controller.update_grail_dict(
            dct=herokuapp_controller.default_data, item_upg_dict=upd_dict)

        tabcontrol = ttk.Notebook(window)
        tabcontrol.pack(expand=True, fill=tk.BOTH)

        # Build the new tabs
        unique_armor = tkd.Frame(tabcontrol)
        unique_weapons = tkd.Frame(tabcontrol)
        unique_other = tkd.Frame(tabcontrol)
        sets = tkd.Frame(tabcontrol)
        runes = tkd.Frame(tabcontrol)

        tabcontrol.add(unique_armor, text='Unique Armor')
        tabcontrol.add(unique_weapons, text='Unique Weapons')
        tabcontrol.add(unique_other, text='Unique Other')
        tabcontrol.add(sets, text='Sets')
        tabcontrol.add(runes, text='Runes')

        rec_checkbox_add(self, unique_armor, nested_grail['uniques']['armor'],
                         3)
        rec_checkbox_add(self, unique_weapons,
                         nested_grail['uniques']['weapons'], 4)
        rec_checkbox_add(self, unique_other, nested_grail['uniques']['other'],
                         3)
        rec_checkbox_add(self, sets, nested_grail['sets'], 5)
        rec_checkbox_add(self, runes, nested_grail['runes'], 1)

        # Make sure to update the theme for the newly created widgets
        theme = Theme(self.main_frame.active_theme)
        theme.update_colors()
Exemplo n.º 3
0
    def open_archive_browser(self):
        chosen = self.archive_dropdown.get()
        if chosen == '':
            # If nothing is selected the function returns
            return

        # We build the new tkinter window to be opened
        new_win = tkd.Toplevel()
        new_win.title('Archive browser')
        new_win.wm_attributes('-topmost', 1)

        disp_coords = tk_utils.get_displaced_geom(self.main_frame.root, 400, 460)
        new_win.geometry(disp_coords)
        new_win.focus_get()
        new_win.iconbitmap(os.path.join(getattr(sys, '_MEIPASS', os.path.abspath('.')), media_path + 'icon.ico'))
        new_win.minsize(400, 460)
        title = tkd.Label(new_win, text='Archive browser', font='Helvetica 14')

        # Handle how loading of session data should be treated in the 3 different cases
        if chosen == 'Active session':
            # Load directly from timer module
            session_time = self.main_frame.timer_tab.session_time
            laps = self.main_frame.timer_tab.laps
            drops = self.main_frame.drops_tab.drops
        elif chosen == 'Profile history':
            # Load everything from profile .json, and append data from timer module
            active = self.main_frame.load_state_file()
            laps = []
            session_time = 0
            drops = dict()
            # Concatenate information from each available session
            for key in [x for x in active.keys() if x not in ['active_state', 'extra_data']]:
                session_drops = active[key].get('drops', dict())
                for run_no, run_drop in session_drops.items():
                    drops[str(int(run_no)+len(laps))] = run_drop
                laps.extend(active[key].get('laps', []))
                session_time += active[key].get('session_time', 0)

            # Append data for active session from timer module
            for run_no, run_drop in self.main_frame.drops_tab.drops.items():
                drops[str(int(run_no) + len(laps))] = run_drop
            laps.extend(self.main_frame.timer_tab.laps)
            session_time += self.main_frame.timer_tab.session_time
        else:
            # Load selected session data from profile .json
            active = self.main_frame.load_state_file()
            chosen_archive = active.get(chosen, dict())
            session_time = chosen_archive.get('session_time', 0)
            laps = chosen_archive.get('laps', [])
            drops = chosen_archive.get('drops', dict())

        # Ensure no division by zero errors by defaulting to displaying 0
        avg_lap = sum(laps) / len(laps) if laps else 0
        pct = sum(laps) * 100 / session_time if session_time > 0 else 0

        # Configure the list frame with scrollbars which displays the archive of the chosen session
        list_win = tkd.Frame(new_win)
        list_frame = tkd.Frame(list_win)
        vscroll = ttk.Scrollbar(list_frame, orient=tk.VERTICAL)
        hscroll = ttk.Scrollbar(list_win, orient=tk.HORIZONTAL)
        txt_list = tkd.Text(list_frame, yscrollcommand=vscroll.set, xscrollcommand=hscroll.set, font='courier 10', wrap=tk.WORD, state=tk.NORMAL, cursor='', exportselection=1, name='archivebrowser')
        # txt_list.bind('<FocusOut>', lambda e: txt_list.tag_remove(tk.SEL, "1.0", tk.END))  # Lose selection when shifting focus
        vscroll.pack(side=tk.RIGHT, fill=tk.Y)
        txt_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=1)
        txt_list.tag_configure("HEADER", font=tkFont(family='courier', size=12, weight='bold', underline=True))
        hscroll.config(command=txt_list.xview)
        vscroll.config(command=txt_list.yview)

        # Build header for output file with information and descriptive statistics
        output = [['Statistics'],
                  ['Character name: ', self.extra_data.get('Character name', '')],
                  ['Run type:       ', self.extra_data.get('Run type', '')],
                  ['Game mode:      ', self.extra_data.get('Game mode', 'Single Player')],
                  [''],
                  ['Total session time:   ', utils.other_utils.build_time_str(session_time)],
                  ['Total run time:       ', utils.other_utils.build_time_str(sum(laps))],
                  ['Average run time:     ', utils.other_utils.build_time_str(avg_lap)],
                  ['Fastest run time:     ', utils.other_utils.build_time_str(min(laps, default=0))],
                  ['Number of runs:       ', str(len(laps))],
                  ['Time spent in runs:   ', str(round(pct, 2)) + '%'],
                  ['']]

        # Backwards compatibility with old drop format
        for k, v in drops.items():
            for i in range(len(v)):
                if not isinstance(v[i], dict):
                    drops[k][i] = {'item_name': None, 'input': v[i], 'extra': ''}

        # List all drops collected
        if drops:
            if any(drop for drop in drops.values()):
                output.append(['Collected drops'])
            for run_no, drop in drops.items():
                if drop:
                    str_n = ' ' * max(len(str(len(laps))) - len(str(run_no)), 0) + str(run_no)
                    output.append(['Run ' + str_n, '', *[x['input'] for x in drop]])
            output.append([''])

        if laps:
            output.append(['Run times'])

        # Loop through all runs and add run times and drops for each run
        for n, lap in enumerate(laps, 1):
            str_n = ' ' * max(len(str(len(laps))) - len(str(n)), 0) + str(n)
            droplst = drops.get(str(n), [])
            tmp = ['Run ' + str_n + ': ', utils.other_utils.build_time_str(lap)]
            if droplst:
                tmp += [d['input'] for d in droplst]
            output.append(tmp)

        # Format string list to be shown in the archive browser
        for i, op in enumerate(output, 1):
            tmpstr = ''.join(op[:2])
            if len(op) > 2:
                tmpstr += ' --- ' + ', '.join(op[2:])
            if txt_list.get('1.0', tk.END) != '\n':
                tmpstr = '\n' + tmpstr
            txt_list.insert(tk.END, tmpstr)
            if op[0] in ['Statistics', 'Collected drops', 'Run times']:
                txt_list.tag_add("HEADER", str(i) + ".0", str(i) + ".0 lineend")

        # Add bold tags
        # txt_list.tag_add("BOLD", "1.0", "1.15")
        # txt_list.tag_add("BOLD", "2.0", "2.9")
        # txt_list.tag_add("BOLD", "3.0", "3.10")
        # txt_list.tag_add("BOLD", "5.0", "5.19")
        # txt_list.tag_add("BOLD", "6.0", "6.15")
        # txt_list.tag_add("BOLD", "7.0", "7.17")
        # txt_list.tag_add("BOLD", "8.0", "8.17")
        # txt_list.tag_add("BOLD", "9.0", "9.15")
        # txt_list.tag_add("BOLD", "10.0", "10.19")
        # txt_list.tag_add("BOLD", "1.16", "1.0 lineend")
        # txt_list.tag_add("BOLD", "2.16", "2.0 lineend")
        # txt_list.tag_add("BOLD", "3.16", "3.0 lineend")
        # txt_list.tag_add("BOLD", "5.20", "5.0 lineend")
        # txt_list.tag_add("BOLD", "6.20", "6.0 lineend")
        # txt_list.tag_add("BOLD", "7.20", "7.0 lineend")
        # txt_list.tag_add("BOLD", "8.20", "8.0 lineend")
        # txt_list.tag_add("BOLD", "9.20", "9.0 lineend")
        # txt_list.tag_add("BOLD", "10.20", "10.0 lineend")

        txt_list.tag_add("HEADER", "12.0", "12.0 lineend")

        txt_list.config(state=tk.DISABLED)

        button_frame = tkd.Frame(new_win)
        tkd.Button(button_frame, text='Copy to clipboard', command=lambda: self.copy_to_clipboard(new_win, txt_list.get(1.0, tk.END))).pack(side=tk.LEFT, fill=tk.X)
        tkd.Button(button_frame, text='Save as .txt', command=lambda: self.save_to_txt(txt_list.get(1.0, tk.END))).pack(side=tk.LEFT, fill=tk.X)
        tkd.Button(button_frame, text='Save as .csv', command=lambda: self.save_to_csv(output)).pack(side=tk.LEFT, fill=tk.X)

        # Packs all the buttons and UI in the archive browser. Packing order is very important:
        # TOP: Title first (furthest up), then list frame
        # BOTTOM: Buttons first (furthest down) and then horizontal scrollbar
        title.pack(side=tk.TOP)
        list_win.pack(side=tk.TOP, fill=tk.BOTH, expand=1)
        list_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=1)
        hscroll.pack(side=tk.BOTTOM, fill=tk.X)
        button_frame.pack(side=tk.BOTTOM)


        theme = Theme(self.main_frame.active_theme)
        theme.update_colors()