diff --git a/python_easy_chess_gui.py b/python_easy_chess_gui.py index f1c54fd..0cb1564 100644 --- a/python_easy_chess_gui.py +++ b/python_easy_chess_gui.py @@ -69,7 +69,8 @@ BOX_TITLE = f'{APP_NAME} {APP_VERSION}' REVIEW_MAX_DISPLAY_GAMES = 10000 REVIEW_ANALYSIS_MULTIPV_LINES = 3 -REVIEW_ANALYSIS_PV_MOVES = 7 +REVIEW_ANALYSIS_PV_MOVES = 7 +REVIEW_NAV_DEBOUNCE_SEC = 0.3 REVIEW_MOVE_LIST_HEIGHT = 11 REVIEW_ANALYSIS_BOX_HEIGHT = 4 @@ -555,10 +556,13 @@ def run(self): except Exception: logging.exception('Failed to configure runtime analysis options.') - # Set search limits + # Set search limits. + # For infinite analysis pass limit=None so that python-chess sends + # "go infinite" to the engine (Limit() is truthy and would produce + # a bare "go" without the infinite token). if self.tc_type == 'infinite': - limit = chess.engine.Limit( - depth=self.max_depth if self.max_depth != MAX_DEPTH else None) + limit = (chess.engine.Limit(depth=self.max_depth) + if self.max_depth != MAX_DEPTH else None) elif self.tc_type == 'delay': limit = chess.engine.Limit( depth=self.max_depth if self.max_depth != MAX_DEPTH else None, @@ -690,13 +694,19 @@ def run(self): # Apply engine move delay if movetime is small if self.is_move_delay: while True: - if time.perf_counter() - start_time >= self.move_delay_sec: + if (self._kill.is_set() + or time.perf_counter() - start_time + >= self.move_delay_sec): break logging.info('Delay sending of best move {}'.format(self.bm)) time.sleep(1.0) # If bm is None, we will use engine.play() - if self.bm is None: + # Skip this fallback when the search was explicitly interrupted + # to avoid blocking the thread with an unconstrained engine call. + # Also skip when limit is None (infinite analysis) since + # engine.play() requires a concrete Limit object. + if self.bm is None and not self._kill.is_set() and limit is not None: logging.info('bm is none, we will try engine,play().') try: result = self.engine.play(self.board, limit) @@ -829,8 +839,9 @@ def reset_review_state(self): self.review_analysis_lines = [''] * REVIEW_ANALYSIS_MULTIPV_LINES self.review_analysis_enabled = False self.review_analysis_status = 'Analysis stopped' - self.review_analysis_search = None - self.review_analysis_engine = None + self.review_analysis_search = None + self.review_analysis_engine = None + self.review_nav_last_time = 0 def update_game(self, mc: int, user_move: str, time_left: int, user_comment: str): """Saves moves in the game. @@ -2929,41 +2940,46 @@ def refresh_review_analysis(self, window): return self.start_review_analysis(window) - def poll_review_analysis(self, window): - """Consume engine messages for Review mode analysis.""" - updated = False - while True: - try: - msg = self.review_queue.get_nowait() - except queue.Empty: - break - except Exception: - logging.exception('Failed to read Review mode analysis queue.') - break - - msg_str = str(msg) - if 'multipv_info' in msg_str: - try: - line_no, info_line = msg_str.split(' | ', 1) - line_number = int(line_no.strip()) - if not 1 <= line_number <= REVIEW_ANALYSIS_MULTIPV_LINES: - raise ValueError('Invalid MultiPV line number') - line_index = line_number - 1 - info_line = info_line.rsplit(' multipv_info', 1)[0] - self.review_analysis_lines[line_index] = \ - self.shorten_review_analysis_line(info_line) - updated = True - except Exception: - logging.exception('Failed to parse Review mode analysis info.') - elif 'bestmove' in msg_str: - if self.review_analysis_search is not None: - self.review_analysis_search.join() - self.review_analysis_engine = \ - self.review_analysis_search.get_engine() - self.review_analysis_search = None - if self.review_analysis_enabled: - self.review_analysis_status = \ - 'Analysis ready - {}'.format(self.analysis_id_name) + def poll_review_analysis(self, window): + """Consume engine messages for Review mode analysis.""" + updated = False + is_debouncing = bool(self.review_nav_last_time) + while True: + try: + msg = self.review_queue.get_nowait() + except queue.Empty: + break + except Exception: + logging.exception('Failed to read Review mode analysis queue.') + break + + msg_str = str(msg) + if 'multipv_info' in msg_str: + # Skip stale analysis info from the old position while + # waiting for the debounce to restart analysis. + if is_debouncing: + continue + try: + line_no, info_line = msg_str.split(' | ', 1) + line_number = int(line_no.strip()) + if not 1 <= line_number <= REVIEW_ANALYSIS_MULTIPV_LINES: + raise ValueError('Invalid MultiPV line number') + line_index = line_number - 1 + info_line = info_line.rsplit(' multipv_info', 1)[0] + self.review_analysis_lines[line_index] = \ + self.shorten_review_analysis_line(info_line) + updated = True + except Exception: + logging.exception('Failed to parse Review mode analysis info.') + elif 'bestmove' in msg_str: + if self.review_analysis_search is not None: + self.review_analysis_search.join() + self.review_analysis_engine = \ + self.review_analysis_search.get_engine() + self.review_analysis_search = None + if self.review_analysis_enabled and not is_debouncing: + self.review_analysis_status = \ + 'Analysis ready - {}'.format(self.analysis_id_name) updated = True if updated: @@ -3107,9 +3123,17 @@ def start_review_mode(self, window): button, value = review_window.Read(timeout=50) self.poll_review_analysis(review_window) - # Skip timeout events as analysis updates are processed by - # poll_review_analysis() called earlier in the loop. - if button == sg.TIMEOUT_KEY: + # Skip timeout events as analysis updates are processed by + # poll_review_analysis() called earlier in the loop. + if button == sg.TIMEOUT_KEY: + # Restart analysis after debounce delay following navigation. + nav_time = self.review_nav_last_time + if (nav_time + and self.review_analysis_enabled + and time.time() - nav_time + >= REVIEW_NAV_DEBOUNCE_SEC): + self.review_nav_last_time = 0 + self.start_review_analysis(review_window) continue if button is None: @@ -3200,9 +3224,20 @@ def start_review_mode(self, window): self.update_review_window(review_window) continue - if position_changed: - self.update_review_window(review_window) - self.refresh_review_analysis(review_window) + if position_changed: + self.update_review_window(review_window) + if self.review_analysis_enabled: + # Signal the analysis thread to stop without blocking. + # The actual join and restart happen in the debounce + # handler after the user stops pressing buttons. + if self.review_analysis_search is not None: + self.review_analysis_search.stop() + self.review_nav_last_time = time.time() + self.review_analysis_lines = [''] * REVIEW_ANALYSIS_MULTIPV_LINES + self.review_analysis_status = 'Waiting...' + self.update_review_analysis_panel(review_window) + else: + self.refresh_review_analysis(review_window) self.close_review_analysis() review_window.Close()