diff --git a/.gitignore b/.gitignore index 7bdb8a2..afecd05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.puz -__pycache__/* +__pycache__ dist/* build/* +*.swp diff --git a/README.md b/README.md index 77c2e55..eb14b9c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,15 @@ # cursewords -`cursewords` is a "graphical" command line program for solving crossword puzzles in the terminal. It can be used to open files saved in the widely used AcrossLite `.puz` format. +`cursewords` is a "graphical" command line program for solving crossword puzzles in the terminal. You can use it to open, solve, and save puzzle files in the popular AcrossLite .puz format. -`cursewords` includes nearly every major feature you might expect in a crossword program, including: - -* intuitive navigation -* answer-checking for squares, words, and full puzzles -* a pausable puzzle timer -* a puzzle completeness notification - -It is currently under active development, and should not be considered fully "released." That said, it is stable and suitable for everyday use. - ## Installation -`cursewords` is only compatible with `python3`, and can be installed through `pip`. If you don't know what that means, the best command is probably: +`cursewords` is only compatible with `python3`, and can be installed on through `pip`. If you don't know what that means, the best command is probably: ```bash -pip3 install --user cursewords +pip3 install cursewords ``` You should then be ready to go. You can then use `cursewords` to open `.puz` files directly: @@ -27,10 +18,24 @@ You should then be ready to go. You can then use `cursewords` to open `.puz` fi cursewords todaysnyt.puz ``` +`cursewords` has been tested to work on Linux, Mac, and Windows computers. + ## Usage +Controls are printed in a panel at the bottom of the screen. Note that (for now) `cursewords` is not very accommodating of changes in window size, so you may have to quit and re-open if you need to resize your terminal. + +### Navigation + If you've used a program to solve crossword puzzles, navigation should be pretty intuitive. `tab` and `shift+tab` are the workhorses for navigation between blanks. Arrow keys will navigate the grid according to the direction of the cursor, and `shift+arrow` will move through words perpendicular to the cursor. `page up` and `page down` (on Mac, `Fn+` up/down arrow keys) jump between words without considering blank spaces. `ctrl+g`, followed by a number, will jump directly to the space with that number. If you need some help, `ctrl+c` will check the current square, word, or entire puzzle for errors, and `ctrl+r` will reveal answers (subject to the same scoping options). To clear all entries on the puzzle, use `ctrl+x`, and to reset the puzzle to its original state (resetting the timer and removing any stored information about hints and corrections), use `ctrl+z`. To open a puzzle in `downs-only` mode, where only the down clues are visible, use the `--downs-only` flag when opening the file on the command line. + +### Print mode + +If `cursewords` is not running in an interactive terminal (because its output is being piped to another command or redirected to a file) or if you pass the `--print` flag directly, it will print a formatted grid and list of clues to stdout and quit. The output of that command can be modified with the following flags: + +* `--blank` ensures the grid is unfilled, even if you've saved solving progress +* `--solution` prints the filled grid +* `--width INT` caps the program output at INT characters wide. (If this flag isn't passed at runtime, `cursewords` will attempt to pick a reasonable output size. In many cases that will be 92 characters or the width of the puzzle.) diff --git a/cursewords/chars.py b/cursewords/characters.py similarity index 100% rename from cursewords/chars.py rename to cursewords/characters.py diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py index 14571da..4cf0360 100755 --- a/cursewords/cursewords.py +++ b/cursewords/cursewords.py @@ -1,19 +1,32 @@ +# pylint: disable=invalid-name +# pylint: disable=missing-docstring +# pylint: disable=too-many-lines +# pylint: disable=too-many-instance-attributes +# pylint: disable=too-many-branches +# pylint: disable=too-many-statements +# pylint: disable=too-many-arguments +# pylint: disable=too-many-boolean-expressions +# pylint: disable=too-many-locals +# pylint: disable=too-many-public-methods +# pylint: disable=bare-except + #! /usr/bin/env python3 import argparse -import itertools +import functools import os import sys import time import textwrap import threading -import puz - from blessed import Terminal -from . import chars +from . import characters +from . import puz +from .printer import printer_output +echo = functools.partial(print, end='', flush=True) class Cell: def __init__(self, solution, entry=None): @@ -34,20 +47,25 @@ def clear(self): self.marked_wrong = False self.corrected = True + @property def is_block(self): return self.solution == "." + @property def is_letter(self): return self.solution.isalnum() + @property def is_blank(self): return self.entry == "-" + @property def is_blankish(self): - return self.is_blank() or self.marked_wrong + return self.is_blank or self.marked_wrong + @property def is_correct(self): - return self.entry == self.solution or self.is_block() + return self.entry == self.solution or self.is_block class Grid: @@ -56,6 +74,22 @@ def __init__(self, grid_x, grid_y, term): self.grid_y = grid_y self.term = term + self.puzfile = None + self.cells = {} + self.row_count = 0 + self.column_count = 0 + + self.title = '' + self.author = '' + + self.words = dict() + self.clues = dict() + self.spaces = dict() + + self.start_time = 0 + self.timer_active = False + self.notification_timer = None + self.notification_area = (term.height-2, self.grid_x) def load(self, puzfile): @@ -65,57 +99,57 @@ def load(self, puzfile): self.column_count = puzfile.width self.title = puzfile.title - self.author = puzfile.author + self.author = puzfile.author.strip() for i in range(self.row_count): for j in range(self.column_count): idx = i * self.column_count + j entry = self.puzfile.fill[idx] - self.cells[(j, i)] = Cell( - self.puzfile.solution[idx], - entry) + self.cells[(j, i)] = Cell(self.puzfile.solution[idx], entry) - self.across_words = [] + self.words['across'] = [] for i in range(self.row_count): current_word = [] for j in range(self.column_count): - if self.cells[(j, i)].is_letter(): + if self.cells[(j, i)].is_letter: current_word.append((j, i)) elif len(current_word) > 1: - self.across_words.append(current_word) + self.words['across'].append(current_word) current_word = [] elif current_word: current_word = [] if len(current_word) > 1: - self.across_words.append(current_word) + self.words['across'].append(current_word) - self.down_words = [] + self.words['down'] = [] for j in range(self.column_count): current_word = [] for i in range(self.row_count): - if self.cells[(j, i)].is_letter(): + if self.cells[(j, i)].is_letter: current_word.append((j, i)) elif len(current_word) > 1: - self.down_words.append(current_word) + self.words['down'].append(current_word) current_word = [] elif current_word: current_word = [] if len(current_word) > 1: - self.down_words.append(current_word) + self.words['down'].append(current_word) + + self.words['down'].sort(key=lambda word: (word[0][1], word[0][0])) - self.down_words_grouped = sorted(self.down_words, - key=lambda word: (word[0][1], word[0][0])) + self.number() num = self.puzfile.clue_numbering() - self.across_clues = [word['clue'] for word in num.across] - self.down_clues = [word['clue'] for word in num.down] + self.clues['across'] = num.across + self.clues['down'] = num.down + + self.spaces['across'] = [(j, i) for i in range(self.row_count) + for j in range(self.column_count) + if self.cells[(j, i)].is_letter] + self.spaces['down'] = [(j, i) for j in range(self.column_count) + for i in range(self.row_count) + if self.cells[(j, i)].is_letter] - self.across_spaces = [(j,i) for i in range(self.column_count) - for j in range(self.row_count) - if self.cells[(j,i)].is_letter()] - self.down_spaces = [(j,i) for j in range(self.row_count) - for i in range(self.column_count) - if self.cells[(j,i)].is_letter()] if self.puzfile.has_markup(): markup = self.puzfile.markup().markup @@ -140,33 +174,77 @@ def load(self, puzfile): else: self.start_time, self.timer_active = 0, 1 - def draw(self): - top_row = self.get_top_row() - bottom_row = self.get_bottom_row() - middle_row = self.get_middle_row() - divider_row = self.get_divider_row() - - print(self.term.move(self.grid_y, self.grid_x) - + self.term.dim(top_row)) - for index, y_val in enumerate( - range(self.grid_y + 1, - self.grid_y + self.row_count * 2), - 1): - if index % 2 == 0: - print(self.term.move(y_val, self.grid_x) + - self.term.dim(divider_row)) - else: - print(self.term.move(y_val, self.grid_x) + - self.term.dim(middle_row)) - print(self.term.move(self.grid_y + self.row_count * 2, self.grid_x) - + self.term.dim(bottom_row)) + def render_grid(self, empty=False, blank=False, solution=False): + grid_rows = [] + for i in range(self.row_count): + rows = [self.term.dim, self.term.dim] + for j in range(self.column_count): + pos = (j, i) + cell = self.cells.get(pos) + if i == 0 and j == 0: + rows[0] += characters.ulcorner + elif j == 0: + rows[0] += characters.ltee + elif i == 0: + rows[0] += characters.ttee + else: + rows[0] += characters.bigplus + + rows[1] += characters.vline + + if cell.number and not empty: + small = str(cell.number).translate(characters.small_nums) + else: + small = '' + + # This is the right way to do it but as long as I'm doing + # the weird term.dim dance I have to write it a little uglier + # rows[0] += f'{small:{characters.hline}<3.3}' + rows[0] += (self.term.normal + + small + + self.term.dim + + characters.hline * (3 - len(small))) + + if empty: + rows[1] += ' ' + elif cell.is_block: + rows[1] += characters.squareblock + elif blank: + rows[1] += ' ' + elif solution: + rows[1] += ' '.join([self.term.normal, self.cells[pos].solution, + self.term.dim]) + else: + value, markup = self.compile_cell(pos) + value += markup + rows[1] += self.term.normal + ' ' + value + self.term.dim + + if j == self.column_count - 1: + if i == 0: + rows[0] += characters.urcorner + else: + rows[0] += characters.rtee + rows[1] += characters.vline + self.term.normal + rows[0] += self.term.normal + grid_rows.extend(rows) + + bottom_row = self.term.dim + characters.llcorner + for col in range(1, self.column_count * 4): + bottom_row += characters.btee if col % 4 == 0 else characters.hline + bottom_row += characters.lrcorner + self.term.normal + + grid_rows.append(bottom_row) + + return grid_rows + + def draw(self, empty=False): + grid_rows = self.render_grid(empty=empty) + for index, row in enumerate(grid_rows): + echo(self.term.move(self.grid_y + index, self.grid_x) + row) def number(self): numbered_squares = [] - for word in self.across_words: - numbered_squares.append(word[0]) - - for word in self.down_words: + for word in self.words['across'] + self.words['down']: if word[0] not in numbered_squares: numbered_squares.append(word[0]) @@ -175,46 +253,39 @@ def number(self): for number, square in enumerate(numbered_squares, 1): self.cells.get(square).number = number - def fill(self): - for position in self.cells: - y_coord, x_coord = self.to_term(position) - cell = self.cells[position] - if cell.is_letter(): - self.draw_cell(position) - elif cell.is_block(): - print(self.term.move(y_coord, x_coord - 1) + - self.term.dim(chars.squareblock)) - - if cell.number: - small = str(cell.number).translate(chars.small_nums) - x_pos = x_coord - 1 - print(self.term.move(y_coord - 1, x_pos) + small) + @property + def blank_cells_remaining(self): + return any(self.cells.get(pos).is_blankish for pos in self.cells) def confirm_quit(self, modified_since_save): if modified_since_save: confirmation = self.get_notification_input( "Quit without saving? (y/n)", - chars=1, blocking=True, timeout=5) + char_limit=1, blocking=True, timeout=5) return confirmation.lower() == 'y' return True def confirm_clear(self): confirmation = self.get_notification_input("Clear puzzle? (y/n)", - chars=1, blocking=True, timeout=5) + char_limit=1, + blocking=True, + timeout=5) return confirmation.lower() == 'y' def confirm_reset(self): confirmation = self.get_notification_input("Reset puzzle? (y/n)", - chars=1, blocking=True, timeout=5) + char_limit=1, + blocking=True, + timeout=5) return confirmation.lower() == 'y' def save(self, filename): fill = '' for pos in self.cells: cell = self.cells[pos] - if cell.is_block(): + if cell.is_block: entry = "." - elif cell.is_blank(): + elif cell.is_blank: entry = "-" else: entry = cell.entry @@ -245,7 +316,7 @@ def save(self, filename): def reveal_cell(self, pos): cell = self.cells.get(pos) - if cell.is_blankish() or not cell.is_correct(): + if cell.is_blankish or not cell.is_correct: cell.entry = cell.solution cell.revealed = True self.draw_cell(pos) @@ -256,7 +327,7 @@ def reveal_cells(self, pos_list): def check_cell(self, pos): cell = self.cells.get(pos) - if not cell.is_blank() and not cell.is_correct(): + if not cell.is_blank and not cell.is_correct: cell.marked_wrong = True self.draw_cell(pos) @@ -270,31 +341,12 @@ def to_term(self, position): term_y = self.grid_y + (2 * point_y) + 1 return (term_y, term_x) - - def make_row(self, leftmost, middle, divider, rightmost): - chars = '' - for col in range(1, self.column_count * 4): - chars += divider if col % 4 == 0 else middle - return leftmost + chars + rightmost - - def get_top_row(self): - return self.make_row(chars.ulcorner, chars.hline, chars.ttee, chars.urcorner) - - def get_bottom_row(self): - return self.make_row(chars.llcorner, chars.hline, chars.btee, chars.lrcorner) - - def get_middle_row(self): - return self.make_row(chars.vline, " ", chars.vline, chars.vline) - - def get_divider_row(self): - return self.make_row(chars.ltee, chars.hline, chars.bigplus, chars.rtee) - def compile_cell(self, position): cell = self.cells.get(position) - value = " " if cell.is_blank() else cell.entry + value = " " if cell.is_blank else cell.entry if cell.circled: - value = value.translate(chars.encircle) + value = value.translate(characters.encircle) if cell.marked_wrong and not cell.revealed: value = self.term.red(value.lower()) @@ -313,20 +365,20 @@ def compile_cell(self, position): def draw_cell(self, position): value, markup = self.compile_cell(position) value += markup - print(self.term.move(*self.to_term(position)) + value) + echo(self.term.move(*self.to_term(position)) + value) def draw_highlighted_cell(self, position): value, markup = self.compile_cell(position) value = self.term.underline(value) + markup - print(self.term.move(*self.to_term(position)) + value) + echo(self.term.move(*self.to_term(position)) + value) def draw_cursor_cell(self, position): value, markup = self.compile_cell(position) value = self.term.reverse(value) + markup - print(self.term.move(*self.to_term(position)) + value) + echo(self.term.move(*self.to_term(position)) + value) - def get_notification_input(self, message, timeout=5, chars=3, - input_condition=str.isalnum, blocking=False): + def get_notification_input(self, message, timeout=5, char_limit=3, + input_condition=str.isalnum, blocking=False): # If there's already a notification timer running, stop it. try: @@ -336,24 +388,26 @@ def get_notification_input(self, message, timeout=5, chars=3, input_phrase = message + " " key_input_place = len(input_phrase) - print(self.term.move(*self.notification_area) - + self.term.reverse(input_phrase) - + self.term.clear_eol) + echo(self.term.move(*self.notification_area) + + self.term.reverse(input_phrase) + + self.term.clear_eol) user_input = '' keypress = None - while keypress != '' and len(user_input) < chars: + while keypress != '' and len(user_input) < char_limit: keypress = self.term.inkey(timeout) if input_condition(keypress): user_input += keypress - print(self.term.move(self.notification_area[0], - self.notification_area[1] + key_input_place), - user_input) - elif keypress.name in ['KEY_DELETE']: + echo(self.term.move(self.notification_area[0], + self.notification_area[1] + + key_input_place), + user_input) + elif keypress.name in ['KEY_DELETE', 'KEY_BACKSPACE']: user_input = user_input[:-1] - print(self.term.move(self.notification_area[0], - self.notification_area[1] + key_input_place), - user_input + self.term.clear_eol) + echo(self.term.move(self.notification_area[0], + self.notification_area[1] + + key_input_place), + user_input + self.term.clear_eol) elif blocking and keypress.name not in ['KEY_ENTER', 'KEY_ESCAPE']: continue else: @@ -363,14 +417,14 @@ def get_notification_input(self, message, timeout=5, chars=3, def send_notification(self, message, timeout=5): self.notification_timer = threading.Timer(timeout, - self.clear_notification_area) + self.clear_notification_area) self.notification_timer.daemon = True - print(self.term.move(*self.notification_area) - + self.term.reverse(message) + self.term.clear_eol) + echo(self.term.move(*self.notification_area) + + self.term.reverse(message) + self.term.clear_eol) self.notification_timer.start() def clear_notification_area(self): - print(self.term.move(*self.notification_area) + self.term.clear_eol) + echo(self.term.move(*self.notification_area) + self.term.clear_eol) class Cursor: @@ -426,7 +480,7 @@ def move_within_word(self, overwrite_mode=False, wrap_mode=False): if not overwrite_mode: ordered_spaces = [pos for pos in ordered_spaces - if self.grid.cells.get(pos).is_blankish()] + if self.grid.cells.get(pos).is_blankish] return next(iter(ordered_spaces), None) @@ -444,12 +498,9 @@ def retreat_within_word(self, end_placement=False, blank_placement=False): self.retreat_to_previous_word(end_placement, blank_placement) def advance_to_next_word(self, blank_placement=False): - if self.direction == "across": - word_group = self.grid.across_words - next_words = self.grid.down_words_grouped - elif self.direction == "down": - word_group = self.grid.down_words_grouped - next_words = self.grid.across_words + word_group = self.grid.words[self.direction] + next_words = (self.grid.words['across'] if self.direction == 'down' + else self.grid.words['down']) while self.current_word() not in word_group: self.retreat() @@ -466,9 +517,7 @@ def advance_to_next_word(self, blank_placement=False): # If there are no blank squares left, override # the blank_placement setting - if (blank_placement and - not any(self.grid.cells.get(pos).is_blankish() for - pos in self.grid.across_spaces + self.grid.down_spaces)): + if blank_placement and not self.grid.blank_cells_remaining: blank_placement = False # Otherwise, if blank_placement is on, put the @@ -482,12 +531,10 @@ def advance_to_next_word(self, blank_placement=False): def retreat_to_previous_word(self, end_placement=False, blank_placement=False): - if self.direction == "across": - word_group = self.grid.across_words - next_words = self.grid.down_words_grouped - elif self.direction == "down": - word_group = self.grid.down_words_grouped - next_words = self.grid.across_words + + word_group = self.grid.words[self.direction] + next_words = (self.grid.words['across'] if self.direction == 'down' + else self.grid.words['down']) while self.current_word() not in word_group: self.advance() @@ -505,9 +552,7 @@ def retreat_to_previous_word(self, # If there are no blank squares left, override # the blank_placement setting - if (blank_placement and - not any(self.grid.cells.get(pos).is_blankish() for - pos in self.grid.across_spaces + self.grid.down_spaces)): + if blank_placement and not self.grid.blank_cells_remaining: blank_placement = False if blank_placement and self.earliest_blank_in_word(): @@ -517,18 +562,18 @@ def retreat_to_previous_word(self, def earliest_blank_in_word(self): blanks = (pos for pos in self.current_word() - if self.grid.cells.get(pos).is_blankish()) + if self.grid.cells.get(pos).is_blankish) return next(blanks, None) def move_right(self): - spaces = self.grid.across_spaces + spaces = self.grid.spaces['across'] current_space = spaces.index(self.position) ordered_spaces = spaces[current_space + 1:] + spaces[:current_space] return next(iter(ordered_spaces)) def move_left(self): - spaces = self.grid.across_spaces + spaces = self.grid.spaces['across'] current_space = spaces.index(self.position) ordered_spaces = (spaces[current_space - 1::-1] + spaces[:current_space:-1]) @@ -536,14 +581,14 @@ def move_left(self): return next(iter(ordered_spaces)) def move_down(self): - spaces = self.grid.down_spaces + spaces = self.grid.spaces['down'] current_space = spaces.index(self.position) ordered_spaces = spaces[current_space + 1:] + spaces[:current_space] return next(iter(ordered_spaces)) def move_up(self): - spaces = self.grid.down_spaces + spaces = self.grid.spaces['down'] current_space = spaces.index(self.position) ordered_spaces = (spaces[current_space - 1::-1] + spaces[:current_space:-1]) @@ -552,12 +597,9 @@ def move_up(self): def current_word(self): pos = self.position - word = [] - if self.direction == "across": - word = next((w for w in self.grid.across_words if pos in w), [pos]) - if self.direction == "down": - word = next((w for w in self.grid.down_words if pos in w), [pos]) + word = next((w for w in self.grid.words[self.direction] if pos in w), + [pos]) return word @@ -566,12 +608,11 @@ def go_to_numbered_square(self): input_condition=str.isdigit) if num: pos = next((pos for pos in self.grid.cells - if self.grid.cells.get(pos).number == int(num)), - None) + if self.grid.cells.get(pos).number == int(num)), None) if pos: self.position = pos self.grid.send_notification( - "Moved cursor to square {}.".format(num)) + "Moved cursor to square {}.".format(num)) else: self.grid.send_notification("Not a valid number.") else: @@ -583,6 +624,8 @@ def __init__(self, grid, starting_seconds=0, is_running=True, active=True): self.starting_seconds = starting_seconds self.is_running = is_running self.active = active + self.time_passed = 0 + self.start_time = 0 super().__init__(daemon=True) @@ -596,8 +639,8 @@ def run(self): while self.active: if self.is_running: - self.time_passed = (self.starting_seconds - + int(time.time() - self.start_time)) + self.time_passed = (self.starting_seconds + + int(time.time() - self.start_time)) self.show_time() time.sleep(0.5) @@ -606,8 +649,8 @@ def show_time(self): y_coord = 2 x_coord = self.grid.grid_x + self.grid.column_count * 4 - 7 - print(self.grid.term.move(y_coord, x_coord) - + self.display_format()) + echo(self.grid.term.move(y_coord, x_coord) + + self.display_format()) def display_format(self): time_amount = self.time_passed @@ -624,7 +667,7 @@ def save_format(self): time_amount = self.time_passed save_string = '{t},{r}'.format( - t=int(time_amount), r=int(self.active)) + t=int(time_amount), r=int(self.active)) save_bytes = save_string.encode(puz.ENCODING) @@ -646,18 +689,49 @@ def main(): version = f.read().strip() parser = argparse.ArgumentParser( - prog='cursewords', - description="""A terminal-based crossword puzzle solving interface.""") + prog='cursewords', + description="""cursewords is a terminal-based crossword puzzle + solving interface. Use it to open, solve, and save puzzles in the + standard AcrossLite .puz format. Arrow keys and tab navigate, + and space bar switches the cursor direction.""", + usage=textwrap.dedent("""\ + cursewords [-h] [--downs-only] [--version] PUZfile + print mode: cursewords [--print] [--blank | --solution] [--width INT] PUZfile""")) parser.add_argument('filename', metavar='PUZfile', - help="""path of puzzle file in the AcrossLite .puz format""") + help="""path of puzzle file in the \ + AcrossLite .puz format""") parser.add_argument('--downs-only', action='store_true', - help="""displays only the down clues""") + help="""displays only the down clues""") + + print_group = parser.add_argument_group('print mode', description="""\ + If the --print flag is explicitly provided, or if cursewords + is not running in an interactive terminal (because its + output is being piped or redirected), print a formatted + grid and set of clues to stdout instead of starting an interactive + session.""") + print_group.add_argument('--print', action='store_true', help="""\ + output formatted grid and clues to stdout""") + + print_fill = print_group.add_mutually_exclusive_group() + print_fill.add_argument('--blank', action='store_true', help="""\ + format the puzzle grid with no answers""") + print_fill.add_argument('--solution', action='store_true', help="""\ + format the puzzle grid with a filled solution""") + + print_group.add_argument('--width', action='store', type=int, help="""\ + maximum width in characters of the output (default 92)""") + parser.add_argument('--version', action='version', version=version) args = parser.parse_args() filename = args.filename downs_only = args.downs_only + print_mode = args.print or not sys.stdout.isatty() + print_style = ('solution' if args.solution + else 'blank' if args.blank + else None) + print_width = args.width try: puzfile = puz.read(filename) @@ -672,19 +746,24 @@ def main(): grid = Grid(grid_x, grid_y, term) grid.load(puzfile) + if print_mode: + printer_output(grid, style=print_style, width=print_width, + downs_only=downs_only) + sys.exit() + puzzle_width = max(4 * grid.column_count, 40) puzzle_height = 2 * grid.row_count min_width = (puzzle_width - + grid_x - + 2) # a little breathing room + + grid_x + + 2) # a little breathing room min_height = (puzzle_height - + grid_y # includes the top bar + timer - + 2 # padding above clues - + 3 # clue area - + 2 # toolbar - + 2) # again, just some breathing room + + grid_y # includes the top bar + timer + + 2 # padding above clues + + 3 # clue area + + 2 # toolbar + + 2) # again, just some breathing room necessary_resize = [] if term.width < min_width: @@ -707,12 +786,8 @@ def main(): by cursewords. Sorry about that!""") sys.exit(' '.join(exit_text.splitlines())) - print(term.enter_fullscreen()) - print(term.clear()) - - grid.draw() - grid.number() - grid.fill() + echo(term.enter_fullscreen()) + echo(term.clear()) software_info = 'cursewords v{}'.format(version) puzzle_info = '{grid.title} - {grid.author}'.format(grid=grid) @@ -723,11 +798,14 @@ def main(): puzzle_info = "{}…".format(puzzle_info[:pz_width - 1]) headline = " {:<{pz_w}}{:>{sw_w}} ".format( - puzzle_info, software_info, - pz_w=pz_width, sw_w=sw_width) + puzzle_info, software_info, + pz_w=pz_width, sw_w=sw_width) with term.location(x=0, y=0): - print(term.dim(term.reverse(headline))) + echo(term.dim + term.reverse(headline) + term.normal) + + grid.draw() + toolbar = '' commands = [("^Q", "quit"), @@ -745,7 +823,7 @@ def main(): toolbar += "{:<25}".format(' '.join([shortcut, action])) with term.location(x=grid_x, y=term.height): - print(toolbar, end='') + echo(toolbar) else: grid.notification_area = (grid.notification_area[0] - 1, grid_x) command_split = int(len(commands)/2) - 1 @@ -757,17 +835,17 @@ def main(): toolbar += '\n' + grid_x * ' ' with term.location(x=grid_x, y=term.height - 2): - print(toolbar, end='') + echo(toolbar) clue_width = min(int(1.3 * (puzzle_width) - grid_x), term.width - 2 - grid_x) clue_wrapper = textwrap.TextWrapper( - width=clue_width, - max_lines=3, - subsequent_indent=grid_x * ' ') + width=clue_width, + max_lines=3, + subsequent_indent=grid_x * ' ') - start_pos = grid.across_words[0][0] + start_pos = grid.words['across'][0][0] cursor = Cursor(start_pos, "across", grid) old_word = [] @@ -776,7 +854,7 @@ def main(): puzzle_paused = False puzzle_complete = False modified_since_save = False - to_quit = False + to_quit = not sys.stdout.isatty() timer = Timer(grid, starting_seconds=int(grid.start_time), is_running=True, active=bool(int(grid.timer_active))) @@ -795,40 +873,34 @@ def main(): for pos in cursor.current_word(): grid.draw_highlighted_cell(pos) - # Draw the clue for the new word: - if cursor.direction == "across": - if cursor.current_word() in grid.across_words: - num_index = grid.across_words.index( - cursor.current_word()) - clue = grid.across_clues[num_index] - if downs_only: - clue = "—" - else: - clue = "" - elif cursor.direction == "down": - if cursor.current_word() in grid.down_words: - num_index = grid.down_words_grouped.index( - cursor.current_word()) - clue = grid.down_clues[num_index] - else: - clue = "" + # Draw the clue for the new word: + if cursor.current_word() in grid.words[cursor.direction]: + num_index = grid.words[cursor.direction].index( + cursor.current_word()) + clue = grid.clues[cursor.direction][num_index]['clue'] + if cursor.direction == 'across' and downs_only: + clue = "—" + else: + clue = "" + + num = (str(grid.cells.get(cursor.current_word()[0]).number) + if clue else "") - num = str(grid.cells.get(cursor.current_word()[0]).number) if clue else "" - compiled_clue = (num + " " + cursor.direction.upper() - + ": " + clue) if num else "" + compiled_clue = (num + " " + cursor.direction.upper() + + ": " + clue) if num else "" wrapped_clue = clue_wrapper.wrap(compiled_clue) wrapped_clue += [''] * (3 - len(wrapped_clue)) wrapped_clue = [line + term.clear_eol for line in wrapped_clue] # This is fun: since we're in raw mode, \n isn't sufficient to - # return the printing location to the first column. If you + # return the printing location to the first column. If you # don't also have \r, # it # prints # like # this after each newline - print(term.move(info_location['y'], info_location['x']) - + '\r\n'.join(wrapped_clue)) + echo(term.move(info_location['y'], info_location['x']) + + '\r\n'.join(wrapped_clue)) # Otherwise, just draw the old square now that it's not under # the cursor @@ -836,22 +908,18 @@ def main(): grid.draw_highlighted_cell(old_position) current_cell = grid.cells.get(cursor.position) - value = current_cell.entry grid.draw_cursor_cell(cursor.position) # Check if the puzzle is complete! - if not puzzle_complete and all(grid.cells.get(pos).is_correct() - for pos in grid.cells): + if not puzzle_complete and all(grid.cells.get(pos).is_correct + for pos in grid.cells): puzzle_complete = True with term.location(x=grid_x, y=2): - print(term.reverse("You've completed the puzzle!"), - term.clear_eol) + echo(term.reverse("You've completed the puzzle! 🎉"), + term.clear_eol) timer.show_time() timer.active = False - blank_cells_remaining = any(grid.cells.get(pos).is_blankish() - for pos in grid.cells) - # Where the magic happens: get key input keypress = term.inkey() @@ -874,18 +942,18 @@ def main(): elif keypress == chr(16) and not puzzle_complete: if timer.is_running: timer.pause() - grid.draw() + grid.draw(empty=True) with term.location(**info_location): - print('\r\n'.join(['PUZZLE PAUSED' + term.clear_eol, - term.clear_eol, - term.clear_eol])) + echo('\r\n'.join(['PUZZLE PAUSED' + term.clear_eol, + term.clear_eol, + term.clear_eol])) puzzle_paused = True else: timer.unpause() - grid.fill() + grid.draw() old_word = [] puzzle_paused = False @@ -897,7 +965,7 @@ def main(): grid.send_notification("Puzzle reset.") for pos in grid.cells: cell = grid.cells.get(pos) - if cell.is_letter(): + if cell.is_letter: cell.clear() cell.corrected = False cell.revealed = False @@ -918,8 +986,8 @@ def main(): # ctrl-c elif keypress == chr(3): group = grid.get_notification_input( - "Check (l)etter, (w)ord, or (p)uzzle?", - chars=1) + "Check (l)etter, (w)ord, or (p)uzzle?", + char_limit=1) scope = '' if group.lower() == 'l': scope = 'letter' @@ -933,7 +1001,7 @@ def main(): if scope: grid.send_notification("Checked {scope} for errors.". - format(scope=scope)) + format(scope=scope)) else: grid.send_notification("No valid input entered.") @@ -950,7 +1018,7 @@ def main(): grid.send_notification("Puzzle cleared.") for pos in grid.cells: cell = grid.cells.get(pos) - if cell.is_letter(): + if cell.is_letter: cell.clear() grid.draw_cell(pos) old_word = [] @@ -962,8 +1030,8 @@ def main(): # ctrl-r elif keypress == chr(18): group = grid.get_notification_input( - "Reveal (l)etter, (w)ord, or (p)uzzle?", - chars=1) + "Reveal (l)etter, (w)ord, or (p)uzzle?", + char_limit=1) scope = '' if group.lower() == 'l': scope = 'letter' @@ -977,7 +1045,7 @@ def main(): if scope: grid.send_notification("Revealed answers for {scope}.". - format(scope=scope)) + format(scope=scope)) else: grid.send_notification("No valid input entered.") @@ -985,7 +1053,7 @@ def main(): # Letter entry elif not puzzle_complete and keypress.isalnum(): - if not current_cell.is_blankish(): + if not current_cell.is_blankish: overwrite_mode = True current_cell.entry = keypress.upper() @@ -995,18 +1063,24 @@ def main(): modified_since_save = True cursor.advance_within_word(overwrite_mode, wrap_mode=True) - # Delete key - elif not puzzle_complete and keypress.name == 'KEY_DELETE': + # Deletion keys + elif (not puzzle_complete and + keypress.name in ['KEY_BACKSPACE', 'KEY_DELETE']): current_cell.clear() overwrite_mode = True modified_since_save = True - cursor.retreat_within_word(end_placement=True) + if keypress.name == 'KEY_BACKSPACE': + cursor.retreat_within_word(end_placement=True) + elif keypress.name == 'KEY_DELETE': + cursor.advance_within_word(overwrite_mode=True) # Navigation elif (keypress.name in ['KEY_TAB'] or - (cursor.direction == "across" and keypress.name == "KEY_SRIGHT") or - (cursor.direction == "down" and keypress.name == "KEY_SDOWN")): - if current_cell.is_blankish(): + (cursor.direction == "across" and + keypress.name == "KEY_SRIGHT") or + (cursor.direction == "down" and + keypress.name == "KEY_SDOWN")): + if current_cell.is_blankish: cursor.advance_to_next_word(blank_placement=True) else: cursor.advance_within_word(overwrite_mode=False) @@ -1015,54 +1089,62 @@ def main(): cursor.advance_to_next_word() elif (keypress.name in ['KEY_BTAB'] or - (cursor.direction == "across" and keypress.name == "KEY_SLEFT") or - (cursor.direction == "down" and keypress.name == "KEY_SUP")): + (cursor.direction == "across" and + keypress.name == "KEY_SLEFT") or + (cursor.direction == "down" and + keypress.name == "KEY_SUP")): cursor.retreat_within_word(blank_placement=True) elif keypress.name in ['KEY_PGUP']: cursor.retreat_to_previous_word() elif (keypress.name == 'KEY_ENTER' or keypress == ' ' or - (cursor.direction == "across" and - keypress.name in ['KEY_DOWN', 'KEY_UP']) or - (cursor.direction == "down" and - keypress.name in ['KEY_LEFT', 'KEY_RIGHT'])): + (cursor.direction == "across" and + keypress.name in ['KEY_DOWN', 'KEY_UP']) or + (cursor.direction == "down" and + keypress.name in ['KEY_LEFT', 'KEY_RIGHT'])): cursor.switch_direction() if not cursor.current_word(): cursor.switch_direction() elif ((cursor.direction == "across" and - keypress.name == 'KEY_RIGHT') or - (cursor.direction == "down" and - keypress.name == 'KEY_DOWN')): + keypress.name == 'KEY_RIGHT') or + (cursor.direction == "down" and + keypress.name == 'KEY_DOWN')): cursor.advance() elif ((cursor.direction == "across" and - keypress.name == 'KEY_LEFT') or - (cursor.direction == "down" and - keypress.name == 'KEY_UP')): + keypress.name == 'KEY_LEFT') or + (cursor.direction == "down" and + keypress.name == 'KEY_UP')): cursor.retreat() elif (keypress in ['}', ']'] or - (cursor.direction == "across" and keypress.name == 'KEY_SDOWN') or - (cursor.direction == "down" and keypress.name == 'KEY_SRIGHT')): + (cursor.direction == "across" and + keypress.name == 'KEY_SDOWN') or + (cursor.direction == "down" and + keypress.name == 'KEY_SRIGHT')): cursor.advance_perpendicular() - if (keypress == '}' and blank_cells_remaining): - while not grid.cells.get(cursor.position).is_blankish(): + + if (keypress == '}' and grid.blank_cells_remaining): + while not grid.cells.get(cursor.position).is_blankish: cursor.advance_perpendicular() elif (keypress in ['{', '['] or - (cursor.direction == "across" and keypress.name == 'KEY_SUP') or - (cursor.direction == "down" and keypress.name == 'KEY_SLEFT')): + (cursor.direction == "across" and + keypress.name == 'KEY_SUP') or + (cursor.direction == "down" and + keypress.name == 'KEY_SLEFT')): cursor.retreat_perpendicular() - if (keypress == '{' and blank_cells_remaining): - while not grid.cells.get(cursor.position).is_blankish(): + + if (keypress == '{' and grid.blank_cells_remaining): + while not grid.cells.get(cursor.position).is_blankish: cursor.retreat_perpendicular() - print(term.exit_fullscreen()) + echo(term.exit_fullscreen()) if __name__ == '__main__': diff --git a/cursewords/printer.py b/cursewords/printer.py new file mode 100644 index 0000000..defbda2 --- /dev/null +++ b/cursewords/printer.py @@ -0,0 +1,70 @@ +import math +import sys +import textwrap + +def printer_output(grid, style=None, width=None, downs_only=False): + print_width = width or (92 if not sys.stdout.isatty() + else min(grid.term.width, 96)) + + clue_lines = ['ACROSS', ''] + clue_lines.extend(['. '.join([str(entry['num']), entry['clue'].strip()]) + for entry in grid.clues['across']]) + clue_lines.append('') + + if downs_only: + clue_lines = [] + + clue_lines.extend(['DOWN', '']) + clue_lines.extend(['. '.join([str(entry['num']), entry['clue'].strip()]) + for entry in grid.clues['down']]) + + render_args = {'blank': style == 'blank', 'solution': style == 'solution'} + + grid_lines = [grid.term.strip(l) for l in + grid.render_grid(**render_args)] + grid_lines.append('') + + if print_width < len(grid_lines[0]): + sys.exit(f'Puzzle is {len(grid_lines[0])} columns wide, ' + f'cannot be printed at {print_width} columns.') + + print_width = min(print_width, 2 * len(grid_lines[0])) + + print(f'{grid.title} - {grid.author}') + print() + + current_clue = [] + current_line = '' + f_width = print_width - len(grid_lines[0]) - 2 + + if f_width > 12: + while grid_lines: + current_clue = (current_clue or + textwrap.wrap(clue_lines.pop(0), f_width) or + ['']) + current_line = current_clue.pop(0) + current_grid_line = grid_lines.pop(0) + print(f'{current_line:{f_width}.{f_width}} {current_grid_line}') + else: + print('\n'.join(grid_lines)) + + remainder = ' '.join(current_clue) + if remainder: + clue_lines.insert(0, remainder) + + wrapped_clue_lines = [] + num_cols = 3 if print_width > 64 else 2 + column_width = print_width // num_cols - 2 + for l in clue_lines: + if len(l) < column_width: + wrapped_clue_lines.append(l) + else: + wrapped_clue_lines.extend(textwrap.wrap(l, width=column_width)) + + num_wrapped_rows = math.ceil(len(wrapped_clue_lines)/num_cols) + + for r in range(num_wrapped_rows): + clue_parts = [wrapped_clue_lines[i] for i in + range(r, len(wrapped_clue_lines), num_wrapped_rows)] + current_row = ' '.join([f'{{:{column_width}}}'] * len(clue_parts)) + print(current_row.format(*clue_parts)) diff --git a/cursewords/puz.py b/cursewords/puz.py new file mode 100644 index 0000000..3754448 --- /dev/null +++ b/cursewords/puz.py @@ -0,0 +1,768 @@ +# pylint: skip-file + +import functools +import operator +import math +import string +import struct +import sys + +__title__ = 'puzpy' +__version__ = '0.2.3' +__author__ = 'Alex DeJarnatt' +__author_email__ = 'adejarnatt@gmail.com' +__maintainer__ = 'Simeon Visser' +__maintainer_email__ = 'simeonvisser@gmail.com' +__license__ = 'MIT' +__copyright__ = 'Copyright 2009 Alex DeJarnatt' + + +PY3 = sys.version_info[0] >= 3 + +if PY3: + str = str + range = range +else: + str = unicode # noqa: F821 + range = xrange # noqa: F821 + +HEADER_FORMAT = '''< + H 11s xH + Q 4s 2sH + 12s BBH + H H ''' + +HEADER_CKSUM_FORMAT = '= 1.3) + if self.title: + cksum = data_cksum(self.encode_zstring(self.title), cksum) + if self.author: + cksum = data_cksum(self.encode_zstring(self.author), cksum) + if self.copyright: + cksum = data_cksum(self.encode_zstring(self.copyright), cksum) + + for clue in self.clues: + if clue: + cksum = data_cksum(self.encode(clue), cksum) + + # notes included in global cksum starting v1.3 of format + if self.version_tuple() >= (1, 3) and self.notes: + cksum = data_cksum(self.encode_zstring(self.notes), cksum) + + return cksum + + def global_cksum(self): + cksum = self.header_cksum() + cksum = data_cksum(self.encode(self.solution), cksum) + cksum = data_cksum(self.encode(self.fill), cksum) + cksum = self.text_cksum(cksum) + # extensions do not seem to be included in global cksum + return cksum + + def magic_cksum(self): + cksums = [ + self.header_cksum(), + data_cksum(self.encode(self.solution)), + data_cksum(self.encode(self.fill)), + self.text_cksum() + ] + + cksum_magic = 0 + for (i, cksum) in enumerate(reversed(cksums)): + cksum_magic <<= 8 + cksum_magic |= ( + ord(MASKSTRING[len(cksums) - i - 1]) ^ (cksum & 0x00ff) + ) + cksum_magic |= ( + (ord(MASKSTRING[len(cksums) - i - 1 + 4]) ^ (cksum >> 8)) << 32 + ) + + return cksum_magic + + +class PuzzleBuffer: + """PuzzleBuffer class + wraps a data buffer ('' or []) and provides .puz-specific methods for + reading and writing data + """ + def __init__(self, data=None, encoding=ENCODING): + self.data = data or [] + self.encoding = encoding + self.pos = 0 + + def can_read(self, n_bytes=1): + return self.pos + n_bytes <= len(self.data) + + def length(self): + return len(self.data) + + def read(self, n_bytes): + start = self.pos + self.pos += n_bytes + return self.data[start:self.pos] + + def read_to_end(self): + start = self.pos + self.pos = self.length() + return self.data[start:self.pos] + + def read_string(self): + return self.read_until(b'\0') + + def read_until(self, c): + start = self.pos + self.seek_to(c, 1) # read past + return str(self.data[start:self.pos-1], self.encoding) + + def seek_to(self, s, offset=0): + try: + self.pos = self.data.index(s, self.pos) + offset + return True + except ValueError: + # s not found, advance to end + self.pos = self.length() + return False + + def write(self, s): + self.data.append(s) + + def write_string(self, s): + s = s or '' + self.data.append(s.encode(self.encoding, ENCODING_ERRORS) + b'\0') + + def pack(self, struct_format, *values): + self.data.append(struct.pack(struct_format, *values)) + + def can_unpack(self, struct_format): + return self.can_read(struct.calcsize(struct_format)) + + def unpack(self, struct_format): + start = self.pos + try: + res = struct.unpack_from(struct_format, self.data, self.pos) + self.pos += struct.calcsize(struct_format) + return res + except struct.error: + message = 'could not unpack values at {} for format {}'.format( + start, struct_format + ) + raise PuzzleFormatError(message) + + def tobytes(self): + return b''.join(self.data) + + +# clue numbering helper + +class DefaultClueNumbering: + def __init__(self, grid, clues, width, height): + self.grid = grid + self.clues = clues + self.width = width + self.height = height + + # compute across & down + a = [] + d = [] + c = 0 + n = 1 + for i in range(0, len(grid)): + if not is_blacksquare(grid[i]): + lastc = c + is_across = self.col(i) == 0 or is_blacksquare(grid[i - 1]) + if is_across and self.len_across(i) > 1: + a.append({ + 'num': n, + 'clue': clues[c], + 'clue_index': c, + 'cell': i, + 'len': self.len_across(i) + }) + c += 1 + is_down = self.row(i) == 0 or is_blacksquare(grid[i - width]) + if is_down and self.len_down(i) > 1: + d.append({ + 'num': n, + 'clue': clues[c], + 'clue_index': c, + 'cell': i, + 'len': self.len_down(i) + }) + c += 1 + if c > lastc: + n += 1 + + self.across = a + self.down = d + + def col(self, index): + return index % self.width + + def row(self, index): + return int(math.floor(index / self.width)) + + def len_across(self, index): + for c in range(0, self.width - self.col(index)): + if is_blacksquare(self.grid[index + c]): + return c + return c + 1 + + def len_down(self, index): + for c in range(0, self.height - self.row(index)): + if is_blacksquare(self.grid[index + c*self.width]): + return c + return c + 1 + + +class Rebus: + def __init__(self, puzzle): + self.puzzle = puzzle + # parse rebus data + rebus_data = self.puzzle.extensions.get(Extensions.Rebus, b'') + self.table = parse_bytes(rebus_data) + r_sol_data = self.puzzle.extensions.get(Extensions.RebusSolutions, b'') + solutions_str = r_sol_data.decode(puzzle.encoding) + fill_data = self.puzzle.extensions.get(Extensions.RebusFill, b'') + fill_str = fill_data.decode(puzzle.encoding) + self.solutions = dict( + (int(item[0]), item[1]) + for item in parse_dict(solutions_str).items() + ) + self.fill = dict( + (int(item[0]), item[1]) + for item in parse_dict(fill_str).items() + ) + + def has_rebus(self): + return Extensions.Rebus in self.puzzle.extensions + + def is_rebus_square(self, index): + return bool(self.table[index]) + + def get_rebus_squares(self): + return [i for i, b in enumerate(self.table) if b] + + def get_rebus_solution(self, index): + if self.is_rebus_square(index): + return self.solutions[self.table[index] - 1] + return None + + def get_rebus_fill(self, index): + if self.is_rebus_square(index): + return self.fill[self.table[index] - 1] + return None + + def set_rebus_fill(self, index, value): + if self.is_rebus_square(index): + self.fill[self.table[index] - 1] = value + + def save(self): + if self.has_rebus(): + # commit changes back to puzzle.extensions + self.puzzle.extensions[Extensions.Rebus] = pack_bytes(self.table) + rebus_solutions = self.puzzle.encode(dict_to_string(self.solutions)) + self.puzzle.extensions[Extensions.RebusSolutions] = rebus_solutions + rebus_fill = self.puzzle.encode(dict_to_string(self.fill)) + self.puzzle.extensions[Extensions.RebusFill] = rebus_fill + + +class Markup: + def __init__(self, puzzle): + self.puzzle = puzzle + # parse markup data + markup_data = self.puzzle.extensions.get(Extensions.Markup, b'') + self.markup = parse_bytes(markup_data) + + def has_markup(self): + return any(bool(b) for b in self.markup) + + def get_markup_squares(self): + return [i for i, b in enumerate(self.markup) if b] + + def is_markup_square(self, index): + return bool(self.table[index]) + + def save(self): + if self.has_markup(): + self.puzzle.extensions[Extensions.Markup] = pack_bytes(self.markup) + + +# helper functions for cksums and scrambling +def data_cksum(data, cksum=0): + for b in data: + if isinstance(b, bytes): + b = ord(b) + # right-shift one with wrap-around + lowbit = (cksum & 0x0001) + cksum = (cksum >> 1) + if lowbit: + cksum = (cksum | 0x8000) + + # then add in the data and clear any carried bit past 16 + cksum = (cksum + b) & 0xffff + + return cksum + + +def replace_chars(s, chars, replacement=''): + for ch in chars: + s = s.replace(ch, replacement) + return s + + +def scramble_solution(solution, width, height, key, ignore_chars=BLACKSQUARE): + sq = square(solution, width, height) + data = restore(sq, scramble_string(replace_chars(sq, ignore_chars), key)) + return square(data, height, width) + + +def scramble_string(s, key): + """ + s is the puzzle's solution in column-major order, omitting black squares: + i.e. if the puzzle is: + C A T + # # A + # # R + solution is CATAR + + + Key is a 4-digit number in the range 1000 <= key <= 9999 + + """ + key = key_digits(key) + for k in key: # foreach digit in the key + s = shift(s, key) # for each char by each digit in the key in sequence + s = s[k:] + s[:k] # cut the sequence around the key digit + s = shuffle(s) # do a 1:1 shuffle of the 'deck' + + return s + + +def unscramble_solution(scrambled, width, height, key, ignore_chars=BLACKSQUARE): + # width and height are reversed here + sq = square(scrambled, width, height) + data = restore(sq, unscramble_string(replace_chars(sq, ignore_chars), key)) + return square(data, height, width) + + +def unscramble_string(s, key): + key = key_digits(key) + l = len(s) # noqa: E741 + for k in key[::-1]: + s = unshuffle(s) + s = s[l-k:] + s[:l-k] + s = unshift(s, key) + + return s + + +def scrambled_cksum(scrambled, width, height, ignore_chars=BLACKSQUARE, encoding=ENCODING): + data = replace_chars(square(scrambled, width, height), ignore_chars) + return data_cksum(data.encode(encoding, ENCODING_ERRORS)) + + +def key_digits(key): + return [int(c) for c in str(key).zfill(4)] + + +def square(data, w, h): + aa = [data[i:i+w] for i in range(0, len(data), w)] + return ''.join( + [''.join([aa[r][c] for r in range(0, h)]) for c in range(0, w)] + ) + + +def shift(s, key): + atoz = string.ascii_uppercase + return ''.join( + atoz[(atoz.index(c) + key[i % len(key)]) % len(atoz)] + for i, c in enumerate(s) + ) + + +def unshift(s, key): + return shift(s, [-k for k in key]) + + +def shuffle(s): + mid = int(math.floor(len(s) / 2)) + items = functools.reduce(operator.add, zip(s[mid:], s[:mid])) + return ''.join(items) + (s[-1] if len(s) % 2 else '') + + +def unshuffle(s): + return s[1::2] + s[::2] + + +def restore(s, t): + """ + s is the source string, it can contain '.' + t is the target, it's smaller than s by the number of '.'s in s + + Each char in s is replaced by the corresponding + char in t, jumping over '.'s in s. + + >>> restore('ABC.DEF', 'XYZABC') + 'XYZ.ABC' + """ + t = (c for c in t) + return ''.join(next(t) if not is_blacksquare(c) else c for c in s) + + +def is_blacksquare(c): + if isinstance(c, int): + c = chr(c) + return c in [BLACKSQUARE, BLACKSQUARE2] + + +# +# functions for parsing / serializing primitives +# + + +def parse_bytes(s): + return list(struct.unpack('B' * len(s), s)) + + +def pack_bytes(a): + return struct.pack('B' * len(a), *a) + + +# dict string format is k1:v1;k2:v2;...;kn:vn; +# (for whatever reason there's a trailing ';') +def parse_dict(s): + return dict(p.split(':') for p in s.split(';') if ':' in p) + + +def dict_to_string(d): + return ';'.join(':'.join(map(str, [k, v])) for k, v in d.items()) + ';' diff --git a/cursewords/version b/cursewords/version index 66c4c22..9459d4b 100644 --- a/cursewords/version +++ b/cursewords/version @@ -1 +1 @@ -1.0.9 +1.1 diff --git a/demo.gif b/demo.gif index 8e4e62f..c17ab00 100644 Binary files a/demo.gif and b/demo.gif differ diff --git a/requirements.txt b/requirements.txt index ba25d0e..e89a4c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -blessed==1.16.1 -puzpy==0.2.4 +blessed==1.18.1