diff --git a/CONTRIBUTIONS.md b/CONTRIBUTIONS.md index 5e48e29..09da604 100644 --- a/CONTRIBUTIONS.md +++ b/CONTRIBUTIONS.md @@ -51,6 +51,7 @@ Many thanks to these additional authors: * Carton He for contributions to sgf parsing and handling. * "blamarche" for adding the board coordinates toggle. * "pdeblanc" for adding the ancient chinese scoring option. +* "LiamHz" for adding the undo to main branch keyboard shortcut. ## Translators diff --git a/README.md b/README.md index daf7b9c..3a2bf1b 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ In addition to shortcuts mentioned above and those shown in the main menu: * **[Ctrl-delete]** Delete current node. * **[c]** Collapse/Uncollapse the branch from the current node to the previous branching point. * **[b]** Go back to the previous branching point. +* **[Shift-b]** Go back the the main branch. * **[n]** As in clicking the forward red arrow, go to one move before the next mistake (orange or worse) by a human player. * **[Shift-n]** As in clicking the backward red arrow, go to one move before the previous mistake. * **[scroll up]**: Undo move. Only works when hovering the cursor over the board. diff --git a/THEMES.md b/THEMES.md index 5f62630..169a132 100644 --- a/THEMES.md +++ b/THEMES.md @@ -12,11 +12,18 @@ Version 1.7 brings basic support for themes. ``` * All resources (including icons which can not be renamed for now) will be looked up in `/.katrain` first, so files with identical names there can be used to override sounds and images. -## Available themes +## Installation -* See [here](https://github.com/sanderland/katrain/blob/master/themes/) for available themes. * To install a theme, simply unzip the theme.zip to your .katrain folder. * On windows you can find it in C:\Users\you\.katrain and on linux in ~/.katrain. * When in doubt, the general settings dialog will also show the location. * To uninstall a theme, remove theme.json and all relevant images from that folder. +## Available themes + +### Alternate board/stones theme by "koast" + +[Download](https://github.com/sanderland/katrain/blob/master/themes/koast-theme.zip) + +![Preview](https://raw.githubusercontent.com/sanderland/katrain/master/themes/koast.png) + diff --git a/katrain/__main__.py b/katrain/__main__.py index 1834d35..05e9dc6 100644 --- a/katrain/__main__.py +++ b/katrain/__main__.py @@ -610,6 +610,8 @@ class KaTrainGui(Screen, KaTrainBase): self.controls.set_status(i18n._("Copied SGF to clipboard."), STATUS_INFO) elif keycode[1] == "v" and ctrl_pressed: self.load_sgf_from_clipboard() + elif keycode[1] == "b" and shift_pressed: + self("undo", "main-branch") elif keycode[1] in shortcuts.keys() and not ctrl_pressed: shortcut = shortcuts[keycode[1]] if isinstance(shortcut, Widget): diff --git a/katrain/core/constants.py b/katrain/core/constants.py index c955d03..2ca4216 100644 --- a/katrain/core/constants.py +++ b/katrain/core/constants.py @@ -1,5 +1,5 @@ PROGRAM_NAME = "KaTrain" -VERSION = "1.7.1" +VERSION = "1.7.2" HOMEPAGE = "https://github.com/sanderland/katrain" CONFIG_MIN_VERSION = "1.7.0" # keep config files from this version ANALYSIS_FORMAT_VERSION = "1.0" diff --git a/katrain/core/engine.py b/katrain/core/engine.py index 6db9412..ec6e2d2 100644 --- a/katrain/core/engine.py +++ b/katrain/core/engine.py @@ -52,7 +52,7 @@ class KataGoEngine: self.write_stdin_thread = None self.shell = False self.write_queue = queue.Queue() - + self.thread_lock = threading.Lock() exe = config.get("katago", "").strip() if config.get("altcommand", ""): self.command = config["altcommand"] @@ -92,39 +92,43 @@ class KataGoEngine: self.start() def start(self): - self.write_queue = queue.Queue() - try: - self.katrain.log(f"Starting KataGo with {self.command}", OUTPUT_DEBUG) - startupinfo = None - if hasattr(subprocess, "STARTUPINFO"): - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # stop command box popups on win/pyinstaller - self.katago_process = subprocess.Popen( - self.command, - startupinfo=startupinfo, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - shell=self.shell, - ) - except (FileNotFoundError, PermissionError, OSError) as e: - self.katrain.log( - i18n._("Starting Kata failed").format(command=self.command, error=e), - OUTPUT_ERROR, - ) - return # don't start - self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True) - self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True) - self.write_stdin_thread = threading.Thread(target=self._write_stdin_thread, daemon=True) - self.analysis_thread.start() - self.stderr_thread.start() - self.write_stdin_thread.start() + with self.thread_lock: + self.write_queue = queue.Queue() + try: + self.katrain.log(f"Starting KataGo with {self.command}", OUTPUT_DEBUG) + startupinfo = None + if hasattr(subprocess, "STARTUPINFO"): + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # stop command box popups on win/pyinstaller + self.katago_process = subprocess.Popen( + self.command, + startupinfo=startupinfo, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=self.shell, + ) + except (FileNotFoundError, PermissionError, OSError) as e: + self.katrain.log( + i18n._("Starting Kata failed").format(command=self.command, error=e), + OUTPUT_ERROR, + ) + return # don't start + self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True) + self.stderr_thread = threading.Thread(target=self._read_stderr_thread, daemon=True) + self.write_stdin_thread = threading.Thread(target=self._write_stdin_thread, daemon=True) + self.analysis_thread.start() + self.stderr_thread.start() + self.write_stdin_thread.start() def on_new_game(self): self.base_priority += 1 - for query_id in list(self.queries.keys()): - self.terminate_query(query_id) - self.queries = {} + if not self.is_idle(): + with self.thread_lock: + for query_id in list(self.queries.keys()): + self.terminate_query(query_id) + self.queries = {} + self.write_queue = queue.Queue() def restart(self): self.queries = {} @@ -248,16 +252,17 @@ class KataGoEngine: query, callback, error_callback, next_move = self.write_queue.get(block=True, timeout=0.1) except queue.Empty: continue - if "id" not in query: - self.query_counter += 1 - query["id"] = f"QUERY:{str(self.query_counter)}" - self.queries[query["id"]] = (callback, error_callback, time.time(), next_move) - self.katrain.log(f"Sending query {query['id']}: {json.dumps(query)}", OUTPUT_DEBUG) - try: - self.katago_process.stdin.write((json.dumps(query) + "\n").encode()) - self.katago_process.stdin.flush() - except OSError as e: - self.check_alive(os_error=str(e), exception_if_dead=False) + with self.thread_lock: + if "id" not in query: + self.query_counter += 1 + query["id"] = f"QUERY:{str(self.query_counter)}" + self.queries[query["id"]] = (callback, error_callback, time.time(), next_move) + self.katrain.log(f"Sending query {query['id']}: {json.dumps(query)}", OUTPUT_DEBUG) + try: + self.katago_process.stdin.write((json.dumps(query) + "\n").encode()) + self.katago_process.stdin.flush() + except OSError as e: + self.check_alive(os_error=str(e), exception_if_dead=False) def send_query(self, query, callback, error_callback, next_move=None): self.write_queue.put((query, callback, error_callback, next_move)) diff --git a/katrain/core/game.py b/katrain/core/game.py index 603d78d..c1b9062 100644 --- a/katrain/core/game.py +++ b/katrain/core/game.py @@ -279,9 +279,14 @@ class Game: self._calculate_groups() return break_on_branch = False + break_on_main_branch = False + last_branching_node = cn if n_times == "branch": n_times = 9999 break_on_branch = True + elif n_times == "main-branch": + n_times = 9999 + break_on_main_branch = True for move in range(n_times): if ( stop_on_mistake is not None @@ -291,13 +296,21 @@ class Game: ): self.set_current_node(cn.parent) return + previous_cn = cn if cn.shortcut_from: cn = cn.shortcut_from elif not cn.is_root: cn = cn.parent + else: + break # root if break_on_branch and len(cn.children) > 1: break - self.set_current_node(cn) + elif break_on_main_branch and cn.ordered_children[0] != previous_cn: # implies > 1 child + last_branching_node = cn + if break_on_main_branch: + cn = last_branching_node + if cn is not self.current_node: + self.set_current_node(cn) def redo(self, n_times=1, stop_on_mistake=None): if self.insert_mode: diff --git a/katrain/core/sgf_parser.py b/katrain/core/sgf_parser.py index 441780f..ad7d346 100644 --- a/katrain/core/sgf_parser.py +++ b/katrain/core/sgf_parser.py @@ -304,7 +304,7 @@ class SGFNode: def initial_player(self): # player for first node root = self.root if "PL" in root.properties: # explicit - return "B" if self.get_property("PL").upper().strip() == "B" else "W" + return "B" if self.root.get_property("PL").upper().strip() == "B" else "W" elif root.children: # child exist, use it if not placement for child in root.children: for color in "BW": diff --git a/katrain/gui/popups.py b/katrain/gui/popups.py index ae28635..7d5f81f 100644 --- a/katrain/gui/popups.py +++ b/katrain/gui/popups.py @@ -3,6 +3,8 @@ import json import os import re import stat +import threading +import time from typing import Any, Dict, List, Tuple, Union from zipfile import ZipFile @@ -426,7 +428,10 @@ class ConfigAIPopup(QuickConfigGui): class ConfigPopup(QuickConfigGui): - MODEL_ENDPOINTS = {"Latest distributed model": "https://katagotraining.org/api/networks/newest_training/"} + MODEL_ENDPOINTS = { + "Latest distributed model": "https://katagotraining.org/api/networks/newest_training/", + "Strongest distributed model": "https://katagotraining.org/api/networks/get_strongest/", + } MODELS = { "20 block model": "https://github.com/lightvector/KataGo/releases/download/v1.4.5/g170e-b20c256x2-s5303129600-d1228401921.bin.gz", "30 block model": "https://github.com/lightvector/KataGo/releases/download/v1.4.5/g170-b30c320x2-s4824661760-d1229536699.bin.gz", @@ -461,6 +466,7 @@ class ConfigPopup(QuickConfigGui): self.paths = [self.katrain.config("engine/model"), "katrain/models", DATA_FOLDER] self.katago_paths = [self.katrain.config("engine/katago"), DATA_FOLDER] Clock.schedule_once(self.check_katas) + self.last_clicked_download_models = 0 MDApp.get_running_app().bind(language=self.check_models) MDApp.get_running_app().bind(language=self.check_katas) @@ -506,7 +512,11 @@ class ConfigPopup(QuickConfigGui): self.paths.append(path) # persistent on paths with models found model_files += files - model_files = sorted([(find_description(path), path) for path in model_files]) + # no description to bottom + model_files = sorted( + [(find_description(path), path) for path in model_files], + key=lambda descpath: "Z" * 10 + path if descpath[0] == descpath[1] else descpath[0], + ) models_available_msg = i18n._("models available").format(num=len(model_files)) self.model_files.values = [models_available_msg] + [desc for desc, path in model_files] self.model_files.value_keys = [""] + [path for desc, path in model_files] @@ -557,18 +567,23 @@ class ConfigPopup(QuickConfigGui): self.katago_files.text = katas_available_msg def download_models(self, *_largs): + if time.time() - self.last_clicked_download_models > 5: + self.last_clicked_download_models = time.time() + threading.Thread(target=self._download_models, daemon=True).start() + + def _download_models(self): def download_complete(req, tmp_path, path, model): try: os.rename(tmp_path, path) - self.katrain.log(f"Download of {model} model complete -> {path}", OUTPUT_INFO) + self.katrain.log(f"Download of {model} complete -> {path}", OUTPUT_INFO) except Exception as e: - self.katrain.log(f"Download of {model} model complete, but could not move file: {e}", OUTPUT_ERROR) + self.katrain.log(f"Download of {model} complete, but could not move file: {e}", OUTPUT_ERROR) self.check_models() for c in self.download_progress_box.children: if isinstance(c, ProgressLoader) and c.request: c.request.cancel() - self.download_progress_box.clear_widgets() + Clock.schedule_once(lambda _dt: self.download_progress_box.clear_widgets(), -1) # main thread downloading = False dist_models = {k: v for k, v in self.katrain.config("dist_models", {}).items() if k in self.MODEL_ENDPOINTS} @@ -577,6 +592,10 @@ class ConfigPopup(QuickConfigGui): try: http = urllib3.PoolManager() response = http.request("GET", url) + if response.status != 200: + raise Exception( + f"Request to {url} returned code {response.status} != 200: {response.data.decode()}" + ) dist_models[name] = json.loads(response.data.decode("utf-8"))["model_file"] except Exception as e: self.katrain.log(f"Failed to retrieve info for model: {e}", OUTPUT_INFO) @@ -590,27 +609,33 @@ class ConfigPopup(QuickConfigGui): savepath = os.path.expanduser(os.path.join(DATA_FOLDER, filename)) savepath_tmp = savepath + ".part" self.katrain.log(f"Downloading {name} model from {url} to {savepath_tmp}", OUTPUT_INFO) - progress = ProgressLoader( - download_url=url, - path_to_file=savepath_tmp, - downloading_text=f"Downloading {name} model: " + "{}", - label_downloading_text=f"Starting download for {name} model", - download_complete=lambda req, tmp=savepath_tmp, path=savepath, model=name: download_complete( - req, tmp, path, model + Clock.schedule_once( + lambda _dt, _savepath=savepath, _savepath_tmp=savepath_tmp, _url=url, _name=name: ProgressLoader( + self.download_progress_box, + download_url=_url, + path_to_file=_savepath_tmp, + downloading_text=f"Downloading {_name}: " + "{}", + label_downloading_text=f"Starting download for {_name}", + download_complete=lambda req, tmp=_savepath_tmp, path=_savepath, model=_name: download_complete( + req, tmp, path, model + ), + download_redirected=lambda req, mname=_name: self.katrain.log( + f"Download {mname} redirected {req.resp_headers}", OUTPUT_DEBUG + ), + download_error=lambda req, error, mname=_name: self.katrain.log( + f"Download of {mname} failed or cancelled ({error})", OUTPUT_ERROR + ), ), - download_redirected=lambda req, mname=name: self.katrain.log( - f"Download {mname} redirected {req.resp_headers}", OUTPUT_DEBUG - ), - download_error=lambda req, error, mname=name: self.katrain.log( - f"Download of {mname} failed or cancelled ({error})", OUTPUT_ERROR - ), - ) - progress.start(self.download_progress_box) + 0, + ) # main thread downloading = True if not downloading: - self.download_progress_box.add_widget( - Label(text=i18n._("All models downloaded"), font_name=i18n.font_name, text_size=(None, dp(50))) - ) + Clock.schedule_once( + lambda _dt: self.download_progress_box.add_widget( + Label(text=i18n._("All models downloaded"), font_name=i18n.font_name, text_size=(None, dp(50))) + ), + 0, + ) # main thread def download_katas(self, *_largs): def unzipped_name(zipfile): @@ -641,7 +666,7 @@ class ConfigPopup(QuickConfigGui): os.remove(tmp_path) else: os.rename(tmp_path, path) - self.katrain.log(f"Download of katago binary {binary} model complete -> {path}", OUTPUT_INFO) + self.katrain.log(f"Download of katago binary {binary} complete -> {path}", OUTPUT_INFO) except Exception as e: self.katrain.log( f"Download of katago binary {binary} complete, but could not move file: {e}", OUTPUT_ERROR diff --git a/katrain/gui/widgets/progress_loader.py b/katrain/gui/widgets/progress_loader.py index c7f4b14..cbfa9b1 100644 --- a/katrain/gui/widgets/progress_loader.py +++ b/katrain/gui/widgets/progress_loader.py @@ -59,13 +59,13 @@ class ProgressLoader(BoxLayout): request = ObjectProperty() """UrlRequest object.""" - def __init__(self, **kwargs): + def __init__(self, root_instance, **kwargs): super().__init__(**kwargs) - self.root_instance = None - self.request = None - - def start(self, root_instance): self.root_instance = root_instance + self.request = None + Clock.schedule_once(self.start, 0) + + def start(self, _dt): self.root_instance.add_widget(self) self.request_download_file(self.download_url, self.path_to_file) Clock.schedule_once(self.animation_show, 1) diff --git a/themes/koast-theme.zip b/themes/koast-theme.zip index 464e241..384a432 100644 Binary files a/themes/koast-theme.zip and b/themes/koast-theme.zip differ diff --git a/themes/koast.png b/themes/koast.png index 637f85b..22224a0 100644 Binary files a/themes/koast.png and b/themes/koast.png differ