Repository: ppizarror/pygame-menu

Issue:
Provide way to disable render until all widgets have been added?
**Is your feature request related to a problem? Please describe.**
I'm using a two column menu with 20 rows.
Each time a widget is added _render() is called 
Adding the widgets has gotten noticeably slower (over half a second).


**Describe the solution you'd like**
Maybe there is some other way to do this but so far the most effective approach I have tried is adding a flag to exit Menu._render() before doing any work.  After all the widgets have been added _render is re-enabled and everything seems to work fine.
This does defer all of the height/width checks which might make it more tedious to fix the layout. Leaving the render flag on during development should make working around that drawback acceptable.

**Describe alternatives you've considered**
I tried widget.hide() after each menu.add.label() which did not help much, and I expect un-hiding will force new renders anyway.
Setting center_content=False also helped a bit, but not enough.
I did not find any existing early exits from the render calls.

**Additional context**
I thought my issues were related to using a mess of images/tables/frames but it looks like it is mostly driven simply by increasing the number of rows.

(proof of concept code:  [disable_render.py.gz](https://github.com/ppizarror/pygame-menu/files/10311200/disable_render.py.gz) )

Adding labels like this:

    for c in range(columns):
        for r in range(rows):
            label = menu.add.label(f'Col {c}, Row {r}')


produced:

11 rows... 0.122 s
21 rows... 0.497 s
31 rows... 1.309 s
41 rows... 2.583 s
51 rows... 4.621 s
61 rows... 7.556 s
71 rows... 11.519 s
80 rows... 16.113 s

The same loops but with _render disabled while widgets are added:

11 rows... 0.030 s
21 rows... 0.046 s
31 rows... 0.071 s
41 rows... 0.108 s
51 rows... 0.161 s
61 rows... 0.214 s
71 rows... 0.270 s
81 rows... 0.338 s
91 rows... 0.408 s
101 rows... 0.471 s
111 rows... 0.557 s
121 rows... 0.624 s

I did not check if de-duping renders in _widgetmanager._append_widget() helped when self._menu is the same object as self._menu._current on lines 371 and 376


Discussion:

Patch:
diff --git a/pygame_menu/menu.py b/pygame_menu/menu.py
index 6eae48eb..4dcc083c 100644
--- a/pygame_menu/menu.py
+++ b/pygame_menu/menu.py
@@ -362,6 +362,7 @@ class Menu(Base):
         self._last_selected_type = ''  # Last type selection, used for test purposes
         self._mainloop = False  # Menu is in mainloop state
         self._onclose = None  # Function or event called on Menu close
+        self._render_enabled = True
         self._sound = Sound()
         self._stats = _MenuStats()
         self._submenus = {}
@@ -1952,6 +1953,8 @@ class Menu(Base):
 
         :return: ``True`` if the surface has changed (if it was ``None``)
         """
+        if not self._render_enabled:
+            return False  # Modify using Menu.disable_render() and Menu.enable_render()
         t0 = time.time()
         changed = False
 
@@ -1969,6 +1972,39 @@ class Menu(Base):
         self._stats.total_rendering_time += time.time() - t0
         return changed
 
+    def disable_render(self) -> 'Menu':
+        """
+        Disable the render of the Menu. Useful to improve performance when
+        adding many widgets. Must be turned on after finishing the build.
+
+        .. note::
+
+            This is applied only to the base Menu (not the currently displayed,
+            stored in ``_current`` pointer); for such behaviour apply to
+            :py:meth:`pygame_menu.menu.Menu.get_current` object.
+
+        :return: Self reference
+        """
+        self._render_enabled = False
+        return self
+
+    def enable_render(self) -> 'Menu':
+        """
+        Enable the Menu rendering. Useful to improve performance when
+        adding many widgets.
+
+        .. note::
+
+            This is applied only to the base Menu (not the currently displayed,
+            stored in ``_current`` pointer); for such behaviour apply to
+            :py:meth:`pygame_menu.menu.Menu.get_current` object.
+
+        :return: Self reference
+        """
+        self._render_enabled = True
+        self._render()
+        return self
+
     def draw(self, surface: 'pygame.Surface', clear_surface: bool = False) -> 'Menu':
         """
         Draw the **current** Menu into the given surface.
@@ -3380,6 +3416,20 @@ class Menu(Base):
         """
         return self._window_size
 
+    def get_col_rows(self) -> Tuple[int, List[int]]:
+        """
+        Return the number of columns and rows of the Menu.
+
+        .. note::
+
+            This is applied only to the base Menu (not the currently displayed,
+            stored in ``_current`` pointer); for such behaviour apply to
+            :py:meth:`pygame_menu.menu.Menu.get_current` object.
+
+        :return: Tuple of (columns, rows for each column)
+        """
+        return self._columns, self._rows
+
     def get_submenus(self, recursive: bool = False) -> Tuple['Menu', ...]:
         """
         Return the Menu submenus as a tuple.
diff --git a/pygame_menu/version.py b/pygame_menu/version.py
index 01f21bd1..bd574638 100644
--- a/pygame_menu/version.py
+++ b/pygame_menu/version.py
@@ -34,6 +34,6 @@ class Version(tuple):
     patch = property(lambda self: self[2])
 
 
-vernum = Version(4, 3, 3)
+vernum = Version(4, 3, 4)
 ver = str(vernum)
 rev = ''


Test patch:
diff --git a/test/test_menu.py b/test/test_menu.py
index 781b6ba4..70886717 100644
--- a/test/test_menu.py
+++ b/test/test_menu.py
@@ -14,6 +14,8 @@ from test._utils import BaseRSTest, surface, MenuUtils, PygameEventUtils, \
 from typing import Any, Tuple, List
 import copy
 import math
+import sys
+import time
 import timeit
 
 import pygame
@@ -1232,6 +1234,9 @@ class MenuTest(BaseRSTest):
         menu.render()
         self.assertEqual(menu._column_widths, [200, 150, 150])
 
+        menu = MenuUtils.generic_menu()
+        self.assertEqual(menu.get_col_rows(), (1, [10000000]))
+
     def test_screen_dimension(self) -> None:
         """
         Test screen dim.
@@ -2217,6 +2222,8 @@ class MenuTest(BaseRSTest):
         """
         Test baseimage selector + menulink interaction.
         """
+        if sys.version_info.major == 3 and sys.version_info.minor == 8:
+            return
         x = 400
         y = 400
 
@@ -2471,3 +2478,41 @@ class MenuTest(BaseRSTest):
         theme.border_color = pygame_menu.BaseImage(pygame_menu.baseimage.IMAGE_EXAMPLE_TILED_BORDER)
         menu = pygame_menu.Menu('Menu with border image', 250, 250, theme=theme)
         menu.draw(surface)
+
+    def test_menu_render_toggle(self) -> None:
+        """
+        Test add menu widgets with render enable/disable.
+        """
+        menu = MenuUtils.generic_menu(columns=3, rows=50)
+        nc, nr = menu.get_col_rows()
+        self.assertEqual(nc, 3)
+        self.assertEqual(nr, [50, 50, 50])
+
+        # Test with rendering enabled
+        n = sum(nr)  # Number of widgets to be added
+        t0 = time.time()
+        widgets: List[Label] = []
+        for i in range(n):
+            widgets.append(menu.add.label(i))
+        t_on = time.time() - t0
+        position_before: List[Tuple[int, int]] = []
+        for i in range(n):
+            position_before.append(widgets[i].get_position())
+
+        # Test with rendering disabled
+        menu.clear()
+        widgets.clear()
+        menu.disable_render()
+        t0 = time.time()
+        for i in range(n):
+            widgets.append(menu.add.label(i))
+        menu.enable_render()
+        menu.render()
+        t_off = time.time() - t0
+        self.assertGreater(t_on, t_off)
+
+        # Position after render must be equal!
+        for i in range(n):
+            self.assertEqual(position_before[i], widgets[i].get_position())
+
+        print(f'Render on: {t_on}s, off: {t_off}s')