1519 lines
57 KiB
Python
1519 lines
57 KiB
Python
#! /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("<space>", self.progress_box.on_stop)
|
|
self.progress_box.bind("<Escape>", 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("<FocusIn>", self.on_focus)
|
|
self.bind("<KeyPress-Up>", lambda event: gr1d.set_box_focus(self, -1, 0))
|
|
self.bind("<KeyPress-Down>", lambda event: gr1d.set_box_focus(self, +1, 0))
|
|
self.bind("<KeyPress-Left>", lambda event: gr1d.set_box_focus(self, 0, -1))
|
|
self.bind("<KeyPress-Right>", 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("<Enter>", self.onEnter)
|
|
self.widget.bind("<Leave>", self.onLeave)
|
|
self.widget.bind("<ButtonPress>", 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("<space>", 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("<space>", self.on_ok)
|
|
ok_button.focus_set()
|
|
buttons_frame.pack()
|
|
self.bind("<Return>", self.on_ok)
|
|
self.bind("<Escape>", 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("<space>", self.on_cancel)
|
|
self.cancel_button.pack(side=RIGHT, padx=10, pady=5)
|
|
self.cancel_pressed = False
|
|
frame.pack(ipady=5)
|
|
self.bind("<Escape>", 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())
|