From 632621fe040ef2f41374d3155bf04300c7428ccb Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:44:37 +0000 Subject: [PATCH 1/2] fix(ui): use themed dialogs with english buttons --- src/generators/tkinter_generator.py | 18 +++-- src/rooms/tkinter/dialog.py | 116 ++++++++++++++++++++++++++++ test/tkinter_generator_test.py | 45 +++++++++++ 3 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 src/rooms/tkinter/dialog.py diff --git a/src/generators/tkinter_generator.py b/src/generators/tkinter_generator.py index af1f632..04069e0 100644 --- a/src/generators/tkinter_generator.py +++ b/src/generators/tkinter_generator.py @@ -6,7 +6,7 @@ import secrets import tkinter as tk from typing import Callable, List, Optional, Union -from tkinter import filedialog, messagebox +from tkinter import filedialog from tkinter import ttk from floors.floor import Floor @@ -14,6 +14,7 @@ from generators.floor_manager import FloorManager from generators.generator import Generator from generators.theme_handler import ThemeHandler +from rooms.tkinter import dialog from rooms.tkinter.legend_panel import LegendPanel from rooms.tkinter.tooltip import RoomToolTip, ToolTip from utils import util_functions, globals as my_globals @@ -159,8 +160,13 @@ def save(self, path: str = "") -> str: # Check if the file already exists if os.path.exists(path): # Show dialog for overwriting the file - if messagebox.askyesno( - "File already exists", "Do you want to overwrite the file?" + if dialog.ask( + self._tk, + "File already exists", + f"{os.path.basename(path)} already exists. " + "Do you want to overwrite it?", + "Overwrite", + "Cancel", ): save_file = True else: @@ -169,7 +175,7 @@ def save(self, path: str = "") -> str: # Save the file to the given path path = super().save(path) # Show dialog for saving the file - messagebox.showinfo("Save", "File saved successfully!") + dialog.inform(self._tk, "Save", "File saved successfully!") return path options = util_functions.json_file_options.copy() @@ -205,7 +211,9 @@ def quit(self) -> None: """ Shows a dialog for quitting the application. """ - if messagebox.askyesno("Quit", "Do you really want to quit?"): + if dialog.ask( + self._tk, "Quit", "Do you really want to quit?", "Quit", "Cancel" + ): self._tk.quit() def _redraw_floor(self) -> None: diff --git a/src/rooms/tkinter/dialog.py b/src/rooms/tkinter/dialog.py new file mode 100644 index 0000000..6c57aee --- /dev/null +++ b/src/rooms/tkinter/dialog.py @@ -0,0 +1,116 @@ +""" +Simple dialogs in the style of the application. +Native message boxes use the language of the operating system for their buttons, +so they would mix English texts with e.g. German buttons and ignore the dark theme. +""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional + +from rooms.tkinter import style + + +class Dialog: + """ + Modal dialog with a message and one or two buttons. + """ + + def __init__( + self, + parent: tk.Misc, + title: str, + message: str, + confirm_text: str, + cancel_text: Optional[str] = None, + ) -> None: + """ + Creates the dialog without showing it. + @param parent: window the dialog belongs to + @param title: title of the dialog window + @param message: message to show + @param confirm_text: text of the confirm button + @param cancel_text: text of the cancel button, None for no cancel button + """ + self.result = False + palette = style.get_palette() + self.window = tk.Toplevel(parent, background=palette.window) + self.window.title(title) + self.window.resizable(False, False) + self.window.transient(parent.winfo_toplevel()) + + content = ttk.Frame(self.window, padding=(20, 16, 20, 14)) + content.pack(fill=tk.BOTH) + ttk.Label(content, text=message, wraplength=360).pack(anchor=tk.W) + buttons = ttk.Frame(content) + buttons.pack(anchor=tk.E, pady=(16, 0)) + self.confirm_button = ttk.Button( + buttons, text=confirm_text, command=self.confirm + ) + self.confirm_button.pack(side=tk.RIGHT) + self.cancel_button: Optional[ttk.Button] = None + if cancel_text is not None: + self.cancel_button = ttk.Button( + buttons, text=cancel_text, command=self.cancel + ) + self.cancel_button.pack(side=tk.RIGHT, padx=(0, 8)) + + self.window.bind("", lambda event: self.confirm()) + self.window.bind("", lambda event: self.cancel()) + self.window.protocol("WM_DELETE_WINDOW", self.cancel) + self._center_over(parent.winfo_toplevel()) + + def _center_over(self, parent: tk.Misc) -> None: + self.window.update_idletasks() + x = ( + parent.winfo_rootx() + + (parent.winfo_width() - self.window.winfo_reqwidth()) // 2 + ) + y = ( + parent.winfo_rooty() + + (parent.winfo_height() - self.window.winfo_reqheight()) // 3 + ) + self.window.geometry(f"+{max(x, 0)}+{max(y, 0)}") + + def confirm(self) -> None: + """ + Closes the dialog with a positive result. + """ + self.result = True + self.window.destroy() + + def cancel(self) -> None: + """ + Closes the dialog with a negative result. + """ + self.result = False + self.window.destroy() + + def show(self) -> bool: + """ + Shows the dialog and waits until it is closed. + @return: True if the dialog was confirmed + """ + # Wait until the dialog is visible, then keep all input in the dialog + self.window.wait_visibility() + self.window.grab_set() + self.confirm_button.focus_force() + self.window.wait_window() + return self.result + + +def ask( + parent: tk.Misc, title: str, message: str, confirm_text: str, cancel_text: str +) -> bool: + """ + Asks a question with a confirm and a cancel button. + @return: True if the question was confirmed + """ + return Dialog(parent, title, message, confirm_text, cancel_text).show() + + +def inform(parent: tk.Misc, title: str, message: str) -> None: + """ + Shows a message with an OK button. + """ + Dialog(parent, title, message, "OK").show() diff --git a/test/tkinter_generator_test.py b/test/tkinter_generator_test.py index 02bf76f..5345922 100644 --- a/test/tkinter_generator_test.py +++ b/test/tkinter_generator_test.py @@ -395,6 +395,51 @@ def test_buttons_have_tooltips_with_their_shortcuts(self): self.assertEqual("Generate a new floor (Space)", tooltip.get_shown_text()) + def _answer_dialog(self, key: str) -> None: + """ + Presses the key in the next dialog that opens. + Closes the dialog after a few seconds, so a broken dialog fails the test + instead of blocking it. + """ + tk = self._generator._tk + + def dialogs(): + return [w for w in tk.winfo_children() if w.winfo_class() == "Toplevel"] + + def press(): + if not dialogs(): + tk.after(20, press) + return + (tk.focus_get() or dialogs()[-1]).event_generate(key, when="tail") + + def close_stuck_dialogs(): + for window in dialogs(): + window.destroy() + + tk.after(20, press) + tk.after(3000, close_stuck_dialogs) + + def test_dialog_uses_its_own_button_texts(self): + # pylint: disable=import-outside-toplevel + from rooms.tkinter.dialog import Dialog + + dialog = Dialog(self._generator._tk, "Quit", "Quit?", "Quit", "Cancel") + + self.assertEqual("Quit", dialog.confirm_button.cget("text")) + self.assertEqual("Cancel", dialog.cancel_button.cget("text")) + dialog.cancel() + + def test_quit_asks_and_quits_on_confirm(self): + generator = self._generator + with mock.patch.object(generator._tk, "quit") as quit_app: + self._answer_dialog("") + generator.quit() + quit_app.assert_not_called() + + self._answer_dialog("") + generator.quit() + quit_app.assert_called_once() + if __name__ == "__main__": unittest.main() From 59914d9c758230acba9b77f748e9ea035e0509fe Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:45:03 +0000 Subject: [PATCH 2/2] feat(ui): draw floors from the start room outwards --- readme.md | 3 +- src/floors/floor.py | 65 ++++++++++++++++++++++++++++++++++ src/floors/tkinter_floor.py | 24 +++++++------ test/floor_test.py | 45 +++++++++++++++++++++++ test/tkinter_generator_test.py | 11 +++++- 5 files changed, 135 insertions(+), 13 deletions(-) diff --git a/readme.md b/readme.md index 8600d96..3c2fad5 100644 --- a/readme.md +++ b/readme.md @@ -49,7 +49,8 @@ A number based on the current time is appended to the file name, e.g. `generatio python src/main.py --ui ``` -The UI draws a generated floor room by room, including the doors between the rooms +The UI draws a generated floor step by step, spreading from the start room through the doors +and revealing the secret rooms last. It shows the doors between the rooms and an arrow from a teleport room to its target. Every generated floor is kept, so you can switch between them. A legend next to the floor shows the room types and how often they appear on the current floor. diff --git a/src/floors/floor.py b/src/floors/floor.py index d47fa10..fd3240c 100644 --- a/src/floors/floor.py +++ b/src/floors/floor.py @@ -19,6 +19,13 @@ from rooms.room import Room from rooms.teleport_room import TeleportRoom +_DOOR_DIRECTIONS = { + DoorFace.TOP: Direction.UP, + DoorFace.EAST: Direction.RIGHT, + DoorFace.BOTTOM: Direction.DOWN, + DoorFace.WEST: Direction.LEFT, +} + class Floor: """ @@ -285,6 +292,64 @@ def has_special_room_as_neighbour(self, coordinates: Tuple[int, int]) -> bool: return True return False + def rooms_by_distance(self) -> List[List[Room]]: + """ + Groups the rooms by how many doors away from the start room they are, + following teleport rooms to their target. + All cells of a big boss room are in the same group + and secret rooms are hidden, so they come last. + @return: rings of rooms, starting with the start room + """ + hidden_types = (RoomType.SECRET_ROOM, RoomType.SUPER_SECRET_ROOM) + rooms_by_id = {room.get_id(): room for room in self._rooms} + start = [r for r in self._rooms if r.get_type() == RoomType.START_ROOM] + seen = {room.get_id() for room in start} + rings: List[List[Room]] = [start] if start else [] + while rings and rings[-1]: + next_ring: List[Room] = [] + for room in rings[-1]: + for neighbour in self._connected_rooms(room, rooms_by_id): + if ( + neighbour.get_id() not in seen + and neighbour.get_type() not in hidden_types + ): + seen.add(neighbour.get_id()) + next_ring.append(neighbour) + if neighbour.get_type() == RoomType.BOSS_ROOM: + # All cells of a big boss room appear together + for boss_cell in self._rooms: + if ( + boss_cell.get_type() == RoomType.BOSS_ROOM + and boss_cell.get_id() not in seen + ): + seen.add(boss_cell.get_id()) + next_ring.append(boss_cell) + rings.append(sorted(next_ring, key=lambda r: r.get_id())) + rings = [ring for ring in rings if ring] + remaining = [room for room in self._rooms if room.get_id() not in seen] + if remaining: + rings.append(sorted(remaining, key=lambda r: r.get_id())) + return rings + + def _connected_rooms(self, room: Room, rooms_by_id: dict) -> List[Room]: + """ + Returns the rooms behind the doors of a room and the target of a teleport. + """ + connected = [] + for door in room.get_doors(): + neighbour = self.get_room( + utils.util_functions.add_direction_to_coordinates( + _DOOR_DIRECTIONS[door], (room[0], room[1]) + ) + ) + if neighbour is not None: + connected.append(neighbour) + if isinstance(room, TeleportRoom): + target = rooms_by_id.get(room.get_connected_room_id()) + if target is not None: + connected.append(target) + return connected + def get_boss_room(self) -> Optional[Room]: """ Returns the boss room of the floor. diff --git a/src/floors/tkinter_floor.py b/src/floors/tkinter_floor.py index 14e1576..f8d56c9 100644 --- a/src/floors/tkinter_floor.py +++ b/src/floors/tkinter_floor.py @@ -174,25 +174,27 @@ def stop_drawing(self) -> None: def draw_step_by_step(self, delay_ms: int = 500) -> None: """ - Draws the floor room by room using the tkinter event loop. - @param delay_ms: delay between two rooms in milliseconds + Draws the floor step by step using the tkinter event loop, + starting with the start room and spreading through the doors. + @param delay_ms: delay between two steps in milliseconds """ self.stop_drawing() self._draw_background() - sorted_rooms = sorted(self._tkinter_rooms(), key=lambda r: r.get_id()) - self._draw_next_room(sorted_rooms, delay_ms) + rings = cast(List[List[TkinterRoom]], self.rooms_by_distance()) + self._draw_next_ring(rings, delay_ms) - def _draw_next_room(self, rooms: List[TkinterRoom], delay_ms: int) -> None: + def _draw_next_ring(self, rings: List[List[TkinterRoom]], delay_ms: int) -> None: """ - Draws the first of the given rooms and schedules the remaining rooms. - @param rooms: rooms that still have to be drawn - @param delay_ms: delay between two rooms in milliseconds + Draws the first of the given rings of rooms and schedules the remaining rings. + @param rings: rings of rooms that still have to be drawn + @param delay_ms: delay between two rings in milliseconds """ self._after_id = None - if len(rooms) == 0: + if len(rings) == 0: self._draw_teleport_links() return - rooms[0].draw(self._canvas) + for room in rings[0]: + room.draw(self._canvas) self._after_id = self._canvas.after( - delay_ms, self._draw_next_room, rooms[1:], delay_ms + delay_ms, self._draw_next_ring, rings[1:], delay_ms ) diff --git a/test/floor_test.py b/test/floor_test.py index d9a2fa2..57abd08 100644 --- a/test/floor_test.py +++ b/test/floor_test.py @@ -87,6 +87,51 @@ def test_is_dead_end(self): def test_is_dead_end_false(self): self.assertFalse(self._floor_with_rooms.is_dead_end(2, 2)) + def test_rooms_by_distance_spreads_from_the_start_room(self): + # pylint: disable=import-outside-toplevel + from generators.generator import Generator + from rooms.teleport_room import TeleportRoom + + hidden = (RoomType.SECRET_ROOM, RoomType.SUPER_SECRET_ROOM) + for stage_id in (0, 2, 5): + for seed in range(60): + with self.subTest(stage_id=stage_id, seed=seed): + generator = Generator(str(seed), "test", "", stage_id) + generator.generate() + floor = generator._floor + rings = floor.rooms_by_distance() + + ids = [room.get_id() for ring in rings for room in ring] + self.assertEqual( + sorted(r.get_id() for r in floor.get_rooms()), sorted(ids) + ) + self.assertEqual( + [RoomType.START_ROOM], [r.get_type() for r in rings[0]] + ) + self.assertTrue(all(r.get_type() in hidden for r in rings[-1])) + boss_rings = { + index + for index, ring in enumerate(rings) + for room in ring + if room.get_type() == RoomType.BOSS_ROOM + } + self.assertEqual(1, len(boss_rings)) + # Every visible room is reached from the ring before it + for index in range(1, len(rings) - 1): + previous = rings[index - 1] + for room in rings[index]: + if room.get_type() == RoomType.BOSS_ROOM: + continue + reached = any( + abs(room[0] - other[0]) + abs(room[1] - other[1]) == 1 + or ( + isinstance(other, TeleportRoom) + and other.get_connected_room_id() == room.get_id() + ) + for other in previous + ) + self.assertTrue(reached) + if __name__ == "__main__": unittest.main() diff --git a/test/tkinter_generator_test.py b/test/tkinter_generator_test.py index 5345922..6e8a456 100644 --- a/test/tkinter_generator_test.py +++ b/test/tkinter_generator_test.py @@ -101,12 +101,21 @@ def _pump(self, milliseconds: int) -> None: def _drawn_rooms(self) -> int: return len(self._generator._canvas.find_withtag("room")) - def test_draw_step_by_step_draws_one_room_after_another(self): + def test_draw_step_by_step_starts_with_the_start_room(self): floor = self._generator._floor floor.draw_step_by_step(delay_ms=10) self.assertEqual(1, self._drawn_rooms()) + start = next( + r for r in floor.get_rooms() if r.get_type() == RoomType.START_ROOM + ) + start_x = start[0] * my_globals.ROOM_WIDTH + my_globals.ROOM_WIDTH / 2 + start_y = start[1] * my_globals.ROOM_HEIGHT + my_globals.ROOM_HEIGHT / 2 + canvas = self._generator._canvas + drawn = canvas.find_withtag("room")[0] + x0, y0, x1, y1 = canvas.bbox(drawn) + self.assertTrue(x0 < start_x < x1 and y0 < start_y < y1) self._pump(10 * len(floor.get_rooms()) + 300) self.assertEqual(len(floor.get_rooms()), self._drawn_rooms())