diff --git a/PySudoku.ico b/PySudoku.ico new file mode 100644 index 0000000..cf9791c Binary files /dev/null and b/PySudoku.ico differ diff --git a/PySudoku.pyw b/PySudoku.pyw new file mode 100644 index 0000000..fd2c2bc --- /dev/null +++ b/PySudoku.pyw @@ -0,0 +1,1198 @@ +#! /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: + 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 sys import argv, exit + from string import digits +except ImportError as e: + exit(e.msg) + + +# 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 de la grille", "Génération d'une grille"): + # 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="Générer...", underline=0, command=self.app.gr1d.open_nb_clues_message_box) + self.grid.add_separator() + self.grid.add_command( label="Créer", underline=0, command=self.app.gr1d.create) + self.grid.add_command( label="Éditer", underline=4, command=self.app.gr1d.edit, state=DISABLED) + self.grid.add_command( label="Valider", underline=0, command=self.app.gr1d.validate, state=DISABLED) + self.grid.add_separator() + self.grid.add_command( label="Résoudre", underline=0, command=self.app.gr1d.solve, state=DISABLED) + self.add_cascade( label="Grille", underline=0, menu=self.grid) + # Game menu + self.game = IndexedMenu(app) + self.game.add_command( label="Charger...", underline=0, command=self.app.game.open) + self.game.add_command( label="Sauvegarder...", underline=0, command=self.app.game.save, state=DISABLED) + self.game.add_separator() + self.game.add_command( label="Redémarrer...", underline=0, command=self.app.game.restart, state=DISABLED) + self.add_cascade( label="Partie", 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="Thème", 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="Afficher les info-bulles", underline=13, variable=self.app.gr1d.tips_shown) + self.view.add_checkbutton(label="Afficher les conflits", underline=13, variable=self.app.gr1d.conflicts_shown) + self.add_cascade( label="Affichage", underline=0, menu=self.view) + # Help menu + self.help = IndexedMenu(app) + self.help.add_command( label="Wikipédia", underline=0, command=self.app.open_wiki) + self.help.add_separator() + self.help.add_command( label="À propos...", underline=2, command=self.app.show_about) + self.add_cascade( 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, + "Génération d'une grille", + "Génération d'une nouvelle grille...", + 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("Suppression d'indices...") + self.progress_box.progress_bar.configure(maximum=81) + self.progress_box.cancel_button.configure(text="Arrêter", 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("PySudoku") + 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("Éditer", state=DISABLED) + self.app.menu.grid.entryconfigure("Valider", state=NORMAL) + self.app.menu.grid.entryconfigure("Résoudre", state=DISABLED) + self.app.menu.game.entryconfigure("Charger...", state=NORMAL) + self.app.menu.game.entryconfigure("Sauvegarder...", state=NORMAL) + self.app.menu.game.entryconfigure("Redémarrer...", 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 de la grille", + "Vérification que la grille a une solution...", + 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 de la grille": + self.progress_box.destroy() + self.progress_box = None + showerror("Grille insoluble", + "La grille comporte des cases sans valeur possible. " + "Corrigez-les pour pouvoir Valider la grille.", + icon="error") + self.edit() + else: + return False + except CancelInterrupt: + self.app.game.restart(force=True) + if self.progress_box.title() == "Validation de la grille": + self.progress_box.destroy() + self.progress_box = None + self.edit(force=True) + else: + return False + else: + if self.progress_box.title() == "Validation de la grille": + self.progress_box.text.set("Vérification qu'il n'y a pas d'autres solution...") + try: + next(hidden_solutions) + except StopIteration: # Unique solution + self.app.game.restart(force=True) + if self.progress_box.title() == "Validation de la grille": + 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 de la grille": + 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 de la grille": + self.progress_box.destroy() + showerror("Grille incorrecte", + "La grille a plusieurs solutions possibles.", + icon="error") + self.edit(force=True) + else: + return False + else: # Incorrect grid + if not self.progress_box: + showerror("Grille incorrecte", + "La grille comporte des cases d'une même colonne, " + "ligne ou région avec des valeurs identiques. " + "Corrigez-les pour pouvoir Valider la grille.", + 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, "Résolution de la grille", + "Cacul des valeurs sûres...", + 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("Résoudre", state=DISABLED) + if self.progress_box.title() == "Résolution de la grille": + self.progress_box.destroy() + self.progress_box = None + showerror("Grille insoluble", e.args[0] if e.args else "Pas de solution trouvée.", icon="error") + except CancelInterrupt: + if self.progress_box.title() == "Résolution de la grille": + self.progress_box.destroy() + self.progress_box = None + else: + raise CancelInterrupt + else: + self.app.menu.grid.entryconfigure("Résoudre", state=NORMAL) + if self.progress_box.title() == "Résolution de la grille": + self.progress_box.destroy() + self.progress_box = None + else: # Incorrect grid + if not self.progress_box: + showerror("Grille incorrecte", + "La grille comporte des cases d'une même colonne, " + "ligne ou région avec des valeurs identiques. " + "Corrigez-les pour pouvoir résoudre la grille.", + 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("Résoudre", state=DISABLED) + self.app.game.erase_enabled = True + showinfo("Bravo !", "La grille est résolue.") + 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("Valider", state=NORMAL) + self.app.menu.grid.entryconfigure("Éditer", state=NORMAL) + self.app.menu.grid.entryconfigure("Résoudre", state=NORMAL) + self.app.menu.game.entryconfigure("Charger...", state=NORMAL) + self.app.menu.game.entryconfigure("Sauvegarder...", state=NORMAL) + self.app.menu.game.entryconfigure("Redémarrer...", 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() == "Résolution de la grille" \ + or self.progress_box.text.get() == "Génération d'une nouvelle grille...": + if self.progress_box.text.get() == "Génération d'une nouvelle grille...": + 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("Annulation") + # 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() == "Résolution de la grille": + self.progress_box.text.set("Essai : " + tested_digit + " en " + str(tested_box)) + elif self.progress_box.text.get() == "Génération d'une nouvelle grille...": + 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() == "Génération d'une nouvelle grille..." else nb_recursion + 1) + for box in empty_boxes: + if box.digit.get(): + if self.progress_box.title() == "Génération d'une grille": + box.state(("!disabled",)) + box.digit.set("") + else: + yield None + else: + # Incorrect grid, maybe a wrong hypothesis + self.app.menu.grid.entryconfigure("Résoudre", state=DISABLED) + raise StopIteration("La grille comporte des erreurs. Corrigez-les pour pouvoir résoudre la grille.") + for box in found_boxes: + if self.progress_box.title() == "Génération d'une grille": + box.state(("!disabled",)) + box.digit.set("") + self.app.menu.grid.entryconfigure("Résoudre", 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("Générer une nouvelle grille") + 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="Entrez le nombre minimum d'indices :") + 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="← plus\ndifficile", 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="plus →\nfacile", 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="Annuler", 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", 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="Annuler", 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("Annulation...") + 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("Arrêt...") + 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("Effacer la partie en cours ?", + "Une partie est en cours. Voulez-vous l'effacer ?", + 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 = "Ouvrir une partie", + filetypes = [("Partie PySudoku", "*.pysudoku")]) + 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("Erreur de fichier", "Le fichier n'a pas pu être lu.", icon="error") + else: + print(basename(self.file_path), basename(self.file_path).rstrip("pysudoku")) + self.app.title(basename(self.file_path).rstrip(".pysudoku")+" - PySudoku") + self.erase_enabled = True + self.app.menu.grid.entryconfigure("Valider", state=NORMAL) + self.app.self.app.menu.grid.entryconfigure("Éditer", state=NORMAL) + self.app.menu.grid.entryconfigure("Résoudre", state=NORMAL) + self.app.menu.game.entryconfigure("Charger...", state=NORMAL) + self.app.menu.game.entryconfigure("Sauvegarder...", state=DISABLED) + self.app.menu.game.entryconfigure("Redémarrer...", state=NORMAL) + else: + showerror("Erreur de fichier", "Le fichier "+basename(file_path)+" n'a pas été trouvé.", 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 = "Enregistrer la partie", + filetypes = [("Partie PySudoku", "*.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("Éditer", state=NORMAL) + self.app.menu.grid.entryconfigure("Valider", state=DISABLED) + self.app.menu.grid.entryconfigure("Résoudre", state=NORMAL) + self.app.menu.game.entryconfigure("Charger...", state=NORMAL) + self.app.menu.game.entryconfigure("Sauvegarder...", state=NORMAL) + self.app.menu.game.entryconfigure("Redémarrer...", state=NORMAL) + + + + +class App(Tk): + def __init__(self, argv): + Tk.__init__(self) + self.title("PySudoku") + 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("https://fr.wikipedia.org/wiki/Sudoku") + + def show_about(self): + """ About message box """ + showinfo("À propos de PySudoku", + "Auteur : Adrien Malingrey\n" + "Licence : CC-BY-SA") + + + 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("Enregistrer la partie ?", + "Une partie est en cours. Voulez-vous l'enregistrer ?", + 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()) \ No newline at end of file diff --git a/ai_escargot.pysudoku b/ai_escargot.pysudoku new file mode 100644 index 0000000..b40477c Binary files /dev/null and b/ai_escargot.pysudoku differ diff --git a/demo.pysudoku b/demo.pysudoku new file mode 100644 index 0000000..5300fd2 Binary files /dev/null and b/demo.pysudoku differ