Created
August 31, 2024 20:08
-
-
Save thegamecracks/ef7f6ce0196b778899efecb8def5c753 to your computer and use it in GitHub Desktop.
A complete rewrite of someone else's Tkinter quiz GUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Author
thegamecracks
commented
Aug 31, 2024





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