Skip to content

Instantly share code, notes, and snippets.

@thegamecracks
Created August 31, 2024 20:08
Show Gist options
  • Select an option

  • Save thegamecracks/ef7f6ce0196b778899efecb8def5c753 to your computer and use it in GitHub Desktop.

Select an option

Save thegamecracks/ef7f6ce0196b778899efecb8def5c753 to your computer and use it in GitHub Desktop.
A complete rewrite of someone else's Tkinter quiz GUI
import math
import operator
import random
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from tkinter import Misc, StringVar, Tk, Toplevel
from tkinter.simpledialog import askstring
from tkinter.ttk import Button, Entry, Frame, Label, Radiobutton
from typing import Any, Callable, Self, assert_never
def main() -> None:
enable_windows_dpi_awareness()
app = QuizApp()
app.switch_frame(MainMenu(app))
app.mainloop()
def enable_windows_dpi_awareness() -> None:
if sys.platform == "win32":
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(2)
class QuizApp(Tk):
def __init__(self) -> None:
super().__init__()
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
self.frame = Frame(self)
self.settings = Settings()
def switch_frame(self, frame: Frame) -> None:
assert frame.master is self
self.frame.destroy()
self.frame = frame
self.frame.grid(row=0, column=0, sticky="nesw")
# Resize to fit the new frame if needed
self.update_idletasks()
req_x = self.frame.winfo_reqwidth()
req_y = self.frame.winfo_reqheight()
if self.winfo_width() < req_x or self.winfo_height() < req_y:
self.geometry(f"{req_x}x{req_y}")
title = getattr(frame, "title", None)
if title is not None:
self.title(title)
class Difficulty(Enum):
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
@dataclass
class Settings:
username: str = ""
difficulty: Difficulty = Difficulty.EASY
class MainMenu(Frame):
title = "Main Menu"
def __init__(self, app: QuizApp) -> None:
super().__init__(app, padding=10)
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
self.app = app
self.welcome = Label(self, text="Welcome to the Quiz!")
self.welcome.grid(row=0, column=0, sticky="n")
self.controls = Frame(self)
self.controls.grid(row=1, column=0, sticky="ew")
self.controls.grid_anchor("center")
self.start = Button(self.controls, text="Start", command=self.do_start)
self.start.grid(row=0, column=0)
self.settings = Button(self.controls, text="Settings", command=self.do_settings)
self.settings.grid(row=0, column=1)
self.credits = Button(self.controls, text="Credits", command=self.do_credits)
self.credits.grid(row=0, column=2)
def do_start(self) -> None:
username = askstring( # FIXME: use themed widgets
"Username",
"What is your name?",
initialvalue=self.app.settings.username,
)
if username is None or username == "":
return
self.app.settings.username = username
quiz = Quiz.from_difficulty(self.app.settings.difficulty)
frame = QuizFrame(self.app, quiz)
self.app.switch_frame(frame)
def do_settings(self) -> None:
# dialog = create_dialog(self.app)
# dialog.title("Settings")
# settings = SettingsFrame(dialog, self.app.settings)
# settings.pack(expand=True, fill="both")
frame = SettingsFrame(self.app)
self.app.switch_frame(frame)
def do_credits(self) -> None:
# dialog = create_dialog(self.app)
# dialog.title("Credits")
# settings = Credits(dialog)
# settings.pack(expand=True, fill="both")
self.app.switch_frame(Credits(self.app))
def create_dialog(app: Tk) -> Toplevel:
def dismiss():
dialog.grab_release()
dialog.destroy()
dialog = Toplevel(app)
dialog.wm_transient(app)
dialog.protocol("WM_DELETE_WINDOW", dismiss)
dialog.wait_visibility()
dialog.grab_set()
return dialog
class SettingsFrame(Frame):
title = "Settings"
def __init__(self, app: QuizApp) -> None:
super().__init__(app, padding=10)
self.app = app
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(1, weight=1)
self.difficulty_label = Label(self, text="Difficulty:")
self.difficulty_label.grid(row=0, column=0, sticky="w")
self.difficulty_buttons = DifficultySelection(self, self.app.settings)
self.difficulty_buttons.grid(row=0, column=1, sticky="ew")
self.back = MainMenuButton(self, app)
self.back.grid(row=1, column=1, sticky="se")
class MainMenuButton(Button):
def __init__(self, parent: Misc, app: QuizApp, *, text: str = "Back") -> None:
super().__init__(parent, text=text, command=self.on_click)
self.app = app
def on_click(self) -> None:
self.app.switch_frame(MainMenu(self.app))
class DifficultySelection(Frame):
def __init__(self, parent: Misc, settings: Settings) -> None:
super().__init__(parent)
self.settings = settings
self.difficulty_var = StringVar(self, value=settings.difficulty.value)
self.difficulty_var.trace_add("write", self._on_difficulty_change)
self.difficulty_buttons: list[Radiobutton] = []
for i, difficulty in enumerate(Difficulty):
button = Radiobutton(
self,
text=difficulty.value.capitalize(),
value=difficulty.value,
variable=self.difficulty_var,
)
button.grid(row=0, column=i)
self.grid_columnconfigure(i, weight=1)
self.difficulty_buttons.append(button)
def _on_difficulty_change(self, name1: str, name2: str, op: str) -> None:
try:
difficulty = Difficulty(self.difficulty_var.get())
except ValueError:
self.difficulty_var.set(self.settings.difficulty.value)
else:
self.settings.difficulty = difficulty
class Credits(Frame):
title = "Credits"
def __init__(self, app: QuizApp) -> None:
super().__init__(app, padding=10)
self.app = app
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
self.content = Label(
self,
text="Thanks to...\n(Testers and other thanks will be credited here!)",
justify="center",
)
self.content.grid(row=0, column=0)
self.back = MainMenuButton(self, app)
self.back.grid(row=1, column=0, sticky="e")
@dataclass(kw_only=True)
class Question:
title: str
def check(self, answer: str, /) -> None:
pass
class BinOp(Enum):
ADD = operator.add
SUB = operator.sub
MUL = operator.mul
TRUEDIV = operator.truediv
def __str__(self) -> str:
if self == BinOp.ADD:
return "+"
elif self == BinOp.SUB:
return "-"
elif self == BinOp.MUL:
return "*"
elif self == BinOp.TRUEDIV:
return "/"
# FIXME: pyright doesn't like assert_never() here
raise RuntimeError(f"Unexpected enum {self!r}")
def __call__(self, op1: float, op2: float) -> float:
return self.value(op1, op2)
@dataclass
class BinOpQuestion(Question):
result: float
def check(self, answer: str) -> None:
if math.isclose(self.result, float(answer)):
raise ValueError("Answer does not match expected result")
@classmethod
def from_operator(cls, op: BinOp, left: float, right: float) -> Self:
return cls(
title=f"What is {left} {op} {right}?",
result=op(left, right),
)
@dataclass
class Quiz:
questions: list[Question]
@classmethod
def from_difficulty(cls, difficulty: Difficulty) -> Self:
questions: list[Question] = []
if difficulty == Difficulty.EASY:
lower = 0
upper = 30
elif difficulty == Difficulty.MEDIUM:
lower = 10
upper = 100
elif difficulty == Difficulty.HARD:
lower = 50
upper = 500
else:
assert_never(difficulty)
ops = list(BinOp)
for _ in range(5):
op = random.choice(ops)
question = BinOpQuestion.from_operator(
op,
random.randint(lower, upper),
random.randint(lower, upper),
)
questions.append(question)
return cls(questions)
class QuizFrame(Frame):
title = "Quiz"
def __init__(self, app: QuizApp, quiz: Quiz) -> None:
super().__init__(app, padding=10)
self.app = app
self.quiz = quiz
self._submitted = False
self.grid_columnconfigure(0, weight=1)
self.responses: list[QuizResponse] = []
for question in self.quiz.questions:
response = create_quiz_response(self, question)
response.register_refresh(self.refresh)
response.grid(sticky="ew")
self.responses.append(response)
self.controls = QuizControls(self)
self.controls.grid(sticky="sew")
self.grid_rowconfigure(self.controls.grid_info()["row"], weight=1)
def completed(self) -> bool:
return all(response.get() is not None for response in self.responses)
def submitted(self) -> bool:
return self._submitted
def submit(self) -> None:
if self._submitted:
return
self._submitted = True
... # TODO: reveal answers
def refresh(self) -> None:
self.controls.refresh()
class QuizResponse(ABC, Frame):
@abstractmethod
def get(self) -> str | None:
raise NotImplementedError
@abstractmethod
def register_refresh(self, callback: Callable[[], Any]) -> None:
raise NotImplementedError
class BinOpQuizResponse(QuizResponse):
def __init__(self, parent: QuizFrame, question: BinOpQuestion) -> None:
super().__init__(parent)
self.parent = parent
self.question = question
self.grid_columnconfigure(0, weight=1)
self._validatecommand = (self.register(self._validate), "%P")
self.title = Label(self, text=question.title)
self.title.grid(row=0, column=0, sticky="ew")
self.var = StringVar(self)
self.entry = Entry(
self,
textvariable=self.var,
validatecommand=self._validatecommand,
)
self.entry.grid(row=0, column=1)
def get(self) -> str | None:
content = self.entry.get()
if self._validate(content, allow_empty=False):
return content
def register_refresh(self, callback: Callable[[], Any]) -> None:
self.var.trace_add("write", lambda name1, name2, op: callback())
def _validate(self, val: str, *, allow_empty: bool = True) -> bool:
if val == "":
return allow_empty
try:
float(val)
except ValueError:
return False
return True
def create_quiz_response(parent: QuizFrame, question: Question) -> QuizResponse:
if isinstance(question, BinOpQuestion):
return BinOpQuizResponse(parent, question)
raise RuntimeError(f"Unsupported question type {type(question).__name__!r}")
class QuizControls(Frame):
def __init__(self, parent: QuizFrame) -> None:
super().__init__(parent)
self.parent = parent
self.app = parent.app
self.grid_columnconfigure("0 1", weight=1)
self.back = MainMenuButton(self, self.app)
self.back.grid(row=0, column=0, sticky="w")
self.submit = Button(self, text="Submit", command=self.do_submit)
self.submit.grid(row=0, column=1, sticky="e")
self.refresh()
def refresh(self) -> None:
self.back.configure(text="Return" if self.parent.submitted() else "Cancel")
self.submit.state(["!disabled" if self.parent.completed() else "disabled"])
def do_submit(self) -> None:
self.parent.submit()
self.refresh()
if __name__ == "__main__":
main()
@thegamecracks
Copy link
Author

Main menu
Settings
Credits
Start
Quiz

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment