Skip to content

Commit

Permalink
Added functionality to launch and view a crossword
Browse files Browse the repository at this point in the history
The `CrosswordBrowser` class in `main.py` now allows the user to select a crossword (read from `src/cwords/<name>`) and choose a word count preference (either the maximum amount of words for that crossword or a specified amount, starting from 3).

The `load_selected_cword` method in `CrosswordBrowser` gathers the required instance attributes for the selected crossword (chosen word count and name), reads the selected crosswords definitions using `CrosswordHelper.load_definitions`, instantiates the crossword, then the best crossword is found using `CrosswordHelper.find_best_crossword`.

`_configure_optionmenu_state` was implemented to appropriately disable and normalise the state of the custom word count optionmenu so the user cannot select both a maximum word count preference and a custom word count preference.

`on_cword_selection` configures the radiobuttons to display accurate information pertaining to the selected crossword. Whenever a new crossword is selected, `self.selected_cword_name` and `self.selected_cword_word_count` are updated so the program is always ready to instantiate a crossword object when the user is ready.

`CrosswordGame`, which inherits from `CrosswordBrowser`, is a ctk toplevel that fills the users screen and generates an empty version of the generated crossword.

`_make_ref_grid` creates a grid without the word characters, which will be ideal for assigning user input to.

`_make_cells` populates `self.cword_container` with either black or white squares (white being a cell where a character will be).
  • Loading branch information
tomasvana10 committed Jan 17, 2024
1 parent 2a27162 commit 53a2d67
Show file tree
Hide file tree
Showing 10 changed files with 272 additions and 104 deletions.
2 changes: 1 addition & 1 deletion src/config.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[m]
scale = 1.0
appearance = light
appearance = dark
theme = dark-blue
language = en

Expand Down
14 changes: 8 additions & 6 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ class Global:
class Light:
MAIN = "#B0BEC5"
SUB = "#CFD8DC"
TEXT_DISABLED = "#999999"

class Dark:
MAIN = "#263238"
SUB = "#37474F"
TEXT_DISABLED = "#737373"


class Difficulties:
class CrosswordDifficulties:
DIFFICULTIES = ["Easy", "Medium", "Hard", "Extreme"]


Expand All @@ -34,7 +36,7 @@ class Fonts:
BOLD_LABEL_FONT = {"size": 14, "weight": "bold", "slant": "roman"}


class LanguageReplacements:
class LanguageReplacementsForPybabel:
REPLACEMENTS = {
"zh-cn": "zh",
"zh-tw": None,
Expand All @@ -44,18 +46,18 @@ class LanguageReplacements:
}


class Directions:
class CrosswordDirections:
ACROSS = "a"
DOWN = "d"


class Style:
class CrosswordStyle:
EMPTY = "▮"


class Restrictions:
class CrosswordRestrictions:
KEEP_LANGUAGES_PATTERN = r"\PL" # The opposite of \p{l} which matches characters from any language


class OtherConstants:
class BaseEngStrings:
BASE_ENG_APPEARANCES = ["light", "dark", "system"]
97 changes: 52 additions & 45 deletions src/cword_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import regex # Similar to "re" module but with more functionality

from constants import Directions, Style, Restrictions, Paths
from constants import CrosswordDirections, CrosswordStyle, CrosswordRestrictions, Paths
from errors import (
EmptyDefinitions, InsufficientDefinitionsAndOrWordCount, ShorterDefinitionsThanWordCount,
InsufficientWordLength, EscapeCharacterInWord, AlreadyGeneratedCrossword,
Expand Down Expand Up @@ -57,7 +57,8 @@ def __str__(self):
if not self.generated:
raise PrintingCrosswordObjectBeforeGeneration

return f"\nCrossword name: {self.name}\n" + \
return \
f"\nCrossword name: {self.name}\n" + \
f"Word count: {self.inserts}, Failed insertions: {self.word_count - self.inserts}\n" + \
f"Total intersections: {self.total_intersections}\n\n" + \
"\n".join(" ".join(cell for cell in row) for row in self.grid) + "\n\n" + \
Expand All @@ -67,7 +68,7 @@ def generate(self):
'''Create an "EMPTY" two-dimensional array then populate it.'''
if not self.generated:
self.generated = True
self._initialise_cword_grid()
self.grid = self._initialise_cword_grid()
self._populate_grid(list(self.definitions.keys()))
else:
raise AlreadyGeneratedCrossword
Expand All @@ -80,62 +81,66 @@ def _format_definitions(self, definitions):
return dict(random.sample(list(definitions.items()), len(definitions)))
else:
randomly_sampled_definitions = dict(random.sample(list(definitions.items()), self.word_count))
formatted_definitions = {regex.sub(Restrictions.KEEP_LANGUAGES_PATTERN, "", k) .upper(): v \
formatted_definitions = {regex.sub(CrosswordRestrictions.KEEP_LANGUAGES_PATTERN,
"", k).upper(): v \
for k, v in randomly_sampled_definitions.items()}
return formatted_definitions

def _find_dimensions(self):
'''Determine the square dimensions of the crossword based on total word count or maximum
word length.
'''
total_char_count = sum(len(word) for word in self.definitions.keys())
dimensions = math.ceil(math.sqrt(total_char_count * 1.85)) + 1
self.total_char_count = sum(len(word) for word in self.definitions.keys())
dimensions = math.ceil(math.sqrt(self.total_char_count * 1.85)) + 1
if dimensions < (max_word_len := (len(max(self.definitions.keys(), key=len)))):
dimensions = max_word_len

return dimensions

def _initialise_cword_grid(self):
'''Make a two-dimensional array of "EMPTY" characters.'''
self.grid = [[Style.EMPTY for i in range(self.dimensions)] for j in range(self.dimensions)]
grid = [[CrosswordStyle.EMPTY for column in range(self.dimensions)] \
for row in range(self.dimensions)]

return grid

def _place_word(self, word, direction, row, column):
'''Place a word in the grid at the given row, column and direction.'''
if direction == Directions.ACROSS:
if direction == CrosswordDirections.ACROSS:
for i in range(len(word)):
self.grid[row][column + i] = word[i]

if direction == Directions.DOWN:
if direction == CrosswordDirections.DOWN:
for i in range(len(word)):
self.grid[row + i][column] = word[i]

def _find_first_word_placement_position(self, word):
'''Place the first word in a random orientation in the middle of the grid.'''
direction = random.choice([Directions.ACROSS, Directions.DOWN])
direction = random.choice([CrosswordDirections.ACROSS, CrosswordDirections.DOWN])
middle = self.dimensions // 2

if direction == Directions.ACROSS:
if direction == CrosswordDirections.ACROSS:
row = middle
column = middle - len(word) // 2
return {"word": word, "direction": Directions.ACROSS,
return {"word": word, "direction": CrosswordDirections.ACROSS,
"pos": (row, column), "intersections": list()}

if direction == Directions.DOWN:
if direction == CrosswordDirections.DOWN:
row = middle - len(word) // 2
column = middle
return {"word": word, "direction": Directions.DOWN,
return {"word": word, "direction": CrosswordDirections.DOWN,
"pos": (row, column), "intersections": list()}

def _find_intersections(self, word, direction, row, column):
'''Find the indexes of all points of intersection that the parameter "word" has with the grid.'''
intersections = list()

if direction == Directions.ACROSS:
if direction == CrosswordDirections.ACROSS:
for i in range(len(word)):
if self.grid[row][column + i] == word[i]:
intersections.append(tuple([row, column + i]))

if direction == Directions.DOWN:
if direction == CrosswordDirections.DOWN:
for i in range(len(word)):
if self.grid[row + i][column] == word[i]:
intersections.append(tuple([row + i, column]))
Expand All @@ -150,23 +155,23 @@ def _can_word_be_inserted(self, word, direction, row, column):
3. The word intersects with another word of the same orientation at its final letter,
e.x. ATHENSOFIA (Athens + Sofia)
'''
if direction == Directions.ACROSS: # 1
if direction == CrosswordDirections.ACROSS: # 1
if column + len(word) > self.dimensions:
return False

for i in range(len(word)): # 2
if self.grid[row][column + i] not in [Style.EMPTY, word[i]]:
if self.grid[row][column + i] not in [CrosswordStyle.EMPTY, word[i]]:
return False

if word[0] == self.grid[row][column] or word[-1] == self.grid[row][column + len(word) - 1]: # 3
return False

if direction == Directions.DOWN:
if direction == CrosswordDirections.DOWN:
if row + len(word) > self.dimensions:
return False

for i in range(len(word)):
if self.grid[row + i][column] not in [Style.EMPTY, word[i]]:
if self.grid[row + i][column] not in [CrosswordStyle.EMPTY, word[i]]:
return False

if word[0] == self.grid[row][column] or word[-1] == self.grid[row + len(word) - 1][column]:
Expand All @@ -188,7 +193,7 @@ def _prune_placements_for_readability(self, placements):
row, column = placement["pos"]
readability_flags = 0

if placement["direction"] == Directions.ACROSS:
if placement["direction"] == CrosswordDirections.ACROSS:
check_above = row != 0
check_below = row != self.dimensions - 1
check_left = column != 0
Expand All @@ -197,19 +202,19 @@ def _prune_placements_for_readability(self, placements):
if (row, column + i) in placement["intersections"]:
continue
if check_above:
if self.grid[row - 1][column + i] != Style.EMPTY:
if self.grid[row - 1][column + i] != CrosswordStyle.EMPTY:
readability_flags += 1
if check_below:
if self.grid[row + 1][column + i] != Style.EMPTY:
if self.grid[row + 1][column + i] != CrosswordStyle.EMPTY:
readability_flags += 1
if check_left and i == 0:
if self.grid[row][column - 1] != Style.EMPTY:
if self.grid[row][column - 1] != CrosswordStyle.EMPTY:
readability_flags += 1
if check_right and i == word_length - 1:
if self.grid[row][column + i + 1] != Style.EMPTY:
if self.grid[row][column + i + 1] != CrosswordStyle.EMPTY:
readability_flags += 1

if placement["direction"] == Directions.DOWN:
if placement["direction"] == CrosswordDirections.DOWN:
check_above = row != 0
check_below = row + word_length < self.dimensions
check_left = column != 0
Expand All @@ -218,16 +223,16 @@ def _prune_placements_for_readability(self, placements):
if (row + i, column) in placement["intersections"]:
continue
if check_above and i == 0:
if self.grid[row - 1][column] != Style.EMPTY:
if self.grid[row - 1][column] != CrosswordStyle.EMPTY:
readability_flags += 1
if check_below and i == word_length - 1:
if self.grid[row + i + 1][column] != Style.EMPTY:
if self.grid[row + i + 1][column] != CrosswordStyle.EMPTY:
readability_flags += 1
if check_left:
if self.grid[row + i][column - 1] != Style.EMPTY:
if self.grid[row + i][column - 1] != CrosswordStyle.EMPTY:
readability_flags += 1
if check_right:
if self.grid[row + i][column + 1] != Style.EMPTY:
if self.grid[row + i][column + 1] != CrosswordStyle.EMPTY:
readability_flags += 1

if not readability_flags:
Expand All @@ -245,19 +250,21 @@ def _find_insertion_coords(self, word):

for row in range(self.dimensions):
for column in range(self.dimensions):
if self._can_word_be_inserted(word, Directions.ACROSS, row, column):
intersections = self._find_intersections(word, Directions.ACROSS, row, column)
if self._can_word_be_inserted(word, CrosswordDirections.ACROSS, row, column):
intersections = self._find_intersections(word, CrosswordDirections.ACROSS,
row, column)
placements.append({
"word": word,
"direction": Directions.ACROSS,
"direction": CrosswordDirections.ACROSS,
"pos": (row, column),
"intersections": intersections})

if self._can_word_be_inserted(word, Directions.DOWN, row, column):
intersections = self._find_intersections(word, Directions.DOWN, row, column)
if self._can_word_be_inserted(word, CrosswordDirections.DOWN, row, column):
intersections = self._find_intersections(word, CrosswordDirections.DOWN,
row, column)
placements.append({
"word": word,
"direction": Directions.DOWN,
"direction": CrosswordDirections.DOWN,
"pos": (row, column),
"intersections": intersections})

Expand All @@ -273,10 +280,10 @@ def _add_clue(self, placement, word):

def _add_data(self, placement):
'''Append placement data to either list 0 or 1 (across or down) in the self.data array.'''
if placement["direction"] == Directions.ACROSS:
if placement["direction"] == CrosswordDirections.ACROSS:
self.data[0].append(placement)

elif placement["direction"] == Directions.DOWN:
elif placement["direction"] == CrosswordDirections.DOWN:
self.data[1].append(placement)

def _populate_grid(self, words, insert_backlog=False):
Expand Down Expand Up @@ -346,7 +353,7 @@ def find_best_crossword(crossword):
name = crossword.name
word_count = crossword.word_count

attempts_db = CrosswordHelper._load_attempts_db(Paths.ATTEMPTS_DB_PATH)
attempts_db = CrosswordHelper._load_attempts_db()
max_attempts = attempts_db[str(word_count)] # Get amount of attempts based on word count
attempts = 0

Expand All @@ -369,31 +376,31 @@ def find_best_crossword(crossword):
return best_crossword

@staticmethod
def load_definitions(file_path):
def load_definitions(name):
'''Load a definitions json for a given crossword.'''
try:
with open(file_path, "r") as file:
with open(f"{Paths.CWORDS_PATH}/{name}/{name}.json", "r") as file:
definitions = json.load(file)
except json.decoder.JSONDecodeError:
raise EmptyDefinitions

return definitions

@staticmethod
def _load_attempts_db(file_path):
def _load_attempts_db():
'''Load a json that specifies the amount of attempts a crossword should be recreated based
on the amount of words that crossword will contain.
'''
with open(file_path, "r") as file:
with open(Paths.ATTEMPTS_DB_PATH, "r") as file:
attempts_db = json.load(file)

return attempts_db


if __name__ == "__main__": # Example usage
definitions = CrosswordHelper.load_definitions(f"{Paths.CWORDS_PATH}/capitals/capitals.json")
definitions = CrosswordHelper.load_definitions("capitals")

crossword = Crossword(definitions=definitions, word_count=100, name="Capitals")
crossword = Crossword(definitions=definitions, word_count=3, name="Capitals")
crossword = CrosswordHelper.find_best_crossword(crossword)

# You can also generate a single crossword:
Expand Down
5 changes: 0 additions & 5 deletions src/cwords/capitals_copy/info.json

This file was deleted.

5 changes: 5 additions & 0 deletions src/cwords/this is a long name/info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"total_definitions": 69,
"difficulty": 0,
"symbol": "📌"
}
File renamed without changes.
File renamed without changes.
4 changes: 2 additions & 2 deletions src/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def __init__(self):
super().__init__("All keys in words must not contain an escape character")
class AlreadyGeneratedCrossword(Exception):
def __init__(self):
super().__init__("This crossword object already contains a generated crossword")
super().__init__("This crossword object already contains a generated crossword, view it by printing the crossword object")
class PrintingCrosswordObjectBeforeGeneration(Exception):
def __init__(self):
super().__init__("Call generate() on this instance before printing it")
super().__init__("Call generate() on this object before printing it")
6 changes: 3 additions & 3 deletions src/locale_utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import googletrans
import subprocess

from constants import LanguageReplacements
from constants import LanguageReplacementsForPybabel

def parse_langcodes(langcodes):
parsed_langcodes = langcodes
for replacement in LanguageReplacements.REPLACEMENTS:
for replacement in LanguageReplacementsForPybabel.REPLACEMENTS:
parsed_langcodes.remove(replacement)
if (sub := (LanguageReplacements.REPLACEMENTS[replacement])):
if (sub := (LanguageReplacementsForPybabel.REPLACEMENTS[replacement])):
parsed_langcodes.append(sub)

parsed_langcodes.append("en")
Expand Down
Loading

0 comments on commit 53a2d67

Please sign in to comment.