from kivy.clock import Clock from kivy.core.image import Image from kivy.core.text import Label as CoreLabel from kivy.core.text.markup import MarkupLabel as CoreMarkupLabel from kivy.core.window import Window from kivy.graphics import Color, Ellipse, Rectangle from kivy.properties import ( BooleanProperty, ListProperty, NumericProperty, ObjectProperty, OptionProperty, StringProperty, ) from kivy.resources import resource_find from kivy.uix.behaviors import ButtonBehavior, ToggleButtonBehavior from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button from kivy.uix.label import Label from kivy.uix.scrollview import ScrollView from kivy.uix.spinner import Spinner from kivy.uix.widget import Widget from kivymd.app import MDApp from kivymd.uix.behaviors import CircularRippleBehavior, RectangularRippleBehavior from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.button import BaseFlatButton, BasePressedButton from kivymd.uix.navigationdrawer import MDNavigationDrawer from kivymd.uix.selectioncontrol import MDCheckbox from kivymd.uix.textfield import MDTextField from katrain.core.constants import ( AI_STRATEGIES_RECOMMENDED_ORDER, GAME_TYPES, MODE_PLAY, PLAYER_AI, PLAYER_HUMAN, PLAYING_NORMAL, PLAYING_TEACHING, ) from katrain.core.lang import i18n from katrain.gui.theme import Theme class BackgroundMixin(Widget): # -- mixins background_color = ListProperty([0, 0, 0, 0]) background_radius = NumericProperty(0) outline_color = ListProperty([0, 0, 0, 0]) outline_width = NumericProperty(1) class BackgroundLabel(BackgroundMixin, Label): pass class TableCellLabel(Label): background_color = ListProperty([0, 0, 0, 0]) line_width = NumericProperty(0) outlines = ListProperty([]) outline_color = Theme.LINE_COLOR outline_width = NumericProperty(1.1) def __init__(self, **kwargs): kwargs["font_name"] = kwargs.get("font_name", i18n.font_name) super().__init__(**kwargs) class TableStatLabel(TableCellLabel): side = StringProperty("right") value = NumericProperty(0) scale = NumericProperty(100) bar_color = ListProperty([0, 0, 0, 1]) def __init__(self, **kwargs): super().__init__(**kwargs) if "outlines" not in kwargs: self.outlines = ["left"] if self.side == "right" else ["right"] class TableHeaderLabel(TableCellLabel): outlines = ["bottom"] class LeftButtonBehavior(ButtonBehavior): # stops buttons etc activating on right click def __init__(self, **kwargs): self.register_event_type("on_left_release") self.register_event_type("on_left_press") super().__init__(**kwargs) def on_touch_down(self, touch): return super().on_touch_down(touch) def on_release(self): if not self.last_touch or "button" not in self.last_touch.profile or self.last_touch.button == "left": self.dispatch("on_left_release") return super().on_release() def on_press(self): if not self.last_touch or "button" not in self.last_touch.profile or self.last_touch.button == "left": self.dispatch("on_left_press") return super().on_press() def on_left_release(self): pass def on_left_press(self): pass # -- resizeable buttons / avoid baserectangular for sizing class SizedButton(LeftButtonBehavior, RectangularRippleBehavior, BasePressedButton, BaseFlatButton, BackgroundMixin): text = StringProperty("") text_color = ListProperty(Theme.BUTTON_TEXT_COLOR) text_size = ListProperty([100, 100]) halign = OptionProperty("center", options=["left", "center", "right", "justify", "auto"]) label = ObjectProperty(None) padding_x = NumericProperty(6) padding_y = NumericProperty(0) _font_size = NumericProperty(None) font_name = StringProperty(Theme.DEFAULT_FONT) class AutoSizedButton(SizedButton): pass class SizedRectangleButton(SizedButton): pass class AutoSizedRectangleButton(AutoSizedButton): pass class ToggleButtonMixin(ToggleButtonBehavior): inactive_outline_color = ListProperty([0.5, 0.5, 0.5, 0]) active_outline_color = ListProperty([1, 1, 1, 0]) inactive_background_color = ListProperty([0.5, 0.5, 0.5, 1]) active_background_color = ListProperty([1, 1, 1, 1]) @property def active(self): return self.state == "down" class SizedToggleButton(ToggleButtonMixin, SizedButton): pass class SizedRectangleToggleButton(ToggleButtonMixin, SizedRectangleButton): pass class AutoSizedRectangleToggleButton(ToggleButtonMixin, AutoSizedRectangleButton): pass class TransparentIconButton(CircularRippleBehavior, Button): color = ListProperty([1, 1, 1, 1]) icon_size = ListProperty([25, 25]) icon = StringProperty("") disabled = BooleanProperty(False) class PauseButton(CircularRippleBehavior, LeftButtonBehavior, Widget): active = BooleanProperty(True) active_line_color = ListProperty([0.5, 0.5, 0.8, 1]) inactive_line_color = ListProperty([1, 1, 1, 1]) active_fill_color = ListProperty([0.5, 0.5, 0.5, 1]) inactive_fill_color = ListProperty([1, 1, 1, 0]) line_width = NumericProperty(5) fill_color = ListProperty([0.5, 0.5, 0.5, 1]) line_color = ListProperty([0.5, 0.5, 0.5, 1]) min_size = NumericProperty(100) # -- basic styles class LightLabel(Label): pass class StatsLabel(MDBoxLayout): text = StringProperty("") label = StringProperty("") color = ListProperty([1, 1, 1, 1]) hidden = BooleanProperty(False) font_name = StringProperty(Theme.DEFAULT_FONT) class MyNavigationDrawer(MDNavigationDrawer): def on_touch_down(self, touch): return super().on_touch_down(touch) def on_touch_up(self, touch): # in PR - closes NavDrawer on any outside click if self.status == "opened" and self.close_on_click and not self.collide_point(touch.ox, touch.oy): self.set_state("close", animation=True) return True return super().on_touch_up(touch) class CircleWithText(Widget): text = StringProperty("0") player = OptionProperty("B", options=["B", "W"]) min_size = NumericProperty(50) class BGBoxLayout(BoxLayout, BackgroundMixin): pass # -- gui elements class IMETextField(MDTextField): _imo_composition = StringProperty("") _imo_cursor = ListProperty(None, allownone=True) def _bind_keyboard(self): super()._bind_keyboard() Window.bind(on_textedit=self.window_on_textedit) def _unbind_keyboard(self): super()._unbind_keyboard() Window.unbind(on_textedit=self.window_on_textedit) def do_backspace(self, from_undo=False, mode="bkspc"): if self._imo_composition == "": # IMO handles sub-character backspaces return super().do_backspace(from_undo, mode) def window_on_textedit(self, window, imo_input): text_lines = self._lines if self._imo_composition: pcc, pcr = self._imo_cursor text = text_lines[pcr] if text[pcc - len(self._imo_composition) : pcc] == self._imo_composition: # should always be true remove_old_imo_text = text[: pcc - len(self._imo_composition)] + text[pcc:] ci = self.cursor_index() self._refresh_text_from_property("insert", *self._get_line_from_cursor(pcr, remove_old_imo_text)) self.cursor = self.get_cursor_from_index(ci - len(self._imo_composition)) if imo_input: if self._selection: self.delete_selection() cc, cr = self.cursor text = text_lines[cr] new_text = text[:cc] + imo_input + text[cc:] self._refresh_text_from_property("insert", *self._get_line_from_cursor(cr, new_text)) self.cursor = self.get_cursor_from_index(self.cursor_index() + len(imo_input)) self._imo_composition = imo_input self._imo_cursor = self.cursor class KeyValueSpinner(Spinner): __events__ = ["on_select"] sync_height_frac = NumericProperty(1.0) value_refs = ListProperty() selected_index = NumericProperty(0) font_name = StringProperty(Theme.DEFAULT_FONT) def __init__(self, **kwargs): super().__init__(**kwargs) self.build_values() self.bind(size=self.update_dropdown_props, pos=self.update_dropdown_props, value_refs=self.build_values) @property def input_value(self): try: return self.value_refs[self.selected_index] except KeyError: return "" @property def selected(self): try: selected = self.selected_index return selected, self.value_refs[selected], self.values[selected] except (ValueError, IndexError): return 0, "", "" def on_text(self, _widget, text): try: new_index = self.values.index(text) if new_index != self.selected_index: self.selected_index = new_index self.dispatch("on_select") except (ValueError, IndexError): pass def on_select(self, *args): pass def select_key(self, key): try: ix = self.value_refs.index(key) self.text = self.values[ix] except (ValueError, IndexError): pass def build_values(self, *_args): if self.value_refs and self.values: self.text = self.values[self.selected_index] self.font_name = i18n.font_name self.update_dropdown_props() def update_dropdown_props(self, *largs): if not self.sync_height_frac: return dp = self._dropdown if not dp: return container = dp.container if not container: return h = self.height fsz = self.font_size for item in container.children[:]: item.height = h * self.sync_height_frac item.font_size = fsz item.font_name = self.font_name class I18NSpinner(KeyValueSpinner): __events__ = ["on_select"] sync_height_frac = NumericProperty(1.0) value_refs = ListProperty() selected_index = NumericProperty(0) font_name = StringProperty(Theme.DEFAULT_FONT) def __init__(self, **kwargs): super().__init__(**kwargs) MDApp.get_running_app().bind(language=self.build_values) def build_values(self, *_args): self.values = [i18n._(ref) for ref in self.value_refs] super().build_values() class PlayerSetup(MDBoxLayout): player = OptionProperty("B", options=["B", "W"]) mode = StringProperty("") def __init__(self, **kwargs): super().__init__(**kwargs) self.player_subtype_ai.value_refs = AI_STRATEGIES_RECOMMENDED_ORDER self.player_subtype_human.value_refs = GAME_TYPES self.setup_options() def setup_options(self, *_args): if self.player_type.selected[1] == self.mode: return self.mode = self.player_type.selected[1] self.update_global_player_info() @property def player_type_dump(self): if self.mode == PLAYER_AI: return {"player_type": self.player_type.selected[1], "player_subtype": self.player_subtype_ai.selected[1]} else: return { "player_type": self.player_type.selected[1], "player_subtype": self.player_subtype_human.selected[1], } def update_widget(self, player_type, player_subtype): self.player_type.select_key(player_type) # should trigger setup options if self.mode == PLAYER_AI: self.player_subtype_ai.select_key(player_subtype) # should trigger setup options else: self.player_subtype_human.select_key(player_subtype) # should trigger setup options def update_global_player_info(self): if self.parent and self.parent.update_global: katrain = MDApp.get_running_app().gui if katrain.game and katrain.game.current_node: katrain.update_player(self.player, **self.player_type_dump) class PlayerSetupBlock(MDBoxLayout): players = ObjectProperty(None) black = ObjectProperty(None) white = ObjectProperty(None) update_global = BooleanProperty(False) INSTANCES = [] def __init__(self, **kwargs): super().__init__(**kwargs) self.black = PlayerSetup(player="B") self.white = PlayerSetup(player="W") self.players = {"B": self.black, "W": self.white} self.add_widget(self.black) self.add_widget(self.white) 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_player_params(bw, player_dump["B" if bw == "W" else "W"]) def update_player_params(self, bw, params): self.players[bw].update_widget(**params) def update_player_info(self, bw, player_info): # update sub widget based on gui state change Clock.schedule_once( lambda _dt: self.players[bw].update_widget( player_type=player_info.player_type, player_subtype=player_info.player_subtype ), -1, ) class PlayerInfo(MDBoxLayout, BackgroundMixin): captures = ObjectProperty(0) player = OptionProperty("B", options=["B", "W"]) player_type = StringProperty("Player") komi = NumericProperty(0) player_subtype = StringProperty("") name = StringProperty("", allownone=True) rank = StringProperty("", allownone=True) active = BooleanProperty(True) alignment = StringProperty("right") def __init__(self, **kwargs): super().__init__(**kwargs) self.bind(player_type=self.set_label, player_subtype=self.set_label, name=self.set_label, rank=self.set_label) def set_label(self, *args): if not self.subtype_label: # building return show_player_name = self.name and self.player_type == PLAYER_HUMAN and self.player_subtype == PLAYING_NORMAL if show_player_name: text = self.name else: text = i18n._(self.player_subtype) if ( self.rank and self.player_subtype != PLAYING_TEACHING and (show_player_name or self.player_type == PLAYER_AI) ): text += " ({})".format(self.rank) self.subtype_label.text = text class TimerOrMoveTree(MDBoxLayout): mode = StringProperty(MODE_PLAY) class Timer(BGBoxLayout): state = ListProperty([30, 5, 1]) timeout = BooleanProperty(False) class AnalysisToggle(MDBoxLayout): text = StringProperty("") default_active = BooleanProperty(False) font_name = StringProperty(Theme.DEFAULT_FONT) disabled = BooleanProperty(False) tri_state = BooleanProperty(False) def trigger_action(self, *args, **kwargs): return self.checkbox._do_press() def activate(self, *_args): self.checkbox.active = True @property def active(self): return self.checkbox.active class MenuItem(RectangularRippleBehavior, LeftButtonBehavior, MDBoxLayout, BackgroundMixin): __events__ = ["on_action", "on_close"] icon = StringProperty("") text = StringProperty("") shortcut = StringProperty("") font_name = StringProperty(Theme.DEFAULT_FONT) content_width = NumericProperty(100) def on_left_release(self): self.anim_complete() # kill ripple self.dispatch("on_close") self.dispatch("on_action") def on_action(self): pass def on_close(self): pass class CollapsablePanelHeader(MDBoxLayout): pass class CollapsablePanelTab(AutoSizedRectangleToggleButton): pass class CollapsablePanel(MDBoxLayout): __events__ = ["on_option_state"] options = ListProperty([]) options_height = NumericProperty(25) content_height = NumericProperty(100) size_hint_y_open = NumericProperty(None) # total height inc tabs, overrides content_height options_spacing = NumericProperty(6) option_labels = ListProperty([]) option_active = ListProperty([]) option_colors = ListProperty([]) contents = ListProperty([]) closed_label = StringProperty("Closed Panel") state = OptionProperty("open", options=["open", "close"]) close_icon = "Previous-5.png" open_icon = "Next-5.png" def __init__(self, **kwargs): self.open_close_button, self.header = None, None self.option_buttons = [] super().__init__(**kwargs) self.orientation = "vertical" self.bind( options=self.build_options, option_colors=self.build_options, options_height=self.build_options, option_active=self.build_options, options_spacing=self.build_options, ) self.bind(state=self._on_state, content_height=self._on_size, options_height=self._on_size) MDApp.get_running_app().bind(language=lambda *_: Clock.schedule_once(self.build_options, 0)) self.build_options() def _on_state(self, *_args): self.build() self.trigger_select(ix=None) def _on_size(self, *_args): height, size_hint_y = 1, None if self.state == "open" and self.contents: if self.size_hint_y_open is not None: size_hint_y = self.size_hint_y_open else: height = self.content_height + self.options_height else: height = self.header.height self.height, self.size_hint_y = height, size_hint_y @property def option_state(self): return {option: active for option, active in zip(self.options, self.option_active)} def set_option_state(self, state_dict): for ix, (option, button) in enumerate(zip(self.options, self.option_buttons)): if option in state_dict: self.option_active[ix] = state_dict[option] button.state = "down" if state_dict[option] else "normal" self.trigger_select(ix=None) def build_options(self, *args): self.header = CollapsablePanelHeader( height=self.options_height, size_hint_y=None, spacing=self.options_spacing, padding=[1, 0, 0, 0] ) self.option_buttons = [] option_labels = self.option_labels or [i18n._(f"tab:{opt}") for opt in self.options] for ix, (lbl, opt_col, active) in enumerate(zip(option_labels, self.option_colors, self.option_active)): button = CollapsablePanelTab( text=lbl, font_name=i18n.font_name, active_outline_color=opt_col, height=self.options_height, state="down" if active else "normal", ) self.option_buttons.append(button) button.bind(state=lambda *_args, _ix=ix: self.trigger_select(_ix)) self.open_close_button = TransparentIconButton( # << / >> collapse button icon=self.open_close_icon(), icon_size=[0.5 * self.options_height, 0.5 * self.options_height], width=0.75 * self.options_height, size_hint_x=None, on_press=lambda *_args: self.set_state("toggle"), ) self.bind(state=lambda *_args: self.open_close_button.setter("icon")(None, self.open_close_icon())) self.build() def build(self, *args): self.header.clear_widgets() if self.state == "open": for button in self.option_buttons: self.header.add_widget(button) self.header.add_widget(Label()) # spacer else: self.header.add_widget( Label( text=i18n._(self.closed_label), font_name=i18n.font_name, halign="right", height=self.options_height ) ) self.header.add_widget(self.open_close_button) super().clear_widgets() super().add_widget(self.header) if self.state == "open" and self.contents: for w in self.contents: super().add_widget(w) self._on_size() def open_close_icon(self): return self.open_icon if self.state == "open" else self.close_icon def add_widget(self, widget, index=0, **_kwargs): self.contents.append(widget) self.build() def set_state(self, state="toggle"): if state == "toggle": state = "close" if self.state == "open" else "open" self.state = state self.build() if self.state == "open": self.trigger_select(ix=None) def trigger_select(self, ix): if ix is not None and self.option_buttons: self.option_active[ix] = self.option_buttons[ix].state == "down" if self.state == "open": self.dispatch("on_option_state", {opt: btn.active for opt, btn in zip(self.options, self.option_buttons)}) return False def on_option_state(self, options): pass class StatsBox(MDBoxLayout, BackgroundMixin): winrate = StringProperty("...") score = StringProperty("...") points_lost = NumericProperty(None, allownone=True) player = StringProperty("") class ClickableLabel(LeftButtonBehavior, Label): pass class ClickableCircle(LeftButtonBehavior, CircleWithText): pass class ScrollableLabel(ScrollView, BackgroundMixin): __events__ = ["on_ref_press"] outline_color = ListProperty([0, 0, 0, 0]) # mixin not working for some reason text = StringProperty("") line_height = NumericProperty(1) markup = BooleanProperty(False) def on_ref_press(self, ref): pass def cached_text_texture(text, font_name, markup, _cache={}, **kwargs): args = (text, font_name, markup, *[(k, v) for k, v in kwargs.items()]) texture = _cache.get(args) if texture: return texture label_cls = CoreMarkupLabel if markup else CoreLabel label = label_cls(text=text, bold=True, font_name=font_name or i18n.font_name, **kwargs) label.refresh() texture = _cache[args] = label.texture return texture def draw_text(pos, text, font_name=None, markup=False, **kwargs): texture = cached_text_texture(text, font_name, markup, **kwargs) Rectangle(texture=texture, pos=(pos[0] - texture.size[0] / 2, pos[1] - texture.size[1] / 2), size=texture.size) def draw_circle(pos, r, col): Color(*col) Ellipse(pos=(pos[0] - r, pos[1] - r), size=(2 * r, 2 * r)) # direct cache to texture, bypassing resource_find def cached_texture(path, _cache={}): tex = _cache.get(path) if not tex: tex = _cache[path] = Image(resource_find(path)).texture return tex