From b435d01d8620dc3ae5af88b55ca943215ad9f33e Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:09:48 +0000 Subject: [PATCH 1/6] feat(ui): draw doors, teleport links and themed room colors --- src/floors/tkinter_floor.py | 76 +++++++++++++++++++- src/rooms/tkinter/style.py | 111 ++++++++++++++++++++++++++++++ src/rooms/tkinter/tkinter_room.py | 91 ++++++++++++++++++++---- test/tkinter_generator_test.py | 22 ++++-- 4 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 src/rooms/tkinter/style.py diff --git a/src/floors/tkinter_floor.py b/src/floors/tkinter_floor.py index a2a091d..7aec780 100644 --- a/src/floors/tkinter_floor.py +++ b/src/floors/tkinter_floor.py @@ -8,8 +8,10 @@ from rooms.room import Room from rooms.teleport_room import TeleportRoom +from rooms.tkinter import style from rooms.tkinter.tkinter_room import TkinterRoom from rooms.tkinter.tkinter_teleport_room import TkinterTeleportRoom +from utils import globals as my_globals from utils.room_type import RoomType @@ -88,9 +90,78 @@ def draw(self) -> None: Draws the floor with all the rooms on the canvas. """ self.stop_drawing() - self._canvas.delete("all") + self._draw_background() for room in self._tkinter_rooms(): room.draw(self._canvas) + self._draw_teleport_links() + + def _draw_background(self) -> None: + """ + Clears the canvas and draws the empty cells of the floor. + """ + palette = style.get_palette() + self._canvas.delete("all") + self._canvas.configure(background=palette.canvas) + for x in range(self._width): + for y in range(self._height): + x0 = x * my_globals.ROOM_WIDTH + style.ROOM_PADDING_X + y0 = y * my_globals.ROOM_HEIGHT + style.ROOM_PADDING_Y + self._canvas.create_rectangle( + x0, + y0, + x0 + my_globals.ROOM_WIDTH - 2 * style.ROOM_PADDING_X, + y0 + my_globals.ROOM_HEIGHT - 2 * style.ROOM_PADDING_Y, + outline=palette.grid, + width=1, + tags=("grid",), + ) + + def _draw_teleport_links(self) -> None: + """ + Draws a dashed line from every teleport room to its connected room. + """ + rooms_by_id = {room.get_id(): room for room in self._rooms} + for room in self._rooms: + if not isinstance(room, TeleportRoom): + continue + target = rooms_by_id.get(room.get_connected_room_id()) + if target is None: + continue + start = self._cell_center(room) + end = self._cell_center(target) + self._canvas.create_line( + *self._room_edge(start, end), + *self._room_edge(end, start), + fill=style.get_palette().teleport_link, + width=2, + dash=(6, 4), + arrow="last", + arrowshape=(10, 12, 5), + tags=("link",), + ) + + @staticmethod + def _room_edge(center, towards): + """ + Returns the point where a line from the center of a room + towards another point leaves the room. + """ + delta_x = towards[0] - center[0] + delta_y = towards[1] - center[1] + half_width = my_globals.ROOM_WIDTH / 2 - style.ROOM_PADDING_X + half_height = my_globals.ROOM_HEIGHT / 2 - style.ROOM_PADDING_Y + scale = min( + half_width / abs(delta_x) if delta_x else float("inf"), + half_height / abs(delta_y) if delta_y else float("inf"), + ) + return center[0] + delta_x * scale, center[1] + delta_y * scale + + @staticmethod + def _cell_center(room: Room): + return ( + room[0] * my_globals.ROOM_WIDTH + my_globals.ROOM_WIDTH / 2, + room[1] * my_globals.ROOM_HEIGHT + my_globals.ROOM_HEIGHT / 2, + ) def stop_drawing(self) -> None: """ @@ -106,7 +177,7 @@ def draw_step_by_step(self, delay_ms: int = 500) -> None: @param delay_ms: delay between two rooms in milliseconds """ self.stop_drawing() - self._canvas.delete("all") + self._draw_background() sorted_rooms = sorted(self._tkinter_rooms(), key=lambda r: r.get_id()) self._draw_next_room(sorted_rooms, delay_ms) @@ -118,6 +189,7 @@ def _draw_next_room(self, rooms: List[TkinterRoom], delay_ms: int) -> None: """ self._after_id = None if len(rooms) == 0: + self._draw_teleport_links() return rooms[0].draw(self._canvas) self._after_id = self._canvas.after( diff --git a/src/rooms/tkinter/style.py b/src/rooms/tkinter/style.py new file mode 100644 index 0000000..aaebaa9 --- /dev/null +++ b/src/rooms/tkinter/style.py @@ -0,0 +1,111 @@ +""" +Colors and sizes used to draw the floors in the tkinter UI. +""" + +from dataclasses import dataclass +from typing import Dict + +from utils.room_type import RoomType + +# Space between the border of a cell and the room drawn in it +ROOM_PADDING_X = 10 +ROOM_PADDING_Y = 8 +# Radius of the rounded room corners +ROOM_CORNER_RADIUS = 8 +# Width of the door connectors between two rooms +DOOR_WIDTH = 14 +ICON_SIZE = 28 + + +@dataclass(frozen=True) +class Palette: + """ + Colors of one theme. + """ + + name: str + window: str + panel: str + canvas: str + grid: str + room_outline: str + door: str + teleport_link: str + text: str + muted_text: str + button: str + button_active: str + entry: str + rooms: Dict[RoomType, str] + + +LIGHT = Palette( + name="light", + window="#e5e9f0", + panel="#e5e9f0", + canvas="#f4f6f9", + grid="#dde2ea", + room_outline="#4c566a", + door="#4c566a", + teleport_link="#8e6bb8", + text="#2e3440", + muted_text="#5e6779", + button="#ffffff", + button_active="#d8dee9", + entry="#ffffff", + rooms={ + RoomType.NORMAL_ROOM: "#cdd5e1", + RoomType.DEAD_END: "#b7c1d1", + RoomType.ITEM_ROOM: "#92c47f", + RoomType.SHOP_ROOM: "#ebcb6e", + RoomType.START_ROOM: "#ee9d5e", + RoomType.TELEPORT_ROOM: "#b28fd8", + RoomType.BOSS_ROOM: "#d9665f", + RoomType.SECRET_ROOM: "#7393cf", + }, +) + +DARK = Palette( + name="dark", + window="#1b1e24", + panel="#1b1e24", + canvas="#232730", + grid="#2c313c", + room_outline="#11131a", + door="#8d97a8", + teleport_link="#b28fd8", + text="#e5e9f0", + muted_text="#a3abb9", + button="#2e3440", + button_active="#3b4252", + entry="#2e3440", + rooms={ + RoomType.NORMAL_ROOM: "#4c5669", + RoomType.DEAD_END: "#3e4757", + RoomType.ITEM_ROOM: "#5f9a55", + RoomType.SHOP_ROOM: "#c4a446", + RoomType.START_ROOM: "#cf7a3d", + RoomType.TELEPORT_ROOM: "#8a64b5", + RoomType.BOSS_ROOM: "#b84d47", + RoomType.SECRET_ROOM: "#4f6fae", + }, +) + +_current = LIGHT + + +def get_palette() -> Palette: + """ + Returns the palette of the current theme. + @return: current palette + """ + return _current + + +def set_palette(palette: Palette) -> None: + """ + Sets the palette of the current theme. + @param palette: palette to use + """ + global _current # pylint: disable=global-statement + _current = palette diff --git a/src/rooms/tkinter/tkinter_room.py b/src/rooms/tkinter/tkinter_room.py index 19e9624..9212bb4 100644 --- a/src/rooms/tkinter/tkinter_room.py +++ b/src/rooms/tkinter/tkinter_room.py @@ -3,11 +3,27 @@ """ import tkinter as tk +from typing import List + from rooms.room import Room -from utils import util_functions, globals as my_globals, room_type +from rooms.tkinter import style +from utils import util_functions, globals as my_globals +from utils.globals import DoorFace from utils.room_type import RoomType +def _rounded_rectangle(x0, y0, x1, y1, radius) -> List[float]: + """ + Returns the points of a rectangle with rounded corners + for a smoothed canvas polygon. + """ + return [ + x0 + radius, y0, x1 - radius, y0, x1, y0, x1, y0 + radius, + x1, y1 - radius, x1, y1, x1 - radius, y1, x0 + radius, y1, + x0, y1, x0, y1 - radius, x0, y0 + radius, x0, y0, + ] # fmt: skip + + class TkinterRoom(Room): """ Tkinter version of room. @@ -33,20 +49,69 @@ def __init__( def draw(self, canvas: tk.Canvas): """ - Draws the room on the given canvas. - """ - x0 = self._x * my_globals.ROOM_WIDTH - y0 = self._y * my_globals.ROOM_HEIGHT - x1 = x0 + self._width - y1 = y0 + self._height - color = room_type.room_colors[self._type].value - hex_color = util_functions.rgb2hex(color[0], color[1], color[2]) - canvas.create_rectangle((x0, y0), (x1, y1), fill=hex_color) - center_x = x0 + self._width / 2 - center_y = y0 + self._height / 2 + Draws the room with its doors on the given canvas. + """ + palette = style.get_palette() + cell_x0 = self._x * my_globals.ROOM_WIDTH + cell_y0 = self._y * my_globals.ROOM_HEIGHT + x0 = cell_x0 + style.ROOM_PADDING_X + y0 = cell_y0 + style.ROOM_PADDING_Y + x1 = cell_x0 + self._width - style.ROOM_PADDING_X + y1 = cell_y0 + self._height - style.ROOM_PADDING_Y + center_x = cell_x0 + self._width / 2 + center_y = cell_y0 + self._height / 2 + + self._draw_doors(canvas, cell_x0, cell_y0, (x0, y0, x1, y1)) + canvas.create_polygon( + _rounded_rectangle(x0, y0, x1, y1, style.ROOM_CORNER_RADIUS), + smooth=True, + fill=palette.rooms[self._type], + outline=palette.room_outline, + width=2, + dash=(4, 3) if self._type == RoomType.SECRET_ROOM else (), + tags=("room",), + ) + if self._type == RoomType.TELEPORT_ROOM: + for radius, width in ((14, 3), (7, 2)): + canvas.create_oval( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + outline=palette.canvas, + width=width, + tags=("icon",), + ) + return self._image = util_functions.get_picture_for_room_type(self._type) if self._image is not None: - canvas.create_image(center_x, center_y, image=self._image, anchor="center") + canvas.create_image( + center_x, center_y, image=self._image, anchor="center", tags=("icon",) + ) + + def _draw_doors(self, canvas: tk.Canvas, cell_x0, cell_y0, room) -> None: + """ + Draws a connector from the room to the border of its cell for every door. + """ + x0, y0, x1, y1 = room + cell_x1 = cell_x0 + self._width + cell_y1 = cell_y0 + self._height + center_x = cell_x0 + self._width / 2 + center_y = cell_y0 + self._height / 2 + half = style.DOOR_WIDTH / 2 + connectors = { + DoorFace.TOP: (center_x - half, cell_y0, center_x + half, y0 + 2), + DoorFace.BOTTOM: (center_x - half, y1 - 2, center_x + half, cell_y1), + DoorFace.WEST: (cell_x0, center_y - half, x0 + 2, center_y + half), + DoorFace.EAST: (x1 - 2, center_y - half, cell_x1, center_y + half), + } + for door in self._doors: + canvas.create_rectangle( + *connectors[door], + fill=style.get_palette().door, + width=0, + tags=("door",), + ) @classmethod def from_room(cls, room: Room): diff --git a/test/tkinter_generator_test.py b/test/tkinter_generator_test.py index f052b89..c5c2f92 100644 --- a/test/tkinter_generator_test.py +++ b/test/tkinter_generator_test.py @@ -112,10 +112,7 @@ def _pump(self, milliseconds: int) -> None: time.sleep(0.005) def _drawn_rooms(self) -> int: - canvas = self._generator._canvas - return len( - [item for item in canvas.find_all() if canvas.type(item) == "rectangle"] - ) + return len(self._generator._canvas.find_withtag("room")) def test_draw_step_by_step_draws_one_room_after_another(self): floor = self._generator._floor @@ -145,6 +142,23 @@ def test_generating_a_new_floor_stops_drawing_the_previous_one(self): # after 500 ms. Rooms of the previous floor must not be drawn anymore. self.assertEqual(1, self._drawn_rooms()) + def test_draw_shows_every_door(self): + floor = self._generator._floor + + floor.draw() + + doors = sum(len(room.get_doors()) for room in floor.get_rooms()) + self.assertEqual(doors, len(self._generator._canvas.find_withtag("door"))) + + def test_draw_links_teleport_rooms_to_their_target(self): + # Seed "2" on floor 2 has a boss room behind a teleport room + self._generator.generate("2") + floor = self._generator._floor + + floor.draw() + + self.assertEqual(1, len(self._generator._canvas.find_withtag("link"))) + if __name__ == "__main__": unittest.main() From 77588676db0a2bbbe557a469403828df39733dfb Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:10:12 +0000 Subject: [PATCH 2/6] feat(ui): complete the dark theme and tidy up the window layout --- src/generators/floor_manager.py | 10 +++ src/generators/theme_handler.py | 84 ++++++++++++------- src/generators/tkinter_generator.py | 121 ++++++++++++++++++++-------- src/rooms/tkinter/legend_window.py | 62 ++++++++++---- test/tkinter_generator_test.py | 18 +++++ 5 files changed, 214 insertions(+), 81 deletions(-) diff --git a/src/generators/floor_manager.py b/src/generators/floor_manager.py index 476339a..d70578a 100644 --- a/src/generators/floor_manager.py +++ b/src/generators/floor_manager.py @@ -25,6 +25,7 @@ def __init__(self, output_file_name: str) -> None: self.current_floor_name = tk.StringVar() self.current_floor_name.set(output_file_name) self.current_floor_seed_var = tk.StringVar() + self.position = tk.StringVar() self.next_seed = tk.StringVar() self.next_seed.set(secrets.token_hex(16)) @@ -37,6 +38,7 @@ def add_floor(self, floor: TkinterFloor) -> None: self.current_floor_index = len(self._floors) - 1 self.current_floor_name.set(floor.name) self.current_floor_seed_var.set(floor.seed) + self._update_position() def get_current_floor(self) -> TkinterFloor: """ @@ -63,6 +65,7 @@ def decrease_floor(self) -> None: self._floors[self.current_floor_index].draw() self.current_floor_name.set(self._floors[self.current_floor_index].name) self.current_floor_seed_var.set(self.get_current_floor().seed) + self._update_position() def increase_floor(self) -> None: """ @@ -76,3 +79,10 @@ def increase_floor(self) -> None: current_floor.draw() self.current_floor_name.set(current_floor.name) self.current_floor_seed_var.set(self.get_current_floor().seed) + self._update_position() + + def _update_position(self) -> None: + """ + Updates the text that shows which floor is displayed. + """ + self.position.set(f"Floor {self.current_floor_index + 1} / {len(self._floors)}") diff --git a/src/generators/theme_handler.py b/src/generators/theme_handler.py index 3fa585e..a6c5360 100644 --- a/src/generators/theme_handler.py +++ b/src/generators/theme_handler.py @@ -2,59 +2,85 @@ Module to handle the theme of the application. """ +from typing import Callable, Optional from tkinter import Tk, Canvas, ttk +from rooms.tkinter import style + class ThemeHandler: """ Class to handle the theme of the application. """ - def __init__(self, tk: Tk, canvas: Canvas) -> None: + def __init__( + self, tk: Tk, canvas: Canvas, on_change: Optional[Callable[[], None]] = None + ) -> None: """ Creates a new theme handler. @param tk: Root of the application @param canvas: Canvas of the application + @param on_change: Called after the theme has changed, e.g. to redraw the floor """ self._tk = tk self._canvas = canvas - self._current_theme = "light" + self._on_change = on_change + self._palette = style.LIGHT self.apply_theme() - def _set_light_theme(self) -> None: - self._tk.configure(background="light gray", highlightbackground="black") - style = ttk.Style(self._tk) - style.configure( - "TButton", background="white", foreground="black", hover="black" - ) - style.configure("TFrame", background="light gray", foreground="black") - style.theme_use("default") - self._canvas.configure(background="white", highlightbackground="black") - - def _set_dark_theme(self) -> None: - self._tk.configure(background="dark gray", highlightbackground="white") - self._canvas.configure(background="black", highlightbackground="white") - style = ttk.Style(self._tk) - style.configure("TFrame", background="dark gray", foreground="white") - style.configure("TButton", background="black", foreground="white") - # set color on hover - style.map("TButton", background=[("active", "gray")]) - def switch_theme(self) -> None: """ Switches the theme of the application. """ - if self._current_theme == "light": - self._current_theme = "dark" - else: - self._current_theme = "light" + self._palette = style.DARK if self._palette is style.LIGHT else style.LIGHT self.apply_theme() + if self._on_change is not None: + self._on_change() def apply_theme(self) -> None: """ Applies the current theme to the application. """ - if self._current_theme == "light": - self._set_light_theme() - else: - self._set_dark_theme() + palette = self._palette + style.set_palette(palette) + self._tk.configure(background=palette.window) + self._canvas.configure( + background=palette.canvas, highlightthickness=0, borderwidth=0 + ) + ttk_style = ttk.Style(self._tk) + ttk_style.theme_use("clam") + ttk_style.configure( + ".", + background=palette.panel, + foreground=palette.text, + fieldbackground=palette.entry, + bordercolor=palette.grid, + lightcolor=palette.panel, + darkcolor=palette.panel, + insertcolor=palette.text, + font=("DejaVu Sans", 10), + ) + ttk_style.configure("TFrame", background=palette.panel) + ttk_style.configure("TLabel", background=palette.panel, foreground=palette.text) + ttk_style.configure( + "Muted.TLabel", background=palette.panel, foreground=palette.muted_text + ) + ttk_style.configure( + "TButton", + background=palette.button, + foreground=palette.text, + bordercolor=palette.grid, + focuscolor=palette.button, + padding=(12, 4), + ) + ttk_style.map( + "TButton", + background=[("pressed", palette.grid), ("active", palette.button_active)], + ) + ttk_style.configure( + "TEntry", + fieldbackground=palette.entry, + foreground=palette.text, + bordercolor=palette.grid, + padding=(6, 3), + ) diff --git a/src/generators/tkinter_generator.py b/src/generators/tkinter_generator.py index da272ba..554d596 100644 --- a/src/generators/tkinter_generator.py +++ b/src/generators/tkinter_generator.py @@ -5,6 +5,7 @@ import os import secrets import tkinter as tk +from typing import Optional from tkinter import filedialog, messagebox from tkinter import ttk @@ -37,16 +38,17 @@ def __init__( """ super().__init__(seed, output_file_name, output_file_path, stage_id) self._tk = tk.Tk() - color = my_globals.Color.LIGHT_GRAY.value - hex_color = util_functions.rgb2hex(color[0], color[1], color[2]) self._canvas = tk.Canvas( + self._tk, height=my_globals.FLOOR_HEIGHT * my_globals.ROOM_HEIGHT, width=my_globals.FLOOR_WIDTH * my_globals.ROOM_WIDTH, - background=hex_color, ) + self._bottom_bar: Optional[ttk.Frame] = None self._path = tk.StringVar() self._path.set(self._output_file_path) - self._theme_handler = ThemeHandler(self._tk, self._canvas) + self._theme_handler = ThemeHandler( + self._tk, self._canvas, on_change=self._redraw_floor + ) self._floor_manager = FloorManager(output_file_name) self._tk.protocol("WM_DELETE_WINDOW", self.quit) @@ -141,11 +143,10 @@ def run(self) -> None: Sets up the ui elements and starts the main loop of the application. """ self._tk.title(util_functions.window_title) - self._tk.geometry(util_functions.window_size) self._tk.resizable(False, False) self.generate() self._floor_manager.get_current_floor().draw() - self._canvas.pack(anchor=tk.NW, expand=True) + self._canvas.pack(padx=16, pady=(16, 8)) self.add_information_frame() self.add_buttons() self.create_menu_bar() @@ -158,29 +159,44 @@ def quit(self) -> None: if messagebox.askyesno("Quit", "Do you really want to quit?"): self._tk.quit() + def _redraw_floor(self) -> None: + """ + Draws the current floor again, e.g. after the theme has changed. + """ + self._floor_manager.get_current_floor().draw() + + def _get_bottom_bar(self) -> ttk.Frame: + """ + Returns the bar below the floor that contains the status and the controls. + """ + if self._bottom_bar is None: + self._bottom_bar = ttk.Frame(self._tk, padding=(16, 0, 16, 14)) + self._bottom_bar.pack(fill=tk.X) + return self._bottom_bar + def add_information_frame(self) -> None: """ - Adds a frame to the application which shows information about the current floor. + Adds the status of the current floor below the floor. """ - information_frame = ttk.Frame() - # Create information frame - information_frame.pack(pady=(0, 10)) - name_text = ttk.Label(information_frame, text="Floor name: ") - name_text.pack(side=tk.LEFT) + status = ttk.Frame(self._get_bottom_bar()) + status.pack(side=tk.LEFT) name_label = ttk.Label( - information_frame, textvariable=self._floor_manager.current_floor_name + status, + textvariable=self._floor_manager.current_floor_name, + font=("DejaVu Sans", 10, "bold"), ) name_label.pack(side=tk.LEFT) - path_text = ttk.Label(information_frame, text="Current path: ") - path_text.pack(side=tk.LEFT, padx=(25, 0)) - path_label = ttk.Label(information_frame, textvariable=self._path) - path_label.pack(side=tk.LEFT) - seed_text = ttk.Label(information_frame, text="Seed: ") - seed_text.pack(side=tk.LEFT, padx=(25, 0)) - seed_label = ttk.Label( - information_frame, textvariable=self._floor_manager.current_floor_seed_var - ) - seed_label.pack(side=tk.LEFT) + _ToolTip(name_label, self._path) + ttk.Label(status, text=" · Seed ", style="Muted.TLabel").pack(side=tk.LEFT) + ttk.Label( + status, + textvariable=self._floor_manager.current_floor_seed_var, + style="Muted.TLabel", + ).pack(side=tk.LEFT) + ttk.Label(status, text=" · ", style="Muted.TLabel").pack(side=tk.LEFT) + ttk.Label( + status, textvariable=self._floor_manager.position, style="Muted.TLabel" + ).pack(side=tk.LEFT) def _increase_floor(self) -> None: """ @@ -200,34 +216,39 @@ def add_buttons(self) -> None: """ Adds buttons to the application. """ - # Create button frame - button_frame = ttk.Frame(self._tk) - button_frame.pack(pady=(0, 10)) - button_pre = ttk.Button(button_frame, text="<-", command=self._decrease_floor) - button_pre.pack(side=tk.LEFT, padx=10) + button_frame = ttk.Frame(self._get_bottom_bar()) + button_frame.pack(side=tk.RIGHT) + button_pre = ttk.Button( + button_frame, text="◀", width=3, command=self._decrease_floor + ) + button_pre.pack(side=tk.LEFT) # Left arrow to decrease self._tk.bind("", lambda event: self._decrease_floor()) button_gen = ttk.Button( button_frame, text="Generate", command=self._generate_and_draw_floor ) - button_gen.pack(side=tk.LEFT, padx=10) + button_gen.pack(side=tk.LEFT, padx=6) # Space to generate self._tk.bind("", lambda event: self._generate_and_draw_floor()) - button_next = ttk.Button(button_frame, text="->", command=self._increase_floor) - button_next.pack(side=tk.LEFT, padx=10) + button_next = ttk.Button( + button_frame, text="▶", width=3, command=self._increase_floor + ) + button_next.pack(side=tk.LEFT) # Right arrow to increase self._tk.bind("", lambda event: self._increase_floor()) # Next seed entry - next_seed_label = ttk.Label(button_frame, text="Next seed: ") - next_seed_label.pack(side=tk.LEFT, padx=10) + next_seed_label = ttk.Label( + button_frame, text="Next seed", style="Muted.TLabel" + ) + next_seed_label.pack(side=tk.LEFT, padx=(20, 6)) self._next_seed_entry = ttk.Entry( - button_frame, textvariable=self._floor_manager.next_seed + button_frame, textvariable=self._floor_manager.next_seed, width=34 ) # Enter generates a floor with the entered seed self._next_seed_entry.bind( "", lambda event: self._generate_and_draw_floor() ) - self._next_seed_entry.pack(side=tk.LEFT, padx=10) + self._next_seed_entry.pack(side=tk.LEFT) def create_menu_bar(self) -> None: """ @@ -266,3 +287,33 @@ def _save_current_floor(self) -> None: Saves the current floor to the current output file. """ self.save(os.path.join(self._output_file_path, self._output_file_name)) + + +class _ToolTip: + """ + Shows the value of a variable in a small window while the mouse is over a widget. + """ + + def __init__(self, widget: tk.Widget, text: tk.StringVar) -> None: + self._widget = widget + self._text = text + self._window: Optional[tk.Toplevel] = None + widget.bind("", lambda event: self._show()) + widget.bind("", lambda event: self._hide()) + + def _show(self) -> None: + if self._window is not None or not self._text.get(): + return + self._window = tk.Toplevel(self._widget) + self._window.wm_overrideredirect(True) + x = self._widget.winfo_rootx() + y = self._widget.winfo_rooty() - 28 + self._window.wm_geometry(f"+{x}+{y}") + ttk.Label( + self._window, textvariable=self._text, padding=(6, 2), relief=tk.SOLID + ).pack() + + def _hide(self) -> None: + if self._window is not None: + self._window.destroy() + self._window = None diff --git a/src/rooms/tkinter/legend_window.py b/src/rooms/tkinter/legend_window.py index 1b3c737..ebc2300 100644 --- a/src/rooms/tkinter/legend_window.py +++ b/src/rooms/tkinter/legend_window.py @@ -1,30 +1,58 @@ """ -This module contains the function to display the legend on the canvas. +This module contains the function to display the legend of the room types. """ import tkinter as tk -import utils.globals as my_globals -from utils import util_functions -from utils.room_type import RoomType, room_colors, room_names +from rooms.tkinter import style +from rooms.tkinter.tkinter_room import TkinterRoom +from utils.room_type import RoomType, room_names + +_ROW_HEIGHT = 44 def display_legend(): """ - Displays the legend on the canvas. + Displays the legend of the room types in a new window. """ - legend_window = tk.Toplevel() + palette = style.get_palette() + legend_window = tk.Toplevel(background=palette.window) legend_window.title("Room Type Legend") - legend_canvas = tk.Canvas(legend_window) - legend_canvas.pack() - y_offset = 10 - room_type: RoomType - for room_type in RoomType.get_all(): - color: my_globals.Color = room_colors[room_type] - legend_canvas.create_rectangle( - 10, y_offset, 30, y_offset + 20, fill=util_functions.rgb2hex(*color.value) - ) + legend_window.resizable(False, False) + room_types = RoomType.get_all() + legend_canvas = tk.Canvas( + legend_window, + width=260, + height=len(room_types) * _ROW_HEIGHT + 16, + background=palette.window, + highlightthickness=0, + ) + legend_canvas.pack(padx=8, pady=8) + # Keep references to the room icons, otherwise tkinter discards them + legend_canvas.rooms = [] # type: ignore[attr-defined] + for index, room_type in enumerate(room_types): + room = TkinterRoom(0, 0, index, room_type, width=72, height=_ROW_HEIGHT) + legend_canvas.rooms.append(room) # type: ignore[attr-defined] + _draw_at(legend_canvas, room, 8, 8 + index * _ROW_HEIGHT) legend_canvas.create_text( - 40, y_offset + 10, anchor=tk.W, text=room_names[room_type] + 92, + 8 + index * _ROW_HEIGHT + _ROW_HEIGHT / 2, + anchor=tk.W, + text=room_names[room_type], + fill=palette.text, + font=("DejaVu Sans", 10), ) - y_offset += 30 + + +def _draw_at(canvas: tk.Canvas, room: TkinterRoom, x: int, y: int) -> str: + """ + Draws the room on the canvas and moves it to the given position. + @return: tag of the drawn items + """ + tag = f"legend-{room.get_id()}" + before = set(canvas.find_all()) + room.draw(canvas) + for item in set(canvas.find_all()) - before: + canvas.addtag_withtag(tag, item) + canvas.move(tag, x, y) + return tag diff --git a/test/tkinter_generator_test.py b/test/tkinter_generator_test.py index c5c2f92..b991922 100644 --- a/test/tkinter_generator_test.py +++ b/test/tkinter_generator_test.py @@ -159,6 +159,24 @@ def test_draw_links_teleport_rooms_to_their_target(self): self.assertEqual(1, len(self._generator._canvas.find_withtag("link"))) + def test_switching_the_theme_redraws_the_floor_in_the_new_colors(self): + # pylint: disable=import-outside-toplevel + from rooms.tkinter import style + + floor = self._generator._floor + floor.draw() + canvas = self._generator._canvas + + self._generator._theme_handler.switch_theme() + + self.assertIs(style.DARK, style.get_palette()) + self.assertEqual(style.DARK.canvas, canvas.cget("background")) + start_room = canvas.find_withtag("room")[0] + self.assertIn(canvas.itemcget(start_room, "fill"), style.DARK.rooms.values()) + + self._generator._theme_handler.switch_theme() + self.assertIs(style.LIGHT, style.get_palette()) + if __name__ == "__main__": unittest.main() From 3a2abf49daa2da15a2d6575172958563c3b7cde3 Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:11:41 +0000 Subject: [PATCH 3/6] refactor: remove the unused legacy room colors --- src/utils/globals.py | 21 --------------------- src/utils/room_type.py | 20 -------------------- src/utils/util_functions.py | 8 -------- test/globals_test.py | 17 ----------------- 4 files changed, 66 deletions(-) diff --git a/src/utils/globals.py b/src/utils/globals.py index acf3125..d7139ba 100644 --- a/src/utils/globals.py +++ b/src/utils/globals.py @@ -24,27 +24,6 @@ def list(cls): WEST = 3 -class Color(Enum): - """ - Enum for the colors. - """ - - ORANGE = (255, 140, 0) - RED = (255, 0, 0) - GREEN = (0, 255, 0) - DARK_GREEN = (0, 100, 0) - BLACK = (0, 0, 0) - WHITE = (255, 255, 255) - GRAY = (150, 150, 150) - LIGHT_GRAY = (200, 200, 200) - DARK_GRAY = (100, 100, 100) - YELLOW = (250, 250, 55) - VIOLET = (148, 0, 211) - BLUE = (0, 0, 255) - - -DOOR_COLOR = Color.BLACK - # Room constants MAX_DOOR_AMOUNT: int = 4 ROOM_WIDTH: int = 120 diff --git a/src/utils/room_type.py b/src/utils/room_type.py index edd1ca2..a79ee33 100644 --- a/src/utils/room_type.py +++ b/src/utils/room_type.py @@ -5,8 +5,6 @@ from enum import Enum from typing import List -from utils.globals import Color - class RoomType(Enum): """ @@ -45,13 +43,6 @@ def is_special(self) -> bool: RoomType.SECRET_ROOM, ) - def get_color(self) -> Color: - """ - Returns the color of the room type. - @return: color of the room type - """ - return room_colors[self] - NORMAL_ROOM = 0 DEAD_END = 1 ITEM_ROOM = 2 @@ -62,17 +53,6 @@ def get_color(self) -> Color: SECRET_ROOM = 7 -room_colors = { - RoomType.NORMAL_ROOM: Color.LIGHT_GRAY, - RoomType.DEAD_END: Color.LIGHT_GRAY, - RoomType.ITEM_ROOM: Color.GREEN, - RoomType.SHOP_ROOM: Color.YELLOW, - RoomType.START_ROOM: Color.ORANGE, - RoomType.TELEPORT_ROOM: Color.VIOLET, - RoomType.BOSS_ROOM: Color.RED, - RoomType.SECRET_ROOM: Color.BLUE, -} - room_names = { RoomType.NORMAL_ROOM: "Normal Room", RoomType.DEAD_END: "Dead End", diff --git a/src/utils/util_functions.py b/src/utils/util_functions.py index 1f75fc0..de4f22f 100644 --- a/src/utils/util_functions.py +++ b/src/utils/util_functions.py @@ -56,13 +56,6 @@ def place_room(rng: random.Random) -> bool: return rng.randint(1, 50) >= 25 -def rgb2hex(r, g, b): - """ - Converts rgb to hex. - """ - return f"#{r:02x}{g:02x}{b:02x}" - - def add_direction_to_coordinates( direction: Direction, coordinates: Tuple[int, int] ) -> Tuple[int, int]: @@ -85,7 +78,6 @@ def add_direction_to_coordinates( save_file_text: str = "Save File" window_title: str = "Floor Generator" -window_size: str = "1200x550" def calculate_distance(cord1, cord2) -> int: diff --git a/test/globals_test.py b/test/globals_test.py index 383cf6c..e7f307c 100644 --- a/test/globals_test.py +++ b/test/globals_test.py @@ -2,7 +2,6 @@ import unittest from utils import globals, room_type from utils.direction import Direction -from utils.globals import Color from utils.room_type import RoomType @@ -57,22 +56,6 @@ def test_special_rooms(self): (RoomType.ITEM_ROOM, RoomType.SHOP_ROOM), room_type.SPECIAL_ROOMS ) - def test_door_color(self): - self.assertEqual(Color.BLACK, globals.DOOR_COLOR) - - def test_room_colors(self): - expected = { - RoomType.NORMAL_ROOM: Color.LIGHT_GRAY, - RoomType.DEAD_END: Color.LIGHT_GRAY, - RoomType.ITEM_ROOM: Color.GREEN, - RoomType.SHOP_ROOM: Color.YELLOW, - RoomType.START_ROOM: Color.ORANGE, - RoomType.TELEPORT_ROOM: Color.VIOLET, - RoomType.BOSS_ROOM: Color.RED, - RoomType.SECRET_ROOM: Color.BLUE, - } - self.assertEqual(expected, room_type.room_colors) - def test_room_width(self): self.assertEqual(120, globals.ROOM_WIDTH) From 2017cb9cfc488157b186f005bc40afc3379940a4 Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:40:39 +0000 Subject: [PATCH 4/6] feat(ui): show the legend as a sidebar with room counts --- src/generators/tkinter_generator.py | 54 +++++++++++++--- src/rooms/tkinter/legend_panel.py | 98 +++++++++++++++++++++++++++++ src/rooms/tkinter/legend_window.py | 58 ----------------- test/tkinter_generator_test.py | 34 +++++++++- 4 files changed, 178 insertions(+), 66 deletions(-) create mode 100644 src/rooms/tkinter/legend_panel.py delete mode 100644 src/rooms/tkinter/legend_window.py diff --git a/src/generators/tkinter_generator.py b/src/generators/tkinter_generator.py index 554d596..cfb3196 100644 --- a/src/generators/tkinter_generator.py +++ b/src/generators/tkinter_generator.py @@ -14,7 +14,7 @@ from generators.floor_manager import FloorManager from generators.generator import Generator from generators.theme_handler import ThemeHandler -from rooms.tkinter import legend_window +from rooms.tkinter.legend_panel import LegendPanel from utils import util_functions, globals as my_globals @@ -38,8 +38,9 @@ def __init__( """ super().__init__(seed, output_file_name, output_file_path, stage_id) self._tk = tk.Tk() + self._content = ttk.Frame(self._tk) self._canvas = tk.Canvas( - self._tk, + self._content, height=my_globals.FLOOR_HEIGHT * my_globals.ROOM_HEIGHT, width=my_globals.FLOOR_WIDTH * my_globals.ROOM_WIDTH, ) @@ -49,7 +50,13 @@ def __init__( self._theme_handler = ThemeHandler( self._tk, self._canvas, on_change=self._redraw_floor ) + self._legend = LegendPanel(self._content) + self.show_legend = tk.BooleanVar(self._tk, value=True) self._floor_manager = FloorManager(output_file_name) + # The position changes whenever another floor is shown + self._floor_manager.position.trace_add( + "write", lambda *args: self._update_legend() + ) self._tk.protocol("WM_DELETE_WINDOW", self.quit) def _create_floor(self) -> None: @@ -146,7 +153,9 @@ def run(self) -> None: self._tk.resizable(False, False) self.generate() self._floor_manager.get_current_floor().draw() - self._canvas.pack(padx=16, pady=(16, 8)) + self._content.pack(padx=16, pady=(16, 8)) + self._canvas.pack(side=tk.LEFT) + self._apply_legend_visibility() self.add_information_frame() self.add_buttons() self.create_menu_bar() @@ -161,9 +170,40 @@ def quit(self) -> None: def _redraw_floor(self) -> None: """ - Draws the current floor again, e.g. after the theme has changed. + Draws the current floor and the legend again, e.g. after the theme has changed. """ - self._floor_manager.get_current_floor().draw() + self._legend.redraw() + if self._floor_manager.current_floor_index >= 0: + self._floor_manager.get_current_floor().draw() + + def generate(self, new_seed=None) -> None: + """ + Generates a new floor and updates the legend. + """ + super().generate(new_seed) + self._update_legend() + + def _update_legend(self) -> None: + """ + Shows the room counts of the current floor in the legend. + """ + self._legend.update_counts(self._floor_manager.get_current_floor()) + + def toggle_legend(self) -> None: + """ + Shows or hides the legend next to the floor. + """ + self.show_legend.set(not self.show_legend.get()) + self._apply_legend_visibility() + + def _apply_legend_visibility(self) -> None: + """ + Shows or hides the legend depending on show_legend. + """ + if self.show_legend.get(): + self._legend.pack(side=tk.LEFT, anchor=tk.N, padx=(20, 0)) + else: + self._legend.pack_forget() def _get_bottom_bar(self) -> ttk.Frame: """ @@ -272,14 +312,14 @@ def create_menu_bar(self) -> None: accelerator="Ctrl+t", ) menu_bar.add_command( - label="Legend", command=legend_window.display_legend, accelerator="F1" + label="Legend", command=self.toggle_legend, accelerator="F1" ) menu_bar.add_command(label="Exit", command=self.quit, accelerator="Ctrl+q") self._tk.bind("", lambda event: self.open()) self._tk.bind("", lambda event: self._save_current_floor()) self._tk.bind("", lambda event: self.save()) self._tk.bind("", lambda event: self._theme_handler.switch_theme()) - self._tk.bind("", lambda event: legend_window.display_legend()) + self._tk.bind("", lambda event: self.toggle_legend()) self._tk.bind("", lambda event: self.quit()) def _save_current_floor(self) -> None: diff --git a/src/rooms/tkinter/legend_panel.py b/src/rooms/tkinter/legend_panel.py new file mode 100644 index 0000000..3329cd4 --- /dev/null +++ b/src/rooms/tkinter/legend_panel.py @@ -0,0 +1,98 @@ +""" +This module contains the legend that explains the room types. +""" + +import tkinter as tk +from collections import Counter +from tkinter import ttk +from typing import List + +from floors.floor import Floor +from rooms.tkinter import style +from rooms.tkinter.tkinter_room import TkinterRoom +from utils.room_type import RoomType, room_names + +_ROW_HEIGHT = 44 +_WIDTH = 230 + + +class LegendPanel(ttk.Frame): + """ + Panel next to the floor that shows the room types + and how often they appear on the current floor. + """ + + def __init__(self, parent: tk.Misc) -> None: + super().__init__(parent) + ttk.Label(self, text="Legend", font=("DejaVu Sans", 11, "bold")).pack( + anchor=tk.W, pady=(0, 4) + ) + self._canvas = tk.Canvas( + self, + width=_WIDTH, + height=len(RoomType.get_all()) * _ROW_HEIGHT, + highlightthickness=0, + ) + self._canvas.pack() + # Keep references to the drawn rooms, otherwise tkinter discards their icons + self._rooms: List[TkinterRoom] = [] + self._counts: Counter = Counter() + self.redraw() + + def update_counts(self, floor: Floor) -> None: + """ + Counts the room types of the given floor and shows them in the legend. + @param floor: floor to count the rooms of + """ + self._counts = Counter(room.get_type() for room in floor.get_rooms()) + self.redraw() + + def get_count(self, room_type: RoomType) -> int: + """ + Returns how often the room type appears on the current floor. + """ + return self._counts[room_type] + + def redraw(self) -> None: + """ + Draws the legend in the colors of the current theme. + """ + palette = style.get_palette() + canvas = self._canvas + canvas.delete("all") + canvas.configure(background=palette.panel) + self._rooms = [] + for index, room_type in enumerate(RoomType.get_all()): + top = index * _ROW_HEIGHT + room = TkinterRoom(0, 0, index, room_type, width=72, height=_ROW_HEIGHT) + self._rooms.append(room) + _draw_at(canvas, room, 0, top) + canvas.create_text( + 84, + top + _ROW_HEIGHT / 2, + anchor=tk.W, + text=room_names[room_type], + fill=palette.text, + font=("DejaVu Sans", 10), + ) + canvas.create_text( + _WIDTH - 4, + top + _ROW_HEIGHT / 2, + anchor=tk.E, + text=str(self._counts[room_type]), + fill=palette.muted_text, + font=("DejaVu Sans", 10), + tags=("count",), + ) + + +def _draw_at(canvas: tk.Canvas, room: TkinterRoom, x: int, y: int) -> None: + """ + Draws the room on the canvas and moves it to the given position. + """ + tag = f"legend-{room.get_id()}" + before = set(canvas.find_all()) + room.draw(canvas) + for item in set(canvas.find_all()) - before: + canvas.addtag_withtag(tag, item) + canvas.move(tag, x, y) diff --git a/src/rooms/tkinter/legend_window.py b/src/rooms/tkinter/legend_window.py deleted file mode 100644 index ebc2300..0000000 --- a/src/rooms/tkinter/legend_window.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -This module contains the function to display the legend of the room types. -""" - -import tkinter as tk - -from rooms.tkinter import style -from rooms.tkinter.tkinter_room import TkinterRoom -from utils.room_type import RoomType, room_names - -_ROW_HEIGHT = 44 - - -def display_legend(): - """ - Displays the legend of the room types in a new window. - """ - palette = style.get_palette() - legend_window = tk.Toplevel(background=palette.window) - legend_window.title("Room Type Legend") - legend_window.resizable(False, False) - room_types = RoomType.get_all() - legend_canvas = tk.Canvas( - legend_window, - width=260, - height=len(room_types) * _ROW_HEIGHT + 16, - background=palette.window, - highlightthickness=0, - ) - legend_canvas.pack(padx=8, pady=8) - # Keep references to the room icons, otherwise tkinter discards them - legend_canvas.rooms = [] # type: ignore[attr-defined] - for index, room_type in enumerate(room_types): - room = TkinterRoom(0, 0, index, room_type, width=72, height=_ROW_HEIGHT) - legend_canvas.rooms.append(room) # type: ignore[attr-defined] - _draw_at(legend_canvas, room, 8, 8 + index * _ROW_HEIGHT) - legend_canvas.create_text( - 92, - 8 + index * _ROW_HEIGHT + _ROW_HEIGHT / 2, - anchor=tk.W, - text=room_names[room_type], - fill=palette.text, - font=("DejaVu Sans", 10), - ) - - -def _draw_at(canvas: tk.Canvas, room: TkinterRoom, x: int, y: int) -> str: - """ - Draws the room on the canvas and moves it to the given position. - @return: tag of the drawn items - """ - tag = f"legend-{room.get_id()}" - before = set(canvas.find_all()) - room.draw(canvas) - for item in set(canvas.find_all()) - before: - canvas.addtag_withtag(tag, item) - canvas.move(tag, x, y) - return tag diff --git a/test/tkinter_generator_test.py b/test/tkinter_generator_test.py index b991922..c704cf0 100644 --- a/test/tkinter_generator_test.py +++ b/test/tkinter_generator_test.py @@ -84,7 +84,7 @@ def test_menu_shortcuts_are_bound(self): self._press("") m.assert_called_once() - with mock.patch("rooms.tkinter.legend_window.display_legend") as m: + with mock.patch.object(generator, "toggle_legend") as m: self._press("") m.assert_called_once() @@ -177,6 +177,38 @@ def test_switching_the_theme_redraws_the_floor_in_the_new_colors(self): self._generator._theme_handler.switch_theme() self.assertIs(style.LIGHT, style.get_palette()) + def test_f1_shows_and_hides_the_legend(self): + generator = self._generator + generator.create_menu_bar() + generator._content.pack() + generator._apply_legend_visibility() + + self._press("") + self.assertFalse(generator.show_legend.get()) + self.assertFalse(generator._legend.winfo_ismapped()) + + self._press("") + self.assertTrue(generator.show_legend.get()) + self.assertTrue(generator._legend.winfo_ismapped()) + + def test_legend_counts_the_rooms_of_the_current_floor(self): + # pylint: disable=import-outside-toplevel + from collections import Counter + + generator = self._generator + first = generator._floor + generator.generate("2") + + def expected(floor): + return Counter(room.get_type() for room in floor.get_rooms()) + + for room_type, count in expected(generator._floor).items(): + self.assertEqual(count, generator._legend.get_count(room_type)) + + generator._decrease_floor() + for room_type, count in expected(first).items(): + self.assertEqual(count, generator._legend.get_count(room_type)) + if __name__ == "__main__": unittest.main() From 65bba880a093e2dfd72c7df5d8985895f47328cd Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:40:46 +0000 Subject: [PATCH 5/6] feat(ui): group the actions in file, view and floor menus --- src/generators/theme_handler.py | 39 ++++++++++++++++-- src/generators/tkinter_generator.py | 61 +++++++++++++++++++++-------- test/tkinter_generator_test.py | 22 +++++++++++ 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/src/generators/theme_handler.py b/src/generators/theme_handler.py index a6c5360..ae981b4 100644 --- a/src/generators/theme_handler.py +++ b/src/generators/theme_handler.py @@ -2,8 +2,8 @@ Module to handle the theme of the application. """ -from typing import Callable, Optional -from tkinter import Tk, Canvas, ttk +from typing import Callable, List, Optional +from tkinter import BooleanVar, Menu, Tk, Canvas, ttk from rooms.tkinter import style @@ -26,17 +26,48 @@ def __init__( self._canvas = canvas self._on_change = on_change self._palette = style.LIGHT + self._menus: List[Menu] = [] + # Shown as check mark in the view menu + self.dark = BooleanVar(tk, value=False) self.apply_theme() def switch_theme(self) -> None: """ Switches the theme of the application. """ - self._palette = style.DARK if self._palette is style.LIGHT else style.LIGHT + self.set_dark(self._palette is style.LIGHT) + + def set_dark(self, dark: bool) -> None: + """ + Switches to the dark or the light theme. + @param dark: True for the dark theme, False for the light theme + """ + self._palette = style.DARK if dark else style.LIGHT + self.dark.set(dark) self.apply_theme() if self._on_change is not None: self._on_change() + def register_menu(self, menu: Menu) -> None: + """ + Colors the menu in the current theme and whenever the theme changes. + @param menu: menu to color + """ + self._menus.append(menu) + self._apply_to_menu(menu) + + def _apply_to_menu(self, menu: Menu) -> None: + palette = self._palette + menu.configure( + background=palette.panel, + foreground=palette.text, + activebackground=palette.button_active, + activeforeground=palette.text, + selectcolor=palette.text, + borderwidth=0, + relief="flat", + ) + def apply_theme(self) -> None: """ Applies the current theme to the application. @@ -44,6 +75,8 @@ def apply_theme(self) -> None: palette = self._palette style.set_palette(palette) self._tk.configure(background=palette.window) + for menu in self._menus: + self._apply_to_menu(menu) self._canvas.configure( background=palette.canvas, highlightthickness=0, borderwidth=0 ) diff --git a/src/generators/tkinter_generator.py b/src/generators/tkinter_generator.py index cfb3196..f794a5e 100644 --- a/src/generators/tkinter_generator.py +++ b/src/generators/tkinter_generator.py @@ -292,29 +292,56 @@ def add_buttons(self) -> None: def create_menu_bar(self) -> None: """ - Creates a menu bar for the application. + Creates the menu bar with the file, view and floor menus. """ - # Create a menu bar - menu_bar = tk.Menu(self._tk) + menu_bar = tk.Menu(self._tk, tearoff=False) self._tk.config(menu=menu_bar) - menu_bar.add_command(label="Open", command=self.open, accelerator="Ctrl+o") - menu_bar.add_command( - label="Save", - command=self._save_current_floor, - accelerator="Ctrl+s", + self._theme_handler.register_menu(menu_bar) + + file_menu = tk.Menu(menu_bar, tearoff=False) + file_menu.add_command(label="Open…", command=self.open, accelerator="Ctrl+O") + file_menu.add_command( + label="Save", command=self._save_current_floor, accelerator="Ctrl+S" + ) + file_menu.add_command( + label="Save As…", command=self.save, accelerator="Ctrl+Shift+S" ) - menu_bar.add_command( - label="Save As", command=self.save, accelerator="Ctrl+Shift+s" + file_menu.add_separator() + file_menu.add_command(label="Exit", command=self.quit, accelerator="Ctrl+Q") + menu_bar.add_cascade(label="File", menu=file_menu) + + view_menu = tk.Menu(menu_bar, tearoff=False) + view_menu.add_checkbutton( + label="Dark Theme", + variable=self._theme_handler.dark, + command=lambda: self._theme_handler.set_dark( + self._theme_handler.dark.get() + ), + accelerator="Ctrl+T", ) - menu_bar.add_command( - label="Switch Theme", - command=self._theme_handler.switch_theme, - accelerator="Ctrl+t", + view_menu.add_checkbutton( + label="Legend", + variable=self.show_legend, + command=self._apply_legend_visibility, + accelerator="F1", + ) + menu_bar.add_cascade(label="View", menu=view_menu) + + floor_menu = tk.Menu(menu_bar, tearoff=False) + floor_menu.add_command( + label="Generate", command=self._generate_and_draw_floor, accelerator="Space" ) - menu_bar.add_command( - label="Legend", command=self.toggle_legend, accelerator="F1" + floor_menu.add_command( + label="Previous Floor", command=self._decrease_floor, accelerator="←" ) - menu_bar.add_command(label="Exit", command=self.quit, accelerator="Ctrl+q") + floor_menu.add_command( + label="Next Floor", command=self._increase_floor, accelerator="→" + ) + menu_bar.add_cascade(label="Floor", menu=floor_menu) + + for menu in (file_menu, view_menu, floor_menu): + self._theme_handler.register_menu(menu) + self._tk.bind("", lambda event: self.open()) self._tk.bind("", lambda event: self._save_current_floor()) self._tk.bind("", lambda event: self.save()) diff --git a/test/tkinter_generator_test.py b/test/tkinter_generator_test.py index c704cf0..bec5054 100644 --- a/test/tkinter_generator_test.py +++ b/test/tkinter_generator_test.py @@ -209,6 +209,28 @@ def expected(floor): for room_type, count in expected(first).items(): self.assertEqual(count, generator._legend.get_count(room_type)) + def test_menu_bar_has_file_view_and_floor_menus(self): + generator = self._generator + generator.create_menu_bar() + menu_bar = generator._tk.nametowidget(generator._tk["menu"]) + + labels = [ + menu_bar.entrycget(index, "label") + for index in range(menu_bar.index("end") + 1) + ] + + self.assertEqual(["File", "View", "Floor"], labels) + + def test_dark_theme_check_mark_follows_the_shortcut(self): + generator = self._generator + generator.create_menu_bar() + + self._press("") + self.assertTrue(generator._theme_handler.dark.get()) + + self._press("") + self.assertFalse(generator._theme_handler.dark.get()) + if __name__ == "__main__": unittest.main() From cf6f16f30fcae79f771d3f3839095639b4e17649 Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:40:58 +0000 Subject: [PATCH 6/6] docs: describe the legend and menus in readme --- readme.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index c259702..b7c1503 100644 --- a/readme.md +++ b/readme.md @@ -46,8 +46,11 @@ 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. Every generated floor is kept, -so you can switch between them. +The UI draws a generated floor room by room, including 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. +All actions are also available in the File, View and Floor menus. | Key | Action | |---|---| @@ -58,7 +61,7 @@ so you can switch between them. | `Ctrl+S` | Save the current floor | | `Ctrl+Shift+S` | Save the current floor as a new file | | `Ctrl+T` | Switch between light and dark theme | -| `F1` | Show the legend of the room colors | +| `F1` | Show or hide the legend | | `Ctrl+Q` | Quit | ## REST API