Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba1cccea17 | ||
|
|
9b5881412c | ||
|
|
e1bd8a1cff | ||
|
|
39893a852c | ||
|
|
8e25103a0f | ||
|
|
a764e3d40d |
No files matched your search
@@ -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:`.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
+2
-52
@@ -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"
|
||||
|
||||
@@ -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}",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+1
-1
@@ -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__
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user