From 9b5881412c91ef058524492fb3c20edfa4d68ce7 Mon Sep 17 00:00:00 2001 From: TARS Date: Wed, 12 Aug 2026 03:59:31 -0400 Subject: [PATCH] CORE: separate Kivy from core services --- katrain/__main__.py | 4 +- katrain/core/base_katrain.py | 30 +++++++++------ katrain/core/contribute_engine.py | 8 ++-- katrain/core/engine.py | 13 +++---- katrain/core/events.py | 34 +++++++++++++++++ katrain/core/game.py | 40 ++++++++++---------- katrain/core/game_node.py | 11 +++--- katrain/core/lang.py | 54 +------------------------- katrain/core/remote_engine.py | 14 ++----- katrain/gui.kv | 2 +- katrain/gui/badukpan.py | 2 +- katrain/gui/controlspanel.py | 10 ++++- katrain/gui/kivy_adapter.py | 39 +++++++++++++++++++ katrain/gui/kivy_lang.py | 63 +++++++++++++++++++++++++++++++ katrain/gui/kivyutils.py | 2 +- katrain/gui/popups.py | 2 +- katrain/popups.kv | 2 +- tests/test_core_boundaries.py | 34 +++++++++++++++++ tests/test_remote_engine.py | 31 ++++++++------- 19 files changed, 263 insertions(+), 132 deletions(-) create mode 100644 katrain/core/events.py create mode 100644 katrain/gui/kivy_adapter.py create mode 100644 katrain/gui/kivy_lang.py create mode 100644 tests/test_core_boundaries.py diff --git a/katrain/__main__.py b/katrain/__main__.py index ae7bff8..e86df01 100644 --- a/katrain/__main__.py +++ b/katrain/__main__.py @@ -70,7 +70,7 @@ from kivy.clock import Clock from kivy.metrics import dp from katrain.core.ai import generate_ai_move -from katrain.core.lang import DEFAULT_LANGUAGE, i18n +from katrain.gui.kivy_lang import DEFAULT_LANGUAGE, i18n from katrain.core.constants import ( OUTPUT_ERROR, OUTPUT_KATAGO_STDERR, @@ -105,6 +105,7 @@ from katrain.core.game import Game, IllegalMoveException, KaTrainSGF, BaseGame from katrain.core.sgf_parser import Move, ParseError from katrain.gui.popups import ConfigPopup, LoadSGFPopup, NewGamePopup, ConfigAIPopup from katrain.gui.theme import Theme +from katrain.gui.kivy_adapter import KivyEventSink from kivymd.app import MDApp # used in kv @@ -122,6 +123,7 @@ class KaTrainGui(Screen, KaTrainBase): def __init__(self, **kwargs): super().__init__(**kwargs) + self.events = KivyEventSink(self) self.engine = None self.contributing = False diff --git a/katrain/core/base_katrain.py b/katrain/core/base_katrain.py index ea0c7f7..61a804d 100644 --- a/katrain/core/base_katrain.py +++ b/katrain/core/base_katrain.py @@ -1,9 +1,7 @@ import os import shutil import sys - -from kivy import Config -from kivy.storage.jsonstore import JsonStore +import json from katrain.core.ai import ai_rank_estimation from katrain.core.constants import ( @@ -75,12 +73,6 @@ class KaTrainBase: self.config_file = self._load_config(force_package_config=force_package_config) self.debug_level = self.config("general/debug_level", OUTPUT_INFO) if debug_level is None else debug_level - Config.set("kivy", "log_level", "warning") - if self.debug_level >= OUTPUT_DEBUG: - Config.set("kivy", "log_enable", 1) - Config.set("kivy", "log_level", "debug") - # if self.debug_level >= OUTPUT_EXTRA_DEBUG: - # Config.set("kivy", "log_level", "trace") self.players_info = {"B": Player("B"), "W": Player("W")} self.reset_players() @@ -113,7 +105,8 @@ class KaTrainBase: self.log(f"Copied package config to local file {config_file}", OUTPUT_INFO) else: # user file exists try: - version_str = JsonStore(user_config_file).get("general")["version"] + with open(user_config_file, encoding="utf-8") as config_handle: + version_str = json.load(config_handle)["general"]["version"] version = parse_version(version_str) self.log(f"Parsed version: {version}", OUTPUT_DEBUG) except Exception as e: # noqa E722 broken file etc @@ -138,7 +131,7 @@ class KaTrainBase: OUTPUT_INFO, ) try: - self._config_store = JsonStore(config_file, indent=4) + self._config_store = JsonConfigStore(config_file) except Exception as e: self.log(f"Failed to load config {config_file}: {e}", OUTPUT_ERROR) sys.exit(1) @@ -187,3 +180,18 @@ class KaTrainBase: @property def next_player_info(self) -> Player: return self.players_info[self.game.current_node.next_player] + + +class JsonConfigStore(dict): + """Small replacement for Kivy's JsonStore used by the reusable core.""" + + def __init__(self, filename): + self.filename = filename + with open(filename, encoding="utf-8") as config_handle: + super().__init__(json.load(config_handle)) + + def put(self, key, **value): + self[key] = value + with open(self.filename, "w", encoding="utf-8") as config_handle: + json.dump(self, config_handle, indent=4) + config_handle.write("\n") diff --git a/katrain/core/contribute_engine.py b/katrain/core/contribute_engine.py index 3c19b27..bccc736 100644 --- a/katrain/core/contribute_engine.py +++ b/katrain/core/contribute_engine.py @@ -12,6 +12,7 @@ from collections import defaultdict from katrain.core.constants import OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_INFO, OUTPUT_KATAGO_STDERR, DATA_FOLDER from katrain.core.engine import BaseEngine +from katrain.core.events import event_sink from katrain.core.game import BaseGame from katrain.core.lang import i18n from katrain.core.sgf_parser import Move @@ -103,7 +104,7 @@ class KataGoContributeEngine(BaseEngine): if current_game.current_node.children: current_game.redo(1) self.last_advance = time.time() - self.katrain("update-state") + event_sink(self.katrain).state_changed() elif time.time() - self.last_advance > self.GIVE_UP_AFTER: self.katrain.log( f"Giving up on game {self.showing_game} which appears stuck, finding a new one", OUTPUT_INFO @@ -125,8 +126,7 @@ class KataGoContributeEngine(BaseEngine): self.last_advance = time.time() self.katrain.log(f"Showing game {self.showing_game}, {best_count} moves left to show.", OUTPUT_INFO) - self.katrain.game = self.active_games[self.showing_game] - self.katrain("update-state", redraw_board=True) + event_sink(self.katrain).contribution_game_changed(self.active_games[self.showing_game]) def status(self): return f"Contributing to distributed training\nGames: {self.uploaded_games_count} uploaded, {len(self.active_games)} in buffer, {len(self.finished_games)} shown\n{self.move_count} moves played ({60*self.move_count/(time.time()-self.start_time):.1f}/min, {self.visits_count / (time.time() - self.start_time):.1f} visits/s)\n" @@ -287,7 +287,7 @@ class KataGoContributeEngine(BaseEngine): f"[{time.time()-self.start_time:.1f}] Game {game_id} Move {analysis['turnNumber']}: {' '.join(analysis['move'])} Visits {analysis['rootInfo']['visits']} Time {dt:.1f}s\t Moves/min {60*self.move_count/(time.time()-self.start_time):.1f} Visits/s {self.visits_count/(time.time()-self.start_time):.1f}", OUTPUT_DEBUG, ) - self.katrain("update-state") + event_sink(self.katrain).state_changed() except Exception as e: traceback.print_exc() self.katrain.log(f"Exception {e} in parsing or processing JSON: {line}", OUTPUT_ERROR) diff --git a/katrain/core/engine.py b/katrain/core/engine.py index acd3d3d..eed9337 100644 --- a/katrain/core/engine.py +++ b/katrain/core/engine.py @@ -8,10 +8,9 @@ import subprocess import threading import time import traceback +import sys from typing import Callable, Dict, List, Optional -from kivy.utils import platform as kivy_platform - from katrain.core.constants import ( OUTPUT_DEBUG, OUTPUT_ERROR, @@ -21,6 +20,7 @@ from katrain.core.constants import ( KATAGO_EXCEPTION, PONDERING_REPORT_DT, ) +from katrain.core.events import event_sink from katrain.core.game_node import GameNode from katrain.core.lang import i18n from katrain.core.sgf_parser import Move @@ -82,9 +82,9 @@ class BaseEngine: # some common elements between analysis and contribute engine def get_engine_path(self, exe): if not exe: - if kivy_platform == "win": + if os.name == "nt": exe = "katrain/KataGo/katago.exe" - elif kivy_platform == "linux": + elif sys.platform.startswith("linux"): exe = "katrain/KataGo/katago" else: exe = find_package_resource("katrain/KataGo/katago-osx") # github actions built @@ -166,7 +166,7 @@ class KataGoEngine(BaseEngine): def on_error(self, message, code=None, allow_popup=True): self.katrain.log(message, OUTPUT_ERROR) if self.allow_recovery and allow_popup: - self.katrain("engine_recovery_popup", message, code, self.ENGINE_TYPE) + event_sink(self.katrain).engine_failure(message, code, self.ENGINE_TYPE) def start(self): with self.thread_lock: @@ -347,8 +347,7 @@ class KataGoEngine(BaseEngine): except Exception as e: self.katrain.log(f"Error in engine callback for query {query_id}: {e}", OUTPUT_ERROR) traceback.print_exc() - if getattr(self.katrain, "update_state", None): # easier mocking etc - self.katrain.update_state() + event_sink(self.katrain).state_changed() except Exception as e: self.katrain.log(f"Unexpected exception {e} while processing KataGo output {line}", OUTPUT_ERROR) traceback.print_exc() diff --git a/katrain/core/events.py b/katrain/core/events.py new file mode 100644 index 0000000..473b01f --- /dev/null +++ b/katrain/core/events.py @@ -0,0 +1,34 @@ +"""Framework-neutral notifications emitted by core services.""" + + +class CoreEventSink: + """Application boundary for status, refresh, and recovery requests. + + Frontends must marshal these calls to their own UI thread. + """ + + def status(self, message, priority, node=None): + pass + + def state_changed(self, redraw_board=False): + pass + + def engine_failure(self, message, code, engine_type): + pass + + def insert_mode_changed(self, insert_node): + pass + + def move_tree_changed(self): + pass + + def contribution_game_changed(self, game): + pass + + +NULL_EVENTS = CoreEventSink() + + +def event_sink(host): + """Return a host's event sink without making it a required UI object.""" + return getattr(host, "events", NULL_EVENTS) diff --git a/katrain/core/game.py b/katrain/core/game.py index 1faa90a..627cb4e 100644 --- a/katrain/core/game.py +++ b/katrain/core/game.py @@ -6,8 +6,6 @@ import threading from datetime import datetime from typing import Dict, List, Optional, Union -from kivy.clock import Clock - from katrain.core.constants import ( OUTPUT_DEBUG, OUTPUT_EXTRA_DEBUG, @@ -28,6 +26,7 @@ from katrain.core.constants import ( PRIORITY_DEFAULT, ) from katrain.core.engine import KataGoEngine +from katrain.core.events import event_sink from katrain.core.game_node import GameNode from katrain.core.lang import i18n, rank_label from katrain.core.sgf_parser import SGF, Move @@ -467,7 +466,7 @@ class Game(BaseGame): def set_current_node(self, node): if self.insert_mode: - self.katrain.controls.set_status(i18n._("finish inserting before navigating"), STATUS_ERROR) + event_sink(self.katrain).status(i18n._("finish inserting before navigating"), STATUS_ERROR) return super().set_current_node(node) @@ -505,7 +504,7 @@ class Game(BaseGame): self.insert_mode = False else: self.insert_after = self.current_node.ordered_children[0] - self.katrain.controls.set_status(i18n._("starting insert mode"), STATUS_INFO) + event_sink(self.katrain).status(i18n._("starting insert mode"), STATUS_INFO) else: copy_from_node = self.insert_after copy_to_node = self.current_node @@ -531,15 +530,14 @@ class Game(BaseGame): except IllegalMoveException: pass # illegal move = stop self._calculate_groups() # recalculate groups - self.katrain.controls.set_status( + event_sink(self.katrain).status( i18n._("ending insert mode").format(num_copied=num_copied), STATUS_INFO ) self.analyze_all_nodes(analyze_fast=True, even_if_present=False) else: - self.katrain.controls.set_status("", STATUS_INFO) - self.katrain.controls.move_tree.insert_node = self.insert_after if self.insert_mode else None - self.katrain.controls.move_tree.redraw() - self.katrain.update_state(redraw_board=True) + event_sink(self.katrain).status("", STATUS_INFO) + event_sink(self.katrain).insert_mode_changed(self.insert_after if self.insert_mode else None) + event_sink(self.katrain).state_changed(redraw_board=True) # Play a Move from the current position, raise IllegalMoveException if invalid. def play(self, move: Move, ignore_ko: bool = False, analyze=True): @@ -561,7 +559,7 @@ class Game(BaseGame): self.region_of_interest = [xmin, xmax, ymin, ymax] else: self.region_of_interest = None - self.katrain.controls.set_status("", OUTPUT_INFO) + event_sink(self.katrain).status("", OUTPUT_INFO) def analyze_extra(self, mode, **kwargs): stones = {s.coords for s in self.stones} @@ -588,7 +586,7 @@ class Game(BaseGame): if mode == "extra": visits = cn.analysis_visits_requested + engine.config["max_visits"] - self.katrain.controls.set_status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS) + event_sink(self.katrain).status(i18n._("extra analysis").format(visits=visits), STATUS_ANALYSIS) cn.analyze( engine, visits=visits, @@ -619,9 +617,9 @@ class Game(BaseGame): continue node.analyze(engine, visits=visits, priority=-1_000_000, time_limit=False, report_every=None) if not move_range: - self.katrain.controls.set_status(i18n._("game re-analysis").format(visits=visits), STATUS_ANALYSIS) + event_sink(self.katrain).status(i18n._("game re-analysis").format(visits=visits), STATUS_ANALYSIS) else: - self.katrain.controls.set_status( + event_sink(self.katrain).status( i18n._("move range analysis").format( start_move=move_range[0], end_move=move_range[1], visits=visits ), @@ -655,19 +653,19 @@ class Game(BaseGame): if (x, y) not in stones ] visits = engine.config["fast_visits"] - self.katrain.controls.set_status(i18n._("sweep analysis").format(visits=visits), STATUS_ANALYSIS) + event_sink(self.katrain).status(i18n._("sweep analysis").format(visits=visits), STATUS_ANALYSIS) priority = PRIORITY_SWEEP elif mode in ["equalize", "alternative", "local"]: if not cn.analysis_complete and mode != "local": - self.katrain.controls.set_status(i18n._("wait-before-extra-analysis"), STATUS_INFO, self.current_node) + event_sink(self.katrain).status(i18n._("wait-before-extra-analysis"), STATUS_INFO, self.current_node) return if mode == "alternative": # also do a quick update on current candidates so it doesn't look too weird - self.katrain.controls.set_status(i18n._("alternative analysis"), STATUS_ANALYSIS) + event_sink(self.katrain).status(i18n._("alternative analysis"), STATUS_ANALYSIS) cn.analyze(engine, priority=PRIORITY_ALTERNATIVES, time_limit=False, find_alternatives="alternative") visits = engine.config["fast_visits"] else: # equalize visits = max(d["visits"] for d in cn.analysis["moves"].values()) - self.katrain.controls.set_status(i18n._("equalizing analysis").format(visits=visits), STATUS_ANALYSIS) + event_sink(self.katrain).status(i18n._("equalizing analysis").format(visits=visits), STATUS_ANALYSIS) priority = PRIORITY_EQUALIZE analyze_moves = [Move.from_gtp(gtp, player=cn.next_player) for gtp, _ in cn.analysis["moves"].items()] else: @@ -761,7 +759,7 @@ class Game(BaseGame): new_node.ai_thoughts = ai_thoughts if until_move != "end" and target_b_advantage is not None: self.set_current_node(new_node) - self.katrain.controls.set_status( + event_sink(self.katrain).status( i18n._("setup game status message").format(move=new_node.depth, until_move=until_move), STATUS_INFO, ) @@ -770,7 +768,7 @@ class Game(BaseGame): node.remove_shortcut() cn.add_shortcut(new_node) - self.katrain.controls.move_tree.redraw_tree_trigger() + event_sink(self.katrain).move_tree_changed() request_analysis_for_node(new_node) request_analysis_for_node(cn) @@ -797,7 +795,7 @@ class Game(BaseGame): node.auto_undo = undo if undo: self.undo(1) - self.katrain.controls.set_status( + event_sink(self.katrain).status( i18n._("teaching undo message").format(move=move.gtp(), points_lost=points_lost), STATUS_TEACHING ) - self.katrain.update_state() + event_sink(self.katrain).state_changed() diff --git a/katrain/core/game_node.py b/katrain/core/game_node.py index 702ae34..7ab74fa 100644 --- a/katrain/core/game_node.py +++ b/katrain/core/game_node.py @@ -18,7 +18,6 @@ from katrain.core.constants import ( from katrain.core.lang import i18n from katrain.core.sgf_parser import Move, SGFNode from katrain.core.utils import evaluation_class, pack_floats, unpack_floats, var_to_grid -from katrain.gui.theme import Theme def analysis_dumps(analysis): @@ -318,13 +317,11 @@ class GameNode(SGFNode): return ix + 1, p, policy_ranking return None, 0.0, [] - def make_pv(self, player, pv, interactive): + def make_pv(self, player, pv, interactive=False): pvtext = f"{player}{' '.join(pv)}" - if interactive: - pvtext = f"[u][ref={pvtext}][color={Theme.INFO_PV_COLOR}]{pvtext}[/color][/ref][/u]" return pvtext - def comment(self, sgf=False, teach=False, details=False, interactive=True): + def comment(self, sgf=False, teach=False, details=False, interactive=True, pv_formatter=None): single_move = self.move if not self.parent or not single_move: # root if self.root: @@ -359,7 +356,9 @@ class GameNode(SGFNode): else: text += i18n._("Info:best move") + "\n" if previous_top_move.get("pv") and (sgf or details): - pv = self.make_pv(single_move.player, previous_top_move["pv"], interactive) + pv = self.make_pv(single_move.player, previous_top_move["pv"]) + if interactive and pv_formatter: + pv = pv_formatter(pv) text += i18n._("Info:PV").format(pv=pv) + "\n" if sgf or details or teach: currmove_pol_rank, currmove_pol_prob, policy_ranking = self.move_policy_stats() diff --git a/katrain/core/lang.py b/katrain/core/lang.py index a8257b9..790297b 100644 --- a/katrain/core/lang.py +++ b/katrain/core/lang.py @@ -1,78 +1,28 @@ import gettext import os -import sys - -from kivy._event import Observable from katrain.core.utils import find_package_resource -from katrain.gui.theme import Theme - - -class Lang(Observable): - observers = [] - callbacks = [] +class Lang: FONTS = {"jp": "NotoSansJP-Regular.otf", "tr": "NotoSans-Regular.ttf", "ua": "NotoSans-Regular.ttf"} def __init__(self, lang): - super(Lang, self).__init__() self.lang = None self.switch_lang(lang) def _(self, text): return self.ugettext(text) - def set_widget_font(self, widget): - widget.font_name = self.font_name - for sub_widget in [getattr(widget, "_hint_lbl", None), getattr(widget, "_msg_lbl", None)]: # MDText - if sub_widget: - sub_widget.font_name = self.font_name - - def fbind(self, name, func, *args): - if name == "_": - widget, property, *_ = args[0] - self.observers.append((widget, func, args)) - try: - self.set_widget_font(widget) - except Exception as e: - print(e) - # pass - else: - return super(Lang, self).fbind(name, func, *args) - - def funbind(self, name, func, *args): - if name == "_": - widget, *_ = args[0] - key = (widget, func, args) - if key in self.observers: - self.observers.remove(key) - else: - return super(Lang, self).funbind(name, func, *args) - def switch_lang(self, lang): if lang == self.lang: return # get the right locales directory, and instantiate a gettext self.lang = lang - self.font_name = self.FONTS.get(lang) or Theme.DEFAULT_FONT + self.font_name = self.FONTS.get(lang) i18n_dir, _ = os.path.split(find_package_resource("katrain/i18n/__init__.py")) locale_dir = os.path.join(i18n_dir, "locales") locales = gettext.translation("katrain", locale_dir, languages=[lang, DEFAULT_LANGUAGE]) self.ugettext = locales.gettext - # update all the kv rules attached to this text - for widget, func, args in self.observers: - try: - func(args[0], None, None) - self.set_widget_font(widget) - except ReferenceError: - pass # proxy no longer exists - except Exception as e: - print("Error in switching languages", e) - for cb in self.callbacks: - try: - cb(self) - except Exception as e: - print(f"Failed callback on language change: {e}", file=sys.stderr) DEFAULT_LANGUAGE = "en" diff --git a/katrain/core/remote_engine.py b/katrain/core/remote_engine.py index 0ac4c85..e5b5070 100644 --- a/katrain/core/remote_engine.py +++ b/katrain/core/remote_engine.py @@ -33,6 +33,7 @@ from katrain.core.constants import ( OUTPUT_INFO, STATUS_INFO, ) +from katrain.core.events import event_sink from katrain.core.engine import BaseEngine, KataGoEngine, resolve_engine_backend from katrain.core.lang import i18n from katrain.core.utils import json_truncate_arrays @@ -277,10 +278,7 @@ class RemoteKataGoEngine(KataGoEngine): return def _set_status(self, message): - try: - self.katrain.controls.set_status(message, STATUS_INFO) - except Exception: - pass + event_sink(self.katrain).status(message, STATUS_INFO) def on_new_game(self): # Parent clears self.queries; drop the payloads too so a later @@ -520,10 +518,7 @@ class RemoteKataGoEngine(KataGoEngine): f"Remote KataGo warning: {warning_text}", OUTPUT_INFO, ) - try: - self.katrain.controls.set_status(warning_text, STATUS_INFO) - except Exception: - pass + event_sink(self.katrain).status(warning_text, STATUS_INFO) if "error" in analysis: del self.queries[query_id] @@ -565,8 +560,7 @@ class RemoteKataGoEngine(KataGoEngine): ) traceback.print_exc() - if getattr(self.katrain, "update_state", None): - self.katrain.update_state() + event_sink(self.katrain).state_changed() except Exception as e: self.katrain.log( f"Unexpected exception {e} processing remote KataGo output: {line[:200]!r}", diff --git a/katrain/gui.kv b/katrain/gui.kv index b3d75d1..049dbdc 100644 --- a/katrain/gui.kv +++ b/katrain/gui.kv @@ -1,5 +1,5 @@ #:kivy 2.3.0 -#:import i18n katrain.core.lang.i18n +#:import i18n katrain.gui.kivy_lang.i18n #:import PLAYER_TYPES katrain.core.constants.PLAYER_TYPES #:import PLAYER_HUMAN katrain.core.constants.PLAYER_HUMAN diff --git a/katrain/gui/badukpan.py b/katrain/gui/badukpan.py index 73ffbdd..608cb8f 100644 --- a/katrain/gui/badukpan.py +++ b/katrain/gui/badukpan.py @@ -30,7 +30,7 @@ from katrain.core.constants import ( TOP_MOVE_WINRATE, ) from katrain.core.game import Move -from katrain.core.lang import i18n +from katrain.gui.kivy_lang import i18n from katrain.core.utils import evaluation_class, format_visits, var_to_grid, json_truncate_arrays from katrain.gui.kivyutils import draw_circle, draw_text, cached_texture from katrain.gui.popups import I18NPopup, ReAnalyzeGamePopup, GameReportPopup, TsumegoFramePopup diff --git a/katrain/gui/controlspanel.py b/katrain/gui/controlspanel.py index a73a375..b1df182 100644 --- a/katrain/gui/controlspanel.py +++ b/katrain/gui/controlspanel.py @@ -14,12 +14,16 @@ from katrain.core.constants import ( AI_DEFAULT, PLAYER_AI, ) -from katrain.core.lang import rank_label +from katrain.gui.kivy_lang import rank_label from katrain.gui.kivyutils import AnalysisToggle, CollapsablePanel from katrain.gui.theme import Theme from katrain.gui.sound import play_sound, stop_sound +def format_interactive_pv(pv): + return f"[u][ref={pv}][color={Theme.INFO_PV_COLOR}]{pv}[/color][/ref][/u]" + + class PlayAnalyzeSelect(MDFloatLayout): katrain = ObjectProperty(None) mode = OptionProperty(MODE_PLAY, options=[MODE_PLAY, MODE_ANALYZE]) @@ -160,7 +164,9 @@ class ControlsPanel(BoxLayout): if move or current_node.is_root: info += self.active_comment_node.comment( - teach=katrain.players_info[self.active_comment_node.player].being_taught, details=details + teach=katrain.players_info[self.active_comment_node.player].being_taught, + details=details, + pv_formatter=format_interactive_pv, ) if self.active_comment_node.analysis_exists: diff --git a/katrain/gui/kivy_adapter.py b/katrain/gui/kivy_adapter.py new file mode 100644 index 0000000..9013e4e --- /dev/null +++ b/katrain/gui/kivy_adapter.py @@ -0,0 +1,39 @@ +"""Kivy implementations of framework-neutral core notifications.""" + +from kivy.clock import Clock + + +class KivyEventSink: + def __init__(self, katrain): + self.katrain = katrain + + def status(self, message, priority, node=None): + def update(_dt): + if self.katrain.controls: + self.katrain.controls.set_status(message, priority, at_node=node) + + Clock.schedule_once(update, -1) + + def state_changed(self, redraw_board=False): + self.katrain.update_state(redraw_board=redraw_board) + + def engine_failure(self, message, code, engine_type): + Clock.schedule_once( + lambda _dt: self.katrain._do_engine_recovery_popup(message, code, engine_type), + -1, + ) + + def insert_mode_changed(self, insert_node): + def update(_dt): + move_tree = self.katrain.controls.move_tree + move_tree.insert_node = insert_node + move_tree.redraw() + + Clock.schedule_once(update, -1) + + def move_tree_changed(self): + Clock.schedule_once(lambda _dt: self.katrain.controls.move_tree.redraw_tree_trigger(), -1) + + def contribution_game_changed(self, game): + self.katrain.game = game + self.state_changed(redraw_board=True) diff --git a/katrain/gui/kivy_lang.py b/katrain/gui/kivy_lang.py new file mode 100644 index 0000000..ac056c5 --- /dev/null +++ b/katrain/gui/kivy_lang.py @@ -0,0 +1,63 @@ +"""Kivy observable adapter for the framework-neutral translator.""" + +from kivy._event import Observable + +from katrain.core.lang import DEFAULT_LANGUAGE, Lang as CoreLang +from katrain.gui.theme import Theme + + +class Lang(CoreLang, Observable): + observers = [] + + def __init__(self, lang): + Observable.__init__(self) + self.callbacks = [] + super().__init__(lang) + + def set_widget_font(self, widget): + widget.font_name = self.font_name or Theme.DEFAULT_FONT + for sub_widget in [getattr(widget, "_hint_lbl", None), getattr(widget, "_msg_lbl", None)]: + if sub_widget: + sub_widget.font_name = widget.font_name + + def fbind(self, name, func, *args): + if name != "_": + return Observable.fbind(self, name, func, *args) + widget, _property, *_ = args[0] + self.observers.append((widget, func, args)) + self.set_widget_font(widget) + + def funbind(self, name, func, *args): + if name != "_": + return Observable.funbind(self, name, func, *args) + widget, *_ = args[0] + key = (widget, func, args) + if key in self.observers: + self.observers.remove(key) + + def switch_lang(self, lang): + previous = self.lang + super().switch_lang(lang) + if lang == previous: + return + for widget, func, args in self.observers: + try: + func(args[0], None, None) + self.set_widget_font(widget) + except ReferenceError: + pass + for callback in self.callbacks: + callback(self) + + +i18n = Lang(DEFAULT_LANGUAGE) + + +def rank_label(rank): + if rank is None: + return "??k" + if rank >= 0.5: + return f"{rank:.0f}{i18n._('strength:dan')}" + return f"{1-rank:.0f}{i18n._('strength:kyu')}" + +__all__ = ["DEFAULT_LANGUAGE", "i18n", "rank_label"] diff --git a/katrain/gui/kivyutils.py b/katrain/gui/kivyutils.py index fefc8cc..ab8852c 100644 --- a/katrain/gui/kivyutils.py +++ b/katrain/gui/kivyutils.py @@ -37,7 +37,7 @@ from katrain.core.constants import ( PLAYING_NORMAL, PLAYING_TEACHING, ) -from katrain.core.lang import i18n +from katrain.gui.kivy_lang import i18n from katrain.gui.theme import Theme diff --git a/katrain/gui/popups.py b/katrain/gui/popups.py index 5a9a1fa..3949915 100644 --- a/katrain/gui/popups.py +++ b/katrain/gui/popups.py @@ -40,7 +40,7 @@ from katrain.core.constants import ( PLAYER_HUMAN, ADDITIONAL_MOVE_ORDER, ) -from katrain.core.lang import i18n, rank_label +from katrain.gui.kivy_lang import i18n, rank_label from katrain.core.sgf_parser import Move from katrain.core.utils import PATHS, find_package_resource, evaluation_class from katrain.gui.kivyutils import ( diff --git a/katrain/popups.kv b/katrain/popups.kv index 72462f0..ee5b6a1 100644 --- a/katrain/popups.kv +++ b/katrain/popups.kv @@ -1,5 +1,5 @@ #:kivy 2.3.0 -#:import i18n katrain.core.lang.i18n +#:import i18n katrain.gui.kivy_lang.i18n #:import expanduser os.path.expanduser #:import abspath os.path.abspath #:import kivy_version kivy.__version__ diff --git a/tests/test_core_boundaries.py b/tests/test_core_boundaries.py new file mode 100644 index 0000000..6bc51d6 --- /dev/null +++ b/tests/test_core_boundaries.py @@ -0,0 +1,34 @@ +"""Regression tests for Kivy-free reusable core modules.""" + +import importlib +import builtins + + +CORE_MODULES = ( + "katrain.core.base_katrain", + "katrain.core.lang", + "katrain.core.game_node", + "katrain.core.engine", + "katrain.core.game", + "katrain.core.remote_engine", + "katrain.core.contribute_engine", +) + + +def test_core_imports_without_kivy(monkeypatch): + original_import = builtins.__import__ + + def reject_kivy(name, *args, **kwargs): + if name == "kivy" or name.startswith("kivy."): + raise AssertionError(f"core imported Kivy: {name}") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", reject_kivy) + for module in CORE_MODULES: + importlib.reload(importlib.import_module(module)) + + +def test_pv_text_has_no_frontend_markup(): + from katrain.core.game_node import GameNode + + assert GameNode().make_pv("B", ["D4", "Q16"], interactive=True) == "BD4 Q16" diff --git a/tests/test_remote_engine.py b/tests/test_remote_engine.py index 7083869..b943e03 100644 --- a/tests/test_remote_engine.py +++ b/tests/test_remote_engine.py @@ -44,25 +44,31 @@ class FakeWS: self._recv.put(WebSocketException("connection lost")) -class FakeControls: - def set_status(self, *args, **kwargs): - pass +class FakeEvents: + def __init__(self): + self.failures = [] + self.statuses = [] + self.state_changes = [] + + def engine_failure(self, message, code, engine_type): + self.failures.append((message, code, engine_type)) + + def status(self, message, priority, node=None): + self.statuses.append((message, priority, node)) + + def state_changed(self, redraw_board=False): + self.state_changes.append(redraw_board) class FakeKatrain: - """Records calls (e.g. the engine_recovery_popup trigger).""" + """Minimal framework-neutral engine host.""" def __init__(self): - self.controls = FakeControls() - self.calls = [] - self.update_state = lambda *a, **k: None + self.events = FakeEvents() def log(self, *args, **kwargs): pass - def __call__(self, *args, **kwargs): - self.calls.append(args) - def wait_until(pred, timeout=5.0, interval=0.02): end = time.time() + timeout @@ -72,7 +78,7 @@ def wait_until(pred, timeout=5.0, interval=0.02): def popup_codes(katrain): - return [args[2] for args in katrain.calls if args and args[0] == "engine_recovery_popup"] + return [code for _message, code, _engine_type in katrain.events.failures] @pytest.fixture @@ -144,8 +150,7 @@ def test_reconnect_failure_opens_recovery_popup(monkeypatch, fast_backoff): # The popup is told this is a remote engine so it shows remote- # specific advice (check URL) instead of the local executable hints. - popup_call = next(args for args in katrain.calls if args and args[0] == "engine_recovery_popup") - assert popup_call[3] == "remote" + assert katrain.events.failures[0][2] == "remote" finally: engine.shutdown()