diff --git a/base/move.py b/base/move.py index 8177757..354e9e1 100644 --- a/base/move.py +++ b/base/move.py @@ -1,5 +1,5 @@ class Move(object): - def __init__(self, squares, annotation=''): + def __init__(self, squares, annotation=""): self.affected_squares = squares self.annotation = annotation diff --git a/game/gamemanager.py b/game/gamemanager.py index 7aa77e4..028420c 100644 --- a/game/gamemanager.py +++ b/game/gamemanager.py @@ -113,6 +113,7 @@ def load_game(self, filepath): else: game = reader.read_game(0) if game is not None: + sg = SavedGame() self.model.curr_state.clear() self.model.curr_state.to_move = game.next_to_move self.num_players = 2 @@ -130,10 +131,10 @@ def load_game(self, filepath): for i in game.white_kings: squares[square_map[i]] = WHITE | KING self.model.curr_state.reset_undo() - self.model.curr_state.redo_list = game.moves + self.model.curr_state.redo_list = sg.moves self.model.curr_state.update_piece_count() self.view.reset_view(self.model) - # self.view.serializer.restore(saved_game.description) + self.view.serializer.restore(game.description) self.view.curr_annotation = self.view.get_annotation() self.view.flip_view(game.board_orientation == "white_on_top") self.view.update_statusbar() diff --git a/gui/boardview.py b/gui/boardview.py index 96b6e81..98afc78 100644 --- a/gui/boardview.py +++ b/gui/boardview.py @@ -13,6 +13,7 @@ keymap, reverse_dict from gui.autoscrollbar import AutoScrollbar from gui.hyperlinkmgr import HyperlinkManager +from parsing.textserialize import Serializer from gui.tooltip import ToolTip @@ -116,6 +117,7 @@ def __init__(self, root, **props): 'bullet': self.bullets, 'number': self.numbers, 'hyper': self.addLink} self.link_manager = HyperlinkManager(self.txt, self._gameMgr.load_game) + self.serializer = Serializer(self.txt, self.link_manager) self.curr_annotation = '' self._setup_board(root) starting_squares = [i for i in self._model.curr_state.valid_squares @@ -144,8 +146,8 @@ def _toggle_state(self, tags, btn): if not already_tagged: self.txt.tag_add(tag, 'sel.first', 'sel.last') btn.configure(relief='sunken') - other_btns = self.button_set.difference([btn]) - for b in other_btns: + other_buttons = self.button_set.difference([btn]) + for b in other_buttons: b.configure(relief='raised') else: btn.configure(relief='raised') @@ -171,20 +173,20 @@ def _on_numbers(self): def _process_button_click(self, tag, tooltip, add_func, remove_func): tooltip.hide() if self.txt.tag_ranges('sel'): - startline, _ = parse_index(self.txt.index('sel.first')) - endline, _ = parse_index(self.txt.index('sel.last')) + start_line, _ = parse_index(self.txt.index('sel.first')) + end_line, _ = parse_index(self.txt.index('sel.last')) else: - startline, _ = parse_index(self.txt.index(INSERT)) - endline = startline - current_tags = self.txt.tag_names('%d.0' % startline) + start_line, _ = parse_index(self.txt.index(INSERT)) + end_line = start_line + current_tags = self.txt.tag_names('%d.0' % start_line) if tag not in current_tags: - add_func(startline, endline) + add_func(start_line, end_line) else: - remove_func(startline, endline) + remove_func(start_line, end_line) - def _add_bullets_if_needed(self, startline, endline): - self._remove_numbers_if_needed(startline, endline) - for line in range(startline, endline+1): + def _add_bullets_if_needed(self, start_line, end_line): + self._remove_numbers_if_needed(start_line, end_line) + for line in range(start_line, end_line + 1): current_tags = self.txt.tag_names('%d.0' % line) if 'bullet' not in current_tags: start = '%d.0' % line @@ -196,8 +198,8 @@ def _add_bullets_if_needed(self, startline, endline): self.bullets.configure(relief='sunken') self.numbers.configure(relief='raised') - def _remove_bullets_if_needed(self, startline, endline): - for line in range(startline, endline+1): + def _remove_bullets_if_needed(self, start_line, end_line): + for line in range(start_line, end_line + 1): current_tags = self.txt.tag_names('%d.0' % line) if 'bullet' in current_tags: start = '%d.0' % line @@ -208,26 +210,26 @@ def _remove_bullets_if_needed(self, startline, endline): self.txt.delete(start, end) self.bullets.configure(relief='raised') - def _add_numbers_if_needed(self, startline, endline): - self._remove_bullets_if_needed(startline, endline) + def _add_numbers_if_needed(self, start_line, end_line): + self._remove_bullets_if_needed(start_line, end_line) num = 1 - for line in range(startline, endline+1): + for line in range(start_line, end_line + 1): current_tags = self.txt.tag_names('%d.0' % line) if 'number' not in current_tags: start = '%d.0' % line end = '%d.end' % line self.txt.insert(start, '\t') - numstr = '%d.' % num - self.txt.insert(start, numstr) + num_str = '%d.' % num + self.txt.insert(start, num_str) self.txt.insert(start, '\t') self.txt.tag_add('number', start, end) num += 1 self.numbers.configure(relief='sunken') self.bullets.configure(relief='raised') - def _remove_numbers_if_needed(self, startline, endline): + def _remove_numbers_if_needed(self, start_line, end_line): cnt = IntVar() - for line in range(startline, endline+1): + for line in range(start_line, end_line + 1): current_tags = self.txt.tag_names('%d.0' % line) if 'number' in current_tags: start = '%d.0' % line @@ -344,8 +346,8 @@ def calc_grid_pos(self, pos): def highlight_square(self, idx, color): row, col = self._grid_pos[idx] - hpos = col + row * 8 - self.canvas.itemconfigure('o'+str(hpos), outline=color) + h_pos = col + row * 8 + self.canvas.itemconfigure('o' + str(h_pos), outline=color) def calc_valid_xy(self, x, y): return (min(max(0, self.canvas.canvasx(x)), self._board_side-1), @@ -354,15 +356,15 @@ def calc_valid_xy(self, x, y): def notify(self, move): add_lst = [] rem_lst = [] - for idx, _, newval in move.affected_squares: - if newval & FREE: + for idx, _, new_val in move.affected_squares: + if new_val & FREE: rem_lst.append(idx) else: add_lst.append(idx) cmd = Command(add=add_lst, remove=rem_lst) self._draw_checkers(cmd) self.txt.delete('1.0', END) - # self.serializer.restore(move.annotation) + self.serializer.restore(move.annotation) self.curr_annotation = move.annotation if self.txt.get('1.0', 'end').strip() == '': start = keymap[move.affected_squares[FIRST][0]] @@ -371,8 +373,7 @@ def notify(self, move): self.txt.insert('1.0', move_str) def get_annotation(self): - # return self.serializer.dump() - return "" + return self.serializer.dump() def erase_checker(self, index): self.canvas.delete('c'+str(index)) @@ -455,9 +456,9 @@ def _setup_board(self, _): def _label_board(self): for key, pair in self._grid_pos.items(): row, col = pair - xpos, ypos = col * self._square_size, row * self._square_size - self.canvas.create_text(xpos+self._square_size-7, - ypos+self._square_size-7, + x_pos, y_pos = col * self._square_size, row * self._square_size + self.canvas.create_text(x_pos + self._square_size - 7, + y_pos + self._square_size - 7, text=str(keymap[key]), fill=LIGHT_SQUARES, tag='label') diff --git a/parsing/PDN.py b/parsing/PDN.py index f39a779..b7b8905 100644 --- a/parsing/PDN.py +++ b/parsing/PDN.py @@ -1,6 +1,7 @@ import charset_normalizer import os import textwrap +from parsing.gamepersist import SavedGame from io import StringIO from pyparsing import Combine, Forward, Group, LineStart, LineEnd, Literal, OneOrMore, Optional, \ QuotedString, Suppress, Word, WordEnd, WordStart, nums, one_of, rest_of_line, srange @@ -142,7 +143,7 @@ def _read_result(self, value): def _read_board_orientation(self, value): if value == "white_on_top" or value == "black_on_top": - self._flip_board = "white_on_top" == True + self._flip_board = value == "white_on_top" else: raise SyntaxError(f"Unknown {value} used in board_orientation tag.") @@ -244,7 +245,7 @@ def read_game(self, idx): for item in game.body: if len(item) > 1: idx = 1 - move_list = [list(item[idx])] + move_list = list(item[idx]) if item.comment1: idx += 1 annotation = item[idx] @@ -261,10 +262,11 @@ def read_game(self, idx): self._moves.append([move_list, annotation]) else: raise RuntimeError(f"Cannot interpret item {item} in game.body") + sg = SavedGame() return Game(self._event, self._site, self._date, self._round, self._black_player, self._white_player, self._next_to_move, self._black_men, self._white_men, self._black_kings, self._white_kings, self._result, self._flip_board, - self._description, self._moves) + self._description, sg.translate_moves_to_board(self._moves)) def _get_player_to_move(self, turn): turn = turn.upper() diff --git a/parsing/gamepersist.py b/parsing/gamepersist.py index 0536817..43cb40c 100644 --- a/parsing/gamepersist.py +++ b/parsing/gamepersist.py @@ -1,7 +1,6 @@ import copy -from util.globalconst import FIRST, KING_IDX, LAST, square_map +from util.globalconst import square_map from game.checkers import Checkers, Checkerboard -from base.move import Move class SavedGame(object): @@ -22,16 +21,18 @@ def __init__(self): self._wk_check = False def _is_move(self, delta): - return delta in KING_IDX + return delta in [4, 5] def _try_move(self, squares: list, annotation: str, state_copy: Checkerboard): legal_moves = self._model.legal_moves(state_copy) # try to match squares with available moves on checkerboard + sq_len = len(squares) for move in legal_moves: - if all(sq == move.affected_squares[i] for i, sq in enumerate(squares)): + if sq_len == len(move.affected_squares) and \ + all(square_map[sq] == move.affected_squares[i][0] for i, sq in enumerate(squares)): move.annotation = annotation self._model.make_move(move, state_copy, False, False) - self.moves.append(move) #TODO: why do we need this in self.moves instead of returning it? + self.moves.append(move) return True else: raise RuntimeError(f"Illegal move {squares} found") @@ -40,23 +41,17 @@ def _try_jump(self, squares: list, annotation: str, state_copy: Checkerboard): if not self._model.captures_available(state_copy): return False legal_moves = self._model.legal_moves(state_copy) - # PDN move follows format [first, mid1, mid2, ..., last] found = False - valid_moves = [] - for mv in legal_moves: - # a valid jump may either have a single jump in it, or - # multiple jumps. In the multiple jump case, start_square is the - # source of the first jump, and dest_square is the endpoint of the - # last jump. - for sq in mv.affected_squares: - if start == mv.affected_squares[FIRST][0] and dest == mv.affected_squares[LAST]: - self._model.make_move(mv, state_copy, False, False) - valid_moves.append(mv) - found = True - break + for move in legal_moves: + if all(sq == move.affected_squares[i][0] for i, sq in enumerate(squares)): + move.annotation = annotation + self._model.make_move(move, state_copy, False, False) + self.moves.append(move) + found = True + break return found - def _translate_moves_to_board(self, moves: list): + def translate_moves_to_board(self, moves: list): """ Each move in the file lists the beginning and ending square, along with an optional annotation string (in Creole fmt) that describes it. I make sure that each move works on a copy of the model before I commit @@ -75,5 +70,5 @@ def _translate_moves_to_board(self, moves: list): jumped = self._try_jump(squares, annotation, state_copy) if not jumped: raise RuntimeError(f"Bad move at index {idx}") - moves.reverse() # TODO: is this still needed? + moves.reverse() return moves diff --git a/parsing/textserialize.py b/parsing/textserialize.py new file mode 100644 index 0000000..3120823 --- /dev/null +++ b/parsing/textserialize.py @@ -0,0 +1,247 @@ +from tkinter import PhotoImage +from tkinter.constants import END +from util.globalconst import BULLET_IMAGE +from parsing.creole import Parser, LinkRules + + +class TextTagEmitter(object): + """ + Generate tagged output compatible with the Tkinter Text widget + """ + def __init__(self, root, txt_widget, hyper_mgr, bullet_image, link_rules=None): + self.root = root + self.link_rules = link_rules or LinkRules() + self.txtWidget = txt_widget + self.hyperMgr = hyper_mgr + self.line = 1 + self.index = 0 + self.number = 1 + self.bullet = False + self.bullet_image = bullet_image + self.begin_italic = '' + self.begin_bold = '' + self.begin_list_item = '' + self.list_item = '' + self.begin_link = '' + + # visit/leave methods for emitting nodes of the document: + def visit_document(self, node): + pass + + def leave_document(self, _): + # leave_paragraph always leaves two extra carriage returns at the + # end of the text. This deletes them. + text_index = '%d.%d' % (self.line - 1, self.index) + self.txtWidget.delete(text_index, END) + + def visit_text(self, node): + if self.begin_list_item: + self.list_item = node.content + elif self.begin_link: + pass + else: + text_index = '%d.%d' % (self.line, self.index) + self.txtWidget.insert(text_index, node.content) + + def leave_text(self, node): + if not self.begin_list_item: + self.index += len(node.content) + + def visit_separator(self, node): + raise NotImplementedError + + def leave_separator(self, node): + raise NotImplementedError + + def visit_paragraph(self, node): + pass + + def leave_paragraph(self, _): + text_index = '%d.%d' % (self.line, self.index) + self.txtWidget.insert(text_index, '\n\n') + self.line += 2 + self.index = 0 + self.number = 1 + + def visit_bullet_list(self, _): + self.bullet = True + + def leave_bullet_list(self, _): + text_index = '%d.%d' % (self.line, self.index) + self.txtWidget.insert(text_index, '\n') + self.line += 1 + self.index = 0 + self.bullet = False + + def visit_number_list(self, _): + self.number = 1 + + def leave_number_list(self, _): + text_index = '%d.%d' % (self.line, self.index) + self.txtWidget.insert(text_index, '\n') + self.line += 1 + self.index = 0 + + def visit_list_item(self, _): + self.begin_list_item = '%d.%d' % (self.line, self.index) + + def leave_list_item(self, _): + if self.bullet: + self.txtWidget.insert(self.begin_list_item, '\t') + next_tag = '%d.%d' % (self.line, self.index + 1) + self.txtWidget.image_create(next_tag, image=self.bullet_image) + next_tag = '%d.%d' % (self.line, self.index + 2) + content = '\t%s\t\n' % self.list_item + self.txtWidget.insert(next_tag, content) + end_list_item = '%d.%d' % (self.line, self.index + len(content)+2) + self.txtWidget.tag_add('bullet', self.begin_list_item, end_list_item) + elif self.number: + content = '\t%d.\t%s\n' % (self.number, self.list_item) + end_list_item = '%d.%d' % (self.line, self.index + len(content)) + self.txtWidget.insert(self.begin_list_item, content) + self.txtWidget.tag_add('number', self.begin_list_item, end_list_item) + self.number += 1 + self.begin_list_item = '' + self.list_item = '' + self.line += 1 + self.index = 0 + + def visit_emphasis(self, _): + self.begin_italic = '%d.%d' % (self.line, self.index) + + def leave_emphasis(self, _): + end_italic = '%d.%d' % (self.line, self.index) + self.txtWidget.tag_add('italic', self.begin_italic, end_italic) + + def visit_strong(self, _): + self.begin_bold = '%d.%d' % (self.line, self.index) + + def leave_strong(self, _): + end_bold = '%d.%d' % (self.line, self.index) + self.txtWidget.tag_add('bold', self.begin_bold, end_bold) + + def visit_link(self, _): + self.begin_link = '%d.%d' % (self.line, self.index) + + def leave_link(self, node): + # TODO: Revisit unicode encode/decode issues later. + # 1. Decode early. 2. Unicode everywhere 3. Encode late + # However, decoding filename and link_text here works for now. + filename = str(node.content).replace('%20', ' ') + link_text = str(node.children[0].content).replace('%20', ' ') + self.txtWidget.insert(self.begin_link, link_text, + self.hyperMgr.add(filename)) + self.begin_link = '' + + def visit_break(self, _): + text_index = '%d.%d' % (self.line, self.index) + self.txtWidget.insert(text_index, '\n') + + def leave_break(self, _): + self.line += 1 + self.index = 0 + + def visit_default(self, node): + """Fallback function for visiting unknown nodes.""" + raise TypeError + + def leave_default(self, node): + """Fallback function for leaving unknown nodes.""" + raise TypeError + + def emit_children(self, node): + """Emit all the children of a node.""" + for child in node.children: + self.emit_node(child) + + def emit_node(self, node): + """Visit/depart a single node and its children.""" + visit = getattr(self, 'visit_%s' % node.kind, self.visit_default) + visit(node) + self.emit_children(node) + leave = getattr(self, 'leave_%s' % node.kind, self.leave_default) + leave(node) + + def emit(self): + """Emit the document represented by self.root DOM tree.""" + return self.emit_node(self.root) + + +class Serializer(object): + def __init__(self, txt_widget, hyper_mgr): + self.txt = txt_widget + self.hyperMgr = hyper_mgr + self.bullet_image = PhotoImage(file=BULLET_IMAGE) + self.filename = "" + self.number = 0 + self.bullet = False + self.list_end = False + self.link_start = False + self._reset() + + def _reset(self): + self.number = 0 + self.bullet = False + self.filename = "" + self.link_start = False + self.first_tab = True + self.list_end = False + + def dump(self, index1='1.0', index2=END): + # outputs contents from Text widget in Creole fmt. + creole = '' + self._reset() + for key, value, index in self.txt.dump(index1, index2): + if key == 'tagon': + if value == 'bold': + creole += '**' + elif value == 'italic': + creole += '//' + elif value == 'bullet': + creole += '*' + self.bullet = True + self.list_end = False + elif value.startswith('hyper-'): + self.filename = self.hyperMgr.filenames[value] + self.link_start = True + elif value == 'number': + creole += '#' + self.number += 1 + elif key == 'tagoff': + if value == 'bold': + creole += '**' + elif value == 'italic': + creole += '//' + elif value.startswith('hyper-'): + creole += ']]' + elif value == 'number': + num_str = '#\t%d.\t' % self.number + if num_str in creole: + creole = creole.replace(num_str, '# ', 1) + self.list_end = True + elif value == 'bullet': + creole = creole.replace('\n*\t\t', '\n* ', 1) + self.bullet = False + self.list_end = True + elif key == 'text': + if self.link_start: + # TODO: Revisit unicode encode/decode issues later. + # 1. Decode early. 2. Unicode everywhere 3. Encode late + # However, encoding filename and link_text here works for + # now. + filename = self.filename.replace(' ', '%20').encode('utf-8') + link_text = value.replace(' ', '%20') + value = '[[%s|%s' % (filename, link_text) + self.link_start = False + num_str = '%d.\t' % self.number + if self.list_end and value != '\n' and num_str not in value: + creole += '\n' + self.number = 0 + self.list_end = False + creole += value + return creole.rstrip() + + def restore(self, creole): + self.hyperMgr.reset() + document = Parser(creole).parse() + return TextTagEmitter(document, self.txt, self.hyperMgr, self.bullet_image).emit()