Merge pull request #66 from sanderland/movetree

Move tree
This commit is contained in:
sanderland authored and GitHub committed 2020-06-12 23:15:36 +02:00
commit 974219603c
11 files changed
+249 -83

No files matched your search

+15 -12
View File
@@ -68,6 +68,7 @@ from katrain.gui.kivyutils import *
from katrain.gui.popups import ConfigPopup, LoadSGFPopup, NewGamePopup, AIPopup
from katrain.gui.style import ENGINE_BUSY_COL, ENGINE_DOWN_COL, ENGINE_READY_COL, LIGHTGREY
from katrain.gui.widgets.graph import ScoreGraph
from katrain.gui.widgets.movetree import MoveTree
from katrain.gui.widgets.filebrowser import I18NFileBrowser
from katrain.gui.badukpan import AnalysisControls, BadukPanControls, BadukPanWidget
from katrain.gui.controlspanel import ControlsPanel
@@ -148,12 +149,14 @@ class KaTrainGui(Screen, KaTrainBase):
else:
self.board_controls.engine_status_col = ENGINE_BUSY_COL
# redraw
# redraw board/stones
if redraw_board:
self.board_gui.draw_board()
self.board_gui.redraw_board_contents_trigger()
self.controls.update_evaluation()
self.controls.update_timer(1)
# update move tree
self.controls.move_tree.current_node = self.game.current_node
def update_state(
self, redraw_board=False
@@ -242,7 +245,7 @@ class KaTrainGui(Screen, KaTrainBase):
def _do_undo(self, n_times=1):
if n_times == "smart":
n_times = 1
if self.play_analyze_mode == "play" and self.last_player_info.ai and self.next_player_info.human:
if self.play_analyze_mode == MODE_PLAY and self.last_player_info.ai and self.next_player_info.human:
n_times = 2
self.board_gui.animating_pv = None
self.game.undo(n_times)
@@ -251,9 +254,9 @@ class KaTrainGui(Screen, KaTrainBase):
self.board_gui.animating_pv = None
self.game.redo(n_times)
def _do_switch_branch(self, direction):
def _do_switch_branch(self, *args):
self.board_gui.animating_pv = None
self.game.switch_branch(direction)
self.game.switch_branch(*args)
def _do_play(self, coords):
self.board_gui.animating_pv = None
@@ -323,10 +326,10 @@ class KaTrainGui(Screen, KaTrainBase):
self.fileselect_popup.dismiss()
path, file = os.path.split(files[0])
settings_path = self.config("general/sgf_load")
if path!=settings_path:
self.log(f"Updating sgf load path default to {path}",OUTPUT_INFO)
self._config['general']['sgf_load'] = path
self.save_config('general')
if path != settings_path:
self.log(f"Updating sgf load path default to {path}", OUTPUT_INFO)
self._config["general"]["sgf_load"] = path
self.save_config("general")
try:
move_tree = KaTrainSGF.parse_file(files[0])
except ParseError as e:
@@ -386,8 +389,8 @@ class KaTrainGui(Screen, KaTrainBase):
"s": ("analyze-extra", "equalize"),
"d": ("analyze-extra", "sweep"),
"p": ("play", None),
"right": ("switch-branch", 1),
"left": ("switch-branch", -1),
"down": ("switch-branch", 1),
"up": ("switch-branch", -1),
"f5": ("timer-popup",),
"f6": ("teacher-popup",),
"f7": ("ai-popup",),
@@ -420,9 +423,9 @@ class KaTrainGui(Screen, KaTrainBase):
self.controls.timer.paused = not self.controls.timer.paused
elif keycode[1] in ["`", "~", "m"]:
self.zen = (self.zen + 1) % 3
elif keycode[1] in ["up", "z"]:
elif keycode[1] in ["left", "z"]:
self("undo", 1 + ("shift" in modifiers) * 9 + ("ctrl" in modifiers) * 999)
elif keycode[1] in ["down", "x"]:
elif keycode[1] in ["right", "x"]:
self("redo", 1 + ("shift" in modifiers) * 9 + ("ctrl" in modifiers) * 999)
elif keycode[1] == "n" and "ctrl" in modifiers:
self("new-game-popup")
+1
View File
@@ -14,6 +14,7 @@ from katrain.core.utils import find_package_resource
from kivy.utils import platform
class EngineDiedException(Exception):
pass
+8 -3
View File
@@ -173,11 +173,16 @@ class Game:
cn = cn.favourite_child
self.set_current_node(cn)
def switch_branch(self, direction):
def switch_branch(self, direction, cycle=False):
cn = self.current_node # avoid race conditions
if cn.parent and len(cn.parent.children) > 1:
ix = cn.parent.children.index(cn)
self.set_current_node(cn.parent.children[(ix + direction) % len(cn.parent.children)])
ordered_children = GameNode.order_children(cn.parent.children)
ix = ordered_children.index(cn) + direction
if cycle:
ix = (ix + len(ordered_children)) % len(ordered_children)
elif ix < 0 or ix >= len(ordered_children):
return
self.set_current_node(ordered_children[ix])
def place_handicap_stones(self, n_handicaps):
board_size_x, board_size_y = self.board_size
+1 -1
View File
@@ -286,7 +286,7 @@ class SGF:
_NODE_CLASS = SGFNode # Class used for SGF Nodes, can change this to something that inherits from SGFNode
# https://xkcd.com/1171/
SGFPROP_PAT=re.compile(r"\s*(?:\(|\)|;|(\w+)((\s*\[([^\]\\]|\\.)*\])+))",flags=re.DOTALL)
SGFPROP_PAT = re.compile(r"\s*(?:\(|\)|;|(\w+)((\s*\[([^\]\\]|\\.)*\])+))", flags=re.DOTALL)
@classmethod
def parse(cls, input_str) -> SGFNode:
+43 -39
View File
@@ -1,39 +1,34 @@
#:kivy 1.11.0
#:import i18n katrain.core.lang.i18n
# TODO: refactor
#:set CP_SPACING 6
#:set CP_SMALL_SPACING 3
#:set CP_PADDING 6
#:import PLAYER_TYPES katrain.core.constants.PLAYER_TYPES
#:import PLAYER_HUMAN katrain.core.constants.PLAYER_HUMAN
#:import PLAYER_AI katrain.core.constants.PLAYER_AI
#:import MODE_PLAY katrain.core.constants.MODE_PLAY
#:import MODE_ANALYZE katrain.core.constants.MODE_ANALYZE
#:import RED katrain.gui.style.RED
#:import WHITE katrain.gui.style.WHITE
#:import BLACK katrain.gui.style.BLACK
#:import LIGHTGREY katrain.gui.style.LIGHTGREY
#:import GREEN katrain.gui.style.GREEN
#:import YELLOW katrain.gui.style.YELLOW
#:import RED katrain.gui.style.RED
#:import DARKRED katrain.gui.style.DARKRED
#:import ORANGE katrain.gui.style.ORANGE
#:import BACKGROUND_COLOR katrain.gui.style.BACKGROUND_COLOR
#:import BOX_BACKGROUND_COLOR katrain.gui.style.BOX_BACKGROUND_COLOR
#:import LIGHTER_BACKGROUND_COLOR katrain.gui.style.LIGHTER_BACKGROUND_COLOR
#:import TEXT_COLOR katrain.gui.style.TEXT_COLOR
#:import SCORE_COLOR katrain.gui.style.SCORE_COLOR
#:import WINRATE_COLOR katrain.gui.style.WINRATE_COLOR
#:import POINTLOSS_COLOR katrain.gui.style.POINTLOSS_COLOR
#:set SPINNER_OPTION_COLOR [64 / 255, 85 / 255, 110 / 255, 1]
#:set BLACK [0.05,0.05,0.05,1]
#:set GREEN [0.1,0.8,0.1,1]
#:set YELLOW [0.8,0.8,0.1,1]
#:set DARKRED [0.3,0.1,0.1,1]
#:set ORANGE [242/255,96/255,34/255,1]
#:set BUTTON_INACTIVE_COLOR LIGHTGREY
#:set NOTES_FONT_SIZE dp(18)
#:set CP_SPACING dp(6)
#:set CP_SMALL_SPACING dp(3)
#:set CP_PADDING dp(6)
#:set NOTES_FONT_SIZE dp(18)
# for sizing help
#<Label>:
@@ -129,7 +124,7 @@
<I18NSpinnerOption@SpinnerOption>:
background_color: SPINNER_OPTION_COLOR
background_color: LIGHTER_BACKGROUND_COLOR
background_normal: ''
lang_change_tracking: i18n._('') # for font
color: WHITE
@@ -241,7 +236,7 @@
NavIconButton:
id: branch
icon: 'img/Branch.png'
on_press: root.katrain('switch-branch',1)
on_press: root.katrain('switch-branch',1,True)
disabled: True
ClickableLabel:
pos_hint: {'center_x': 0.85,'center_y':0.5}
@@ -350,7 +345,6 @@
TimerLabel:
color: BACKGROUND_COLOR
text: '8:88'
text_size: self.size
font_size: 0.85*self.height
pos_hint: {'right':1,'center_y':0.5}
halign: 'right'
@@ -359,7 +353,6 @@
id: timer
timeout: root.timeout
text: "{:01d}:{:02d}".format(int(root.state[0]+0.99)//60,int((root.state[0]+0.99) % 60) )
text_size: self.size
font_size: 0.85*self.height
pos_hint: {'right':1,'center_y':0.5}
halign: 'right'
@@ -487,6 +480,7 @@
width: 1
points: (*self.pos,self.x+self.width,self.y)
<ControlsPanel>:
katrain: app.gui
orientation: 'vertical'
@@ -498,6 +492,8 @@
info: info
note: note
status: status
timer_or_movetree: timer_or_movetree
move_tree: move_tree
padding: CP_PADDING
spacing: CP_SPACING
player_box_height: max(50, 0.075 * root.height)
@@ -514,26 +510,34 @@
PlayerInfo:
id: W_player
player: 'W'
BoxLayout:
TimerOrMoveTree:
id: timer_or_movetree
size_hint_y: None
height: root.timer_box_height
spacing: CP_SPACING
Timer:
id: timer
size_hint_x: 0.7
MDBoxLayout:
orientation: 'vertical'
padding: 2*CP_PADDING,CP_PADDING
BoxLayout:
size_hint_x: 0.0001 if self.parent.mode != MODE_PLAY else 1
opacity: 0 if self.parent.mode != MODE_PLAY else 1
spacing: CP_SPACING
size_hint_x: 0.3
SizedRoundedRectangleButton:
text: i18n._('pass-button-text')
size_hint: 1, 0.5
on_left_release: root.katrain("play",None)
SizedRoundedRectangleButton:
text: i18n._('undo-button-text')
size_hint: 1, 0.5
on_left_release: root.katrain("undo","smart")
Timer:
id: timer
size_hint_x: 0.7
MDBoxLayout:
orientation: 'vertical'
padding: 2*CP_PADDING,CP_PADDING
spacing: CP_SPACING
size_hint_x: 0.3
SizedRoundedRectangleButton:
text: i18n._('pass-button-text')
size_hint: 1, 0.5
on_left_release: root.katrain("play",None)
SizedRoundedRectangleButton:
text: i18n._('undo-button-text')
size_hint: 1, 0.5
on_left_release: root.katrain("undo","smart")
MoveTree:
id: move_tree
size_hint_x: 0.1 if self.parent.mode == MODE_PLAY else 1
opacity: 0 if self.parent.mode == MODE_PLAY else 1
CollapsablePanel:
id: graph_panel
height_open: root.graph_box_height
@@ -673,7 +677,7 @@
<AnalysisMenuItem@MenuItem>:
on_close: app.gui.analysis_controls.close_dropdown()
background_color: SPINNER_OPTION_COLOR
background_color: LIGHTER_BACKGROUND_COLOR
height: 45
canvas.before:
Color:
+1 -2
View File
@@ -438,7 +438,6 @@ class BadukPanWidget(Widget):
if i > up_to_move:
return
move_player = next_last_player[i % 2]
opp_player = next_last_player[1 - i % 2]
coords = Move.from_gtp(gtpmove).coords
if coords is None: # tee-hee
sizefac = katrain.board_controls.pass_btn.size[1] / 2 / self.stone_size
@@ -453,7 +452,7 @@ class BadukPanWidget(Widget):
sizefac = 1
draw_circle(board_coords, self.stone_size * sizefac, stone_color[move_player])
Color(*stone_color[opp_player])
Color(*STONE_TEXT_COLORS[move_player])
draw_text(pos=board_coords, text=str(i + 1), font_size=self.grid_size * sizefac / 1.45, font_name="Roboto")
def set_animating_pv(self, pv, node):
+1
View File
@@ -47,6 +47,7 @@ class PlayAnalyzeSelect(MDFloatLayout):
return
self.save_ui_state()
self.mode = new_mode
self.katrain.controls.timer_or_movetree.mode = self.mode
self.load_ui_state()
self.katrain.update_state() # for lock ai even if nothing changed
+8 -4
View File
@@ -22,7 +22,7 @@ from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.button import BaseFlatButton, BasePressedButton
from kivymd.uix.navigationdrawer import MDNavigationDrawer
from katrain.core.constants import AI_STRATEGIES_RECOMMENDED_ORDER, GAME_TYPES, PLAYER_AI
from katrain.core.constants import AI_STRATEGIES_RECOMMENDED_ORDER, GAME_TYPES, PLAYER_AI, MODE_PLAY
from katrain.core.lang import i18n
from katrain.gui.style import DEFAULT_FONT, WHITE
@@ -284,9 +284,9 @@ class PlayerSetupBlock(MDBoxLayout):
PlayerSetupBlock.INSTANCES.append(self)
def swap_players(self):
player_dump = {bw: p.player_type_dump for bw,p in self.players.items()}
for bw in 'BW':
self.update_players(bw,player_dump['B' if bw=='W' else 'W'])
player_dump = {bw: p.player_type_dump for bw, p in self.players.items()}
for bw in "BW":
self.update_players(bw, player_dump["B" if bw == "W" else "W"])
def update_players(self, bw, player_info): # update sub widget based on gui state change
self.players[bw].update_widget(player_type=player_info.player_type, player_subtype=player_info.player_subtype)
@@ -300,6 +300,10 @@ class PlayerInfo(MDBoxLayout, BackgroundMixin):
active = BooleanProperty(True)
class TimerOrMoveTree(BoxLayout):
mode = StringProperty(MODE_PLAY)
class Timer(BGBoxLayout):
state = ListProperty([30, 5, 1])
timeout = BooleanProperty(False)
+22 -16
View File
@@ -115,7 +115,7 @@ class LabelledFloatInput(LabelledTextInput):
@property
def input_value(self):
return float('0'+self.text)
return float("0" + self.text)
class LabelledIntInput(LabelledTextInput):
@@ -126,7 +126,7 @@ class LabelledIntInput(LabelledTextInput):
@property
def input_value(self):
return int('0'+self.text)
return int("0" + self.text)
class InputParseError(Exception):
@@ -138,7 +138,7 @@ class QuickConfigGui(MDBoxLayout):
super().__init__()
self.katrain = katrain
self.popup = None
Clock.schedule_once(self.build_and_set_properties,0)
Clock.schedule_once(self.build_and_set_properties, 0)
def collect_properties(self, widget) -> Dict:
if isinstance(widget, (LabelledTextInput, LabelledSpinner, LabelledCheckBox)) and getattr(
@@ -147,7 +147,9 @@ class QuickConfigGui(MDBoxLayout):
try:
ret = {widget.input_property: widget.input_value}
except Exception as e: # TODO : on widget?
raise InputParseError(f"Could not parse value '{widget.raw_input_value}' for {widget.input_property} ({widget.__class__.__name__}): {e}")
raise InputParseError(
f"Could not parse value '{widget.raw_input_value}' for {widget.input_property} ({widget.__class__.__name__}): {e}"
)
else:
ret = {}
for c in widget.children:
@@ -177,7 +179,7 @@ class QuickConfigGui(MDBoxLayout):
)
return config[keys[-1]], config, keys[-1]
def build_and_set_properties(self,*_args):
def build_and_set_properties(self, *_args):
return self._set_properties_subtree(self)
def _set_properties_subtree(self, widget):
@@ -254,7 +256,7 @@ class ConfigTeacherPopup(QuickConfigGui):
for widget in widgets:
self.options_grid.add_widget(wrap_anchor(widget))
def build_and_set_properties(self,*_args):
def build_and_set_properties(self, *_args):
undos = self.katrain.config("trainer/num_undo_prompts")
thresholds = self.katrain.config("trainer/eval_thresholds")
savesgfs = self.katrain.config("trainer/save_feedback")
@@ -306,35 +308,39 @@ class AIPopup(QuickConfigGui):
class ConfigPopup(QuickConfigGui):
def build_and_set_properties(self,*_args):
def build_and_set_properties(self, *_args):
super().build_and_set_properties()
self.check_models()
def check_models(self,*args): # WIP
def check_models(self, *args): # WIP
try:
model = self.collect_properties(self)['engine/model']
model = self.collect_properties(self)["engine/model"]
except InputParseError:
self.model_files.values = []
return
if os.path.exists(model):
if os.path.isdir(model):
path = model.rstrip('\\/')
path = model.rstrip("\\/")
file = None
else:
if model.startswith('katrain'):
if model.startswith("katrain"):
model = find_package_resource(model)
path, file = os.path.split(model)
files = sorted([os.path.split(f)[1] for ftype in ['*.bin.gz','*.txt.gz'] for f in glob.glob(path+os.path.sep+ftype)])
self.model_files.values =files
print(file,files,file in files)
files = sorted(
[
os.path.split(f)[1]
for ftype in ["*.bin.gz", "*.txt.gz"]
for f in glob.glob(path + os.path.sep + ftype)
]
)
self.model_files.values = files
print(file, files, file in files)
if file in files:
self.model_files.text = file
else:
self.model_files.values = []
def update_config(self, save_to_file=True):
updated = super().update_config(save_to_file=save_to_file)
self.katrain.debug_level = self.katrain.config("general/debug_level", OUTPUT_INFO)
+16 -6
View File
@@ -5,19 +5,26 @@ def to_hexcol(kivycol):
DEFAULT_FONT = "fonts/NotoSans-Regular.ttf"
# basic color definitions
RED = [0.8, 0.1, 0.1, 1]
WHITE = [0.95, 0.95, 0.95, 1]
BLACK = [0.05, 0.05, 0.05, 1]
LIGHTGREY = [0.7, 0.7, 0.7, 1]
# gui colors
RED = [0.8, 0.1, 0.1, 1]
GREEN = [0.1, 0.8, 0.1, 1]
YELLOW = [0.8, 0.8, 0.1, 1]
DARKRED = [0.3, 0.1, 0.1, 1]
ORANGE = [242 / 255, 96 / 255, 34 / 255, 1]
# gui colors
BACKGROUND_COLOR = [36 / 255, 48 / 255, 62 / 255, 1]
BOX_BACKGROUND_COLOR = [46 / 255, 65 / 255, 88 / 255, 1]
LIGHTER_BACKGROUND_COLOR = [64 / 255, 85 / 255, 110 / 255, 1]
TEXT_COLOR = WHITE
SCORE_COLOR = [0.3, 0.7, 0.9, 1]
WINRATE_COLOR = [0.1, 0.8, 0.1, 1]
POINTLOSS_COLOR = [0.8, 0.8, 0.1, 1]
WINRATE_COLOR = GREEN
POINTLOSS_COLOR = YELLOW
# eval dots
EVAL_COLORS = [
[0.447, 0.129, 0.42, 1],
@@ -30,6 +37,10 @@ EVAL_COLORS = [
EVAL_DOT_MAX_SIZE = 0.5
EVAL_DOT_MIN_SIZE = 0.25
STONE_COLORS = {"B": BLACK, "W": WHITE}
OUTLINE_COLORS = {"B": [0.3, 0.3, 0.3, 0.5], "W": [0.7, 0.7, 0.7, 0.5]}
STONE_TEXT_COLORS = {"W": BLACK, "B": WHITE}
# board
LINE_COLOR = [0, 0, 0]
POLICY_COLOR = [0.9, 0.2, 0.8]
@@ -37,8 +48,7 @@ STARPOINT_SIZE = 0.1
BOARD_COLOR = [0.85, 0.68, 0.40, 1]
STONE_SIZE = 0.475
VISITS_FRAC_SMALL = 0.1
STONE_COLORS = {"B": [0.05, 0.05, 0.05], "W": [0.95, 0.95, 0.95]}
OUTLINE_COLORS = {"B": [0.3, 0.3, 0.3, 0.5], "W": [0.7, 0.7, 0.7, 0.5]}
GHOST_ALPHA = 0.5
TOP_MOVE_ALPHA = 0.4
CHILD_SCALE = 0.95
+133
View File
@@ -0,0 +1,133 @@
from collections import defaultdict
from kivy.graphics.context_instructions import Color
from kivy.graphics.vertex_instructions import Line, Rectangle
from kivy.lang import Builder
from kivy.properties import ObjectProperty, Clock, DictProperty, NumericProperty
from kivy.uix.scrollview import ScrollView
from kivy.uix.widget import Widget
from kivymd.app import MDApp
from katrain.core.game_node import GameNode
from katrain.gui.kivyutils import BackgroundMixin, draw_circle, draw_text
from katrain.gui.style import (
WHITE,
STONE_COLORS,
LIGHTGREY,
BACKGROUND_COLOR,
OUTLINE_COLORS,
LIGHTER_BACKGROUND_COLOR,
YELLOW,
STONE_TEXT_COLORS,
)
class MoveTreeCanvas(Widget):
move_xy_pos = DictProperty({})
move_size = NumericProperty(5)
move_tree = ObjectProperty(None)
def on_touch_up(self, touch):
if touch.button == "left":
node, (x, y) = min(
self.move_xy_pos.items(), key=lambda n_xy: abs(n_xy[1][0] - touch.x) + abs(n_xy[1][1] - touch.y)
)
if max(abs(x - touch.x), abs(y - touch.y)) <= (self.move_size / 2):
katrain = MDApp.get_running_app().gui
katrain.game.set_current_node(node)
katrain.update_state()
def draw_move_tree(self, current_node):
if not self.move_tree:
return
spacing = 5
moves_vert = 3
self.move_size = (self.move_tree.height - (moves_vert + 1) * spacing) / moves_vert
root = current_node.root
move_pos = {root: (0, 0)}
stack = GameNode.order_children(root.children)[::-1]
next_y_pos = defaultdict(int) # x pos -> max y pos
while stack:
move = stack.pop()
x = move.depth
y = max(next_y_pos[x], move_pos[move.parent][1])
next_y_pos[x] = y + 1
next_y_pos[x - 1] = max(next_y_pos[x], next_y_pos[x - 1])
move_pos[move] = (x, y)
for c in GameNode.order_children(move.children)[::-1]: # stack, so push top child last to process first
stack.append(c)
def draw_stone(pos, player):
draw_circle(pos, self.move_size / 2 - 0.5, STONE_COLORS[player])
Color(*STONE_TEXT_COLORS[player])
Line(circle=(*pos, self.move_size / 2), width=1)
def coord_pos(coord):
return (coord + 0.5) * (spacing + self.move_size) + spacing / 2
self.width = coord_pos(max(x + 0.5 for x, y in move_pos.values()))
self.height = coord_pos(max(y + 0.5 for x, y in move_pos.values()))
def xy_pos(x, y):
return coord_pos(x), self.height - coord_pos(y)
self.move_xy_pos = {n: xy_pos(x, y) for n, (x, y) in move_pos.items()}
self.canvas.clear()
with self.canvas:
Color(*YELLOW)
Rectangle(
pos=[c - self.move_size / 2 - spacing / 2 for c in self.move_xy_pos[current_node]],
size=(self.move_size + spacing, self.move_size + spacing),
)
Color(*LIGHTGREY)
for node, (x, y) in self.move_xy_pos.items():
for ci, c in enumerate(GameNode.order_children(node.children)):
cx, cy = self.move_xy_pos[c]
Line(points=[x, y, x, cy, cx, cy], width=1)
for node, pos in self.move_xy_pos.items():
draw_stone(pos, node.player)
text = str(node.depth)
Color(*STONE_COLORS["W" if node.player == "B" else "B"])
draw_text(pos=pos, text=text, font_size=self.move_size * 1.75 / (1 + 1 * len(text)), font_name="Roboto")
self.move_tree.scroll_to_pixel(*self.move_xy_pos[current_node])
class MoveTree(ScrollView, BackgroundMixin):
current_node = ObjectProperty(None)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.redraw_tree_trigger = Clock.create_trigger(
lambda _dt: self.move_tree_canvas.draw_move_tree(self.current_node)
)
self.bind(current_node=self.redraw_tree_trigger, size=self.redraw_tree_trigger)
def scroll_to_pixel(self, x, y):
if not self._viewport:
return
vp = self._viewport
if vp.width > self.width:
sx = (x - self.width / 2) / (vp.width - self.width)
self.scroll_x = max(0, min(1, sx))
if vp.height > self.height:
sy = (y - self.height / 2) / (vp.height - self.height)
self.scroll_y = max(0, min(1, sy))
Builder.load_string(
"""
<MoveTree>:
background_color: BOX_BACKGROUND_COLOR
move_tree_canvas: move_tree_canvas
MoveTreeCanvas:
move_tree: root
id: move_tree_canvas
size_hint: None, None
"""
)