Compare commits

...
6 Commits
Author SHA1 Message Date
TARS ba1cccea17 DOC: add TODO.md 2026-08-12 04:21:28 -04:00
TARS 9b5881412c CORE: separate Kivy from core services 2026-08-12 03:59:31 -04:00
TARS e1bd8a1cff DOC: document KataGo OpenCL tuning 2026-08-12 03:53:14 -04:00
wyj 39893a852c DOC: update AGENTS.md 2026-08-12 02:01:28 -04:00
TARS 8e25103a0f MINOR: define Qt port scope 2026-08-12 01:52:51 -04:00
TARS a764e3d40d MINOR: add project agent guidance 2026-08-12 01:20:56 -04:00
21 changed files with 392 additions and 132 deletions

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
# KaTrain Qt Port
This repository is a Qt 6 port of KaTrain, a KataGo frontend for Go/Baduk.
## Repository Context
- The port starts from upstream KaTrain 1.19.0.
- Development currently happens on `qt-0.19.0`, which presently matches upstream `v0.19.0`.
- `../katrain-reference` is a read-only checkout of upstream KaTrain 1.19.0. Use it to compare behavior and implementation, but never modify it.
- Make all changes in this repository on `qt-0.19.0`.
## Porting Direction
- Upstream KaTrain is a Python application built with Kivy.
- Kivy does not work well on Wayland, so this project is a Qt 6 port.
- The goal is a PySide6 frontend for KaTrain.
- `katrain/core` is not fully UI-independent. The first major goal is to separate the actual core functionality from UI-specific concerns before or alongside the Qt UI port.
- Preserve upstream behavior where practical, while keeping UI abstractions out of the reusable core.
- Do not rewrite working core logic unless necessary. Keep divergence small so upstream versions can continue to be tracked.
## Scope
### Non-goals
- Engine or model downloading.
- KataGo contribution.
- Mobile support.
- Kivy compatibility in the new frontend.
- Sabaki theme package installation.
### Required Functionality
- SGF loading and saving.
- KataGo analysis.
- Analysis graph, move tree, candidate moves, ownership display, and principal variation display.
- Mistake coloring.
- Play against AI.
- Teaching and training modes.
- Human-like AI settings.
- Engine restart and settings.
- Localization, where practical.
## Board Themes
- Support arbitrary board texture, black stone, and white stone images.
- Allow board, black stone, and white stone components to be mixed independently.
- Support multiple stone image variants internally.
## Environment
- Development environment: Gentoo Linux with OpenRC.
- Shell: Zsh.
- Git pushes to `origin` are available.
- The local KataGo OpenCL binary requires `RUSTICL_ENABLE=radeonsi` to use the Radeon Pro WX 4100.
- Run `RUSTICL_ENABLE=radeonsi katrain/KataGo/katago tuner -config katrain/KataGo/analysis_config.cfg -model katrain/models/b10c384h6nbttflrs.bin.gz` before KataGo integration tests. Re-run it whenever the KataGo binary, model, or analysis config changes.
## Git Workflow
- Push development work only to `origin/qt-0.19.0`.
- Do not merge directly into `main`.
- Merge to `main` only through a pull request after reaching a meaningful milestone.
- Use commit-message prefixes such as `FEAT:`, `FIX:`, `MINOR:`, `CORE:`, `UI:`, `DOC:` or `STYLE:`.
+67
View File
@@ -0,0 +1,67 @@
# Qt Port TODO
## Completed
- [x] Remove Kivy and `katrain.gui` imports from `katrain/core`.
- [x] Replace direct core-to-widget calls with framework-neutral events.
- [x] Move Kivy language observation, widget fonts, PV markup, and UI updates into Kivy adapters.
- [x] Preserve behavior with core-boundary, parser, board, AI, and remote-engine tests.
## 1. Finish Core Host Separation
- [ ] Define `Protocol` types for the remaining host contract: configuration, logging, player/session state, and event delivery.
- [ ] Change `BaseGame`, engines, contribution code, and AI helpers to accept those narrow dependencies instead of an untyped `katrain` object.
- [ ] Move game/session ownership (`game`, `pondering`, active-game replacement) into a framework-neutral application controller.
- [ ] Replace engine-thread callbacks that mutate game state directly with controller-dispatched work.
- [ ] Add unit tests for event ordering, thread dispatch boundaries, configuration migration/backup, and translator fallback.
Done when `katrain/core` has no dependency on a concrete application object and can be embedded by a non-Kivy command-line test host.
## 2. Establish the Qt Application Shell
- [ ] Add PySide6 as the frontend dependency and create a Qt entry point without importing Kivy.
- [ ] Implement Qt versions of the core event sink and language adapter using queued Qt signals.
- [ ] Create application/session, configuration, engine-lifecycle, and error/recovery controllers.
- [ ] Add a minimal window with startup/shutdown, settings loading, KataGo restart, and localized status display.
- [ ] Confirm all core-originated UI events execute on the Qt main thread.
Done when Qt can open a window, start/stop KataGo, show engine failures, and load persisted settings without loading Kivy.
## 3. Build the Game Workspace
- [ ] Implement board rendering with independent board, black-stone, and white-stone image selection plus stone variants.
- [ ] Implement move entry, navigation, branches, insert mode, region-of-interest selection, and undo/redo.
- [ ] Implement the move tree, candidate moves, principal variation interaction, ownership display, and analysis graph.
- [ ] Implement SGF open/save, recent paths, metadata, comments, and generated analysis feedback.
- [ ] Add component and controller tests for SGF round trips, navigation, analysis refresh, and thread-safe redraws.
Done when an existing SGF can be opened, analyzed, navigated, edited, and saved from the Qt UI.
## 4. Port Play, Teaching, and Training
- [ ] Implement player setup, AI strategy settings, human-like model settings, and play-versus-AI flow.
- [ ] Implement timer behavior, mistake coloring, sounds where supported, teaching undo, and training feedback.
- [ ] Implement self-play/setup-game workflows and analysis modes: extra, sweep, equalize, alternatives, local, and full-game reanalysis.
- [ ] Implement tsumego frame generation and region analysis UI.
- [ ] Add deterministic controller tests with fake engines; keep KataGo strategy coverage as an integration test.
Done when normal play, teaching, and training use cases work at parity with the required functionality list in `AGENTS.md`.
## 5. Port Settings, Localization, and Remote Engine Support
- [ ] Build Qt settings dialogs for general, engine, AI, timer, and trainer configuration.
- [ ] Connect locale switching to Qt retranslation and per-language font selection.
- [ ] Build remote-engine configuration, reconnect status, warnings, and recovery UI using core events.
- [ ] Test custom, local, and remote engine backend selection without widget mocks.
Done when settings changes persist and apply immediately, language changes retranslate the active UI, and remote recovery is visible in Qt.
## 6. Release Readiness
- [ ] Add a core-only CI job that runs without Kivy installed.
- [ ] Add a Qt headless smoke-test job and keep KataGo integration tests separately marked.
- [ ] Run KataGo integration tests with `RUSTICL_ENABLE=radeonsi`; rerun the tuner after changing KataGo, its model, or analysis config.
- [ ] Remove Kivy runtime/build dependencies only after Qt reaches required-feature parity.
- [ ] Document packaging for Linux/Wayland and supported KataGo asset locations.
Done when the Qt frontend passes its smoke/integration suites and can be packaged without a Kivy runtime dependency.
+3 -1
View File
@@ -70,7 +70,7 @@ from kivy.clock import Clock
from kivy.metrics import dp from kivy.metrics import dp
from katrain.core.ai import generate_ai_move 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 ( from katrain.core.constants import (
OUTPUT_ERROR, OUTPUT_ERROR,
OUTPUT_KATAGO_STDERR, 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.core.sgf_parser import Move, ParseError
from katrain.gui.popups import ConfigPopup, LoadSGFPopup, NewGamePopup, ConfigAIPopup from katrain.gui.popups import ConfigPopup, LoadSGFPopup, NewGamePopup, ConfigAIPopup
from katrain.gui.theme import Theme from katrain.gui.theme import Theme
from katrain.gui.kivy_adapter import KivyEventSink
from kivymd.app import MDApp from kivymd.app import MDApp
# used in kv # used in kv
@@ -122,6 +123,7 @@ class KaTrainGui(Screen, KaTrainBase):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.events = KivyEventSink(self)
self.engine = None self.engine = None
self.contributing = False self.contributing = False
+19 -11
View File
@@ -1,9 +1,7 @@
import os import os
import shutil import shutil
import sys import sys
import json
from kivy import Config
from kivy.storage.jsonstore import JsonStore
from katrain.core.ai import ai_rank_estimation from katrain.core.ai import ai_rank_estimation
from katrain.core.constants import ( from katrain.core.constants import (
@@ -75,12 +73,6 @@ class KaTrainBase:
self.config_file = self._load_config(force_package_config=force_package_config) 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 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.players_info = {"B": Player("B"), "W": Player("W")}
self.reset_players() self.reset_players()
@@ -113,7 +105,8 @@ class KaTrainBase:
self.log(f"Copied package config to local file {config_file}", OUTPUT_INFO) self.log(f"Copied package config to local file {config_file}", OUTPUT_INFO)
else: # user file exists else: # user file exists
try: 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) version = parse_version(version_str)
self.log(f"Parsed version: {version}", OUTPUT_DEBUG) self.log(f"Parsed version: {version}", OUTPUT_DEBUG)
except Exception as e: # noqa E722 broken file etc except Exception as e: # noqa E722 broken file etc
@@ -138,7 +131,7 @@ class KaTrainBase:
OUTPUT_INFO, OUTPUT_INFO,
) )
try: try:
self._config_store = JsonStore(config_file, indent=4) self._config_store = JsonConfigStore(config_file)
except Exception as e: except Exception as e:
self.log(f"Failed to load config {config_file}: {e}", OUTPUT_ERROR) self.log(f"Failed to load config {config_file}: {e}", OUTPUT_ERROR)
sys.exit(1) sys.exit(1)
@@ -187,3 +180,18 @@ class KaTrainBase:
@property @property
def next_player_info(self) -> Player: def next_player_info(self) -> Player:
return self.players_info[self.game.current_node.next_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")
+4 -4
View File
@@ -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.constants import OUTPUT_DEBUG, OUTPUT_ERROR, OUTPUT_INFO, OUTPUT_KATAGO_STDERR, DATA_FOLDER
from katrain.core.engine import BaseEngine from katrain.core.engine import BaseEngine
from katrain.core.events import event_sink
from katrain.core.game import BaseGame from katrain.core.game import BaseGame
from katrain.core.lang import i18n from katrain.core.lang import i18n
from katrain.core.sgf_parser import Move from katrain.core.sgf_parser import Move
@@ -103,7 +104,7 @@ class KataGoContributeEngine(BaseEngine):
if current_game.current_node.children: if current_game.current_node.children:
current_game.redo(1) current_game.redo(1)
self.last_advance = time.time() 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: elif time.time() - self.last_advance > self.GIVE_UP_AFTER:
self.katrain.log( self.katrain.log(
f"Giving up on game {self.showing_game} which appears stuck, finding a new one", OUTPUT_INFO 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.last_advance = time.time()
self.katrain.log(f"Showing game {self.showing_game}, {best_count} moves left to show.", OUTPUT_INFO) 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] event_sink(self.katrain).contribution_game_changed(self.active_games[self.showing_game])
self.katrain("update-state", redraw_board=True)
def status(self): 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" 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}", 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, OUTPUT_DEBUG,
) )
self.katrain("update-state") event_sink(self.katrain).state_changed()
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
self.katrain.log(f"Exception {e} in parsing or processing JSON: {line}", OUTPUT_ERROR) self.katrain.log(f"Exception {e} in parsing or processing JSON: {line}", OUTPUT_ERROR)
+6 -7
View File
@@ -8,10 +8,9 @@ import subprocess
import threading import threading
import time import time
import traceback import traceback
import sys
from typing import Callable, Dict, List, Optional from typing import Callable, Dict, List, Optional
from kivy.utils import platform as kivy_platform
from katrain.core.constants import ( from katrain.core.constants import (
OUTPUT_DEBUG, OUTPUT_DEBUG,
OUTPUT_ERROR, OUTPUT_ERROR,
@@ -21,6 +20,7 @@ from katrain.core.constants import (
KATAGO_EXCEPTION, KATAGO_EXCEPTION,
PONDERING_REPORT_DT, PONDERING_REPORT_DT,
) )
from katrain.core.events import event_sink
from katrain.core.game_node import GameNode from katrain.core.game_node import GameNode
from katrain.core.lang import i18n from katrain.core.lang import i18n
from katrain.core.sgf_parser import Move 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): def get_engine_path(self, exe):
if not exe: if not exe:
if kivy_platform == "win": if os.name == "nt":
exe = "katrain/KataGo/katago.exe" exe = "katrain/KataGo/katago.exe"
elif kivy_platform == "linux": elif sys.platform.startswith("linux"):
exe = "katrain/KataGo/katago" exe = "katrain/KataGo/katago"
else: else:
exe = find_package_resource("katrain/KataGo/katago-osx") # github actions built 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): def on_error(self, message, code=None, allow_popup=True):
self.katrain.log(message, OUTPUT_ERROR) self.katrain.log(message, OUTPUT_ERROR)
if self.allow_recovery and allow_popup: 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): def start(self):
with self.thread_lock: with self.thread_lock:
@@ -347,8 +347,7 @@ class KataGoEngine(BaseEngine):
except Exception as e: except Exception as e:
self.katrain.log(f"Error in engine callback for query {query_id}: {e}", OUTPUT_ERROR) self.katrain.log(f"Error in engine callback for query {query_id}: {e}", OUTPUT_ERROR)
traceback.print_exc() traceback.print_exc()
if getattr(self.katrain, "update_state", None): # easier mocking etc event_sink(self.katrain).state_changed()
self.katrain.update_state()
except Exception as e: except Exception as e:
self.katrain.log(f"Unexpected exception {e} while processing KataGo output {line}", OUTPUT_ERROR) self.katrain.log(f"Unexpected exception {e} while processing KataGo output {line}", OUTPUT_ERROR)
traceback.print_exc() traceback.print_exc()
+34
View File
@@ -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)
+19 -21
View File
@@ -6,8 +6,6 @@ import threading
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from kivy.clock import Clock
from katrain.core.constants import ( from katrain.core.constants import (
OUTPUT_DEBUG, OUTPUT_DEBUG,
OUTPUT_EXTRA_DEBUG, OUTPUT_EXTRA_DEBUG,
@@ -28,6 +26,7 @@ from katrain.core.constants import (
PRIORITY_DEFAULT, PRIORITY_DEFAULT,
) )
from katrain.core.engine import KataGoEngine from katrain.core.engine import KataGoEngine
from katrain.core.events import event_sink
from katrain.core.game_node import GameNode from katrain.core.game_node import GameNode
from katrain.core.lang import i18n, rank_label from katrain.core.lang import i18n, rank_label
from katrain.core.sgf_parser import SGF, Move from katrain.core.sgf_parser import SGF, Move
@@ -467,7 +466,7 @@ class Game(BaseGame):
def set_current_node(self, node): def set_current_node(self, node):
if self.insert_mode: 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 return
super().set_current_node(node) super().set_current_node(node)
@@ -505,7 +504,7 @@ class Game(BaseGame):
self.insert_mode = False self.insert_mode = False
else: else:
self.insert_after = self.current_node.ordered_children[0] 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: else:
copy_from_node = self.insert_after copy_from_node = self.insert_after
copy_to_node = self.current_node copy_to_node = self.current_node
@@ -531,15 +530,14 @@ class Game(BaseGame):
except IllegalMoveException: except IllegalMoveException:
pass # illegal move = stop pass # illegal move = stop
self._calculate_groups() # recalculate groups 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 i18n._("ending insert mode").format(num_copied=num_copied), STATUS_INFO
) )
self.analyze_all_nodes(analyze_fast=True, even_if_present=False) self.analyze_all_nodes(analyze_fast=True, even_if_present=False)
else: else:
self.katrain.controls.set_status("", STATUS_INFO) event_sink(self.katrain).status("", STATUS_INFO)
self.katrain.controls.move_tree.insert_node = self.insert_after if self.insert_mode else None event_sink(self.katrain).insert_mode_changed(self.insert_after if self.insert_mode else None)
self.katrain.controls.move_tree.redraw() event_sink(self.katrain).state_changed(redraw_board=True)
self.katrain.update_state(redraw_board=True)
# Play a Move from the current position, raise IllegalMoveException if invalid. # Play a Move from the current position, raise IllegalMoveException if invalid.
def play(self, move: Move, ignore_ko: bool = False, analyze=True): 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] self.region_of_interest = [xmin, xmax, ymin, ymax]
else: else:
self.region_of_interest = None 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): def analyze_extra(self, mode, **kwargs):
stones = {s.coords for s in self.stones} stones = {s.coords for s in self.stones}
@@ -588,7 +586,7 @@ class Game(BaseGame):
if mode == "extra": if mode == "extra":
visits = cn.analysis_visits_requested + engine.config["max_visits"] 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( cn.analyze(
engine, engine,
visits=visits, visits=visits,
@@ -619,9 +617,9 @@ class Game(BaseGame):
continue continue
node.analyze(engine, visits=visits, priority=-1_000_000, time_limit=False, report_every=None) node.analyze(engine, visits=visits, priority=-1_000_000, time_limit=False, report_every=None)
if not move_range: 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: else:
self.katrain.controls.set_status( event_sink(self.katrain).status(
i18n._("move range analysis").format( i18n._("move range analysis").format(
start_move=move_range[0], end_move=move_range[1], visits=visits 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 if (x, y) not in stones
] ]
visits = engine.config["fast_visits"] 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 priority = PRIORITY_SWEEP
elif mode in ["equalize", "alternative", "local"]: elif mode in ["equalize", "alternative", "local"]:
if not cn.analysis_complete and mode != "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 return
if mode == "alternative": # also do a quick update on current candidates so it doesn't look too weird 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") cn.analyze(engine, priority=PRIORITY_ALTERNATIVES, time_limit=False, find_alternatives="alternative")
visits = engine.config["fast_visits"] visits = engine.config["fast_visits"]
else: # equalize else: # equalize
visits = max(d["visits"] for d in cn.analysis["moves"].values()) 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 priority = PRIORITY_EQUALIZE
analyze_moves = [Move.from_gtp(gtp, player=cn.next_player) for gtp, _ in cn.analysis["moves"].items()] analyze_moves = [Move.from_gtp(gtp, player=cn.next_player) for gtp, _ in cn.analysis["moves"].items()]
else: else:
@@ -761,7 +759,7 @@ class Game(BaseGame):
new_node.ai_thoughts = ai_thoughts new_node.ai_thoughts = ai_thoughts
if until_move != "end" and target_b_advantage is not None: if until_move != "end" and target_b_advantage is not None:
self.set_current_node(new_node) 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), i18n._("setup game status message").format(move=new_node.depth, until_move=until_move),
STATUS_INFO, STATUS_INFO,
) )
@@ -770,7 +768,7 @@ class Game(BaseGame):
node.remove_shortcut() node.remove_shortcut()
cn.add_shortcut(new_node) 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(new_node)
request_analysis_for_node(cn) request_analysis_for_node(cn)
@@ -797,7 +795,7 @@ class Game(BaseGame):
node.auto_undo = undo node.auto_undo = undo
if undo: if undo:
self.undo(1) 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 i18n._("teaching undo message").format(move=move.gtp(), points_lost=points_lost), STATUS_TEACHING
) )
self.katrain.update_state() event_sink(self.katrain).state_changed()
+5 -6
View File
@@ -18,7 +18,6 @@ from katrain.core.constants import (
from katrain.core.lang import i18n from katrain.core.lang import i18n
from katrain.core.sgf_parser import Move, SGFNode from katrain.core.sgf_parser import Move, SGFNode
from katrain.core.utils import evaluation_class, pack_floats, unpack_floats, var_to_grid from katrain.core.utils import evaluation_class, pack_floats, unpack_floats, var_to_grid
from katrain.gui.theme import Theme
def analysis_dumps(analysis): def analysis_dumps(analysis):
@@ -318,13 +317,11 @@ class GameNode(SGFNode):
return ix + 1, p, policy_ranking return ix + 1, p, policy_ranking
return None, 0.0, [] return None, 0.0, []
def make_pv(self, player, pv, interactive): def make_pv(self, player, pv, interactive=False):
pvtext = f"{player}{' '.join(pv)}" pvtext = f"{player}{' '.join(pv)}"
if interactive:
pvtext = f"[u][ref={pvtext}][color={Theme.INFO_PV_COLOR}]{pvtext}[/color][/ref][/u]"
return pvtext 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 single_move = self.move
if not self.parent or not single_move: # root if not self.parent or not single_move: # root
if self.root: if self.root:
@@ -359,7 +356,9 @@ class GameNode(SGFNode):
else: else:
text += i18n._("Info:best move") + "\n" text += i18n._("Info:best move") + "\n"
if previous_top_move.get("pv") and (sgf or details): 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" text += i18n._("Info:PV").format(pv=pv) + "\n"
if sgf or details or teach: if sgf or details or teach:
currmove_pol_rank, currmove_pol_prob, policy_ranking = self.move_policy_stats() currmove_pol_rank, currmove_pol_prob, policy_ranking = self.move_policy_stats()
+2 -52
View File
@@ -1,78 +1,28 @@
import gettext import gettext
import os import os
import sys
from kivy._event import Observable
from katrain.core.utils import find_package_resource from katrain.core.utils import find_package_resource
from katrain.gui.theme import Theme class Lang:
class Lang(Observable):
observers = []
callbacks = []
FONTS = {"jp": "NotoSansJP-Regular.otf", "tr": "NotoSans-Regular.ttf", "ua": "NotoSans-Regular.ttf"} FONTS = {"jp": "NotoSansJP-Regular.otf", "tr": "NotoSans-Regular.ttf", "ua": "NotoSans-Regular.ttf"}
def __init__(self, lang): def __init__(self, lang):
super(Lang, self).__init__()
self.lang = None self.lang = None
self.switch_lang(lang) self.switch_lang(lang)
def _(self, text): def _(self, text):
return self.ugettext(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): def switch_lang(self, lang):
if lang == self.lang: if lang == self.lang:
return return
# get the right locales directory, and instantiate a gettext # get the right locales directory, and instantiate a gettext
self.lang = lang 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")) i18n_dir, _ = os.path.split(find_package_resource("katrain/i18n/__init__.py"))
locale_dir = os.path.join(i18n_dir, "locales") locale_dir = os.path.join(i18n_dir, "locales")
locales = gettext.translation("katrain", locale_dir, languages=[lang, DEFAULT_LANGUAGE]) locales = gettext.translation("katrain", locale_dir, languages=[lang, DEFAULT_LANGUAGE])
self.ugettext = locales.gettext 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" DEFAULT_LANGUAGE = "en"
+4 -10
View File
@@ -33,6 +33,7 @@ from katrain.core.constants import (
OUTPUT_INFO, OUTPUT_INFO,
STATUS_INFO, STATUS_INFO,
) )
from katrain.core.events import event_sink
from katrain.core.engine import BaseEngine, KataGoEngine, resolve_engine_backend from katrain.core.engine import BaseEngine, KataGoEngine, resolve_engine_backend
from katrain.core.lang import i18n from katrain.core.lang import i18n
from katrain.core.utils import json_truncate_arrays from katrain.core.utils import json_truncate_arrays
@@ -277,10 +278,7 @@ class RemoteKataGoEngine(KataGoEngine):
return return
def _set_status(self, message): def _set_status(self, message):
try: event_sink(self.katrain).status(message, STATUS_INFO)
self.katrain.controls.set_status(message, STATUS_INFO)
except Exception:
pass
def on_new_game(self): def on_new_game(self):
# Parent clears self.queries; drop the payloads too so a later # Parent clears self.queries; drop the payloads too so a later
@@ -520,10 +518,7 @@ class RemoteKataGoEngine(KataGoEngine):
f"Remote KataGo warning: {warning_text}", f"Remote KataGo warning: {warning_text}",
OUTPUT_INFO, OUTPUT_INFO,
) )
try: event_sink(self.katrain).status(warning_text, STATUS_INFO)
self.katrain.controls.set_status(warning_text, STATUS_INFO)
except Exception:
pass
if "error" in analysis: if "error" in analysis:
del self.queries[query_id] del self.queries[query_id]
@@ -565,8 +560,7 @@ class RemoteKataGoEngine(KataGoEngine):
) )
traceback.print_exc() traceback.print_exc()
if getattr(self.katrain, "update_state", None): event_sink(self.katrain).state_changed()
self.katrain.update_state()
except Exception as e: except Exception as e:
self.katrain.log( self.katrain.log(
f"Unexpected exception {e} processing remote KataGo output: {line[:200]!r}", f"Unexpected exception {e} processing remote KataGo output: {line[:200]!r}",
+1 -1
View File
@@ -1,5 +1,5 @@
#:kivy 2.3.0 #: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_TYPES katrain.core.constants.PLAYER_TYPES
#:import PLAYER_HUMAN katrain.core.constants.PLAYER_HUMAN #:import PLAYER_HUMAN katrain.core.constants.PLAYER_HUMAN
+1 -1
View File
@@ -30,7 +30,7 @@ from katrain.core.constants import (
TOP_MOVE_WINRATE, TOP_MOVE_WINRATE,
) )
from katrain.core.game import Move 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.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.kivyutils import draw_circle, draw_text, cached_texture
from katrain.gui.popups import I18NPopup, ReAnalyzeGamePopup, GameReportPopup, TsumegoFramePopup from katrain.gui.popups import I18NPopup, ReAnalyzeGamePopup, GameReportPopup, TsumegoFramePopup
+8 -2
View File
@@ -14,12 +14,16 @@ from katrain.core.constants import (
AI_DEFAULT, AI_DEFAULT,
PLAYER_AI, 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.kivyutils import AnalysisToggle, CollapsablePanel
from katrain.gui.theme import Theme from katrain.gui.theme import Theme
from katrain.gui.sound import play_sound, stop_sound 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): class PlayAnalyzeSelect(MDFloatLayout):
katrain = ObjectProperty(None) katrain = ObjectProperty(None)
mode = OptionProperty(MODE_PLAY, options=[MODE_PLAY, MODE_ANALYZE]) mode = OptionProperty(MODE_PLAY, options=[MODE_PLAY, MODE_ANALYZE])
@@ -160,7 +164,9 @@ class ControlsPanel(BoxLayout):
if move or current_node.is_root: if move or current_node.is_root:
info += self.active_comment_node.comment( 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: if self.active_comment_node.analysis_exists:
+39
View File
@@ -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)
+63
View File
@@ -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"]
+1 -1
View File
@@ -37,7 +37,7 @@ from katrain.core.constants import (
PLAYING_NORMAL, PLAYING_NORMAL,
PLAYING_TEACHING, PLAYING_TEACHING,
) )
from katrain.core.lang import i18n from katrain.gui.kivy_lang import i18n
from katrain.gui.theme import Theme from katrain.gui.theme import Theme
+1 -1
View File
@@ -40,7 +40,7 @@ from katrain.core.constants import (
PLAYER_HUMAN, PLAYER_HUMAN,
ADDITIONAL_MOVE_ORDER, 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.sgf_parser import Move
from katrain.core.utils import PATHS, find_package_resource, evaluation_class from katrain.core.utils import PATHS, find_package_resource, evaluation_class
from katrain.gui.kivyutils import ( from katrain.gui.kivyutils import (
+1 -1
View File
@@ -1,5 +1,5 @@
#:kivy 2.3.0 #:kivy 2.3.0
#:import i18n katrain.core.lang.i18n #:import i18n katrain.gui.kivy_lang.i18n
#:import expanduser os.path.expanduser #:import expanduser os.path.expanduser
#:import abspath os.path.abspath #:import abspath os.path.abspath
#:import kivy_version kivy.__version__ #:import kivy_version kivy.__version__
+34
View File
@@ -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"
+18 -13
View File
@@ -44,25 +44,31 @@ class FakeWS:
self._recv.put(WebSocketException("connection lost")) self._recv.put(WebSocketException("connection lost"))
class FakeControls: class FakeEvents:
def set_status(self, *args, **kwargs): def __init__(self):
pass 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: class FakeKatrain:
"""Records calls (e.g. the engine_recovery_popup trigger).""" """Minimal framework-neutral engine host."""
def __init__(self): def __init__(self):
self.controls = FakeControls() self.events = FakeEvents()
self.calls = []
self.update_state = lambda *a, **k: None
def log(self, *args, **kwargs): def log(self, *args, **kwargs):
pass pass
def __call__(self, *args, **kwargs):
self.calls.append(args)
def wait_until(pred, timeout=5.0, interval=0.02): def wait_until(pred, timeout=5.0, interval=0.02):
end = time.time() + timeout end = time.time() + timeout
@@ -72,7 +78,7 @@ def wait_until(pred, timeout=5.0, interval=0.02):
def popup_codes(katrain): 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 @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- # The popup is told this is a remote engine so it shows remote-
# specific advice (check URL) instead of the local executable hints. # 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 katrain.events.failures[0][2] == "remote"
assert popup_call[3] == "remote"
finally: finally:
engine.shutdown() engine.shutdown()