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