From ea47ed723d7a96f1832674b008169b6f612abb20 Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:26:03 +0000 Subject: [PATCH 1/2] feat: describe rooms in a short text --- src/floors/floor.py | 8 ++--- src/rooms/room_description.py | 56 +++++++++++++++++++++++++++++++ test/room_description_test.py | 62 +++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 src/rooms/room_description.py create mode 100644 test/room_description_test.py diff --git a/src/floors/floor.py b/src/floors/floor.py index 583f3de..d47fa10 100644 --- a/src/floors/floor.py +++ b/src/floors/floor.py @@ -232,11 +232,11 @@ def is_within_border(self, coordinates: Tuple[int, int]) -> bool: """ return 0 <= coordinates[0] < self._width and 0 <= coordinates[1] < self._height - def _get_room(self, coordinates: Tuple[int, int]) -> Optional[Room]: + def get_room(self, coordinates: Tuple[int, int]) -> Optional[Room]: """ Returns the room at the given coordinates. @param coordinates: coordinates of the room - @return: room + @return: room or None if there is no room """ for room in self._rooms: if room[0] == coordinates[0] and room[1] == coordinates[1]: @@ -256,7 +256,7 @@ def has_boss_room_as_neighbour(self, coordinates: Tuple[int, int]) -> bool: if self.is_within_border(new_coordinates) and self.contains_room( new_coordinates ): - room = self._get_room(new_coordinates) + room = self.get_room(new_coordinates) if room is not None and room.get_type() == RoomType.BOSS_ROOM: return True return False @@ -280,7 +280,7 @@ def has_special_room_as_neighbour(self, coordinates: Tuple[int, int]) -> bool: if self.is_within_border(new_coordinates) and self.contains_room( new_coordinates ): - room = self._get_room(new_coordinates) + room = self.get_room(new_coordinates) if room is not None and room.get_type().is_special(): return True return False diff --git a/src/rooms/room_description.py b/src/rooms/room_description.py new file mode 100644 index 0000000..e45f384 --- /dev/null +++ b/src/rooms/room_description.py @@ -0,0 +1,56 @@ +""" +Describes rooms of a floor in a short text, e.g. for tooltips. +""" + +from typing import List + +from floors.floor import Floor +from rooms.room import Room +from rooms.teleport_room import TeleportRoom +from utils.globals import DoorFace +from utils.room_type import RoomType, room_names + +_DOOR_NAMES = { + DoorFace.TOP: "top", + DoorFace.EAST: "right", + DoorFace.BOTTOM: "bottom", + DoorFace.WEST: "left", +} +_BOSS_ROOM_SHAPES = {2: "1 × 2", 4: "2 × 2"} + + +def describe_room(floor: Floor, room: Room) -> str: + """ + Describes a room with its type, id, position, doors and connections. + @param floor: floor that contains the room + @param room: room to describe + @return: description with one fact per line + """ + lines: List[str] = [ + f"{room_names[room.get_type()]} · #{room.get_id()}", + f"Position {room[0]}, {room[1]}", + ] + doors = sorted(room.get_doors(), key=lambda door: door.value) + door_names = ", ".join(_DOOR_NAMES[door] for door in doors) + lines.append(f"Doors: {door_names or 'none'}") + + rooms_by_id = {other.get_id(): other for other in floor.get_rooms()} + if isinstance(room, TeleportRoom): + target = rooms_by_id.get(room.get_connected_room_id()) + if target is not None: + lines.append( + f"Teleports to #{target.get_id()} ({room_names[target.get_type()]})" + ) + if room.get_type() == RoomType.BOSS_ROOM: + cells = sum( + other.get_type() == RoomType.BOSS_ROOM for other in floor.get_rooms() + ) + if cells in _BOSS_ROOM_SHAPES: + lines.append(f"Part of a {_BOSS_ROOM_SHAPES[cells]} boss room") + for other in floor.get_rooms(): + if ( + isinstance(other, TeleportRoom) + and other.get_connected_room_id() == room.get_id() + ): + lines.append(f"Reached through teleport #{other.get_id()}") + return "\n".join(lines) diff --git a/test/room_description_test.py b/test/room_description_test.py new file mode 100644 index 0000000..31165b4 --- /dev/null +++ b/test/room_description_test.py @@ -0,0 +1,62 @@ +"""Unit tests for the room descriptions.""" + +import unittest + +from floor_test_support import teleport_seed +from floors.floor import Floor +from generators.generator import Generator +from rooms.room_description import describe_room +from rooms.teleport_room import TeleportRoom +from utils.globals import DoorFace +from utils.room_type import RoomType + + +class DescribeRoomTest(unittest.TestCase): + def test_describes_type_id_position_and_doors(self): + floor = Floor(8, 9, "1") + floor.add_room(2, 3, RoomType.SHOP_ROOM) + room = floor.get_rooms()[0] + room.add_door(DoorFace.WEST) + room.add_door(DoorFace.TOP) + + self.assertEqual( + "Shop Room · #0\nPosition 2, 3\nDoors: top, left", + describe_room(floor, room), + ) + + def test_room_without_doors(self): + floor = Floor(8, 9, "1") + floor.add_room(0, 0) + + self.assertIn("Doors: none", describe_room(floor, floor.get_rooms()[0])) + + def test_teleport_and_boss_room_name_each_other(self): + generator = Generator(teleport_seed(), "test", "", 2) + generator.generate() + floor = generator._floor + teleport = next(r for r in floor.get_rooms() if isinstance(r, TeleportRoom)) + boss = next( + r + for r in floor.get_rooms() + if r.get_id() == teleport.get_connected_room_id() + ) + + self.assertIn( + f"Teleports to #{boss.get_id()} (Boss Room)", describe_room(floor, teleport) + ) + self.assertIn( + f"Reached through teleport #{teleport.get_id()}", describe_room(floor, boss) + ) + + def test_big_boss_room_names_its_shape(self): + floor = Floor(8, 9, "1") + for x, y in ((0, 0), (1, 0), (0, 1), (1, 1)): + floor.add_room(x, y, RoomType.BOSS_ROOM) + + self.assertIn( + "Part of a 2 × 2 boss room", describe_room(floor, floor.get_rooms()[0]) + ) + + +if __name__ == "__main__": + unittest.main() From a13ffdc8fb0d27e491a6b03bd8bc2b46802ba86b Mon Sep 17 00:00:00 2001 From: Joltras <72395471+Joltras@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:26:03 +0000 Subject: [PATCH 2/2] feat(ui): show room details and control hints in tooltips --- readme.md | 2 + src/generators/tkinter_generator.py | 75 +++++----- src/rooms/tkinter/tooltip.py | 217 ++++++++++++++++++++++++++++ test/tkinter_generator_test.py | 67 +++++++++ 4 files changed, 325 insertions(+), 36 deletions(-) create mode 100644 src/rooms/tkinter/tooltip.py diff --git a/readme.md b/readme.md index eea182c..8600d96 100644 --- a/readme.md +++ b/readme.md @@ -55,6 +55,8 @@ 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. The status bar shows the seed and floor id of the shown floor and its position in the history. +Resting the mouse on a room shows its type, id, position, doors and teleport connection, +and the controls show what they do and their keyboard shortcut. All actions are also available in the File, View and Floor menus. | Key | Action | diff --git a/src/generators/tkinter_generator.py b/src/generators/tkinter_generator.py index f46140a..af1f632 100644 --- a/src/generators/tkinter_generator.py +++ b/src/generators/tkinter_generator.py @@ -5,7 +5,7 @@ import os import secrets import tkinter as tk -from typing import Callable, Optional +from typing import Callable, List, Optional, Union from tkinter import filedialog, messagebox from tkinter import ttk @@ -15,6 +15,7 @@ from generators.generator import Generator from generators.theme_handler import ThemeHandler from rooms.tkinter.legend_panel import LegendPanel +from rooms.tkinter.tooltip import RoomToolTip, ToolTip from utils import util_functions, globals as my_globals # Highest floor id that can be entered in the floor field @@ -63,6 +64,8 @@ def __init__( self._tk, self._canvas, on_change=self._redraw_floor ) self._legend = LegendPanel(self._content) + self._room_tooltip = RoomToolTip(self._canvas, self._get_shown_floor) + self._tooltips: List[ToolTip] = [] self.show_legend = tk.BooleanVar(self._tk, value=True) self._floor_manager = FloorManager(output_file_name) # Floor id for the next generated floor, edited in the floor field @@ -221,10 +224,30 @@ def generate(self, new_seed=None) -> None: self._floor_manager.add_floor(self._floor) self._update_legend() + def _add_tooltip( + self, widget: tk.Widget, text: Union[str, tk.StringVar, Callable[[], str]] + ) -> None: + """ + Adds a tooltip to a widget and keeps a reference to it. + @param widget: widget that shows the tooltip + @param text: text, variable or function that returns the text + """ + self._tooltips.append(ToolTip(widget, text)) + + def _get_shown_floor(self) -> Optional[TkinterFloor]: + """ + Returns the floor that is shown on the canvas or None before the first floor. + """ + if self._floor_manager.current_floor_index < 0: + return None + return self._floor_manager.get_current_floor() + def _update_legend(self) -> None: """ - Shows the room counts of the current floor in the legend. + Shows the room counts of the current floor in the legend + and hides the room tooltip of the previous floor. """ + self._room_tooltip.hide() self._legend.update_counts(self._floor_manager.get_current_floor()) def toggle_legend(self) -> None: @@ -264,7 +287,7 @@ def add_information_frame(self) -> None: font=("DejaVu Sans", 10, "bold"), ) name_label.pack(side=tk.LEFT) - _ToolTip(name_label, self._path) + self._add_tooltip(name_label, self._path) ttk.Label(status, text=" · Seed ", style="Muted.TLabel").pack(side=tk.LEFT) ttk.Label( status, @@ -282,9 +305,7 @@ def add_information_frame(self) -> None: status, textvariable=self._floor_manager.position, style="Muted.TLabel" ) position_label.pack(side=tk.LEFT) - _ToolTip( - position_label, tk.StringVar(self._tk, value="Position in the history") - ) + self._add_tooltip(position_label, "Position in the history of generated floors") def _increase_floor(self) -> None: """ @@ -310,12 +331,14 @@ def add_buttons(self) -> None: button_frame, text="◀", width=3, command=self._decrease_floor ) button_pre.pack(side=tk.LEFT) + self._add_tooltip(button_pre, "Back to the previous floor (←)") # Left arrow to decrease self._tk.bind("", self._shortcut(lambda: self._decrease_floor())) button_gen = ttk.Button( button_frame, text="Generate", command=self._generate_and_draw_floor ) button_gen.pack(side=tk.LEFT, padx=6) + self._add_tooltip(button_gen, "Generate a new floor (Space)") # Space to generate self._tk.bind( "", self._shortcut(lambda: self._generate_and_draw_floor()) @@ -324,6 +347,7 @@ def add_buttons(self) -> None: button_frame, text="▶", width=3, command=self._increase_floor ) button_next.pack(side=tk.LEFT) + self._add_tooltip(button_next, "Forward to the next floor (→)") # Right arrow to increase self._tk.bind("", self._shortcut(lambda: self._increase_floor())) # Floor id for the next floor @@ -342,6 +366,11 @@ def add_buttons(self) -> None: self._floor_id_entry.bind("", lambda event: self._submit_seed()) self._floor_id_entry.bind("", lambda event: self._tk.focus_set()) self._floor_id_entry.pack(side=tk.LEFT) + self._add_tooltip( + self._floor_id_entry, + "Floor id for the next floor, higher ids get more rooms\n" + "(Page Up / Page Down)", + ) self._tk.bind("", self._shortcut(lambda: self.change_floor_id(1))) self._tk.bind("", self._shortcut(lambda: self.change_floor_id(-1))) # Next seed entry @@ -358,6 +387,10 @@ def add_buttons(self) -> None: self._next_seed_entry.bind("", lambda event: self._tk.focus_set()) self._canvas.bind("", lambda event: self._tk.focus_set()) self._next_seed_entry.pack(side=tk.LEFT) + self._add_tooltip( + self._next_seed_entry, + "Seed for the next floor, press Enter to generate it", + ) def _submit_seed(self) -> None: """ @@ -459,33 +492,3 @@ 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/tooltip.py b/src/rooms/tkinter/tooltip.py new file mode 100644 index 0000000..710d101 --- /dev/null +++ b/src/rooms/tkinter/tooltip.py @@ -0,0 +1,217 @@ +""" +Tooltips for widgets and for the rooms on the floor canvas. +""" + +import tkinter as tk +from typing import Callable, Optional, Tuple, Union + +from floors.floor import Floor +from rooms.room_description import describe_room +from rooms.tkinter import style +from utils import globals as my_globals + +# Time the mouse has to rest on an element before its tooltip is shown +TOOLTIP_DELAY_MS = 400 +# Distance between the mouse pointer and the tooltip +_OFFSET = 14 + + +class _TooltipWindow: + """ + Small borderless window that shows a text next to the mouse pointer + in the colors of the current theme. + """ + + def __init__(self, master: tk.Misc) -> None: + self._master = master + self._window: Optional[tk.Toplevel] = None + self._label: Optional[tk.Label] = None + + def is_shown(self) -> bool: + """ + Returns if the tooltip is currently shown. + """ + return self._window is not None + + def get_text(self) -> str: + """ + Returns the shown text or an empty text if the tooltip is hidden. + """ + return "" if self._label is None else str(self._label.cget("text")) + + def show(self, text: str, x_root: int, y_root: int) -> None: + """ + Shows the text next to the given screen position. + """ + palette = style.get_palette() + if self._window is None: + self._window = tk.Toplevel(self._master) + self._window.wm_overrideredirect(True) + self._label = tk.Label(self._window, justify=tk.LEFT, padx=8, pady=4) + self._label.pack() + assert self._label is not None + self._window.configure(background=palette.grid) + self._label.configure( + text=text, + background=palette.button, + foreground=palette.text, + font=("DejaVu Sans", 9), + ) + # The label is surrounded by a 1 pixel border in the grid color + self._label.pack_configure(padx=1, pady=1) + self.move(x_root, y_root) + + def move(self, x_root: int, y_root: int) -> None: + """ + Moves the tooltip next to the given screen position, + above the pointer if there is no space below it. + """ + if self._window is None: + return + self._window.update_idletasks() + height = self._window.winfo_reqheight() + y = y_root + _OFFSET + if y + height > self._window.winfo_screenheight(): + y = y_root - _OFFSET - height + self._window.wm_geometry(f"+{x_root + _OFFSET}+{y}") + + def hide(self) -> None: + """ + Hides the tooltip. + """ + if self._window is not None: + self._window.destroy() + self._window = None + self._label = None + + +class ToolTip: + """ + Shows a text after the mouse rested on a widget for a moment. + """ + + def __init__( + self, widget: tk.Widget, text: Union[str, tk.StringVar, Callable[[], str]] + ) -> None: + """ + @param widget: widget that shows the tooltip + @param text: fixed text, variable or function that returns the text + """ + self.widget = widget + self._text = text + self._window = _TooltipWindow(widget) + self._after_id: Optional[str] = None + self._pointer = (0, 0) + widget.bind("", self._schedule, add="+") + widget.bind("", self._remember_pointer, add="+") + widget.bind("", lambda event: self.hide(), add="+") + widget.bind("", lambda event: self.hide(), add="+") + + def get_text(self) -> str: + """ + Returns the current text of the tooltip. + """ + if isinstance(self._text, str): + return self._text + if isinstance(self._text, tk.StringVar): + return self._text.get() + return self._text() + + def get_shown_text(self) -> str: + """ + Returns the text that is shown right now, empty if the tooltip is hidden. + """ + return self._window.get_text() + + def _remember_pointer(self, event: tk.Event) -> None: + self._pointer = (event.x_root, event.y_root) + + def _schedule(self, event: tk.Event) -> None: + self._remember_pointer(event) + self._cancel() + self._after_id = self.widget.after(TOOLTIP_DELAY_MS, self.show) + + def show(self) -> None: + """ + Shows the tooltip next to the mouse pointer. + """ + self._after_id = None + text = self.get_text() + if text: + self._window.show(text, *self._pointer) + + def hide(self) -> None: + """ + Hides the tooltip and cancels a scheduled tooltip. + """ + self._cancel() + self._window.hide() + + def _cancel(self) -> None: + if self._after_id is not None: + self.widget.after_cancel(self._after_id) + self._after_id = None + + +class RoomToolTip: + """ + Shows the details of the room below the mouse pointer on the floor canvas. + """ + + def __init__( + self, canvas: tk.Canvas, get_floor: Callable[[], Optional[Floor]] + ) -> None: + """ + @param canvas: canvas the floor is drawn on + @param get_floor: returns the floor that is currently drawn + """ + self._canvas = canvas + self._get_floor = get_floor + self._window = _TooltipWindow(canvas) + self._after_id: Optional[str] = None + self._cell: Optional[Tuple[int, int]] = None + self._pointer = (0, 0) + canvas.bind("", self.on_motion, add="+") + canvas.bind("", lambda event: self.hide(), add="+") + + def get_shown_text(self) -> str: + """ + Returns the text that is shown right now, empty if the tooltip is hidden. + """ + return self._window.get_text() + + def on_motion(self, event: tk.Event) -> None: + """ + Shows the details of the room below the pointer after a short delay + and follows the pointer while it stays in the same room. + """ + self._pointer = (event.x_root, event.y_root) + cell = (event.x // my_globals.ROOM_WIDTH, event.y // my_globals.ROOM_HEIGHT) + if cell == self._cell: + self._window.move(*self._pointer) + return + self.hide() + self._cell = cell + self._after_id = self._canvas.after(TOOLTIP_DELAY_MS, self.show) + + def show(self) -> None: + """ + Shows the details of the room in the cell below the pointer, if there is one. + """ + self._after_id = None + floor = self._get_floor() + if floor is None or self._cell is None: + return + room = floor.get_room(self._cell) + if room is not None: + self._window.show(describe_room(floor, room), *self._pointer) + + def hide(self) -> None: + """ + Hides the tooltip and cancels a scheduled tooltip. + """ + if self._after_id is not None: + self._canvas.after_cancel(self._after_id) + self._after_id = None + self._cell = None + self._window.hide() diff --git a/test/tkinter_generator_test.py b/test/tkinter_generator_test.py index 8b2b81f..02bf76f 100644 --- a/test/tkinter_generator_test.py +++ b/test/tkinter_generator_test.py @@ -8,6 +8,7 @@ from floor_test_support import teleport_seed from ui_test_support import requires_display from utils import globals as my_globals +from utils.room_type import RoomType @requires_display() @@ -328,6 +329,72 @@ def test_going_back_shows_the_floor_id_of_the_previous_floor(self): self.assertEqual("2", generator._floor_manager.current_floor_id_var.get()) self.assertEqual("1 / 2", generator._floor_manager.position.get()) + def _hover(self, widget, x: int, y: int) -> None: + # pylint: disable=import-outside-toplevel + from rooms.tkinter.tooltip import TOOLTIP_DELAY_MS + + self._generator._content.pack() + self._generator._canvas.pack() + self._generator._tk.update() + widget.event_generate("", x=x, y=y) + widget.event_generate("", x=x, y=y) + self._pump(TOOLTIP_DELAY_MS + 200) + + def test_room_tooltip_shows_the_room_below_the_pointer(self): + generator = self._generator + generator._floor.draw() + start = next( + room + for room in generator._floor.get_rooms() + if room.get_type() == RoomType.START_ROOM + ) + + self._hover( + generator._canvas, + start[0] * my_globals.ROOM_WIDTH + 40, + start[1] * my_globals.ROOM_HEIGHT + 30, + ) + + self.assertTrue( + generator._room_tooltip.get_shown_text().startswith("Start Room · #0") + ) + generator._canvas.event_generate("") + self.assertEqual("", generator._room_tooltip.get_shown_text()) + + def test_room_tooltip_is_not_shown_for_empty_cells(self): + generator = self._generator + floor = generator._floor + empty = next( + (x, y) + for x in range(my_globals.FLOOR_WIDTH) + for y in range(my_globals.FLOOR_HEIGHT) + if not floor.contains_room((x, y)) + ) + + self._hover( + generator._canvas, + empty[0] * my_globals.ROOM_WIDTH + 40, + empty[1] * my_globals.ROOM_HEIGHT + 30, + ) + + self.assertEqual("", generator._room_tooltip.get_shown_text()) + + def test_buttons_have_tooltips_with_their_shortcuts(self): + generator = self._generator + generator.add_buttons() + button = next( + widget + for widget in generator._get_bottom_bar() + .winfo_children()[0] + .winfo_children() + if widget.winfo_class() == "TButton" and widget.cget("text") == "Generate" + ) + tooltip = next(t for t in generator._tooltips if t.widget is button) + + self._hover(button, 10, 10) + + self.assertEqual("Generate a new floor (Space)", tooltip.get_shown_text()) + if __name__ == "__main__": unittest.main()