#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ PySudoku v0.2 by Adrien MALINGREY Sudoku game assistant Tested on Windows 10 with Python 3.6.3 and Linux Mint 18 with Python 3.5.1 """ try: import gettext from sys import argv, exit from tkinter import * from tkinter import ttk from tkinter.filedialog import askopenfilename, asksaveasfilename from tkinter.messagebox import showinfo, showerror, askokcancel, askyesnocancel from pickle import Pickler, Unpickler, UnpicklingError from itertools import combinations from os.path import basename, dirname, exists from random import sample, shuffle from webbrowser import open as open_web_browser from string import digits except ImportError as e: exit(e.msg) # Translation try: gettext.find('PySudoku') translator = gettext.translation('PySudoku', localedir='locale') except FileNotFoundError: gettext.install('Translation not found') else: translator.install('PySudoku') # Labels APP_TITLE = _("PySudoku") GRID_LABEL = _("Grid") GENERATE_LABEL = _("Generate...") CREATE_LABEL = _("Create") EDIT_LABEL = _("Edit") VALIDATE_LABEL = _("Validate") SOLVE_LABEL = _("Solve") GAME_LABEL = _("Game") LOAD_LABEL = _("Load...") SAVE_LABEL = _("Save...") RESTART_LABEL = _("Restart...") VIEW_LABEL = _("View") THEME_LABEL = _("Theme") SHOW_TIPS_LABEL = _("Show tips") SHOW_CONFLICTS_LABEL = _("Show conflicts") HELP_LABEL = _("?") WIKI_LABEL = _("Wikipedia") ABOUT_LABEL = _("About...") GENERATE_PROGRESS_BOX_TITLE = _("Grid generation") GENERATE_PROGRESS_BOX_TEXT = _("Generating a new grid...") CLUES_DELETING_PROGRESS_BOX_TEXT = _("Deleting clues...") CANCEL_AUTO_CREATE_BUTTON_TEXT = _("Stop") VALIDATION_PROGRESS_BOX_TITLE = _("Grid validation") VALIDATION_PROGRESS_BOX_TEXT = _("Checking if grid has a solution...") NO_SOLUTION_MESSAGE_BOX_TITLE = _("Can't solve grid") NO_SOLUTION_MESSAGE_BOX_TEXT = ( _("Some boxes have no solution. " "Please correct it.") ) NO_OTHER_SOLUTION_PROGESS_BOX_TEXT =_( "Checking if grid has other solutions...") INCORRECT_GRID_MESSAGE_BOX_TITLE = _("Incorrect grid") SEVERAL_SOLUTIONS_MESSAGE_BOX_TEXT = _("The grid has several solutions.") CONFLICTS_MESSAGE_BOX_TEXT = ( _("Some boxes from the same row, column or region " "have same digit. Please correct them.") ) SOLVING_PROGRESS_BOX_TITLE = _("Solving grid") CALCULATING_SURE_DIGITS_TEXT = _("Calculating sure digits...") TESTS_PROGRESS_BOX_TEXT = _("Test: {} on {}") NO_SOLUTION_EXCEPTION = ( _("There are some error. " "Please correct them to solve the grid.") ) CANCEL_EXCEPTION = _("Cancelled") SOLVED_GRID_MESSAGE_BOX_TITLE = _("Congratulations!") SOLVED_GRID_MESSAGE_BOX_TEXT = _("The grid is solved.") NB_CLUES_MESSAGE_BOX_TITLE = _("Generate a new grid") NB_CLUES_INPUT_LABEL = _("Please enter minimum number of clues:") HARDER_LABEL = _("← harder") EASIER_LABEL = _("easier →") CANCEL_BUTTON_TEXT = _("Cancel") OK_BUTTON_TEXT = _("OK") CANCELLED_PROGRESS_BOX_TEXT = _("Cancelling...") STOPPING_PROGRESS_BOX_TEXT = _("Stopping...") CONFIRM_ERASE_MESSAGE_BOX_TITLE = _("Erase current game?") CONFIRM_ERASE_MESSAGE_BOX_TEXT = _("A game is in progress. Do you want to erase it?") OPEN_FILE_MESSAGE_BOX_TITLE = _("Open game") FILE_TYPE_NAME = _("PySudoku game") FILE_ERROR_MESSAGE_BOX_TITLE = _("File error") CORRUPTED_FILE_MESSAGE_BOX_TEXT = _("The file {} can't be read.") FILE_NOT_FOUND_MESSAGE_BOX_TEXT = _("The file {} can't be found.") SAVE_FILE_MESSAGE_BOX_TITLE = _("Save game") WIKI_URL = _("https://en.wikipedia.org/wiki/Sudoku") ABOUT_MESSAGE_BOX_TITLE = _("About PySudoku") ABOUT_MESSAGE_BOX_TEXT = _("Author: Adrien Malingrey\n" "Licence: MIT") CLOSE_MESSAGE_BOX_TITLE = _("Save game?") CLOSE_MESSAGE_BOX_TITLE = _("A game is in progress. Would you like to save it?") # 16x16, 32x32, 48x48 gif images encoded in base64 ICON16 = """ R0lGODlhEAAQAPcAAAAAAGlpaW1tbe0cJKCgoMDAgODggOPj4/Dw8P///wAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAEAAQAAAIfAADCBxI sOCBAAgSIiCgcKGBhwYOIhAgYOHEigQgRkRI0WLHjBAlKmQ4UuNBAihTqlR5MIHLBARewixAs0DL lzEBAJhJ0WYAmTl3EijQ86bLmDhr+lzJNOVBpUNrgnz4tOdQqwYGDNhIVECBq14zauUqFarJA2jT ql17ICAAOw==""" ICON32 = """ R0lGODlhIAAgAPcAAAAAAGlpaX9/f+0cJL23a6CgoPDmjOPj4/Dw8P///wAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAIAAgAAAI/wALCBxI sKDBgwMTFAjAsGHDAw4jQoz4UGEABBgzYoSoUSNEAyBDgjxgsWNGjiYRfBQZkuTCjAIEnLyIMeZM li1L1pS5kSYCmz1xjtT5k6dKn0CPCjXgEqlRlEVvCm2a8mjVlTippoTaEStLlwfCih1LtqzZsBYp Mpyoli3Fpgniyo0Lce5ciATy6s0L1y7dAH7/7t3b12/duAAAyMWbN6bewnYPJ0i8OIBex3wtBk4g mbLgxgIeaw7cWfFnApgJQL4LmLTlwZkXbpYc+TVssGdz60a7UG0AtxJ9/7YIm6/twV5FNi2u+jjh AEuXg35sOzXWAQNyLrwc2jh36iGxaymv3r35d+Phsw/dPt17++bpxzNnfBv6VOLF6SO3n1Xh7v9m bSbggH4FBAA7""" ICON48 = """ R0lGODlhMAAwAPcAAAIAAAAADgkKCgAAEgkXHh0LAAAAOQQVJQcMcAMVeAQdfiI/am0GA20/IV9F K19ebX97RWNjbWRqbGhhaGtjbm9lbW9pZ2hoa2hob29qbW1sbGdwbGp1bHFqbHppbnptbX1obHZp cXJpenJza3V+a31ybHt6bHFycXR0czl8uxJ4xV1yi2VzgWp7jHJ5kWx8p22Aan2NanyQa16Eg3WF l26ArXONunSev3WhyKFNDodobId3bJ1vcJR4dP4BAPwLB/8AHvoUBfkjCP4gLf4vJf4mNcd1I/VR G/ZEJfRxJf1BRftvVJR6kIuJV4qJW4yKWIWTa4uaapaCbJyJbJSXapWfap6aa5+oaqSdXaSJbKyG eqmKeaeVbKKUdLqOcrCSbLebbLeQfKGtaq6oa6Kwaa60abKiarGqa7usarS5abmzZ7qyarq0aLy2 ari5ab25ab+5bMGBU9afVsCqa8+gYM2kdsCyasG5asC6bcG8asK8bvKdQveOb/iOcO7VXe/Yc//G eoCAg4GBhIaGhoWHiIeOjY2Oh4iIiIqKjJqQhpeXl5efn5+fl5iYmJ6enpmZq52jop2sq7CcmqKj naCgoKSkpKqqqq2trauyuLiypbGxsbW1tbm5uZ680Yy55abB1qjL36jM2KTW6bzc7L/c9sOqisuw kt2zh9W9mMq0pd/PktPBtfLLi+PPq+XVs+7fs+/lie/mje7rjeXlne/jlu/tku71lO73mO74lvDl i/DljPLoivLpjfbsjvDzkObmoOvrpfHwoPX0sfv7sPz6tvz8sP78t///vsnJycPM09PMxNLPz9LS 1drW1cHf7cjX5Mb//9Hp89fs9dfv+d3q8d7u8tX28d/w/+XZyvTdxO7hw+Hh2ejn3eno3vTlxfLj 0PT33+Xl5O7u7u7v8OD9+Ony8uv18u7w8O/w9PDu5fDv7vDw5vDw7vL07Pj37f/z7P/+5fDw8PHx 9fL08PXy8PT09PH0+PX7+Pf///j08f369vr6+vr+////+f7+/gAAACwAAAAAMAAwAAAI/wAVHRpI sKDBQQYTEkSoMGGgcIMaVZpIsSJFSpcsapzoyB2WjyBDhhQm71CleChTqkwpjlK9lTDjievGS5fN mzhvEit5MubKli99sqSZs6jNnSZTspuX6tCjeONYumSpzVAhZ1FRzqxpNCfSnvHQmWrBqcsLc1KD xss2AVMmCtGGcu2qk2fKdpKmnbvnQVralKdw5LNXx5Ncuji/rlTXLMwNen9ReguRTJkIa4cRH7Wr Mh0hFDTKndM6VWuPEydcZJVJVLMuxSpHx9PSKTI7L6FQorIhe6vr15zjgWNC7py8LZ9s8wBFT16r Gr1ba4YdT14pFpQSraDWu3Q8VxgWMf+yMCrz9OBLVzmKdE5dZKjYJkF65l6rdMTUhcr0LtS36/z9 8eeTf+cdQok4CCaooIKOyLTgg+JwswssFFZoYYU7DcLJJRx26GGHliDz4YgcWpKOEyimqKKKv5S0 iT8wxiijjJfMaGOM2+iBx4489shjMC7eeGONQs64DRxtJKnkkkoCeciLRdI44zvLMMOPkUgy2cYY bCzpJJRRwkgkjPoUIIAABFyJY5ZLfhFBGV4GGaaYMuaQAox0qCDjkUyiwUEGcDYp55xj9sOAKDC+ koCa/vCp5BodpNFBoEl+OSedMRqxAD79NIAAo462YUcJZbwxaZxPXurPmP704wAAA8z/oACobE5B ghVWdECFG4Kmeimr31wDoxx6rpnkG1OYoKwGI6TRK5hhsgqIAdWoEgA0e7KppKmUtmHprzPGAcAB pGCp5RsVdPstoaqGuuQbTK4bbbvaatmrJqpa0i4bavTr77/+AilIN9sUbPDBB6+D8MIGwwPMwxBH HPEw8iACARwYZ6xxxm00ocfGIMPBBjC98GLyySifvJPF9mrJxhN4tLykGsDMhV/FEDCZx855LPly zEnyzLOSNNvMiy22yJJLYjgviUYEUD9A6c9K6gB1BBJc0GUbRd8ECxI++EAEK0yzrKQZIwytJNVB 73xHCWIQXfNNewxByy1HKCFLXWYn58mFDGdw6TPMTCIbw8xz65JLLXvn4kcRe2/W9xtSMNuBBLwm ybaSc2COuM02wSJEH7DwnfOjW+9QxdqELykFFEx2jZMsQSwRueSnB61kFrBr3vqxH5ARe+I2/eED H7Esbfq2IESRBxodXME60EnasQG8n9uUyx8/sIIL0soD13cbZ3yAdRR3TL8kGDCkn71NSYQdNhC+ LP9uHu6rv23+cnO1ONIADN/KciezNmxOZrI7z/hkdsCWJfBmgphFMCZIwQpWUIIWzOAEi2EMYnjw gyD84DHkEQ55mPCEKEyhClfIwhbKYx8BAQA7""" class CustomStyle(ttk.Style): """ Manage widgets' style """ def __init__(self, app): ttk.Style.__init__(self, app) self.app = app self.theme = StringVar() self.theme.trace_variable("w", self.change_theme) self.theme.set(self.theme_use()) def change_theme(self, *args): """ Called on "View > Theme > theme_name" menu pressed Change and customize theme of tkinter.ttk widgets """ new_theme = self.theme.get() self.theme_use(new_theme) # Redefine Entry layout to permit fieldbackground color change if new_theme in ("vista", "xpnative"): try: self.element_create("clam.field", "from", "clam") except TclError: pass self.layout( "Box.TEntry", [ ( "Entry.clam.field", { "sticky": "nswe", "border": "1", "children": [ ( "Entry.padding", { "sticky": "nswe", "children": [ ("Entry.textarea", {"sticky": "nswe"}) ], }, ) ], }, ) ], ) self.configure( "Box.TEntry", bordercolor="grey", background="grey", foreground="black", fieldbackground="white", ) if new_theme == "vista": self.map( "Box.TEntry", bordercolor=[ ("hover", "!focus", "!disabled", "black"), ("focus", "dodger blue"), ], background=[ ("hover", "!focus", "!disabled", "black"), ("focus", "dodger blue"), ], ) # Define style for boxes with highlighted digits or digits' conflicts self.configure("Box.TEntry", fieldbackground="white") self.map("Box.TEntry", fieldbackground=[("disabled", "light grey")]) self.configure("HighlightArea.Box.TEntry", fieldbackground="light cyan") self.map( "HighlightArea.Box.TEntry", fieldbackground=[("disabled", "light steel blue")], ) self.configure( "HighlightBox.HighlightArea.Box.TEntry", foreground="midnight blue" ) self.map( "HighlightBox.HighlightArea.Box.TEntry", foreground=[("disabled", "blue")] ) self.configure("ErrorArea.Box.TEntry", fieldbackground="khaki") self.map("ErrorArea.Box.TEntry", fieldbackground=[("disabled", "dark khaki")]) self.configure("ErrorBox.ErrorArea.Box.TEntry", foreground="red") self.map("ErrorBox.ErrorArea.Box.TEntry", foreground=[("disabled", "red")]) def redraw(self): """ Called when a box's digit is changed Colorize boxes where highlighted digit can't be written and boxes with same digits in the same area """ if self.app.gr1d.progress_box and self.app.gr1d.progress_box.title() in ( VALIDATION_PROGRESS_BOX_TITLE, GENERATE_PROGRESS_BOX_TITLE, ): # Hide digits behind "?" when grid is validating for box in self.app.gr1d: if box.instate(("!disabled",)): box.configure(style="Box.TEntry", show="?") else: # Reset style for box in self.app.gr1d: box.configure(style="Box.TEntry", show="") # Disable highlight_button if all it's digit are solved for digit, highlight_button in enumerate( self.app.highlight_buttons.buttons, start=1 ): if ( sum( box.digit.get() == highlight_button.digit for box in self.app.gr1d ) == 9 ): highlight_button.disable() else: highlight_button.enable() if HighlightButton.digit: # Highlight selected digitures' area for box in self.app.gr1d: if HighlightButton.digit not in box.possible_digits: box.configure(style="HighlightArea.Box.TEntry") if HighlightButton.digit == box.digit.get(): box.configure(style="HighlightBox.HighlightArea.Box.TEntry") class MenuBar(Menu): """ Window menu bar """ def __init__(self, app): Menu.__init__(self, app) self.app = app # Grid menu self.grid = IndexedMenu(app) self.grid.add_command( label=GENERATE_LABEL, underline=0, command=self.app.gr1d.open_nb_clues_message_box, ) self.grid.add_separator() self.grid.add_command( label=CREATE_LABEL, underline=0, command=self.app.gr1d.create ) self.grid.add_command( label=EDIT_LABEL, underline=4, command=self.app.gr1d.edit, state=DISABLED ) self.grid.add_command( label=VALIDATE_LABEL, underline=0, command=self.app.gr1d.validate, state=DISABLED, ) self.grid.add_separator() self.grid.add_command( label=SOLVE_LABEL, underline=0, command=self.app.gr1d.solve, state=DISABLED ) self.add_cascade(label=GRID_LABEL, underline=0, menu=self.grid) # Game menu self.game = IndexedMenu(app) self.game.add_command(label=LOAD_LABEL, underline=0, command=self.app.game.open) self.game.add_command( label=SAVE_LABEL, underline=0, command=self.app.game.save, state=DISABLED ) self.game.add_separator() self.game.add_command( label=RESTART_LABEL, underline=0, command=self.app.game.restart, state=DISABLED, ) self.add_cascade(label=GAME_LABEL, underline=0, menu=self.game) # View menu self.view = IndexedMenu(app) self.view.theme_menu = Menu(self.view, tearoff=0) self.view.add_cascade(label=THEME_LABEL, underline=0, menu=self.view.theme_menu) for label in self.app.style.theme_names(): self.view.theme_menu.add_radiobutton( label=label, variable=self.app.style.theme ) self.view.add_separator() self.view.add_checkbutton( label=SHOW_TIPS_LABEL, underline=13, variable=self.app.gr1d.tips_shown ) self.view.add_checkbutton( label=SHOW_CONFLICTS_LABEL, underline=13, variable=self.app.gr1d.conflicts_shown, ) self.add_cascade(label=VIEW_LABEL, underline=0, menu=self.view) # Help menu self.help = IndexedMenu(app) self.help.add_command(label=WIKI_LABEL, underline=0, command=self.app.open_wiki) self.help.add_separator() self.help.add_command( label=ABOUT_LABEL, underline=2, command=self.app.show_about ) self.add_cascade(label=HELP_LABEL, underline=0, menu=self.help) class IndexedMenu(Menu): """ tkinter.Menu redefined to get menus indices """ def __init__(self, parent): Menu.__init__(self, parent, tearoff=0) self.labels = [] def add(self, itemType, cnf, **kw): super().add(itemType, cnf, **kw) if "label" in cnf: self.labels.append(cnf["label"]) elif "label" in kw: self.labels.append(kw["label"]) else: self.labels.append(None) def entryconfigure(self, label_or_index, label=None, **options): """ Redefined Menu.entryconfigure label_or_index can be the entry index as usual or its label """ if isinstance(label_or_index, int): index = label_or_index elif isinstance(label_or_index, str): index = self.labels.index(label_or_index) else: raise TypeError( "label_or_index must be the entry index as int or its label as str" ) if label: super().entryconfigure(index, label=label, **options) self.labels[index] = label else: super().entryconfigure(index, **options) class Gr1d(Frame): """ The 9x9 boxes sudoku grid Leet speak for Grid not to confuse with tkinter.Grid class """ def __init__(self, app): Frame.__init__(self, app) self.app = app self.correct = True self.solutions = self.solution_generator() self.progress_box = None self.tips_shown = IntVar(value=True) self.conflicts_shown = IntVar(value=True) self.conflicts_shown.trace_variable("w", self.check) self.regions = [ [Region(self, reg_row, reg_col) for reg_col in range(3)] for reg_row in range(3) ] # Structures of boxes used by Gr1d.__iter__ and Box.neighbourhood self.rows = [ [ Box( app, self, self.regions[row // 3][col // 3], row, col, row // 3 * 3 + col // 3, ) for col in range(9) ] for row in range(9) ] self.boxes_of = { "row": self.rows, "col": [[self.rows[row][col] for row in range(9)] for col in range(9)], "reg": [ [ self.rows[row][col] for row in range(reg_row, reg_row + 3) for col in range(reg_col, reg_col + 3) ] for reg_row in range(0, 9, 3) for reg_col in range(0, 9, 3) ], } self.pack() def __getitem__(self, key): """ Returns a box """ if isinstance(key, int): # grid[row][box] returns box of coordinates [row][col] with row, col its integer indices return self.rows[key] elif isinstance(key, str): # grid[area][m][n] returns the nth box of the mth area # with area in "row" (row), "col" (column) or "reg" (region) return self.boxes_of[key] else: raise TypeError("key must be the row index as int or a area as str") def __iter__(self): """ Browses every box of the grid """ for boxes_of_row in self.rows: for box in boxes_of_row: yield box def open_nb_clues_message_box(self): """ Opens a message box to get the minimum number of clues, then generate a grid with a near number of clues """ if self.app.game.confirm_erase(): self.create() NbCluesMessageBox(self.app) def auto_create(self, min_nb_clues): """ Automatic grid creation """ self.progress_box = ProgressBox( self.app, GENERATE_PROGRESS_BOX_TITLE, GENERATE_PROGRESS_BOX_TEXT, maximum=81, ) # Build a valid solution try: self.solve() except CancelInterrupt: # self.create(force=True) self.progress_box.destroy() self.progress_box = None else: remaining_boxes = [box for box in self] shuffle(remaining_boxes) nb_clues = len(remaining_boxes) self.progress_box.text.set(CLUES_DELETING_PROGRESS_BOX_TEXT) self.progress_box.progress_bar.configure(maximum=81) self.progress_box.cancel_button.configure( text=CANCEL_AUTO_CREATE_BUTTON_TEXT, command=self.progress_box.on_stop ) self.progress_box.cancel_button.bind("", self.progress_box.on_stop) self.progress_box.bind("", self.progress_box.on_stop) # Remove clues while the grid is valid # until the number of clues is near the number requested by user while ( remaining_boxes and nb_clues > min_nb_clues and not self.progress_box.cancel_pressed ): box = remaining_boxes.pop(0) tmp_digit = box.digit.get() box.state(("!disabled",)) self.progress_box.variable.set(81 - len(remaining_boxes)) box.digit.set("") if self.validate(): nb_clues -= 1 else: box.digit.set(tmp_digit) box.state(("disabled",)) self.app.update() self.progress_box.update() self.validate() self.app.game.restart(force=True) self.app.game.erase_enabled = False self.progress_box.destroy() self.progress_box = None self.check() def create(self, force=False): """ Make a blank grid to edit """ if self.app.game.confirm_erase(force): app.title(APP_TITLE) for box in self: box.digit.set("") self.edit(force=True) self[0][0].focus_set() def edit(self, force=False): """ Allow user to write the grid """ if self.app.game.confirm_erase(force): self.app.game.restart(force=True) for box in self: box.state(("!disabled",)) self.app.menu.grid.entryconfigure(EDIT_LABEL, state=DISABLED) self.app.menu.grid.entryconfigure(VALIDATE_LABEL, state=NORMAL) self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=DISABLED) self.app.menu.game.entryconfigure(LOAD_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(SAVE_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(RESTART_LABEL, state=DISABLED) def validate(self): """ Check if grid is valid: if it has a unique solution """ if self.correct: if not self.progress_box: self.progress_box = ProgressBox( self.app, VALIDATION_PROGRESS_BOX_TITLE, VALIDATION_PROGRESS_BOX_TEXT, maximum=sum( len(box.possible_digits) for box in self if not box.digit.get() ), ) self.progress_box.variable.set(0) hidden_solutions = self.solution_generator() try: next(hidden_solutions) except StopIteration: # No solution self.app.game.restart(force=True) if self.progress_box.title() == VALIDATION_PROGRESS_BOX_TITLE: self.progress_box.destroy() self.progress_box = None showerror( NO_SOLUTION_MESSAGE_BOX_TITLE, NO_SOLUTION_MESSAGE_BOX_TEXT, icon="error", ) self.edit() else: return False except CancelInterrupt: self.app.game.restart(force=True) if self.progress_box.title() == VALIDATION_PROGRESS_BOX_TITLE: self.progress_box.destroy() self.progress_box = None self.edit(force=True) else: return False else: if self.progress_box.title() == VALIDATION_PROGRESS_BOX_TITLE: self.progress_box.text.set(NO_OTHER_SOLUTION_PROGESS_BOX_TEXT) try: next(hidden_solutions) except StopIteration: # Unique solution self.app.game.restart(force=True) if self.progress_box.title() == VALIDATION_PROGRESS_BOX_TITLE: self.progress_box.destroy() self.progress_box = None else: return True except CancelInterrupt: self.app.game.restart(force=True) if self.progress_box.title() == VALIDATION_PROGRESS_BOX_TITLE: self.progress_box.destroy() self.progress_box = None self.edit(force=True) else: return False else: # More than 1 solution self.app.game.restart(force=True) if self.progress_box.title() == VALIDATION_PROGRESS_BOX_TITLE: self.progress_box.destroy() showerror( INCORRECT_GRID_MESSAGE_BOX_TITLE, SEVERAL_SOLUTIONS_MESSAGE_BOX_TEXT, icon="error", ) self.edit(force=True) else: return False else: # Incorrect grid if not self.progress_box: showerror( INCORRECT_GRID_MESSAGE_BOX_TITLE, CONFLICTS_MESSAGE_BOX_TEXT, icon="error", ) return False def solve(self): """ Automatic grid solving: the first generated solution """ if self.correct: if not self.progress_box: self.progress_box = ProgressBox( self.app, SOLVING_PROGRESS_BOX_TITLE, CALCULATING_SURE_DIGITS_TEXT, maximum=sum( len(box.possible_digits) for box in self if not box.digit.get() ), ) self.progress_box.variable.set(0) try: next(self.solutions) except StopIteration as e: self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=DISABLED) if self.progress_box.title() == SOLVING_PROGRESS_BOX_TITLE: self.progress_box.destroy() self.progress_box = None showerror( NO_SOLUTION_MESSAGE_BOX_TITLE, e.args[0] if e.args else "Pas de solution trouvée.", icon="error", ) except CancelInterrupt: if self.progress_box.title() == SOLVING_PROGRESS_BOX_TITLE: self.progress_box.destroy() self.progress_box = None else: raise CancelInterrupt else: self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=NORMAL) if self.progress_box.title() == SOLVING_PROGRESS_BOX_TITLE: self.progress_box.destroy() self.progress_box = None else: # Incorrect grid if not self.progress_box: showerror( INCORRECT_GRID_MESSAGE_BOX_TITLE, CONFLICTS_MESSAGE_BOX_TEXT, icon="error", ) return False def check(self, *args): """ Called when a box's digit is changed Check if there is no boxes with same digits in conflict in the same area """ self.app.game.erase_enabled = False self.app.style.redraw() # Redraw GUI # Check and show digits' conflicts self.correct = True for area in "row", "col", "reg": for index in range(9): for box1, box2 in combinations(self.app.gr1d[area][index], 2): if box1.digit.get() == box2.digit.get() != "": self.correct = False if self.conflicts_shown.get(): for box in self.app.gr1d[area][index]: box.config(style="ErrorArea.Box.TEntry") for box in box1, box2: box.config(style="ErrorBox.ErrorArea.Box.TEntry") for box in self.app.gr1d[area][index]: if ( self.conflicts_shown and not box.digit.get() and not box.possible_digits ): box.config(style="ErrorBox.ErrorArea.Box.TEntry") if not self.progress_box: # Check if grid is solved if self.correct: if sum(box.digit.get() == "" for box in self) == 0: self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=DISABLED) self.app.game.erase_enabled = True showinfo( SOLVED_GRID_MESSAGE_BOX_TITLE, SOLVED_GRID_MESSAGE_BOX_TEXT ) else: # Focus to the easiest box to solve try: next( box for box in self if not box.digit.get() and len(box.possible_digits) == 1 and HighlightButton.digit in {""} | box.possible_digits ).focus_set() except StopIteration: pass self.solutions = self.solution_generator() self.app.menu.grid.entryconfigure(VALIDATE_LABEL, state=NORMAL) self.app.menu.grid.entryconfigure(EDIT_LABEL, state=NORMAL) self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(LOAD_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(SAVE_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(RESTART_LABEL, state=NORMAL) def solution_generator(self, nb_recursion=1): """ Yields each solution of the grid """ empty_boxes = [box for box in self if box.digit.get() == ""] found_boxes = [] shuffle(empty_boxes) another_digit_found = True # Find sure digits: when there is only 1 possible digit in the box while ( self.correct and empty_boxes and another_digit_found and not self.progress_box.cancel_pressed ): another_digit_found = False for box in empty_boxes: if box.possible_digits: if len(box.possible_digits) == 1: box.digit.set(box.possible_digits.pop()) if ( self.progress_box.title() == SOLVING_PROGRESS_BOX_TITLE or self.progress_box.text.get() == GENERATE_PROGRESS_BOX_TEXT ): if ( self.progress_box.text.get() == GENERATE_PROGRESS_BOX_TEXT ): box.state(("disabled",)) box.update() self.progress_box.variable.set( self.progress_box.variable.get() + 1 / nb_recursion ) found_boxes.append(box) empty_boxes.remove(box) another_digit_found = True if self.progress_box.cancel_pressed: self.solutions = self.solution_generator() raise CancelInterrupt(CANCEL_EXCEPTION) # Try every possible digits elif self.correct: if empty_boxes: empty_boxes.sort(key=lambda box: len(box.possible_digits)) tested_box = empty_boxes[0] digits_to_try = sample( tested_box.possible_digits, len(tested_box.possible_digits) ) for tested_digit in digits_to_try: tested_box.digit.set(tested_digit) if self.progress_box.title() == SOLVING_PROGRESS_BOX_TITLE: self.progress_box.text.set( TESTS_PROGRESS_BOX_TEXT.format( tested_digit, str(tested_box) ) ) elif self.progress_box.text.get() == GENERATE_PROGRESS_BOX_TEXT: tested_box.state(("disabled",)) tested_box.update() self.progress_box.variable.set( self.progress_box.variable.get() + 1 / nb_recursion ) yield from self.solution_generator( 1 if self.progress_box.text.get() == GENERATE_PROGRESS_BOX_TEXT else nb_recursion + 1 ) for box in empty_boxes: if box.digit.get(): if self.progress_box.title() == GENERATE_PROGRESS_BOX_TITLE: box.state(("!disabled",)) box.digit.set("") else: yield None else: # Incorrect grid, maybe a wrong hypothesis self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=DISABLED) raise StopIteration(NO_SOLUTION_EXCEPTION) for box in found_boxes: if self.progress_box.title() == GENERATE_PROGRESS_BOX_TITLE: box.state(("!disabled",)) box.digit.set("") self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=DISABLED) def set_box_focus(self, box, row_delta, col_delta): """ Select an enabled adjacent box """ row, col = box.row, box.col keep_browse = True while keep_browse: col += col_delta row += row_delta if col > 8: col = 0 row = (row + 1) % 9 elif col < 0: col = 8 row = (row - 1) % 9 if row < 0: row = 8 col = (col - 1) % 9 elif row > 8: row = 0 col = (col + 1) % 9 keep_browse = self.rows[row][col].instate(("disabled",)) self.rows[row][col].focus_set() class Region(Frame): """ 3x3 boxes subgrid/region """ def __init__(self, grid, reg_row, reg_col): Frame.__init__(self, grid, relief=SUNKEN, borderwidth=2) self.grid(row=reg_row, column=reg_col, sticky="nswe") class Box(ttk.Entry): """ Entry box of the grid """ def __init__(self, app, gr1d, region, row, col, reg, *options): self.app = app self.digit = StringVar() self.digit.trace_variable("w", self.on_digit_change) ttk.Entry.__init__( self, region, textvariable=self.digit, style="Box.TEntry", font=("Arial", 16), width=2, justify=CENTER, validate="key", validatecommand=(app.register(self.check), "%P"), *options ) self.state(("disabled",)) self.row, self.col, self.reg = row, col, reg self.possible_digits = set(digits) self.grid(row=row % 3, column=col % 3) self.bind("", self.on_focus) self.bind("", lambda event: gr1d.set_box_focus(self, -1, 0)) self.bind("", lambda event: gr1d.set_box_focus(self, +1, 0)) self.bind("", lambda event: gr1d.set_box_focus(self, 0, -1)) self.bind("", lambda event: gr1d.set_box_focus(self, 0, +1)) self.tip = Tooltip(self) gr1d.tips_shown.trace_variable("w", self.show_tips) def check(self, changed_digit): """ Called on user input Input is allowed only if it's one digit """ return len(changed_digit) <= 1 and changed_digit in digits def on_focus(self, event): """ Selects text """ self.select_range(0, END) def neighbourhood(self, area): """ neighbourhood(area) browses every boxes of the area of the box with area in "row" (row), "col" (column) or "reg" (region) """ for box in self.app.gr1d[area][getattr(self, area)]: yield box def on_digit_change(self, *args): """ Called when a box's digit is changed Find the digits wich aren't in the row, column or region of the box """ for area in "row", "col", "reg": for box in self.neighbourhood(area): box.possible_digits = set(digits) for box_area in "row", "col", "reg": box.possible_digits -= { box.digit.get() for box in box.neighbourhood(box_area) } if self.app.gr1d.tips_shown.get() and box.digit.get() == "": if box.possible_digits: box.tip.text = " ".join(sorted(box.possible_digits)) + " ?" else: box.tip.text = "???" box.tip.shown = True else: box.tip.shown = False if ( not self.app.gr1d.progress_box and not HighlightButton.digit and self.digit.get() ): self.app.gr1d.set_box_focus(self, 0, +1) self.app.gr1d.check() if not self.app.gr1d.correct: self.focus_set() def show_tips(self, *args): """ Called when "View > Show tips" menu is changed Show or hide a tooltip on the box with its possible digits """ self.tip.shown = self.tips_shown.get() def __str__(self): return "case " + str((self.row + 1, self.col + 1)) def __repr__(self): return ( str((self.row, self.col)) + str([self.digit.get()]) + str(self.possible_digits) ) class Tooltip: """ It creates a tooltip for a given widget as the mouse goes on it. see: http://stackoverflow.com/questions/3221956/ what-is-the-simplest-way-to-make-tooltips- in-tkinter/36221216#36221216 http://www.daniweb.com/programming/software-development/ code/484591/a-tooltip-class-for-tkinter - Originally written by vegaseat on 2014.09.09. - Modified to include a delay time by Victor Zaccardo on 2016.03.25. - Modified - to correct extreme right and extreme bottom behavior, - to stay inside the screen whenever the tooltip might go out on the top but still the screen is higher than the tooltip, - to use the more flexible mouse positioning, - to add customizable background color, padding, waittime and wraplength on creation by Alberto Vassena on 2016.11.05. Tested on Ubuntu 16.04/16.10, running Python 3.5.2 TODO: themes styles support """ TIP_DELAY = 2000 def __init__( self, widget, *, bg="white", fg="dim gray", pad=(3, 2, 3, 2), text="widget info", delay=TIP_DELAY, wraplength=250, shown=True ): self.shown = shown # Added by AM self.delay = delay # in milliseconds, originally 500 self.wraplength = wraplength # in pixels, originally 180 self.widget = widget self.text = text self.bg = bg self.fg = fg self.pad = pad self.id = None self.tw = None self.widget.bind("", self.onEnter) self.widget.bind("", self.onLeave) self.widget.bind("", self.onLeave) def onEnter(self, event=None): if self.shown: # Added by AM self.schedule() def onLeave(self, event=None): self.unschedule() self.hide() def schedule(self): self.unschedule() self.id = self.widget.after(self.delay, self.show) def unschedule(self): id_ = self.id self.id = None if id_: self.widget.after_cancel(id_) def show(self): def tip_pos_calculator(widget, label, *, tip_delta=(10, 5), pad=(5, 3, 5, 3)): w = widget s_width, s_height = w.winfo_screenwidth(), w.winfo_screenheight() width, height = ( pad[0] + label.winfo_reqwidth() + pad[2], pad[1] + label.winfo_reqheight() + pad[3], ) mouse_x, mouse_y = w.winfo_pointerxy() x1, y1 = mouse_x + tip_delta[0], mouse_y + tip_delta[1] x2, y2 = x1 + width, y1 + height x_delta = x2 - s_width if x_delta < 0: x_delta = 0 y_delta = y2 - s_height if y_delta < 0: y_delta = 0 offscreen = (x_delta, y_delta) != (0, 0) if offscreen: if x_delta: x1 = mouse_x - tip_delta[0] - width if y_delta: y1 = mouse_y - tip_delta[1] - height offscreen_again = y1 < 0 # out on the top if offscreen_again: # No further checks will be done. # TIP: # A further mod might automagically augment the # wraplength when the tooltip is too high to be # kept inside the screen. y1 = 0 return x1, y1 bg = self.bg fg = self.fg pad = self.pad widget = self.widget # creates a toplevel window self.tw = Toplevel(widget) # Leaves only the label and removes the app window self.tw.wm_overrideredirect(True) border = Frame(self.tw, background=fg) win = Frame(border, background=bg, borderwidth=1) label = Label( win, text=self.text, justify=LEFT, background=bg, foreground=fg, relief=SOLID, borderwidth=0, wraplength=self.wraplength, ) label.grid(padx=(pad[0], pad[2]), pady=(pad[1], pad[3]), sticky=NSEW) win.grid(padx=1, pady=1) border.grid() x, y = tip_pos_calculator(widget, label) self.tw.wm_geometry("+%d+%d" % (x, y)) def hide(self): tw = self.tw if tw: tw.destroy() self.tw = None class NbCluesMessageBox(Toplevel): """ A message box allowing user to choose a number of clues boxes """ DEFAULT_NB_CLUES = 25 MIN_NB_CLUES = 17 MAX_NB_CLUES = 80 def __init__(self, app): Toplevel.__init__(self, app) self.app = app self.title(NB_CLUES_MESSAGE_BOX_TITLE) self.resizable(width=False, height=False) self.grab_set() self.nb_clues = IntVar() self.nb_clues.set(NbCluesMessageBox.DEFAULT_NB_CLUES) self.nb_clues.trace_variable("w", self.round_nb_clues) message_box_frame = ttk.Frame(self) enter_nb_frame = ttk.Frame(message_box_frame) enter_nb_label = ttk.Label(enter_nb_frame, text=NB_CLUES_INPUT_LABEL) enter_nb_label.pack(side=LEFT) spinbox = Spinbox( enter_nb_frame, width=2, from_=NbCluesMessageBox.MIN_NB_CLUES, to=NbCluesMessageBox.MAX_NB_CLUES, increment=1, textvariable=self.nb_clues, ) spinbox.pack(side=RIGHT) enter_nb_frame.pack(padx=5, pady=5, fill=X) scale_frame = ttk.Frame(message_box_frame) simpler_msg = ttk.Label(scale_frame, text=HARDER_LABEL, justify=RIGHT) simpler_msg.pack(side=LEFT) self.scale = ttk.Scale( scale_frame, command=self.round_nb_clues, length=162, from_=NbCluesMessageBox.MIN_NB_CLUES, to=NbCluesMessageBox.MAX_NB_CLUES, orient=HORIZONTAL, variable=self.nb_clues, ) self.scale.pack(side=LEFT, padx=5, pady=5) more_difficult_msg = ttk.Label(scale_frame, text=EASIER_LABEL, justify=LEFT) more_difficult_msg.pack(side=LEFT, padx=5, pady=5) scale_frame.pack(padx=5, pady=5) buttons_frame = ttk.Frame(message_box_frame) cancel_button = ttk.Button( buttons_frame, text=CANCEL_BUTTON_TEXT, command=self.destroy ) cancel_button.pack(side=RIGHT, padx=5, pady=5) cancel_button.bind("", lambda event: self.destroy()) ok_button = ttk.Button(buttons_frame, text=OK_BUTTON_TEXT, command=self.on_ok) ok_button.pack(side=RIGHT, padx=5, pady=5) ok_button.bind("", self.on_ok) ok_button.focus_set() buttons_frame.pack() self.bind("", self.on_ok) self.bind("", self.destroy) message_box_frame.pack() def round_nb_clues(self, *args): try: nb = int(self.nb_clues.get()) except TclError: nb = NbCluesMessageBox.MIN_NB_CLUES finally: if nb < NbCluesMessageBox.MIN_NB_CLUES: nb = NbCluesMessageBox.MIN_NB_CLUES elif nb > NbCluesMessageBox.MAX_NB_CLUES: nb = NbCluesMessageBox.MAX_NB_CLUES self.nb_clues.set(nb) self.update() def on_ok(self, event=None): """ Generate a grid on [OK] button press """ self.destroy() self.app.gr1d.auto_create(self.nb_clues.get()) class CancelInterrupt(KeyboardInterrupt): pass class ProgressBox(Toplevel): """ Message box showing progress used by Gr1d.validate and Gr1d.generate """ def __init__(self, app, title="", text="", **options): Toplevel.__init__(self, app) self.protocol("WM_DELETE_WINDOW", self.on_cancel) self.title(title) self.resizable(width=False, height=False) self.grab_set() frame = ttk.Frame(self) self.text = StringVar() self.text.set(text) label = ttk.Label(frame, textvariable=self.text) label.pack(anchor=W, padx=10, pady=5) self.variable = DoubleVar(0) self.variable.trace_variable("w", lambda *args: self.update()) self.progress_bar = ttk.Progressbar( frame, length=250, orient="horizontal", mode="determinate", variable=self.variable, **options ) self.progress_bar.pack(padx=10, pady=5) self.cancel_button = ttk.Button( frame, text=CANCEL_BUTTON_TEXT, command=self.on_cancel ) self.cancel_button.bind("", self.on_cancel) self.cancel_button.pack(side=RIGHT, padx=10, pady=5) self.cancel_pressed = False frame.pack(ipady=5) self.bind("", self.on_cancel) self.update() def on_cancel(self, event=None): """ Called on Cancel button or quit button press """ self.cancel_button.state(("disabled",)) self.text.set(CANCELLED_PROGRESS_BOX_TEXT) self.cancel_pressed = True self.update() def on_stop(self, event=None): """ Called on Cancel button or quit button press """ self.cancel_button.state(("disabled",)) self.text.set(STOPPING_PROGRESS_BOX_TEXT) self.cancel_pressed = True self.update() class HighlightButtonsFrame(Frame): """ Frame of HighlightButtons looking like a status bar """ def __init__(self, app): Frame.__init__(self, app, border=1, relief=SUNKEN) self.buttons = [HighlightButton(app, self, digit) for digit in digits] self.pack(fill=X) class HighlightButton(ttk.Button): """ Buttons showing every digits Allowing user to see where not to write the selected digit """ digit = "" def __init__(self, app, parent, digit): self.digit = digit self.pressed = False self.app = app ttk.Button.__init__( self, parent, text=digit, width=2, padding="0 0", command=self.on_click ) self.app = app self.state(("disabled",)) self.pack(side="left", expand=True, fill="x") def on_click(self, *args): """ Select or unselect a digit and show where not to write the selected digit """ if self.pressed: self.state(("!pressed",)) self.pressed = False HighlightButton.digit = "" else: for highlight_button in self.app.highlight_buttons.buttons: highlight_button.state(("!pressed",)) highlight_button.pressed = False self.state(("pressed",)) self.pressed = True HighlightButton.digit = self.digit self.app.gr1d.check() def enable(self): """ Enable button when all the same digits aren't found """ self.state(("!disabled",)) def disable(self): """ Disable button when all the same digits are found """ self.state(("disabled",)) if HighlightButton.digit == self.digit: HighlightButton.digit = "" class Game: """ Game functions """ def __init__(self, app): self.app = app self.erase_enabled = True self.file_path = "" def confirm_erase(self, force=False): """ If a game is started, pop a message box to confirm game erasement """ self.erase_enabled = ( self.erase_enabled or force or askokcancel( CONFIRM_ERASE_MESSAGE_BOX_TITLE, CONFIRM_ERASE_MESSAGE_BOX_TEXT, default="cancel", icon="warning", ) ) return self.erase_enabled def open(self, file_path=""): """ Open a game saved in a file """ if self.confirm_erase(): self.file_path = file_path or askopenfilename( parent=self.app, defaultextension=".pysudoku", initialdir=".", title=OPEN_FILE_MESSAGE_BOX_TITLE, filetypes=[(FILE_TYPE_NAME, "*.pysudoku")], ) if self.file_path: if exists(self.file_path): self.app.gr1d.create() with open(self.file_path, "rb") as file: loader = Unpickler(file) try: HighlightButton.digit = loader.load() for box in self.app.gr1d: box.state(("!disabled",)) box.state(loader.load()) box.digit.set(loader.load()) except UnpicklingError: showerror( FILE_ERROR_MESSAGE_BOX_TITLE, CORRUPTED_FILE_MESSAGE_BOX_TEXT.format(basename(file_path)), icon="error", ) else: print( basename(self.file_path), basename(self.file_path).rstrip(APP_TITLE), ) self.app.title( basename(self.file_path).rstrip(".pysudoku") + " - PySudoku" ) self.erase_enabled = True self.app.menu.grid.entryconfigure(VALIDATE_LABEL, state=NORMAL) self.app.self.app.menu.grid.entryconfigure( EDIT_LABEL, state=NORMAL ) self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(LOAD_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(SAVE_LABEL, state=DISABLED) self.app.menu.game.entryconfigure(RESTART_LABEL, state=NORMAL) else: showerror( FILE_ERROR_MESSAGE_BOX_TITLE, FILE_NOT_FOUND_MESSAGE_BOX_TEXT.format(basename(file_path)), icon="error", ) def save(self): """ Save a game in a file """ save_path = asksaveasfilename( parent=self.app, defaultextension=".pysudoku", initialdir=dirname(self.file_path), initialfile=basename(self.file_path), title=SAVE_FILE_MESSAGE_BOX_TITLE, filetypes=[(FILE_TYPE_NAME, "*.pysudoku")], ) if save_path: self.file_path = save_path with open(save_path, "wb") as file: saver = Pickler(file) saver.dump(HighlightButton.digit) for box in self.app.gr1d: saver.dump(box.state()) saver.dump(box.digit.get()) self.erase_enabled = True self.app.title(basename(self.file_path).rstrip(".pysudoku") + " - PySudoku") return save_path def restart(self, force=False): """ Restart game: blank enabled boxes and keep clues boxes """ if force or self.confirm_erase(): for box in self.app.gr1d: if box.instate(("!disabled",)): box.digit.set("") self.erase_enabled = True self.app.menu.grid.entryconfigure(EDIT_LABEL, state=NORMAL) self.app.menu.grid.entryconfigure(VALIDATE_LABEL, state=DISABLED) self.app.menu.grid.entryconfigure(SOLVE_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(LOAD_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(SAVE_LABEL, state=NORMAL) self.app.menu.game.entryconfigure(RESTART_LABEL, state=NORMAL) class App(Tk): def __init__(self, argv): Tk.__init__(self) self.title(APP_TITLE) self.iconphoto( True, PhotoImage(name="icon16", data=ICON16), PhotoImage(name="icon32", data=ICON32), PhotoImage(name="icon48", data=ICON48), ) # Titlebar try: # Windows taskbar icon from ctypes import windll windll.shell32.SetCurrentProcessExplicitAppUserModelID( "MALINGREY.Adrien.PySudoku.0.2" ) except ImportError: # Linux pass self.resizable(width=False, height=False) self.style = CustomStyle(self) self.gr1d = Gr1d( self ) # leet speak for grid not to confuse with tkinter.Grid.grid method self.highlight_buttons = HighlightButtonsFrame(self) self.game = Game(self) self["menu"] = self.menu = MenuBar(self) # Window menu bar self.protocol("WM_DELETE_WINDOW", self.on_close) # Action on close button press if len(argv) > 1: self.game.open(argv[1]) def open_wiki(self): """ Open Sudoku article on wikipedia to learn sudoku rules (and more) """ open_web_browser(WIKI_URL) def show_about(self): """ About message box """ showinfo(ABOUT_MESSAGE_BOX_TITLE, ABOUT_MESSAGE_BOX_TEXT) def on_close(self): """ Called on close button press Allow user to save started game and quit """ save_before_close = not self.game.erase_enabled and askyesnocancel( CLOSE_MESSAGE_BOX_TITLE, CLOSE_MESSAGE_BOX_TITLE, icon="warning", default="cancel", ) if save_before_close != None: # [Yes] or [No] button pressed (not [Cancel]) if save_before_close == True: # [Yes] button pressed if self.game.save() == "": # Save dialog box cancelled self.on_close() # Ask again self.quit() else: # [Cancel] button pressed : do nothing pass if __name__ == "__main__": app = App(argv) exit(app.mainloop())