Repository: aegirhall/console-menu

Issue:
Dynamic, updateable menus
I use console-menu in a script to power a dynamic checklist. So as each menu option is chosen, the user is asked in a `SelectionMenu` whether the task is completed. If answering Yes, the state in the app is updated. But the update is not reflected when the menu refreshes.

Looks like the menu is initialized once and then has stale item objects with title. I need to update the title dynamically to add `(Done)` to those that are completed.

I think it could be possible by adding some `refresh()` method that will cause a re-read of the menu.

Or even better, allow users to pass method references as an alternative to strings, so you could do

    def get_title():
        return my_title

    def get_submenu():
        return ConsoleMenu(.....)

    menu = ConsoleMenu(title="My menu")
    menu.append_item(SubmenuItem(get_title, get_submenu()))
    menu.show()

Whenever the variable my_title changes, the menu would render it on next refresh since it pulls the value from the method instead of from a copied string?

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index aebb0ae..c9704b4 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,3 +1,7 @@
+v0.6.1 (2019-xx-xx)
+===================
+- Dynamic, updateable menus (#25).
+
 v0.6.0 (2019-09-07)
 ===================
 - Fix issue #28, allow console screen argument for SelectionMenu.
diff --git a/README.rst b/README.rst
index dd785e0..3f88e63 100644
--- a/README.rst
+++ b/README.rst
@@ -72,3 +72,12 @@ It's designed to be pretty simple to use. Here's an example
    :target: https://travis-ci.org/aegirhall/console-menu
 .. |Documentation Status| image:: https://readthedocs.org/projects/console-menu/badge/?version=latest
    :target: http://console-menu.readthedocs.org/en/latest/?badge=latest
+
+Development
+-----------
+
+.. code:: shell
+
+   pip install -r requirements-docs.txt
+   pip install -v -e .
+   pytest
diff --git a/consolemenu/console_menu.py b/consolemenu/console_menu.py
index a1d46e0..487364b 100644
--- a/consolemenu/console_menu.py
+++ b/consolemenu/console_menu.py
@@ -14,12 +14,12 @@ class ConsoleMenu(object):
     A class that displays a menu and allows the user to select an option.
 
     Args:
-        title (str): The title of the menu.
-        subtitle (str): The subtitle of the menu.
+        title (str): The title of the menu, or a method reference that returns a string.
+        subtitle (str): The subtitle of the menu, or a method reference that returns a string.
         screen (:obj:`consolemenu.screen.Screen`): The screen object associated with this menu.
         formatter (:obj:`MenuFormatBuilder`): The MenuFormatBuilder instance used to format this menu.
-        prologue_text (str): Text to include in the "prologue" section of the menu.
-        epilogue_text (str): Text to include in the "epilogue" section of the menu.
+        prologue_text (str): Text or method reference to include in the "prologue" section of the menu.
+        epilogue_text (str): Text or method reference to include in the "epilogue" section of the menu.
         show_exit_option (bool): Specifies whether this menu should show an exit item by default. Defaults to True.
             Can be overridden when the menu is started.
         exit_option_text (str): Text for the Exit menu item. Defaults to 'Exit'.
@@ -78,7 +78,7 @@ class ConsoleMenu(object):
         self._running = threading.Event()
 
     def __repr__(self):
-        return "%s: %s. %d items" % (self.title, self.subtitle, len(self.items))
+        return "%s: %s. %d items" % (self.get_title(), self.get_subtitle(), len(self.items))
 
     @property
     def current_item(self):
@@ -229,8 +229,11 @@ class ConsoleMenu(object):
         """
         Refresh the screen and redraw the menu. Should be called whenever something changes that needs to be redrawn.
         """
-        self.screen.printf(self.formatter.format(title=self.title, subtitle=self.subtitle, items=self.items,
-                                                 prologue_text=self.prologue_text, epilogue_text=self.epilogue_text))
+        self.screen.printf(self.formatter.format(title=self.get_title(),
+                                                 subtitle=self.get_subtitle(),
+                                                 items=self.items,
+                                                 prologue_text=self.get_prologue_text(),
+                                                 epilogue_text=self.get_epilogue_text()))
 
     def is_running(self):
         """
@@ -373,6 +376,19 @@ class ConsoleMenu(object):
         """
         self.screen.clear()
 
+    # Getters to get text in case method reference
+    def get_title(self):
+        return self.title() if callable(self.title) else self.title
+
+    def get_subtitle(self):
+        return self.subtitle() if callable(self.subtitle) else self.subtitle
+
+    def get_prologue_text(self):
+        return self.prologue_text() if callable(self.prologue_text) else self.prologue_text
+
+    def get_epilogue_text(self):
+        return self.epilogue_text() if callable(self.epilogue_text) else self.epilogue_text
+
 
 class MenuItem(object):
     """
@@ -390,7 +406,7 @@ class MenuItem(object):
         self.should_exit = should_exit
 
     def __str__(self):
-        return "%s %s" % (self.menu.title, self.text)
+        return "%s %s" % (self.menu.get_title(), self.get_text())
 
     def show(self, index):
         """
@@ -406,7 +422,7 @@ class MenuItem(object):
         :return: The representation of the item to be shown in a menu
         :rtype: str
         """
-        return "%2d - %s" % (index + 1, self.text)
+        return "%2d - %s" % (index + 1, self.get_text())
 
     def set_up(self):
         """
@@ -436,6 +452,10 @@ class MenuItem(object):
     def __eq__(self, o):
         return self.text == o.text and self.menu == o.menu and self.should_exit == o.should_exit
 
+    # Getters to get text in case method reference
+    def get_text(self):
+        return self.text() if callable(self.text) else self.text
+
 
 class ExitItem(MenuItem):
     """
@@ -451,8 +471,8 @@ class ExitItem(MenuItem):
         """
         # If we have a parent menu, and no overriding exit text was specified,
         # change Exit text to "Return to {Parent Menu Title}"
-        if self.menu and self.menu.parent and self.text == 'Exit':
-            self.text = "Return to %s" % self.menu.parent.title
+        if self.menu and self.menu.parent and self.get_text() == 'Exit':
+            self.text = "Return to %s" % self.menu.parent.get_title()
         return super(ExitItem, self).show(index)
 
 
diff --git a/consolemenu/items/submenu_item.py b/consolemenu/items/submenu_item.py
index 6ff650b..746fc80 100644
--- a/consolemenu/items/submenu_item.py
+++ b/consolemenu/items/submenu_item.py
@@ -14,7 +14,7 @@ class SubmenuItem(MenuItem):
 
         self.submenu = submenu
         if menu:
-            self.submenu.parent = menu
+            self.get_submenu().parent = menu
 
     def set_menu(self, menu):
         """
@@ -24,7 +24,7 @@ class SubmenuItem(MenuItem):
         :param ConsoleMenu menu: the menu
         """
         self.menu = menu
-        self.submenu.parent = menu
+        self.get_submenu().parent = menu
 
     def set_up(self):
         """
@@ -37,13 +37,13 @@ class SubmenuItem(MenuItem):
         """
         This class overrides this method
         """
-        self.submenu.start()
+        self.get_submenu().start()
 
     def clean_up(self):
         """
         This class overrides this method
         """
-        self.submenu.join()
+        self.get_submenu().join()
         self.menu.clear_screen()
         self.menu.resume()
 
@@ -51,4 +51,10 @@ class SubmenuItem(MenuItem):
         """
         :return: The returned value in the submenu
         """
-        return self.submenu.returned_value
+        return self.get_submenu().returned_value
+
+    def get_submenu(self):
+        """
+        We unwrap the submenu variable in case it is a reference to a method that returns a submenu
+        """
+        return self.submenu if not callable(self.submenu) else self.submenu()
diff --git a/requirements-docs.txt b/requirements-docs.txt
index 2f06be2..e97510c 100644
--- a/requirements-docs.txt
+++ b/requirements-docs.txt
@@ -3,4 +3,5 @@
 pycodestyle>=2.3.1
 pytest>=2.8.7
 six
+mock
 sphinxcontrib-napoleon
diff --git a/requirements.txt b/requirements.txt
index 3d39e8c..7fa295b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,4 @@
 pycodestyle>=2.3.1
 pytest>=2.8.7
 six
+mock


Test patch:
diff --git a/test/test_dynamic_texts.py b/test/test_dynamic_texts.py
new file mode 100644
index 0000000..5066727
--- /dev/null
+++ b/test/test_dynamic_texts.py
@@ -0,0 +1,65 @@
+from base_test_case import BaseTestCase
+from consolemenu import ConsoleMenu
+from consolemenu.items import MenuItem, SubmenuItem
+
+title_text = "title"
+subtitle_text = "subtitle"
+item_title_text = "itemtext"
+submenu_title_text = "mysub"
+count = 0
+
+
+def menu_title():
+    return title_text
+
+
+def menu_subtitle():
+    return subtitle_text
+
+
+def item_title():
+    return item_title_text
+
+
+def submenu_title():
+    return submenu_title_text
+
+
+def my_submenu_function():
+    global count
+    if count == 0:
+        count = count + 1
+        return ConsoleMenu("version1")
+    else:
+        return ConsoleMenu("version2")
+
+
+class TestDynamicTexts(BaseTestCase):
+    def setUp(self):
+        super(TestDynamicTexts, self).setUp()
+        self.menu = ConsoleMenu(menu_title, menu_subtitle)
+        self.first_call = True
+
+    def test_(self):
+        global title_text, subtitle_text, item_title_text
+        menu_item = MenuItem(item_title, self.menu)
+
+        # Test dynamic change of main menu title
+        self.assertEqual(self.menu.get_title(), "title")
+        title_text = "newtitle"
+        self.assertEqual(self.menu.get_title(), "newtitle")
+
+        # Test dynamic change of main menu subtitle
+        self.assertEqual(self.menu.get_subtitle(), "subtitle")
+        subtitle_text = "newsubtitle"
+        self.assertEqual(self.menu.get_subtitle(), "newsubtitle")
+
+        # Test dynamic menu item text
+        self.assertEqual(menu_item.get_text(), "itemtext")
+        item_title_text = "newtext"
+        self.assertEqual(menu_item.get_text(), "newtext")
+
+        # Test dynamic change of submenu item on each invocation
+        submenu_item = SubmenuItem("a_sub", my_submenu_function)
+        self.assertEqual(submenu_item.get_submenu().get_title(), "version1")
+        self.assertEqual(submenu_item.get_submenu().get_title(), "version2")