#! /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())