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 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 554d596..f794a5e 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: """ @@ -252,34 +292,61 @@ 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" ) - menu_bar.add_command( - label="Save As", command=self.save, accelerator="Ctrl+Shift+s" + file_menu.add_command( + label="Save As…", command=self.save, accelerator="Ctrl+Shift+S" ) - menu_bar.add_command( - label="Switch Theme", - command=self._theme_handler.switch_theme, - accelerator="Ctrl+t", + 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="Legend", command=legend_window.display_legend, accelerator="F1" + view_menu.add_checkbutton( + label="Legend", + variable=self.show_legend, + command=self._apply_legend_visibility, + accelerator="F1", ) - menu_bar.add_command(label="Exit", command=self.quit, accelerator="Ctrl+q") + 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" + ) + floor_menu.add_command( + label="Previous Floor", command=self._decrease_floor, accelerator="←" + ) + 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()) 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..bec5054 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,60 @@ 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)) + + 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()