Repository: jwlodek/py_cui (language: python)

Issue:
Add option to add custom widget based on class created outside of core py_cui classes
Quick summary of pull request...

- [X] I have read the contribution guidelines
- [X] CI Unit tests pass
- [X] New functions/classes have consistent docstrings

**What this pull request changes**

* Adds `add_custom_widget` functions to both `PyCUI` and  `WidgetSet` classes that allow for adding widgets to the CUI that aren't necessarily included with `py_cui` by default. Should make adding custom widgets much easier.
* Use new  `add_custom_widget` functions to remove lots of redundant code from the `add_*` functions for adding widgets
* Minor change in order of arguments for block label - custom widget function expects all common widget args to be first, followed by custom args, and custom kwargs

**Issues fixed with this pull request**

* #143 

**Optionally, what changes should join this PR**

* Docs must be updated to reflect new easier way for adding custom widgets. A section should still be dedicated on how to have the widget included with py_cui in an official capacity, but the docs should focus on writing the widget class, and using the `add_custom_widget` function to add it to the UI.
* Docs should also be modified to outline the importance of the order of args for widget subclasses
* A similar re-format should be done for popups and the `show_*` functions for the `PyCUI` base class.



Discussion / hints:

Fix patch:
diff --git a/docs/DocstringGenerated/PyCui.md b/docs/DocstringGenerated/PyCui.md
index f59d912..dc84342 100644
--- a/docs/DocstringGenerated/PyCui.md
+++ b/docs/DocstringGenerated/PyCui.md
@@ -132,13 +132,14 @@ first create an instance of this class, and then add cells + widgets to it.
  increment_loading_bar | Increments progress bar if loading bar popup is open
  stop_loading_popup | Leaves loading state, and closes popup.
  close_popup | Closes the popup, and resets focus
- _refresh_height_width | Function that updates the height and width of the CUI based on terminal window size."""
+ _refresh_height_width | Function that updates the height and width of the CUI based on terminal window size.
+ get_absolute_size | Returns dimensions of CUI
  _draw_widgets | Function that draws all of the widgets to the screen
  _draw_status_bars | Draws status bar and title bar
  _display_window_warning | Function that prints some basic error info if there is an error with the CUI
  _handle_key_presses | Function that handles all main loop key presses.
  _draw | Main CUI draw loop called by start()
- __format__ | Override of base format function. Prints list of current widgets.
+ format | Override of base format function. Prints list of current widgets.
 
 
 
@@ -1327,10 +1328,10 @@ Closes the popup, and resets focus
 def _refresh_height_width(self) -> None
 ```
 
-Function that updates the height and width of the CUI based on terminal window size."""
-
+Function that updates the height and width of the CUI based on terminal window size.
 
 
+```
 if self._simulated_terminal is None:
 if self._stdscr is None:
 term_size = shutil.get_terminal_size()
@@ -1358,9 +1359,23 @@ if self._popup is not None:
 self._popup.update_height_width()
 if self._logger._live_debug_element is not None:
 self._logger._live_debug_element.update_height_width()
+```
+
+
+
+
+
 
+
+### get_absolute_size
+
+```python
 def get_absolute_size(self) -> Tuple[int,int]:
-"""Returns dimensions of CUI
+```
+
+Returns dimensions of CUI
+
+
 
 
 #### Returns
diff --git a/docs/DocstringGenerated/Ui.md b/docs/DocstringGenerated/Ui.md
index 0cc03ba..b3d6087 100644
--- a/docs/DocstringGenerated/Ui.md
+++ b/docs/DocstringGenerated/Ui.md
@@ -1099,7 +1099,8 @@ Analogous to a RadioButton
 -----|-----
  clear | Clears all items from the Scroll Menu
  set_on_selection_change_event | Function that sets the function fired when menu selection changes.
- _process_selection_change_event | Function that executes on-selection change event either with the current menu item, or with no-args"""
+ _process_selection_change_event | Function that executes on-selection change event either with the current menu item, or with no-args
+ get_selected_item_index | Gets the currently selected item
  set_selected_item_index | Sets the currently selected item
  _scroll_up | Function that scrolls the view up in the scroll menu
  _scroll_down | Function that scrolls the view down in the scroll menu
@@ -1181,10 +1182,10 @@ Event function must take 0 or 1 parameters. If 1 parameter, the new selcted item
 def _process_selection_change_event(self)
 ```
 
-Function that executes on-selection change event either with the current menu item, or with no-args"""
-
+Function that executes on-selection change event either with the current menu item, or with no-args
 
 
+```
 # Identify num of args from callable. This allows for user to create commands that take in x, y
 # coords of the mouse press as input
 num_args = 0
@@ -1203,10 +1204,19 @@ elif num_args == 0:
 self._on_selection_change()
 else:
 raise ValueError('On selection change event must accept either 0 or 1 parameters!')
+```
+
+
+
 
 
+
+### get_selected_item_index
+```python
 def get_selected_item_index(self) -> int:
-"""Gets the currently selected item
+```
+
+Gets the currently selected item
 
 
 #### Returns
@@ -1219,6 +1229,7 @@ def get_selected_item_index(self) -> int:
 
 
 
+
 ### set_selected_item_index
 
 ```python
diff --git a/docs/developers.md b/docs/developers.md
index a896019..f2e202e 100644
--- a/docs/developers.md
+++ b/docs/developers.md
@@ -146,8 +146,11 @@ class ScrollMenu(Widget, py_cui.ui.MenuImplementation):
 
         super().draw()
 ```
+
 The `_handle_key_press` and `_draw` functions must be extended for your new widget. You may leave the `_handle_key_press` as above, if you don't require any keybindings for the widget. The `_draw` function must extended, as the base class does no drawing itself, instead just setting up color rules.
 
+In addition, any widget class you develop must have the arguments `(self, id, title, grid, row, column, row_span, column_span, padx, pady, logger)` in it's initializer, in that order, so that the `add_custom_widget` function will  work. Additional arguments or keyword arguments can follow these.
+
 **Step 3 - Add Key Bindings**
 
 Next, add any default key bindings you wish to have for the widget when in focus mode. In the case of the scroll menu, we wish for the arrow keys to scroll up and down, so we extend the `handle_key_press` function:
@@ -199,43 +202,20 @@ def _draw(self):
 
 Note that you should call `super()._draw()` and `self._renderer.set_color_mode(self._color)` at the start of the function (to initialize color modes), and `self._renderer.unset_color_mode(self._color)` and `self._renderer.reset_cursor(self)` to remove color settings, and place the cursor in the correct location.
 
-**Step 5 - Add a function to `PyCUI` class to add the widget**
+**Step 5 - Use the add_custom_widget function to add your widget to your TUI**
+
+Finally, you are ready to add an instance of your widget to the TUI. To do this, use the `add_custom_widget` function. See the below example adding our above widget. Optionally, you may want to add a conveniance function specifically meant to add instances of your widget to the TUI. In our example it would be `add_scroll_menu`, which can be found in `py_cui/__init__.py`.
 
-Finally, add a function to the `PyCUI` class in `__init__.py` that will add the widget to the CUI. In our case we write the following:
 ```Python
-def add_scroll_menu(self, title, row, column, row_span = 1, column_span = 1, padx = 1, pady = 0):
-
-    id = f'Widget{len(self.get_widgets().keys())}'
-    new_scroll_menu = widgets.ScrollMenu(   id, 
-                                            title, 
-                                            self._grid, 
-                                            row, 
-                                            column, 
-                                            row_span, 
-                                            column_span, 
-                                            padx, 
-                                            pady, 
-                                            self._logger)
-    self.get_widgets()[id] = new_scroll_menu
-    if self._selected_widget is None:
-        self.set_selected_widget(id)
-    self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_scroll_menu))}'
-    return new_scroll_menu
-```
-The function must:
-
-* Create an id titled 'Widget####' where #### is replaced with the number of widget
-* Add the widget to the PyCUI widgets dict with the ID as a key
-* If there is no selected widget, make this new widget the selected one
-* Return a reference to the widget
+# add_custom_widget(Widget class, title, row, column, rowspan, colspan, padx, pady)
+my_widget = root.add_custom_widget(py_cui.widgets.ScrollMenu, "My Title", 1, 1, 1, 1, 1, 1)
+```
 
-**That's it!**
+Note the `add_custom_widget` function signature expects your class that extends `Widget` as the first argument, followed by critical widget information. You can also add additional `*args` and `**kwargs` to the funtion call. The function will return an instance of the initial class with all the remaining arguments passed in in order.
 
-Your widget is now ready to be added to the CUI! Simply call the add function with appropriate parameters on the root `PyCUI` window:
+**That's it!**
 
-```Python
-root.add_scroll_menu('Demo', 1, 1)
-```
+Your widget should now be added to the CUI!
 
 ### Adding a new Popup
 
diff --git a/examples/popups_example.py b/examples/popups_example.py
index ca92849..c430b2a 100644
--- a/examples/popups_example.py
+++ b/examples/popups_example.py
@@ -69,7 +69,7 @@ def quit_cui(self, to_quit):
 
     def show_text_box(self):
         # Here, reset title is a function that takes a string parameter, which will be the user entered string
-        self.master.show_text_box_popup('Please enter a new window title', self.reset_title)
+        self.master.show_text_box_popup('Please enter a new window title', self.reset_title, initial_text='New Title')
 
     def reset_title(self, new_title):
         self.master.set_title(new_title)
diff --git a/py_cui/__init__.py b/py_cui/__init__.py
index 66c585d..1326985 100644
--- a/py_cui/__init__.py
+++ b/py_cui/__init__.py
@@ -13,16 +13,16 @@
 import os
 import time
 import copy
-import shutil       # We use shutil for getting the terminal dimensions
-import threading    # Threading is used for loading icon popups
-import logging      # Use logging library for debug purposes
+import shutil  # We use shutil for getting the terminal dimensions
+import threading  # Threading is used for loading icon popups
+import logging  # Use logging library for debug purposes
 
 
 # py_cui uses the curses library. On windows this does not exist, but
 # there is a open source windows-curses module that adds curses support
 # for python on windows
 import curses
-from typing import Any, Callable, List, Dict, Optional, Tuple
+from typing import Any, Union, Callable, List, Dict, Optional, Tuple
 
 # py_cui imports
 import py_cui
@@ -39,7 +39,7 @@
 from py_cui.colors import *
 
 # Version number
-__version__ = '0.1.4'
+__version__ = "0.1.5"
 
 
 def fit_text(width: int, text: str, center: bool = False) -> str:
@@ -64,19 +64,19 @@ def fit_text(width: int, text: str, center: bool = False) -> str:
     """
 
     if width < 5:
-        return '.' * width
+        return "." * width
     if len(text) >= width:
-        return text[:width - 5] + '...'
+        return text[: width - 5] + "..."
     else:
-        total_num_spaces = (width - len(text) - 1)
+        total_num_spaces = width - len(text) - 1
         if center:
             left_spaces = int(total_num_spaces / 2)
             right_spaces = int(total_num_spaces / 2)
-            if(total_num_spaces % 2 == 1):
+            if total_num_spaces % 2 == 1:
                 right_spaces = right_spaces + 1
-            return ' ' * left_spaces + text + ' ' * right_spaces
+            return " " * left_spaces + text + " " * right_spaces
         else:
-            return text + ' ' * total_num_spaces
+            return text + " " * total_num_spaces
 
 
 class PyCUI:
@@ -107,16 +107,21 @@ class PyCUI:
         Dimensions for an alternative simulated terminal (used for testing)
     """
 
-    def __init__(self, num_rows: int, num_cols: int, auto_focus_buttons: bool=True,
-                 exit_key=py_cui.keys.KEY_Q_LOWER, simulated_terminal: List[int]=None):
-        """Initializer for PyCUI class
-        """
+    def __init__(
+        self,
+        num_rows: int,
+        num_cols: int,
+        auto_focus_buttons: bool = True,
+        exit_key=py_cui.keys.KEY_Q_LOWER,
+        simulated_terminal: List[int] = None,
+    ):
+        """Initializer for PyCUI class"""
+
+        self._title = "PyCUI Window"
 
-        self._title = 'PyCUI Window'
-        
         # When this is not set, the escape character delay
         # is too long for exiting focus mode
-        os.environ.setdefault('ESCDELAY', '25')
+        os.environ.setdefault("ESCDELAY", "25")
 
         # For unit testing purposes, we want to simulate terminal
         # dimensions so that we don't get errors
@@ -124,67 +129,74 @@ def __init__(self, num_rows: int, num_cols: int, auto_focus_buttons: bool=True,
 
         if self._simulated_terminal is None:
             term_size = shutil.get_terminal_size()
-            height  = term_size.lines
-            width   = term_size.columns
+            height = term_size.lines
+            width = term_size.columns
         else:
             height = self._simulated_terminal[0]
-            width  = self._simulated_terminal[1]
+            width = self._simulated_terminal[1]
 
         # Add status and title bar
-        self.title_bar = py_cui.statusbar.StatusBar(self._title, BLACK_ON_WHITE, root=self, is_title_bar=True)
+        self.title_bar = py_cui.statusbar.StatusBar(
+            self._title, BLACK_ON_WHITE, root=self, is_title_bar=True
+        )
         exit_key_char = py_cui.keys.get_char_from_ascii(exit_key)
 
         if exit_key_char:
-            self._init_status_bar_text  = f'Press - {exit_key_char} - to exit. Arrow ' \
-                                        'Keys to move between widgets. Enter to ' \
-                                        'enter focus mode.'
+            self._init_status_bar_text = (
+                f"Press - {exit_key_char} - to exit. Arrow "
+                "Keys to move between widgets. Enter to "
+                "enter focus mode."
+            )
         else:
-            self._init_status_bar_text  = 'Press arrow Keys to move between widgets. ' \
-                                        'Enter to enter focus mode.' \
-
-        self.status_bar = py_cui.statusbar.StatusBar(self._init_status_bar_text,
-                                                     BLACK_ON_WHITE, root=self)
+            self._init_status_bar_text = (
+                "Press arrow Keys to move between widgets. " "Enter to enter focus mode."
+            )
+        self.status_bar = py_cui.statusbar.StatusBar(
+            self._init_status_bar_text, BLACK_ON_WHITE, root=self
+        )
 
         # Init terminal height width. Subtract 4 from height
         # for title/status bar and padding
-        self._height                = height
-        self._width                 = width
-        self._height                = self._height - self.title_bar.get_height() - self.status_bar.get_height() - 2
+        self._height = height
+        self._width = width
+        self._height = (
+            self._height - self.title_bar.get_height() - self.status_bar.get_height() - 2
+        )
 
         # Logging object initialization for py_cui
-        self._logger = py_cui.debug._initialize_logger(self,
-                                                       name='py_cui')
+        self._logger = py_cui.debug._initialize_logger(self, name="py_cui")
 
         # Initialize grid, renderer, and widget dict
-        self._grid                  = py_cui.grid.Grid(num_rows, num_cols, self._height, self._width, self._logger)
-        self._stdscr: Any              = None
-        self._refresh_timeout       = -1
-        self._border_characters: Optional[Dict[str,str]] = None
-        self._widgets: Dict[int,Optional['py_cui.widgets.Widget']] = {}
-        self._renderer: Optional['py_cui.renderer.Renderer'] = None
+        self._grid = py_cui.grid.Grid(
+            num_rows, num_cols, self._height, self._width, self._logger
+        )
+        self._stdscr: Any = None
+        self._refresh_timeout = -1
+        self._border_characters: Optional[Dict[str, str]] = None
+        self._widgets: Dict[int, Optional["py_cui.widgets.Widget"]] = {}
+        self._renderer: Optional["py_cui.renderer.Renderer"] = None
 
         # Variables for determining selected widget/focus mode
         self._selected_widget: Optional[int] = None
-        self._in_focused_mode       = False
+        self._in_focused_mode = False
         self._popup: Any = None
-        self._auto_focus_buttons    = auto_focus_buttons
+        self._auto_focus_buttons = auto_focus_buttons
 
         # CUI blocks when loading popup is open
-        self._loading               = False
-        self._stopped               = False
-        self._post_loading_callback: Optional[Callable[[],Any]] = None
-        self._on_draw_update_func: Optional[Callable[[],Any]]   = None
+        self._loading = False
+        self._stopped = False
+        self._post_loading_callback: Optional[Callable[[], Any]] = None
+        self._on_draw_update_func: Optional[Callable[[], Any]] = None
 
         # Top level keybindings. Exit key is 'q' by default
-        self._keybindings: Dict[int,Callable[[],Any]]  = {}
-        self._exit_key     = exit_key
+        self._keybindings: Dict[int, Callable[[], Any]] = {}
+        self._exit_key = exit_key
         self._forward_cycle_key = py_cui.keys.KEY_CTRL_LEFT
         self._reverse_cycle_key = py_cui.keys.KEY_CTRL_RIGHT
         self._toggle_live_debug_key: Optional[int] = None
 
         # Callback to fire when CUI is stopped.
-        self._on_stop: Optional[Callable[[],Any]] = None
-
+        self._on_stop: Optional[Callable[[], Any]] = None
 
     def set_refresh_timeout(self, timeout: int):
         """Sets the CUI auto-refresh timeout to a number of seconds.
@@ -198,8 +210,7 @@ def set_refresh_timeout(self, timeout: int):
         # We want the refresh timeout in milliseconds as an integer
         self._refresh_timeout = int(timeout * 1000)
 
-
-    def set_on_draw_update_func(self, update_function: Callable[[],Any]):
+    def set_on_draw_update_func(self, update_function: Callable[[], Any]):
         """Adds a function that is fired during each draw call of the CUI
 
         Parameters
@@ -210,8 +221,9 @@ def set_on_draw_update_func(self, update_function: Callable[[],Any]):
 
         self._on_draw_update_func = update_function
 
-
-    def set_widget_cycle_key(self, forward_cycle_key: int=None, reverse_cycle_key: int=None) -> None: 
+    def set_widget_cycle_key(
+        self, forward_cycle_key: int = None, reverse_cycle_key: int = None
+    ) -> None:
         """Assigns a key for automatically cycling through widgets in both focus and overview modes
 
         Parameters
@@ -225,12 +237,15 @@ def set_widget_cycle_key(self, forward_cycle_key: int=None, reverse_cycle_key: i
         if reverse_cycle_key is not None:
             self._reverse_cycle_key = reverse_cycle_key
 
-
     def set_toggle_live_debug_key(self, toggle_debug_key: int) -> None:
         self._toggle_live_debug_key = toggle_debug_key
 
-
-    def enable_logging(self, log_file_path: str='py_cui.log', logging_level = logging.DEBUG, live_debug_key: int = py_cui.keys.KEY_CTRL_D) -> None:
+    def enable_logging(
+        self,
+        log_file_path: str = "py_cui.log",
+        logging_level=logging.DEBUG,
+        live_debug_key: int = py_cui.keys.KEY_CTRL_D,
+    ) -> None:
         """Function enables logging for py_cui library
 
         Parameters
@@ -242,12 +257,13 @@ def enable_logging(self, log_file_path: str='py_cui.log', logging_level = loggin
         """
 
         try:
-            py_cui.debug._enable_logging(self._logger, filename=log_file_path, logging_level=logging_level)
-            self._logger.info('Initialized logger')
+            py_cui.debug._enable_logging(
+                self._logger, filename=log_file_path, logging_level=logging_level
+            )
+            self._logger.info("Initialized logger")
             self._toggle_live_debug_key = live_debug_key
         except PermissionError as e:
-            print(f'Failed to initialize logger: {str(e)}')
-
+            print(f"Failed to initialize logger: {str(e)}")
 
     def apply_widget_set(self, new_widget_set: py_cui.widget_set.WidgetSet) -> None:
         """Function that replaces all widgets in a py_cui with those of a different widget set
@@ -265,19 +281,20 @@ def apply_widget_set(self, new_widget_set: py_cui.widget_set.WidgetSet) -> None:
 
         if isinstance(new_widget_set, py_cui.widget_set.WidgetSet):
             self.lose_focus()
-            self._widgets      = new_widget_set._widgets
-            self._grid         = new_widget_set._grid
-            self._keybindings  = new_widget_set._keybindings
+            self._widgets = new_widget_set._widgets
+            self._grid = new_widget_set._grid
+            self._keybindings = new_widget_set._keybindings
 
             self._refresh_height_width()
             if self._stdscr is not None:
                 self._initialize_widget_renderer()
             self._selected_widget = new_widget_set._selected_widget
         else:
-            raise TypeError('Argument must be of type py_cui.widget_set.WidgetSet')
-
+            raise TypeError("Argument must be of type py_cui.widget_set.WidgetSet")
 
-    def create_new_widget_set(self, num_rows: int, num_cols: int) -> 'py_cui.widget_set.WidgetSet':
+    def create_new_widget_set(
+        self, num_rows: int, num_cols: int
+    ) -> "py_cui.widget_set.WidgetSet":
         """Function that is used to create additional widget sets
 
         Use this function instead of directly creating widget set object instances, to allow
@@ -297,36 +314,36 @@ def create_new_widget_set(self, num_rows: int, num_cols: int) -> 'py_cui.widget_
         """
 
         # Use current logging object and simulated terminal for sub-widget sets
-        return py_cui.widget_set.WidgetSet(num_rows, num_cols, self._logger, root=self,
-                                            simulated_terminal=self._simulated_terminal)
-
+        return py_cui.widget_set.WidgetSet(
+            num_rows,
+            num_cols,
+            self._logger,
+            root=self,
+            simulated_terminal=self._simulated_terminal,
+        )
 
     # ----------------------------------------------#
     # Initialization functions                      #
     # Used to initialzie CUI and its features       #
     # ----------------------------------------------#
 
-
     def start(self) -> None:
-        """Function that starts the CUI
-        """
+        """Function that starts the CUI"""
 
-        self._logger.info(f'Starting {self._title} CUI')
+        self._logger.info(f"Starting {self._title} CUI")
         self._stopped = False
         curses.wrapper(self._draw)
 
-
     def stop(self) -> None:
         """Function that stops the CUI, and fires the callback function.
 
         Callback must be a no arg method
         """
 
-        self._logger.info('Stopping CUI')
+        self._logger.info("Stopping CUI")
         self._stopped = True
 
-
-    def run_on_exit(self, command: Callable[[],Any]):
+    def run_on_exit(self, command: Callable[[], Any]):
         """Sets callback function on CUI exit. Must be a no-argument function or lambda function
 
         Parameters
@@ -337,7 +354,6 @@ def run_on_exit(self, command: Callable[[],Any]):
 
         self._on_stop = command
 
-
     def set_title(self, title: str) -> None:
         """Sets the title bar text
 
@@ -349,7 +365,6 @@ def set_title(self, title: str) -> None:
 
         self._title = title
 
-
     def set_status_bar_text(self, text: str) -> None:
         """Sets the status bar text when in overview mode
 
@@ -362,10 +377,8 @@ def set_status_bar_text(self, text: str) -> None:
         self._init_status_bar_text = text
         self.status_bar.set_text(text)
 
-
     def _initialize_colors(self) -> None:
-        """Function for initialzing curses colors. Called when CUI is first created.
-        """
+        """Function for initialzing curses colors. Called when CUI is first created."""
 
         # Start colors in curses.
         # For each color pair in color map, initialize color combination.
@@ -374,10 +387,8 @@ def _initialize_colors(self) -> None:
             fg_color, bg_color = py_cui.colors._COLOR_MAP[color_pair]
             curses.init_pair(color_pair, fg_color, bg_color)
 
-
     def _initialize_widget_renderer(self) -> None:
-        """Function that creates the renderer object that will draw each widget
-        """
+        """Function that creates the renderer object that will draw each widget"""
 
         if self._renderer is None:
             self._renderer = py_cui.renderer.Renderer(self, self._stdscr, self._logger)
@@ -387,27 +398,36 @@ def _initialize_widget_renderer(self) -> None:
                 try:
                     widget._assign_renderer(self._renderer)
                 except py_cui.errors.PyCUIError:
-                    self._logger.debug(f'Renderer already assigned for widget {self.get_widgets()[widget_id]}')
+                    self._logger.debug(
+                        f"Renderer already assigned for widget {self.get_widgets()[widget_id]}"
+                    )
         try:
             if self._popup is not None:
                 self._popup._assign_renderer(self._renderer)
             if self._logger is not None:
                 self._logger._live_debug_element._assign_renderer(self._renderer)
         except py_cui.errors.PyCUIError:
-            self._logger.debug('Renderer already assigned to popup or live-debug elements')
-
+            self._logger.debug("Renderer already assigned to popup or live-debug elements")
 
     def toggle_unicode_borders(self) -> None:
-        """Function for toggling unicode based border rendering
-        """
+        """Function for toggling unicode based border rendering"""
 
-        if self._border_characters is None or self._border_characters['UP_LEFT'] == '+':
-            self.set_widget_border_characters('\u256d', '\u256e', '\u2570', '\u256f', '\u2500', '\u2502')
+        if self._border_characters is None or self._border_characters["UP_LEFT"] == "+":
+            self.set_widget_border_characters(
+                "\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"
+            )
         else:
-            self.set_widget_border_characters('+', '+', '+', '+', '-', '|')
-
-
-    def set_widget_border_characters(self, upper_left_corner: str, upper_right_corner: str, lower_left_corner: str, lower_right_corner: str, horizontal: str, vertical: str) -> None:
+            self.set_widget_border_characters("+", "+", "+", "+", "-", "|")
+
+    def set_widget_border_characters(
+        self,
+        upper_left_corner: str,
+        upper_right_corner: str,
+        lower_left_corner: str,
+        lower_right_corner: str,
+        horizontal: str,
+        vertical: str,
+    ) -> None:
         """Function that can be used to set arbitrary border characters for drawing widget borders by renderer.
 
         Parameters
@@ -427,22 +447,21 @@ def set_widget_border_characters(self, upper_left_corner: str, upper_right_corne
         """
 
         self._border_characters = {
-            'UP_LEFT': upper_left_corner,
-            'UP_RIGHT': upper_right_corner,
-            'DOWN_LEFT': lower_left_corner,
-            'DOWN_RIGHT': lower_right_corner,
-            'HORIZONTAL': horizontal,
-            'VERTICAL': vertical
+            "UP_LEFT": upper_left_corner,
+            "UP_RIGHT": upper_right_corner,
+            "DOWN_LEFT": lower_left_corner,
+            "DOWN_RIGHT": lower_right_corner,
+            "HORIZONTAL": horizontal,
+            "VERTICAL": vertical,
         }
-        self._logger.debug(f'Set border_characters to {self._border_characters}')
+        self._logger.debug(f"Set border_characters to {self._border_characters}")
 
-
-    def get_widgets(self) -> Dict[int,Optional['py_cui.widgets.Widget']]:  
+    def get_widgets(self) -> Dict[int, Optional["py_cui.widgets.Widget"]]:
         """Function that gets current set of widgets
 
         Returns
         -------
-        widgets : dict of int -> widget 
+        widgets : dict of int -> widget
             dictionary mapping widget IDs to object instances
         """
 
@@ -451,11 +470,13 @@ def get_widgets(self) -> Dict[int,Optional['py_cui.widgets.Widget']]:
     # Widget add functions. Each of these adds a particular type of widget
     # to the grid in a specified location.
 
-    def add_scroll_menu(self, title: str, row: int, column: int, row_span: int=1, column_span: int=1, padx: int=1, pady: int=0) -> 'py_cui.widgets.ScrollMenu':
-        """Function that adds a new scroll menu to the CUI grid
+    def add_custom_widget(self, widget_class: type, title: str, row: int, column: int, row_span: int, column_span: int, padx: int, pady: int, *args, **kwargs) -> 'py_cui.widgets.Widget':
+        """Function that allows for adding custom widget types to the CUI - specifically ones not included with py_cui by default
 
         Parameters
         ----------
+        widget_class : type
+            The class type of your custom widget. Note that it must be a subclass of the widget superclass
         title : str
             The title of the scroll menu
         row : int
@@ -471,33 +492,85 @@ def add_scroll_menu(self, title: str, row: int, column: int, row_span: int=1, co
         pady=0 : int
             number of padding characters in the y direction
 
-        Returns
-        -------
-        new_scroll_menu : ScrollMenu
-            A reference to the created scroll menu object.
+        Raises
+        ------
+        TypeError
+            If provided widget class is not a subclass of widget, a typeerror is raised.
         """
 
+        if not issubclass(widget_class, py_cui.widgets.Widget):
+            raise TypeError(f'Widget class {widget_class} is not a subclass of the base Widget class!')
+
         id = len(self.get_widgets().keys())
-        new_scroll_menu = py_cui.widgets.ScrollMenu(id,
-                                                    title,
-                                                    self._grid,
-                                                    row,
-                                                    column,
-                                                    row_span,
-                                                    column_span,
-                                                    padx,
-                                                    pady,
-                                                    self._logger)
+        new_widget = widget_class(id, title, self._grid, row, column, row_span, column_span, padx, pady, self._logger, *args, **kwargs)
         if self._renderer is not None:
-            new_scroll_menu._assign_renderer(self._renderer)
-        self.get_widgets()[id]  = new_scroll_menu
+            new_widget._assign_renderer(self._renderer)
+
+        self.get_widgets()[id] = new_widget
+
         if self._selected_widget is None:
             self.set_selected_widget(id)
-        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(type(new_scroll_menu))}')
-        return new_scroll_menu
 
+        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(widget_class)}')
+        return new_widget
+
+
+
+    def add_scroll_menu(
+        self,
+        title: str,
+        row: int,
+        column: int,
+        row_span: int = 1,
+        column_span: int = 1,
+        padx: int = 1,
+        pady: int = 0,
+    ) -> "py_cui.widgets.ScrollMenu":
+        """Function that adds a new scroll menu to the CUI grid
+
+        Parameters
+        ----------
+        title : str
+            The title of the scroll menu
+        row : int
+            The row value, from the top down
+        column : int
+            The column value from the top down
+        row_span=1 : int
+            The number of rows to span accross
+        column_span=1 : int
+            the number of columns to span accross
+        padx=1 : int
+            number of padding characters in the x direction
+        pady=0 : int
+            number of padding characters in the y direction
+
+        Returns
+        -------
+        new_scroll_menu : ScrollMenu
+            A reference to the created scroll menu object.
+        """
 
-    def add_checkbox_menu(self, title: str, row: int, column: int, row_span: int=1, column_span: int=1, padx: int=1, pady: int=0, checked_char: str='X') -> 'py_cui.widgets.CheckBoxMenu':
+        return self.add_custom_widget(py_cui.widgets.ScrollMenu,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady)
+
+    def add_checkbox_menu(
+        self,
+        title: str,
+        row: int,
+        column: int,
+        row_span: int = 1,
+        column_span: int = 1,
+        padx: int = 1,
+        pady: int = 0,
+        checked_char: str = "X",
+    ) -> "py_cui.widgets.CheckBoxMenu":
         """Function that adds a new checkbox menu to the CUI grid
 
         Parameters
@@ -525,28 +598,28 @@ def add_checkbox_menu(self, title: str, row: int, column: int, row_span: int=1,
             A reference to the created checkbox object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_checkbox_menu = py_cui.widgets.CheckBoxMenu(id,
-                                                        title,
-                                                        self._grid,
-                                                        row,
-                                                        column,
-                                                        row_span,
-                                                        column_span,
-                                                        padx,
-                                                        pady,
-                                                        self._logger,
-                                                        checked_char)
-        if self._renderer is not None:
-            new_checkbox_menu._assign_renderer(self._renderer)
-        self.get_widgets()[id]  = new_checkbox_menu
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(type(new_checkbox_menu))}')
-        return new_checkbox_menu
-
-
-    def add_text_box(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, initial_text: str = '', password: bool = False) -> 'py_cui.widgets.TextBox':
+        return self.add_custom_widget(py_cui.widgets.CheckBoxMenu,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      checked_char)
+
+    def add_text_box(
+        self,
+        title: str,
+        row: int,
+        column: int,
+        row_span: int = 1,
+        column_span: int = 1,
+        padx: int = 1,
+        pady: int = 0,
+        initial_text: str = "",
+        password: bool = False,
+    ) -> "py_cui.widgets.TextBox":
         """Function that adds a new text box to the CUI grid
 
         Parameters
@@ -576,27 +649,26 @@ def add_text_box(self, title: str, row: int, column: int, row_span: int = 1, col
             A reference to the created textbox object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_text_box = py_cui.widgets.TextBox(id,
-                                              title,
-                                              self._grid,
-                                              row, column,
-                                              row_span,
-                                              column_span,
-                                              padx, pady,
-                                              self._logger,
-                                              initial_text,
-                                              password)
-        if self._renderer is not None:
-            new_text_box._assign_renderer(self._renderer)
-        self.get_widgets()[id]    = new_text_box
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(type(new_text_box))}')
-        return new_text_box
-
-
-    def add_text_block(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, initial_text: str = '') -> 'py_cui.widgets.ScrollTextBlock':
+        return self.add_custom_widget(py_cui.widgets.TextBox,
+                                      title,
+                                      row, column,
+                                      row_span,
+                                      column_span,
+                                      padx, pady,
+                                      initial_text,
+                                      password)
+
+    def add_text_block(
+        self,
+        title: str,
+        row: int,
+        column: int,
+        row_span: int = 1,
+        column_span: int = 1,
+        padx: int = 1,
+        pady: int = 0,
+        initial_text: str = "",
+    ) -> "py_cui.widgets.ScrollTextBlock":
         """Function that adds a new text block to the CUI grid
 
         Parameters
@@ -624,28 +696,26 @@ def add_text_block(self, title: str, row: int, column: int, row_span: int = 1, c
             A reference to the created textblock object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_text_block = py_cui.widgets.ScrollTextBlock(id,
-                                                        title,
-                                                        self._grid,
-                                                        row,
-                                                        column,
-                                                        row_span,
-                                                        column_span,
-                                                        padx,
-                                                        pady,
-                                                        self._logger,
-                                                        initial_text)
-        if self._renderer is not None:
-            new_text_block._assign_renderer(self._renderer)
-        self.get_widgets()[id]  = new_text_block
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(type(new_text_block))}')
-        return new_text_block
-
-
-    def add_label(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0) -> 'py_cui.widgets.Label':
+        return self.add_custom_widget(py_cui.widgets.ScrollTextBlock,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      initial_text)
+
+    def add_label(
+        self,
+        title: str,
+        row: int,
+        column: int,
+        row_span: int = 1,
+        column_span: int = 1,
+        padx: int = 1,
+        pady: int = 0,
+    ) -> "py_cui.widgets.Label":
         """Function that adds a new label to the CUI grid
 
         Parameters
@@ -671,25 +741,26 @@ def add_label(self, title: str, row: int, column: int, row_span: int = 1, column
             A reference to the created label object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_label = py_cui.widgets.Label(id,
-                                         title,
-                                         self._grid,
-                                         row,
-                                         column,
-                                         row_span,
-                                         column_span,
-                                         padx,
-                                         pady,
-                                         self._logger)
-        if self._renderer is not None:
-            new_label._assign_renderer(self._renderer)
-        self.get_widgets()[id]  = new_label
-        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(type(new_label))}')
-        return new_label
-
-
-    def add_block_label(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, center: bool=True) -> 'py_cui.widgets.BlockLabel':
+        return self.add_custom_widget(py_cui.widgets.Label,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady)
+
+    def add_block_label(
+        self,
+        title: str,
+        row: int,
+        column: int,
+        row_span: int = 1,
+        column_span: int = 1,
+        padx: int = 1,
+        pady: int = 0,
+        center: bool = True,
+    ) -> "py_cui.widgets.BlockLabel":
         """Function that adds a new block label to the CUI grid
 
         Parameters
@@ -717,26 +788,27 @@ def add_block_label(self, title: str, row: int, column: int, row_span: int = 1,
             A reference to the created block label object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_label = py_cui.widgets.BlockLabel(id,
-                                              title,
-                                              self._grid,
-                                              row,
-                                              column,
-                                              row_span,
-                                              column_span,
-                                              padx,
-                                              pady,
-                                              center,
-                                              self._logger)
-        if self._renderer is not None:
-            new_label._assign_renderer(self._renderer)
-        self.get_widgets()[id]  = new_label
-        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(type(new_label))}')
-        return new_label
-
-
-    def add_button(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, command: Callable[[],Any]=None) -> 'py_cui.widgets.Button':
+        return self.add_custom_widget(py_cui.widgets.BlockLabel,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      center)
+
+    def add_button(
+        self,
+        title: str,
+        row: int,
+        column: int,
+        row_span: int = 1,
+        column_span: int = 1,
+        padx: int = 1,
+        pady: int = 0,
+        command: Callable[[], Any] = None,
+    ) -> "py_cui.widgets.Button":
         """Function that adds a new button to the CUI grid
 
         Parameters
@@ -764,30 +836,30 @@ def add_button(self, title: str, row: int, column: int, row_span: int = 1, colum
             A reference to the created button object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_button = py_cui.widgets.Button(id,
-                                           title,
-                                           self._grid,
-                                           row,
-                                           column,
-                                           row_span,
-                                           column_span,
-                                           padx,
-                                           pady,
-                                           self._logger,
-                                           command)
-        if self._renderer is not None:
-            new_button._assign_renderer(self._renderer)
-        self.get_widgets()[id]  = new_button
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(type(new_button))}')
-        return new_button
-
-    
-    def add_slider(self, title: str, row: int, column: int, row_span: int=1,
-                   column_span: int=1, padx: int=1, pady: int=0,
-                   min_val: int=0, max_val: int=100, step: int=1, init_val: int=0) -> 'py_cui.controls.slider.SliderWidget':
+        return self.add_custom_widget(py_cui.widgets.Button,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      command)
+
+    def add_slider(
+        self,
+        title: str,
+        row: int,
+        column: int,
+        row_span: int = 1,
+        column_span: int = 1,
+        padx: int = 1,
+        pady: int = 0,
+        min_val: int = 0,
+        max_val: int = 100,
+        step: int = 1,
+        init_val: int = 0,
+    ) -> "py_cui.controls.slider.SliderWidget":
         """Function that adds a new label to the CUI grid
 
         Parameters
@@ -821,36 +893,27 @@ def add_slider(self, title: str, row: int, column: int, row_span: int=1,
             A reference to the created slider object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_slider = py_cui.controls.slider.SliderWidget(id,
-                                                         title,
-                                                         self._grid,
-                                                         row,
-                                                         column,
-                                                         row_span,
-                                                         column_span,
-                                                         padx,
-                                                         pady,
-                                                         self._logger,
-                                                         min_val,
-                                                         max_val,
-                                                         step,
-                                                         init_val)
-        if self._renderer is not None:
-            new_slider._assign_renderer(self._renderer)
-        self.get_widgets()[id] = new_slider
-        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(type(new_slider))}')
-        return new_slider
-
-
-    def forget_widget(self, widget : 'py_cui.widgets.Widget') -> None:
+        return self.add_custom_widget(py_cui.controls.slider.SliderWidget,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      min_val,
+                                      max_val,
+                                      step,
+                                      init_val)
+
+    def forget_widget(self, widget: "py_cui.widgets.Widget") -> None:
         """Function that is used to destroy or "forget" widgets. Forgotten widgets will no longer be drawn
 
         Parameters
         ----------
         widget : py_cui.widgets.Widget
             Widget to remove from the UI
-        
+
         Raises
         ------
         TypeError
@@ -860,14 +923,15 @@ def forget_widget(self, widget : 'py_cui.widgets.Widget') -> None:
         """
 
         if not isinstance(widget, py_cui.widgets.Widget):
-            raise TypeError('Argument widget must by of type py_cui.widgets.Widget!')
+            raise TypeError("Argument widget must by of type py_cui.widgets.Widget!")
         elif widget.get_id() not in self.get_widgets().keys():
-            raise KeyError(f'Widget with id {widget.get_id()} has already been removed from the UI!')
+            raise KeyError(
+                f"Widget with id {widget.get_id()} has already been removed from the UI!"
+            )
         else:
             self.get_widgets()[widget.get_id()] = None
-        
 
-    def get_element_at_position(self, x: int, y: int) -> Optional['py_cui.ui.UIElement']:
+    def get_element_at_position(self, x: int, y: int) -> Optional["py_cui.ui.UIElement"]:
         """Returns containing widget for character position
 
         Parameters
@@ -888,14 +952,15 @@ def get_element_at_position(self, x: int, y: int) -> Optional['py_cui.ui.UIEleme
 
         elif self._popup is None:
             for widget_id in self.get_widgets().keys():
-                widget = self.get_widgets()[widget_id] 
+                widget = self.get_widgets()[widget_id]
                 if widget is not None:
                     if widget._contains_position(x, y):
-                        return widget 
+                        return widget
         return None
 
-
-    def _get_horizontal_neighbors(self, widget: 'py_cui.widgets.Widget', direction: int) -> Optional[List[int]]:
+    def _get_horizontal_neighbors(
+        self, widget: "py_cui.widgets.Widget", direction: int
+    ) -> Optional[List[int]]:
         """Gets all horizontal (left, right) neighbor widgets
 
         Parameters
@@ -914,10 +979,10 @@ def _get_horizontal_neighbors(self, widget: 'py_cui.widgets.Widget', direction:
         if not direction in py_cui.keys.ARROW_KEYS:
             return None
 
-        _,          num_cols    = self._grid.get_dimensions()
-        row_start,  col_start   = widget.get_grid_cell()
-        row_span,   col_span    = widget.get_grid_cell_spans()
-        id_list                 = []
+        _, num_cols = self._grid.get_dimensions()
+        row_start, col_start = widget.get_grid_cell()
+        row_span, col_span = widget.get_grid_cell_spans()
+        id_list = []
 
         if direction == py_cui.keys.KEY_LEFT_ARROW:
             col_range_start = 0
@@ -929,21 +994,28 @@ def _get_horizontal_neighbors(self, widget: 'py_cui.widgets.Widget', direction:
         for col in range(col_range_start, col_range_stop):
             for row in range(row_start, row_start + row_span):
                 for widget_id in self.get_widgets().keys():
-                    item_value = self.get_widgets()[widget_id] #using temporary variable, for mypy
+                    item_value = self.get_widgets()[
+                        widget_id
+                    ]  # using temporary variable, for mypy
                     if item_value is not None:
-                        if item_value._is_row_col_inside(row, col) and widget_id not in id_list:
+                        if (
+                            item_value._is_row_col_inside(row, col)
+                            and widget_id not in id_list
+                        ):
                             id_list.append(widget_id)
 
         if direction == py_cui.keys.KEY_LEFT_ARROW:
             id_list.reverse()
 
-        self._logger.debug(f'Neighbors with ids {id_list} for cell \
-                             {row_start},{col_start} span {row_span},{col_span}')
+        self._logger.debug(
+            f"Neighbors with ids {id_list} for cell {row_start},{col_start} span {row_span},{col_span}"
+        )
 
         return id_list
 
-
-    def _get_vertical_neighbors(self, widget: 'py_cui.widgets.Widget', direction: int) -> Optional[List[int]]:
+    def _get_vertical_neighbors(
+        self, widget: "py_cui.widgets.Widget", direction: int
+    ) -> Optional[List[int]]:
         """Gets all vertical (up, down) neighbor widgets
 
         Parameters
@@ -962,10 +1034,10 @@ def _get_vertical_neighbors(self, widget: 'py_cui.widgets.Widget', direction: in
         if not direction in py_cui.keys.ARROW_KEYS:
             return None
 
-        num_rows,   _           = self._grid.get_dimensions()
-        row_start,  col_start   = widget.get_grid_cell()
-        row_span,   col_span    = widget.get_grid_cell_spans()
-        id_list                 = []
+        num_rows, _ = self._grid.get_dimensions()
+        row_start, col_start = widget.get_grid_cell()
+        row_span, col_span = widget.get_grid_cell_spans()
+        id_list = []
 
         if direction == py_cui.keys.KEY_UP_ARROW:
             row_range_start = 0
@@ -977,16 +1049,20 @@ def _get_vertical_neighbors(self, widget: 'py_cui.widgets.Widget', direction: in
         for row in range(row_range_start, row_range_stop):
             for col in range(col_start, col_start + col_span):
                 for widget_id in self.get_widgets().keys():
-                    item_value = self.get_widgets()[widget_id] 
+                    item_value = self.get_widgets()[widget_id]
                     if item_value is not None:
-                        if item_value._is_row_col_inside(row, col) and widget_id not in id_list:
+                        if (
+                            item_value._is_row_col_inside(row, col)
+                            and widget_id not in id_list
+                        ):
                             id_list.append(widget_id)
 
         if direction == py_cui.keys.KEY_UP_ARROW:
             id_list.reverse()
 
-        self._logger.debug(f'Neighbors with ids {id_list} for cell \
-                             {row_start},{col_start} span {row_span},{col_span}')
+        self._logger.debug(
+            f"Neighbors with ids {id_list} for cell {row_start},{col_start} span {row_span},{col_span}"
+        )
 
         return id_list
 
@@ -1005,12 +1081,14 @@ def _check_if_neighbor_exists(self, direction: int) -> Optional[int]:
 
         Returns
         -------
-        widget_id : int  
+        widget_id : int
             The widget neighbor ID if found, None otherwise
         """
 
         if self._selected_widget is not None:
-            start_widget: Optional[py_cui.widgets.Widget] = self.get_widgets()[self._selected_widget]
+            start_widget: Optional[py_cui.widgets.Widget] = self.get_widgets()[
+                self._selected_widget
+            ]
 
         # Find all the widgets in the given row or column
         neighbors: Optional[List[int]] = []
@@ -1026,8 +1104,7 @@ def _check_if_neighbor_exists(self, direction: int) -> Optional[int]:
         # We select the best match to jump to (first neighbor)
         return neighbors[0]
 
-
-    def get_selected_widget(self) -> Optional['py_cui.widgets.Widget']:
+    def get_selected_widget(self) -> Optional["py_cui.widgets.Widget"]:
         """Function that gets currently selected widget
 
         Returns
@@ -1036,28 +1113,31 @@ def get_selected_widget(self) -> Optional['py_cui.widgets.Widget']:
             Reference to currently selected widget object
         """
 
-        if self._selected_widget is not None and self._selected_widget in self.get_widgets().keys():
+        if (
+            self._selected_widget is not None
+            and self._selected_widget in self.get_widgets().keys()
+        ):
             return self.get_widgets()[self._selected_widget]
         else:
-            self._logger.warn('Selected widget ID is None or invalid')
+            self._logger.warn("Selected widget ID is None or invalid")
             return None
 
-
     def set_selected_widget(self, widget_id: int) -> None:
         """Function that sets the selected widget for the CUI
 
         Parameters
         ----------
-        widget_id : int 
+        widget_id : int
             the id of the widget to select
         """
 
         if widget_id in self.get_widgets().keys():
-            self._logger.debug(f'Setting selected widget to ID {widget_id}')
+            self._logger.debug(f"Setting selected widget to ID {widget_id}")
             self._selected_widget = widget_id
         else:
-            self._logger.warn(f'Widget w/ ID {widget_id} does not exist among current widgets.')
-
+            self._logger.warn(
+                f"Widget w/ ID {widget_id} does not exist among current widgets."
+            )
 
     def lose_focus(self) -> None:
         """Function that forces py_cui out of focus mode.
@@ -1073,10 +1153,11 @@ def lose_focus(self) -> None:
                 if widget is not None:
                     widget.set_selected(False)
         else:
-            self._logger.info('lose_focus: Not currently in focus mode')
-
+            self._logger.info("lose_focus: Not currently in focus mode")
 
-    def move_focus(self, widget: 'py_cui.widgets.Widget', auto_press_buttons: bool=True) -> None:
+    def move_focus(
+        self, widget: "py_cui.widgets.Widget", auto_press_buttons: bool = True
+    ) -> None:
         """Moves focus mode to different widget
 
         Parameters
@@ -1089,11 +1170,17 @@ def move_focus(self, widget: 'py_cui.widgets.Widget', auto_press_buttons: bool=T
         self.set_selected_widget(widget.get_id())
 
         # If autofocus buttons is selected, we automatically process the button command and reset to overview mode
-        if self._auto_focus_buttons and auto_press_buttons and isinstance(widget, py_cui.widgets.Button):
+        if (
+            self._auto_focus_buttons
+            and auto_press_buttons
+            and isinstance(widget, py_cui.widgets.Button)
+        ):
             if widget.command is not None:
                 widget.command()
 
-            self._logger.debug(f'Moved focus to button {widget.get_title()} - ran autofocus command')
+            self._logger.debug(
+                f"Moved focus to button {widget.get_title()} - ran autofocus command"
+            )
 
         elif self._auto_focus_buttons and isinstance(widget, py_cui.widgets.Button):
             self.status_bar.set_text(self._init_status_bar_text)
@@ -1102,10 +1189,9 @@ def move_focus(self, widget: 'py_cui.widgets.Widget', auto_press_buttons: bool=T
             self._in_focused_mode = True
             self.status_bar.set_text(widget.get_help_text())
 
-        self._logger.debug(f'Moved focus to widget {widget.get_title()}')
+        self._logger.debug(f"Moved focus to widget {widget.get_title()}")
 
-
-    def _cycle_widgets(self, reverse: bool=False) -> None:
+    def _cycle_widgets(self, reverse: bool = False) -> None:
         """Function that is fired if cycle key is pressed to move to next widget
 
         Parameters
@@ -1114,49 +1200,48 @@ def _cycle_widgets(self, reverse: bool=False) -> None:
             Default false. If true, cycle widgets in reverse order.
         """
 
-        num_widgets = len(self.get_widgets().keys())
+        num_widgets: int = len(self.get_widgets())
         current_widget_num: Optional[int] = self._selected_widget
 
-        if current_widget_num is not None:
-            if not reverse:
-                next_widget_num = current_widget_num + 1
-                if self.get_widgets()[next_widget_num] is None:
-                    if next_widget_num == num_widgets:
-                        next_widget_num = 0
-                    next_widget_num = next_widget_num + 1
-                cycle_key = self._forward_cycle_key
-            else:
-                next_widget_num = current_widget_num - 1
-                if self.get_widgets()[next_widget_num] is None:
-                    if next_widget_num < 0:
-                        next_widget_num = num_widgets - 1
-                    next_widget_num = next_widget_num + 1
-                cycle_key = self._reverse_cycle_key
-
-            current_widget_id: int = current_widget_num
-            next_widget_id: int = next_widget_num
-        current_widget = self.get_widgets()[current_widget_id] 
-        next_widget = self.get_widgets()[next_widget_id] 
-        if current_widget and next_widget is not None: #pls check again
+        if current_widget_num is None:
+            return
+
+        if reverse:
+            next_widget_num = current_widget_num - 1
+            if next_widget_num < 0:
+                next_widget_num = num_widgets - 1
+            cycle_key = self._reverse_cycle_key
+        else:
+            next_widget_num = current_widget_num + 1
+            if next_widget_num >= num_widgets:
+                next_widget_num = 0
+            cycle_key = self._forward_cycle_key
+
+        current_widget = self.get_widgets().get(current_widget_num)
+        next_widget = self.get_widgets().get(next_widget_num)
+
+        if current_widget is not None and next_widget is not None:
             if self._in_focused_mode and cycle_key in current_widget._key_commands.keys():
                 # In the event that we are focusing on a widget with that key defined, we do not cycle.
-                pass
-            else:
-                self.move_focus(next_widget, auto_press_buttons=False)
-
+                return
+            self.move_focus(next_widget, auto_press_buttons=False)
 
-    def add_key_command(self, key: int, command: Callable[[],Any]) -> None:
+    def add_key_command(self, key: Union[int, List[int]], command: Callable[[], Any]) -> None:
         """Function that adds a keybinding to the CUI when in overview mode
 
         Parameters
         ----------
         key : py_cui.keys.KEY_*
-            The key bound to the command
+            ascii keycode used to map the key
         command : Function
             A no-arg or lambda function to fire on keypress
         """
 
-        self._keybindings[key] = command
+        if isinstance(key, list):
+            for value in key:
+                self._keybindings[value] = command
+        else:
+            self._keybindings[key] = command
 
     # Popup functions. Used to display messages, warnings, and errors to the user.
 
@@ -1173,9 +1258,10 @@ def show_message_popup(self, title: str, text: str, color: int = WHITE_ON_BLACK)
             Popup color with format FOREGOUND_ON_BACKGROUND. See colors module. Default: WHITE_ON_BLACK.
         """
 
-        self._popup = py_cui.popups.MessagePopup(self, title, text, color, self._renderer, self._logger)
-        self._logger.debug(f'Opened {str(type(self._popup))} popup with title {title}')
-
+        self._popup = py_cui.popups.MessagePopup(
+            self, title, text, color, self._renderer, self._logger
+        )
+        self._logger.debug(f"Opened {str(type(self._popup))} popup with title {title}")
 
     def show_warning_popup(self, title: str, text: str) -> None:
         """Shows a warning popup
@@ -1190,7 +1276,6 @@ def show_warning_popup(self, title: str, text: str) -> None:
 
         self.show_message_popup(title=title, text=text, color=YELLOW_ON_BLACK)
 
-
     def show_error_popup(self, title: str, text: str) -> None:
         """Shows an error popup
 
@@ -1204,7 +1289,6 @@ def show_error_popup(self, title: str, text: str) -> None:
 
         self.show_message_popup(title=title, text=text, color=RED_ON_BLACK)
 
-
     def show_yes_no_popup(self, title: str, command: Callable[[bool], Any]):
         """Shows a yes/no popup.
 
@@ -1219,11 +1303,20 @@ def show_yes_no_popup(self, title: str, command: Callable[[bool], Any]):
         """
 
         color = WHITE_ON_BLACK
-        self._popup = py_cui.popups.YesNoPopup(self, title + '- (y/n)', 'Yes - (y), No - (n)', color, command, self._renderer, self._logger)
-        self._logger.debug(f'Opened {str(type(self._popup))} popup with title {title}')
-
-
-    def show_text_box_popup(self, title: str, command: Callable[[str], Any], password: bool=False):
+        self._popup = py_cui.popups.YesNoPopup(
+            self,
+            title + "- (y/n)",
+            "Yes - (y), No - (n)",
+            color,
+            command,
+            self._renderer,
+            self._logger,
+        )
+        self._logger.debug(f"Opened {str(type(self._popup))} popup with title {title}")
+
+    def show_text_box_popup(
+        self, title: str, command: Callable[[str], Any], initial_text = '', password: bool = False
+    ):
         """Shows a textbox popup.
 
         The 'command' parameter must be a function with a single string parameter
@@ -1239,11 +1332,18 @@ def show_text_box_popup(self, title: str, command: Callable[[str], Any], passwor
         """
 
         color = WHITE_ON_BLACK
-        self._popup = py_cui.popups.TextBoxPopup(self, title, color, command, self._renderer, password, self._logger)
-        self._logger.debug(f'Opened {str(type(self._popup))} popup with title {title}')
-
-
-    def show_menu_popup(self, title: str, menu_items: List[str], command: Callable[[str], Any], run_command_if_none: bool=False):
+        self._popup = py_cui.popups.TextBoxPopup(
+            self, title, initial_text, color, command, self._renderer, password, self._logger
+        )
+        self._logger.debug(f"Opened {str(type(self._popup))} popup with title {title}")
+
+    def show_menu_popup(
+        self,
+        title: str,
+        menu_items: List[str],
+        command: Callable[[str], Any],
+        run_command_if_none: bool = False,
+    ):
         """Shows a menu popup.
 
         The 'command' parameter must be a function with a single string parameter
@@ -1261,11 +1361,21 @@ def show_menu_popup(self, title: str, menu_items: List[str], command: Callable[[
         """
 
         color = WHITE_ON_BLACK
-        self._popup = py_cui.popups.MenuPopup(self, menu_items, title, color, command, self._renderer, self._logger, run_command_if_none)
-        self._logger.debug(f'Opened {str(type(self._popup))} popup with title {title}')
-
-
-    def show_loading_icon_popup(self, title: str, message: str, callback: Callable[[],Any]=None):
+        self._popup = py_cui.popups.MenuPopup(
+            self,
+            menu_items,
+            title,
+            color,
+            command,
+            self._renderer,
+            self._logger,
+            run_command_if_none,
+        )
+        self._logger.debug(f"Opened {str(type(self._popup))} popup with title {title}")
+
+    def show_loading_icon_popup(
+        self, title: str, message: str, callback: Callable[[], Any] = None
+    ):
         """Shows a loading icon popup
 
         Parameters
@@ -1280,15 +1390,18 @@ def show_loading_icon_popup(self, title: str, message: str, callback: Callable[[
 
         if callback is not None:
             self._post_loading_callback = callback
-            self._logger.debug(f'Post loading callback funciton set to {str(callback)}')
+            self._logger.debug(f"Post loading callback funciton set to {str(callback)}")
 
         color = WHITE_ON_BLACK
         self._loading = True
-        self._popup = py_cui.popups.LoadingIconPopup(self, title, message, color, self._renderer, self._logger)
-        self._logger.debug(f'Opened {str(type(self._popup))} popup with title {title}')
-
-
-    def show_loading_bar_popup(self, title: str, num_items: List[int], callback: Callable[[],Any]=None) -> None:
+        self._popup = py_cui.popups.LoadingIconPopup(
+            self, title, message, color, self._renderer, self._logger
+        )
+        self._logger.debug(f"Opened {str(type(self._popup))} popup with title {title}")
+
+    def show_loading_bar_popup(
+        self, title: str, num_items: List[int], callback: Callable[[], Any] = None
+    ) -> None:
         """Shows loading bar popup.
 
         Use 'increment_loading_bar' to show progress
@@ -1305,15 +1418,23 @@ def show_loading_bar_popup(self, title: str, num_items: List[int], callback: Cal
 
         if callback is not None:
             self._post_loading_callback = callback
-            self._logger.debug(f'Post loading callback funciton set to {str(callback)}')
+            self._logger.debug(f"Post loading callback funciton set to {str(callback)}")
 
         color = WHITE_ON_BLACK
         self._loading = True
-        self._popup = py_cui.popups.LoadingBarPopup(self, title, num_items, color, self._renderer, self._logger)
-        self._logger.debug(f'Opened {str(type(self._popup))} popup with title {title}')
-
-
-    def show_form_popup(self, title: str, fields: List[str], passwd_fields: List[str]=[], required: List[str]=[], callback: Callable[[],Any]=None) -> None:
+        self._popup = py_cui.popups.LoadingBarPopup(
+            self, title, num_items, color, self._renderer, self._logger
+        )
+        self._logger.debug(f"Opened {str(type(self._popup))} popup with title {title}")
+
+    def show_form_popup(
+        self,
+        title: str,
+        fields: List[str],
+        passwd_fields: List[str] = [],
+        required: List[str] = [],
+        callback: Callable[[], Any] = None,
+    ) -> None:
         """Shows form popup.
 
         Used for inputting several fields worth of values
@@ -1332,24 +1453,32 @@ def show_form_popup(self, title: str, fields: List[str], passwd_fields: List[str
             If not none, fired after loading is completed. Must be a no-arg function
         """
 
-        self._popup = py_cui.dialogs.form.FormPopup(self, 
-                                                    fields, 
-                                                    passwd_fields, 
-                                                    required, 
-                                                    {}, 
-                                                    title, 
-                                                    py_cui.WHITE_ON_BLACK, 
-                                                    self._renderer, 
-                                                    self._logger)
+        self._popup = py_cui.dialogs.form.FormPopup(
+            self,
+            fields,
+            passwd_fields,
+            required,
+            {},
+            title,
+            py_cui.WHITE_ON_BLACK,
+            self._renderer,
+            self._logger,
+        )
 
         if callback is not None:
             self._popup.set_on_submit_action(callback)
-            self._logger.debug(f'Form enter callback funciton set to {str(callback)}')
-
-        self._logger.debug(f'Opened {str(type(self._popup))} popup with title {title}')
-
-
-    def show_filedialog_popup(self, popup_type: str='openfile', initial_dir: str ='.', callback: Callable[[],Any]=None, ascii_icons: bool=True, limit_extensions: List[str]=[]) -> None:
+            self._logger.debug(f"Form enter callback funciton set to {str(callback)}")
+
+        self._logger.debug(f"Opened {str(type(self._popup))} popup with title {title}")
+
+    def show_filedialog_popup(
+        self,
+        popup_type: str = "openfile",
+        initial_dir: str = ".",
+        callback: Callable[[], Any] = None,
+        ascii_icons: bool = True,
+        limit_extensions: List[str] = [],
+    ) -> None:
         """Shows form popup.
 
         Used for inputting several fields worth of values
@@ -1368,28 +1497,27 @@ def show_filedialog_popup(self, popup_type: str='openfile', initial_dir: str ='.
             Only show files with extensions in this list if not empty. Default, []
         """
 
-        self._popup = py_cui.dialogs.filedialog.FileDialogPopup(self, 
-                                                                callback, 
-                                                                initial_dir, 
-                                                                popup_type, 
-                                                                ascii_icons, 
-                                                                limit_extensions, 
-                                                                py_cui.WHITE_ON_BLACK, 
-                                                                self._renderer, 
-                                                                self._logger)
-
-        self._logger.debug(f'Opened {str(type(self._popup))} popup with type {popup_type}')
+        self._popup = py_cui.dialogs.filedialog.FileDialogPopup(
+            self,
+            callback,
+            initial_dir,
+            popup_type,
+            ascii_icons,
+            limit_extensions,
+            py_cui.WHITE_ON_BLACK,
+            self._renderer,
+            self._logger,
+        )
 
+        self._logger.debug(f"Opened {str(type(self._popup))} popup with type {popup_type}")
 
     def increment_loading_bar(self) -> None:
-        """Increments progress bar if loading bar popup is open
-        """
+        """Increments progress bar if loading bar popup is open"""
 
         if self._popup is not None:
             self._popup._increment_counter()
         else:
-            self._logger.warn('No popup is currently opened.')
-
+            self._logger.warn("No popup is currently opened.")
 
     def stop_loading_popup(self) -> None:
         """Leaves loading state, and closes popup.
@@ -1399,41 +1527,38 @@ def stop_loading_popup(self) -> None:
 
         self._loading = False
         self.close_popup()
-        self._logger.debug('Stopping open loading popup')
-
+        self._logger.debug("Stopping open loading popup")
 
     def close_popup(self) -> None:
-        """Closes the popup, and resets focus
-        """
+        """Closes the popup, and resets focus"""
 
         self.lose_focus()
         self._popup = None
 
-
     def _refresh_height_width(self) -> None:
         """Function that updates the height and width of the CUI based on terminal window size."""
-        
+
         if self._simulated_terminal is None:
             if self._stdscr is None:
                 term_size = shutil.get_terminal_size()
-                height  = term_size.lines
-                width   = term_size.columns
+                height = term_size.lines
+                width = term_size.columns
             else:
                 # Use curses termsize when possible to fix resize bug on windows.
                 height, width = self._stdscr.getmaxyx()
         else:
-            height  = self._simulated_terminal[0]
-            width   = self._simulated_terminal[1]
+            height = self._simulated_terminal[0]
+            width = self._simulated_terminal[1]
+
+        height = height - self.title_bar.get_height() - self.status_bar.get_height() - 2
 
-        height  = height - self.title_bar.get_height() - self.status_bar.get_height() - 2
-        
-        self._logger.debug(f'Resizing CUI to new dimensions {height} by {width}')
+        self._logger.debug(f"Resizing CUI to new dimensions {height} by {width}")
 
         self._height = height
-        self._width  = width
+        self._width = width
         self._grid.update_grid_height_width(self._height, self._width)
         for widget_id in self.get_widgets().keys():
-            widget = self.get_widgets()[widget_id] #using temp variable, for mypy
+            widget = self.get_widgets()[widget_id]  # using temp variable, for mypy
             if widget is not None:
                 widget.update_height_width()
         if self._popup is not None:
@@ -1441,7 +1566,7 @@ def _refresh_height_width(self) -> None:
         if self._logger._live_debug_element is not None:
             self._logger._live_debug_element.update_height_width()
 
-    def get_absolute_size(self) -> Tuple[int,int]:
+    def get_absolute_size(self) -> Tuple[int, int]:
         """Returns dimensions of CUI
 
         Returns
@@ -1455,12 +1580,11 @@ def get_absolute_size(self) -> Tuple[int,int]:
     # Draw Functions. Function for drawing widgets, status bars, and popups
 
     def _draw_widgets(self) -> None:
-        """Function that draws all of the widgets to the screen
-        """
+        """Function that draws all of the widgets to the screen"""
 
         for widget_id in self.get_widgets().keys():
             if widget_id != self._selected_widget:
-                widget = self.get_widgets()[widget_id] 
+                widget = self.get_widgets()[widget_id]
                 if widget is not None:
                     widget._draw()
 
@@ -1473,8 +1597,7 @@ def _draw_widgets(self) -> None:
         if self._logger is not None and self._logger.is_live_debug_enabled():
             self._logger.draw_live_debug()
 
-        self._logger.info('Drew widgets')
-
+        self._logger.info("Drew widgets")
 
     def _draw_status_bars(self, stdscr, height: int, width: int) -> None:
         """Draws status bar and title bar
@@ -1499,7 +1622,6 @@ def _draw_status_bars(self, stdscr, height: int, width: int) -> None:
             stdscr.addstr(0, 0, fit_text(width, self._title, center=True))
             stdscr.attroff(curses.color_pair(self.title_bar.get_color()))
 
-
     def _display_window_warning(self, stdscr, error_info: str) -> None:
         """Function that prints some basic error info if there is an error with the CUI
 
@@ -1513,13 +1635,12 @@ def _display_window_warning(self, stdscr, error_info: str) -> None:
 
         stdscr.clear()
         stdscr.attron(curses.color_pair(RED_ON_BLACK))
-        stdscr.addstr(0, 0, 'Error displaying CUI!!!')
-        stdscr.addstr(1, 0, f'Error Type: {error_info}')
-        stdscr.addstr(2, 0, 'Most likely terminal dimensions are too small.')
+        stdscr.addstr(0, 0, "Error displaying CUI!!!")
+        stdscr.addstr(1, 0, f"Error Type: {error_info}")
+        stdscr.addstr(2, 0, "Most likely terminal dimensions are too small.")
         stdscr.attroff(curses.color_pair(RED_ON_BLACK))
         stdscr.refresh()
-        self._logger.error(f'Encountered error -> {error_info}')
-
+        self._logger.error(f"Encountered error -> {error_info}")
 
     def _handle_key_presses(self, key_pressed: int) -> None:
         """Function that handles all main loop key presses.
@@ -1554,21 +1675,31 @@ def _handle_key_presses(self, key_pressed: int) -> None:
                 self.status_bar.set_text(self._init_status_bar_text)
                 self._in_focused_mode = False
                 selected_widget.set_selected(False)
-                self._logger.debug(f'Exiting focus mode on widget {selected_widget.get_title()}')
+                self._logger.debug(
+                    f"Exiting focus mode on widget {selected_widget.get_title()}"
+                )
             else:
                 # widget handles remaining py_cui.keys
-                self._logger.debug(f'Widget {selected_widget.get_title()} handling {key_pressed} key')
+                self._logger.debug(
+                    f"Widget {selected_widget.get_title()} handling {key_pressed} key"
+                )
                 selected_widget._handle_key_press(key_pressed)
 
         # Otherwise, barring a popup, we are in overview mode, meaning that arrow py_cui.keys move between widgets, and Enter key starts focus mode
         elif self._popup is None:
-            if key_pressed == py_cui.keys.KEY_ENTER and self._selected_widget is not None and selected_widget.is_selectable():
+            if (
+                key_pressed == py_cui.keys.KEY_ENTER
+                and self._selected_widget is not None
+                and selected_widget.is_selectable()
+            ):
                 self.move_focus(selected_widget)
 
             for key in self._keybindings.keys():
                 if key_pressed == key:
                     command = self._keybindings[key]
-                    self._logger.info(f'Detected binding for key {key_pressed}, running command {command.__name__}')
+                    self._logger.info(
+                        f"Detected binding for key {key_pressed}, running command {command.__name__}"
+                    )
                     command()
 
             # If not in focus mode, use the arrow py_cui.keys to move around the selectable widgets.
@@ -1579,15 +1710,14 @@ def _handle_key_presses(self, key_pressed: int) -> None:
                 self.set_selected_widget(neighbor)
                 widget = self.get_widgets()[self._selected_widget]
                 if widget is not None:
-                    self._logger.debug(f'Navigated to neighbor widget {widget.get_title()}')
+                    self._logger.debug(f"Navigated to neighbor widget {widget.get_title()}")
 
         # if we have a popup, that takes key control from both overview and focus mode
         elif self._popup is not None:
-            self._logger.debug(f'Popup {self._popup.get_title()} handling key {key_pressed}')
+            self._logger.debug(f"Popup {self._popup.get_title()} handling key {key_pressed}")
             self._popup._handle_key_press(key_pressed)
 
-
-    def _draw(self, stdscr) -> None: 
+    def _draw(self, stdscr) -> None:
         """Main CUI draw loop called by start()
 
         Parameters
@@ -1604,12 +1734,12 @@ def _draw(self, stdscr) -> None:
         stdscr.refresh()
         curses.mousemask(curses.ALL_MOUSE_EVENTS)
         # stdscr.nodelay(False)
-        #stdscr.keypad(True)
+        # stdscr.keypad(True)
 
         # Initialization functions. Generates colors and renderer
         self._initialize_colors()
         self._initialize_widget_renderer()
-        
+
         # If user specified a refresh timeout, apply it here
         if self._refresh_timeout > 0:
             self._stdscr.timeout(self._refresh_timeout)
@@ -1619,7 +1749,9 @@ def _draw(self, stdscr) -> None:
             self._renderer._set_border_renderer_chars(self._border_characters)
 
         # Loop where key_pressed is the last character pressed. Wait for exit key while no popup or focus mode
-        while key_pressed != self._exit_key or self._in_focused_mode or self._popup is not None:
+        while (
+            key_pressed != self._exit_key or self._in_focused_mode or self._popup is not None
+        ):
 
             try:
                 # If we call stop, we want to break out of the main draw loop
@@ -1641,36 +1773,42 @@ def _draw(self, stdscr) -> None:
                     try:
                         self._refresh_height_width()
                     except py_cui.errors.PyCUIOutOfBoundsError as e:
-                        self._logger.info('Resized terminal too small')
+                        self._logger.info("Resized terminal too small")
                         self._display_window_warning(stdscr, str(e))
 
                 # Here we handle mouse click events globally, or pass them to the UI element to handle
                 elif key_pressed == curses.KEY_MOUSE:
-                    self._logger.info('Detected mouse click')
-                    
+                    self._logger.info("Detected mouse click")
+
                     valid_mouse_event = True
                     try:
                         id, x, y, _, mouse_event = curses.getmouse()
                     except curses.error as e:
                         valid_mouse_event = False
-                        self._logger.error(f'Failed to handle mouse event: {str(e)}')
+                        self._logger.error(f"Failed to handle mouse event: {str(e)}")
 
                     if valid_mouse_event:
                         in_element = self.get_element_at_position(x, y)
 
                         # In first case, we click inside already selected widget, pass click for processing
                         if in_element is not None:
-                            self._logger.info(f'handling mouse press for elem: {in_element.get_title()}')
+                            self._logger.info(
+                                f"handling mouse press for elem: {in_element.get_title()}"
+                            )
                             in_element._handle_mouse_press(x, y, mouse_event)
 
                         # Otherwise, if not a popup, select the clicked on widget
-                        elif in_element is not None and not isinstance(in_element, py_cui.popups.Popup):
+                        elif in_element is not None and not isinstance(
+                            in_element, py_cui.popups.Popup
+                        ):
                             self.move_focus(in_element)
                             in_element._handle_mouse_press(x, y, mouse_event)
 
                 # If we have a post_loading_callback, fire it here
                 if self._post_loading_callback is not None and not self._loading:
-                    self._logger.debug(f'Firing post-loading callback function {self._post_loading_callback.__name__}')
+                    self._logger.debug(
+                        f"Firing post-loading callback function {self._post_loading_callback.__name__}"
+                    )
                     self._post_loading_callback()
                     self._post_loading_callback = None
 
@@ -1696,10 +1834,10 @@ def _draw(self, stdscr) -> None:
                         self._logger.draw_live_debug()
 
                 except curses.error as e:
-                    self._logger.error('Curses error while drawing TUI')
+                    self._logger.error("Curses error while drawing TUI")
                     self._display_window_warning(stdscr, str(e))
                 except py_cui.errors.PyCUIOutOfBoundsError as e:
-                    self._logger.error('Resized terminal too small')
+                    self._logger.error("Resized terminal too small")
                     self._display_window_warning(stdscr, str(e))
 
                 # Refresh the screen
@@ -1714,22 +1852,20 @@ def _draw(self, stdscr) -> None:
                 elif self._stopped:
                     key_pressed = self._exit_key
                 else:
-                    self._logger.info('Waiting for next keypress')
+                    self._logger.info("Waiting for next keypress")
                     key_pressed = stdscr.getch()
 
             except KeyboardInterrupt:
-                self._logger.info('Detect Keyboard Interrupt, Exiting...')
+                self._logger.info("Detect Keyboard Interrupt, Exiting...")
                 self._stopped = True
 
-
         stdscr.erase()
         stdscr.refresh()
         curses.endwin()
         if self._on_stop is not None:
-            self._logger.debug(f'Firing onstop function {self._on_stop.__name__}')
+            self._logger.debug(f"Firing onstop function {self._on_stop.__name__}")
             self._on_stop()
 
-
     def __format__(self, fmt):
         """Override of base format function. Prints list of current widgets.
 
@@ -1739,8 +1875,8 @@ def __format__(self, fmt):
             The format to override
         """
 
-        out = ''
+        out = ""
         for widget_id in self.get_widgets().keys():
             if self.get_widgets()[widget_id] is not None:
-                out += f'{self.get_widgets()[widget_id].get_title()}\n'
+                out += f"{self.get_widgets()[widget_id].get_title()}\n"
         return out
diff --git a/py_cui/controls/slider.py b/py_cui/controls/slider.py
index b4b742c..58ecfa0 100644
--- a/py_cui/controls/slider.py
+++ b/py_cui/controls/slider.py
@@ -2,6 +2,7 @@
 import py_cui.widgets
 import py_cui.popups
 import py_cui.errors
+import py_cui.keys
 
 
 class SliderImplementation(py_cui.ui.UIImplementation):
@@ -274,5 +275,42 @@ def _handle_key_press(self, key_pressed: int) -> None:
             self.update_slider_value(1)
 
 
+    def _handle_mouse_press(self, x: int, y: int, mouse_event: int):
+        """Override of base class handle mouse press function
+
+        Parameters
+        ----------
+        x : int
+            x-position of the mouse event in the terminal
+        y : int
+            y position of the mouse event in the terminal
+        mouse_event : int
+            Mouse event type code
+        """
+
+        super()._handle_mouse_press(x, y, mouse_event)
+        if mouse_event == py_cui.keys.LEFT_MOUSE_CLICK or mouse_event == py_cui.keys.LEFT_MOUSE_RELEASED:
+            x_start, _ = self.get_absolute_start_pos()
+            x_stop, _  = self.get_absolute_stop_pos()
+
+            # Get first x postion where the progress bar exists
+            x_start = x_start + 2 + self._padx
+
+            # Get last x postion where the progress bar exists
+            x_stop = x_stop - 2 - self._padx
+
+            if x < x_start or x > x_stop:
+                pass
+            else:
+                num_characters_to_press = x - x_start
+                prop_of_slider = num_characters_to_press / (x_stop - x_start)
+                slider_val_width = self._max_val - self._min_val
+                self._cur_val = int(prop_of_slider * slider_val_width) + self._min_val
+
+                # Round up. Avoids awkward situation where mousepress is to the right of the slider pos
+                if not (prop_of_slider * slider_val_width).is_integer():
+                    self._cur_val += 1
+
+
 class SliderPopup(py_cui.popups.Popup, SliderImplementation):
     pass
diff --git a/py_cui/dialogs/filedialog.py b/py_cui/dialogs/filedialog.py
index 3a9d4c2..940d2ae 100644
--- a/py_cui/dialogs/filedialog.py
+++ b/py_cui/dialogs/filedialog.py
@@ -65,7 +65,7 @@ def __init__(self, elem_type: str, name: str, fullpath: str, ascii_icons: bool=F
         # for compatibility reasons.
         if not ascii_icons:
             self._folder_icon = '\U0001f4c1'
-            # Folder icon is two characters, so 
+            # Folder icon is two characters, so
             self._file_icon = '\U0001f5ce' + ' '
         else:
             self._folder_icon = '<DIR>'
@@ -121,7 +121,7 @@ def __init__(self, initial_loc: str, dialog_type: str, ascii_icons, logger, limi
         """
 
         super().__init__(logger)
-        
+
         self._current_dir = os.path.abspath(initial_loc)
         self._ascii_icons = ascii_icons
         self._dialog_type = dialog_type
@@ -391,7 +391,7 @@ def _handle_key_press(self, key_pressed: int) -> None:
             self._move_left()
         elif key_pressed == py_cui.keys.KEY_RIGHT_ARROW:
             self._move_right()
-        elif key_pressed == py_cui.keys.KEY_BACKSPACE:
+        elif key_pressed in py_cui.keys.KEY_BACKSPACE:
             self._erase_char()
         elif key_pressed == py_cui.keys.KEY_DELETE:
             self._delete_char()
@@ -416,7 +416,7 @@ def _handle_key_press(self, key_pressed: int) -> None:
                     fp = open(new_elem, 'w')
                     fp.close()
                     self._parent_dialog._file_dir_select.refresh_view()
-                    
+
             except FileExistsError:
                 self._parent_dialog.display_warning('File/Directory already exists!')
             except PermissionError:
@@ -510,7 +510,7 @@ def get_absolute_stop_pos(self) -> Tuple[int,int]:
 
 
     def _handle_mouse_press(self, x: int, y: int, mouse_event: int) -> None:
-        """Handles mouse presses 
+        """Handles mouse presses
 
         Parameters
         ----------
@@ -540,7 +540,7 @@ def _handle_key_press(self, key_pressed: int) -> None:
         if key_pressed == py_cui.keys.KEY_ENTER:
             self.perform_command()
 
-    
+
     def perform_command(self) -> None:
         if self.command is not None:
             if self._button_num == 1:
@@ -698,7 +698,7 @@ def output_valid(self, output) -> Tuple[bool,Optional[str]]:
 
     def get_absolute_start_pos(self) -> Tuple[int,int]:
         """Override of base class, computes position based on root dimensions
-        
+
         Returns
         -------
         start_x, start_y : int
@@ -714,7 +714,7 @@ def get_absolute_start_pos(self) -> Tuple[int,int]:
 
     def get_absolute_stop_pos(self) -> Tuple[int,int]:
         """Override of base class, computes position based on root dimensions
-        
+
         Returns
         -------
         stop_x, stop_y : int
@@ -803,12 +803,12 @@ def _handle_mouse_press(self, x: int, y: int, mouse_event: int) -> None:
             self._filename_input.set_selected(False)
             self._file_dir_select.set_selected(True)
             self._file_dir_select._handle_mouse_press(x, y, mouse_event)
-            
+
         elif self._filename_input._contains_position(x, y):
             self._filename_input.set_selected(True)
             self._file_dir_select.set_selected(False)
             self._filename_input._handle_mouse_press(x, y, mouse_event)
-        
+
         elif self._submit_button._contains_position(x, y):
             self._submit_button._handle_mouse_press(x, y, mouse_event)
 
@@ -818,7 +818,7 @@ def _handle_mouse_press(self, x: int, y: int, mouse_event: int) -> None:
 
     def _draw(self) -> None:
         """Override of base class.
-        
+
         Here, we only draw a border, and then the individual form elements
         """
 
diff --git a/py_cui/dialogs/form.py b/py_cui/dialogs/form.py
index af641ad..ceac820 100644
--- a/py_cui/dialogs/form.py
+++ b/py_cui/dialogs/form.py
@@ -69,7 +69,7 @@ def is_valid(self) -> Tuple[bool,Optional[str]]:
 
     def is_required(self) -> bool:
         """Checks if field is required
-        
+
         Returns
         -------
         required : bool
@@ -164,7 +164,7 @@ def _handle_key_press(self, key_pressed: int) -> None:
             self._move_left()
         elif key_pressed == py_cui.keys.KEY_RIGHT_ARROW:
             self._move_right()
-        elif key_pressed == py_cui.keys.KEY_BACKSPACE:
+        elif key_pressed in py_cui.keys.KEY_BACKSPACE:
             self._erase_char()
         elif key_pressed == py_cui.keys.KEY_DELETE:
             self._delete_char()
@@ -225,7 +225,7 @@ def __init__(self, field_implementations: List['FormField'], required_fields: Li
         super().__init__(logger)
         self._form_fields = field_implementations
         self._required_fields = required_fields
-        
+
         self._selected_form_index = 0
         self._on_submit_action: Optional[Callable[[],Any]] = None
 
@@ -366,17 +366,17 @@ def __init__(self, root, fields, passwd_fields, required_fields, fields_init_tex
             init_text = ''
             if field in fields_init_text:
                 init_text = fields_init_text[field]
-            self._form_fields.append(FormFieldElement(self, 
-                                              i, 
-                                              field, 
-                                              init_text, 
-                                              (field in passwd_fields), 
-                                              (field in required_fields), 
+            self._form_fields.append(FormFieldElement(self,
+                                              i,
+                                              field,
+                                              init_text,
+                                              (field in passwd_fields),
+                                              (field in required_fields),
                                               renderer,
                                               logger))
         self._form_fields[0].set_selected(True)
         FormImplementation.__init__(self, self._form_fields, required_fields, logger)
-        
+
         self._internal_popup = None
 
 
@@ -394,7 +394,7 @@ def get_num_fields(self) -> int:
 
     def get_absolute_start_pos(self) -> Tuple[int,int]:
         """Override of base class, computes position based on root dimensions
-        
+
         Returns
         -------
         start_x, start_y : int
@@ -410,9 +410,9 @@ def get_absolute_start_pos(self) -> Tuple[int,int]:
         min_required_y = 4 + (2 * self._pady) + 5 * self._num_fields
         if root_height < min_required_y:
             min_required_y = root_height
-        
+
         form_start_x = int(root_width / 2) - int(min_required_x / 2)
-        
+
         form_start_y = int(root_height / 2) - int(min_required_y / 2)
 
         return form_start_x, form_start_y
@@ -420,7 +420,7 @@ def get_absolute_start_pos(self) -> Tuple[int,int]:
 
     def get_absolute_stop_pos(self) -> Tuple[int,int]:
         """Override of base class, computes position based on root dimensions
-        
+
         Returns
         -------
         stop_x, stop_y : int
@@ -436,9 +436,9 @@ def get_absolute_stop_pos(self) -> Tuple[int,int]:
         min_required_y = 4 + (2 * self._pady) + 5 * self._num_fields
         if root_height < min_required_y:
             min_required_y = root_height
-        
+
         form_stop_x = int(root_width / 2) + int(min_required_x / 2)
-        
+
         form_stop_y = int(root_height / 2) + int(min_required_y / 2)
 
         return form_stop_x, form_stop_y
@@ -479,14 +479,14 @@ def _handle_key_press(self, key_pressed: int) -> None:
                 if valid:
                     self._root.close_popup()
                     if self._on_submit_action is not None:
-                        self._on_submit_action(self.get()) 
+                        self._on_submit_action(self.get())
                 else:
                     self._internal_popup = InternalFormPopup(self,
-                                                             self._root, 
-                                                             err_msg, 
+                                                             self._root,
+                                                             err_msg,
                                                              f'Required fields: {str(self._required_fields)}',
-                                                             py_cui.YELLOW_ON_BLACK, 
-                                                             self._renderer, 
+                                                             py_cui.YELLOW_ON_BLACK,
+                                                             self._renderer,
                                                              self._logger)
             elif key_pressed == py_cui.keys.KEY_ESCAPE:
                 self._root.close_popup()
@@ -519,7 +519,7 @@ def _handle_mouse_press(self, x: int, y: int, mouse_event: int) -> None:
 
     def _draw(self) -> None:
         """Override of base class.
-        
+
         Here, we only draw a border, and then the individual form elements
         """
 
diff --git a/py_cui/keys.py b/py_cui/keys.py
index af6fb00..76bde6a 100644
--- a/py_cui/keys.py
+++ b/py_cui/keys.py
@@ -18,7 +18,7 @@ def get_ascii_from_char(char: str) -> int:
     ----------
     char : character
         character to convert to ascii
-    
+
     Returns
     -------
     ascii_code : int
@@ -56,17 +56,9 @@ def get_char_from_ascii(key_num: int) -> Optional[str]:
 KEY_DELETE      = curses.KEY_DC
 KEY_TAB         = get_ascii_from_char('\t')
 
-# Pressing backspace returns 8 on windows?
-if platform == 'win32':
-    KEY_BACKSPACE   = 8
-# Adds support for 'delete/backspace' key on OSX
-elif platform == 'darwin':
-    KEY_BACKSPACE   = 127
-else:
-    KEY_BACKSPACE   = curses.KEY_BACKSPACE
-
-SPECIAL_KEYS = [KEY_ENTER, KEY_ESCAPE, KEY_SPACE, KEY_DELETE, KEY_TAB, KEY_BACKSPACE]
+KEY_BACKSPACE = [8, 127, curses.KEY_BACKSPACE]
 
+SPECIAL_KEYS = [KEY_ENTER, KEY_ESCAPE, KEY_SPACE, KEY_DELETE, KEY_TAB] + KEY_BACKSPACE
 
 # Page navigation keys
 KEY_PAGE_UP     = curses.KEY_PPAGE
@@ -85,13 +77,13 @@ def get_char_from_ascii(key_num: int) -> Optional[str]:
 
 
 if platform == 'linux' or platform == 'darwin':
-    
+
     # Modified arrow keys
     KEY_SHIFT_LEFT  = 393
     KEY_SHIFT_RIGHT = 402
     KEY_SHIFT_UP    = 337
     KEY_SHIFT_DOWN  = 336
-    
+
     KEY_CTRL_LEFT   = 560
     KEY_CTRL_RIGHT  = 545
     KEY_CTRL_UP     = 566
@@ -103,7 +95,7 @@ def get_char_from_ascii(key_num: int) -> Optional[str]:
     KEY_SHIFT_RIGHT = 400
     KEY_SHIFT_UP    = 547
     KEY_SHIFT_DOWN  = 548
-    
+
     KEY_CTRL_LEFT   = 443
     KEY_CTRL_RIGHT  = 444
     KEY_CTRL_UP     = 480
diff --git a/py_cui/popups.py b/py_cui/popups.py
index 470225f..f79d972 100644
--- a/py_cui/popups.py
+++ b/py_cui/popups.py
@@ -49,7 +49,7 @@ def __init__(self, root: 'py_cui.PyCUI', title: str, text: str, color: int, rend
         self._selected_color        = color
         self.update_height_width()
 
-    
+
     def _increment_counter(self):
         """Function that increments an internal counter
         """
@@ -71,7 +71,7 @@ def set_text(self, text: str) -> None :
 
     def get_absolute_start_pos(self) -> Tuple[int,int]:
         """Override of base class, computes position based on root dimensions
-        
+
         Returns
         -------
         start_x, start_y : int
@@ -84,7 +84,7 @@ def get_absolute_start_pos(self) -> Tuple[int,int]:
 
     def get_absolute_stop_pos(self) -> Tuple[int,int]:
         """Override of base class, computes position based on root dimensions
-        
+
         Returns
         -------
         stop_x, stop_y : int
@@ -138,11 +138,10 @@ def __init__(self, root, title, text, color, renderer, logger):
         """
 
         super().__init__(root, title, text, color, renderer, logger)
-        self._close_keys = [ py_cui.keys.KEY_ENTER, 
-                            py_cui.keys.KEY_ESCAPE, 
-                            py_cui.keys.KEY_SPACE, 
-                            py_cui.keys.KEY_BACKSPACE, 
-                            py_cui.keys.KEY_DELETE]
+        self._close_keys = [ py_cui.keys.KEY_ENTER,
+                            py_cui.keys.KEY_ESCAPE,
+                            py_cui.keys.KEY_SPACE,
+                            py_cui.keys.KEY_DELETE] + py_cui.keys.KEY_BACKSPACE
 
 
     def _draw(self) -> None:
@@ -211,12 +210,12 @@ class TextBoxPopup(Popup, py_cui.ui.TextBoxImplementation):
         The command to run when enter is pressed
     """
 
-    def __init__(self, root, title, color, command, renderer, password, logger):
+    def __init__(self, root, title, initial_text, color, command, renderer, password, logger):
         """Initializer for textbox popup. Uses TextBoxImplementation as base
         """
 
-        Popup.__init__(self, root, title, '', color, renderer, logger)
-        py_cui.ui.TextBoxImplementation.__init__(self, '', password, logger)
+        Popup.__init__(self, root, title, initial_text, color, renderer, logger)
+        py_cui.ui.TextBoxImplementation.__init__(self, initial_text, password, logger)
         self._command           = command
         self.update_height_width()
 
@@ -231,6 +230,7 @@ def update_height_width(self) -> None:
         height, width           = self.get_absolute_dimensions()
         self._cursor_text_pos   = 0
         self._cursor_x          = start_x + 2 + padx
+        self._initial_cursor = self._cursor_x 
         self._cursor_max_left   = self._cursor_x
         self._cursor_max_right  = start_x + width - 1 - pady
         self._cursor_y          = start_y + int(height / 2) + 1
@@ -284,7 +284,7 @@ def _handle_key_press(self, key_pressed: int):
             self._move_left()
         elif key_pressed == py_cui.keys.KEY_RIGHT_ARROW:
             self._move_right()
-        elif key_pressed == py_cui.keys.KEY_BACKSPACE:
+        elif key_pressed in py_cui.keys.KEY_BACKSPACE:
             self._erase_char()
         elif key_pressed == py_cui.keys.KEY_DELETE:
             self._delete_char()
@@ -481,7 +481,7 @@ def _draw(self) -> None:
         self._icon_counter = self._icon_counter + 1
         if self._icon_counter == len(self._loading_icons):
             self._icon_counter = 0
-        
+
         # Use Superclass draw after new text is computed
         super()._draw()
 
@@ -554,6 +554,6 @@ def _draw(self) -> None:
 
         self.set_text(f'{"#" * completed_blocks}{"-" * non_completed_blocks} \
                        ({self._completed_items}/{self._num_items}) {self._loading_icons[self._icon_counter]}')
-        
+
         # Use Superclass draw after new text is computed
         super()._draw()
diff --git a/py_cui/ui.py b/py_cui/ui.py
index 66445a0..06ae250 100644
--- a/py_cui/ui.py
+++ b/py_cui/ui.py
@@ -683,6 +683,14 @@ def __init__(self, logger):
         self._page_scroll_len  = 5
         self._view_items       = []
         self._on_selection_change: Optional[Callable[[Any],Any]] = None
+        self._stick_to_bottom = False
+
+    
+    def toggle_stick_to_bottom(self):
+        """Toggle option for keeping the viewport at the bottom of the items
+        """
+
+        self._stick_to_bottom = not self._stick_to_bottom
 
 
     def clear(self) -> None:
@@ -853,6 +861,9 @@ def add_item(self, item: Any) -> None:
         self._logger.debug(f'Adding item {str(item)} to menu')
         self._view_items.append(item)
 
+        if self._stick_to_bottom:
+            self.set_selected_item_index(len(self._view_items) - 1)
+
 
     def add_item_list(self, item_list: List[Any]) -> None:
 
@@ -925,6 +936,18 @@ def get(self) -> Optional[Any]:
         return None
 
 
+    def is_empty(self) -> bool:
+        """Function that returns true if menu has no items within it, false otherwise
+
+        Returns
+        -------
+        bool
+            True if menu has no items, False otherwise. Identical to len(self._view_items) == 0
+        """
+
+        return len(self._view_items) == 0
+
+
     def set_selected_item(self, selected_item: Any):
         """Function that replaces the currently selected item with a new item
 
@@ -1189,6 +1212,8 @@ def clear(self) -> None:
 
         self._cursor_x = self._cursor_max_left
         self._cursor_y = self._cursor_max_up
+        self._viewport_y_start = 0
+        self._viewport_x_start = 0
         self._cursor_text_pos_x = 0
         self._cursor_text_pos_y = 0
         self._text_lines = []
diff --git a/py_cui/widget_set.py b/py_cui/widget_set.py
index 94e6791..9bfacb6 100644
--- a/py_cui/widget_set.py
+++ b/py_cui/widget_set.py
@@ -9,10 +9,10 @@
 # TODO: Should create an initial widget set in PyCUI class that widgets are added to by default.
 
 import shutil
-from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
-import py_cui.widgets as widgets
-import py_cui.grid as grid
-import py_cui.controls as controls
+from typing import Any, Union, Callable, Dict, List, Optional, TYPE_CHECKING
+import py_cui.widgets
+import py_cui.grid
+import py_cui.controls
 
 if TYPE_CHECKING:
     import py_cui
@@ -60,7 +60,7 @@ def __init__(self, num_rows: int, num_cols: int, logger: 'py_cui.debug.PyCUILogg
         status_bars_height = self._root.title_bar.get_height() + self._root.status_bar.get_height()
         self._height = self._height - status_bars_height - 2
 
-        self._grid = grid.Grid(num_rows, num_cols, self._height, self._width, logger)
+        self._grid = py_cui.grid.Grid(num_rows, num_cols, self._height, self._width, logger)
 
         self._selected_widget: Optional[int] = None
         self._logger = logger
@@ -91,21 +91,70 @@ def get_widgets(self) -> Dict[int, Optional['py_cui.widgets.Widget']]:
         return self._widgets
 
 
-    def add_key_command(self, key: int, command: Callable[[],Any]):
+    def add_key_command(self, key: Union[int, List[int]], command: Callable[[],Any]) -> None:
         """Function that adds a keybinding to the CUI when in overview mode
 
         Parameters
         ----------
         key : py_cui.keys.KEY_*
-            The key bound to the command
+            ascii keycode used to map the key
         command : Function
             A no-arg or lambda function to fire on keypress
         """
 
-        self._keybindings[key] = command
+        if isinstance(key, list):
+            for value in key:
+                self._keybindings[value] = command
+        else:
+            self._keybindings[key] = command
+
+
+
+    def add_custom_widget(self, widget_class: type, title: str, row: int, column: int, row_span: int, column_span: int, padx: int, pady: int, *args, **kwargs) -> 'py_cui.widgets.Widget':
+        """Function that allows for adding custom widget types to the CUI - specifically ones not included with py_cui by default
+
+        Parameters
+        ----------
+        widget_class : type
+            The class type of your custom widget. Note that it must be a subclass of the widget superclass
+        title : str
+            The title of the scroll menu
+        row : int
+            The row value, from the top down
+        column : int
+            The column value from the top down
+        row_span=1 : int
+            The number of rows to span accross
+        column_span=1 : int
+            the number of columns to span accross
+        padx=1 : int
+            number of padding characters in the x direction
+        pady=0 : int
+            number of padding characters in the y direction
+
+        Raises
+        ------
+        TypeError
+            If provided widget class is not a subclass of widget, a typeerror is raised.
+        """
+
+        if not issubclass(widget_class, py_cui.widgets.Widget):
+            raise TypeError(f'Widget class {widget_class} is not a subclass of the base Widget class!')
+
+        id = len(self.get_widgets().keys())
+        new_widget = widget_class(id, title, self._grid, row, column, row_span, column_span, padx, pady, self._logger, *args, **kwargs)
 
+        self.get_widgets()[id] = new_widget
 
-    def add_scroll_menu(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0) -> 'py_cui.widgets.ScrollMenu':
+        if self._selected_widget is None:
+            self.set_selected_widget(id)
+
+        self._logger.info(f'Adding widget {title} w/ ID {id} of type {str(widget_class)}')
+        return new_widget
+
+
+
+    def add_scroll_menu(self, title: str, row: int, column: int, row_span: int=1, column_span: int=1, padx: int=1, pady: int=0) -> 'py_cui.widgets.ScrollMenu':
         """Function that adds a new scroll menu to the CUI grid
 
         Parameters
@@ -131,22 +180,14 @@ def add_scroll_menu(self, title: str, row: int, column: int, row_span: int = 1,
             A reference to the created scroll menu object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_scroll_menu     = widgets.ScrollMenu(id,
-                                                 title,
-                                                 self._grid,
-                                                 row,
-                                                 column,
-                                                 row_span,
-                                                 column_span,
-                                                 padx,
-                                                 pady,
-                                                 self._logger)
-        self._widgets[id]  = new_scroll_menu
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_scroll_menu))}')
-        return new_scroll_menu
+        return self.add_custom_widget(py_cui.widgets.ScrollMenu,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady)
 
 
     def add_checkbox_menu(self, title: str, row: int, column: int, row_span: int=1, column_span: int=1, padx: int=1, pady: int=0, checked_char: str='X') -> 'py_cui.widgets.CheckBoxMenu':
@@ -177,23 +218,15 @@ def add_checkbox_menu(self, title: str, row: int, column: int, row_span: int=1,
             A reference to the created checkbox object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_checkbox_menu   = widgets.CheckBoxMenu(id,
-                                                   title,
-                                                   self._grid,
-                                                   row,
-                                                   column,
-                                                   row_span,
-                                                   column_span,
-                                                   padx,
-                                                   pady,
-                                                   self._logger,
-                                                   checked_char)
-        self._widgets[id]  = new_checkbox_menu
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_checkbox_menu))}')
-        return new_checkbox_menu
+        return self.add_custom_widget(py_cui.widgets.CheckBoxMenu,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      checked_char)
 
 
     def add_text_box(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, initial_text: str = '', password: bool = False) -> 'py_cui.widgets.TextBox':
@@ -226,22 +259,14 @@ def add_text_box(self, title: str, row: int, column: int, row_span: int = 1, col
             A reference to the created textbox object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_text_box        = widgets.TextBox(id,
-                                              title,
-                                              self._grid,
-                                              row, column,
-                                              row_span,
-                                              column_span,
-                                              padx, pady,
-                                              self._logger,
-                                              initial_text,
-                                              password)
-        self._widgets[id]    = new_text_box
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_text_box))}')
-        return new_text_box
+        return self.add_custom_widget(py_cui.widgets.TextBox,
+                                      title,
+                                      row, column,
+                                      row_span,
+                                      column_span,
+                                      padx, pady,
+                                      initial_text,
+                                      password)
 
 
     def add_text_block(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, initial_text: str = '') -> 'py_cui.widgets.ScrollTextBlock':
@@ -272,23 +297,15 @@ def add_text_block(self, title: str, row: int, column: int, row_span: int = 1, c
             A reference to the created textblock object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_text_block      = widgets.ScrollTextBlock(id,
-                                                      title,
-                                                      self._grid,
-                                                      row,
-                                                      column,
-                                                      row_span,
-                                                      column_span,
-                                                      padx,
-                                                      pady,
-                                                      self._logger,
-                                                      initial_text)
-        self._widgets[id]  = new_text_block
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_text_block))}')
-        return new_text_block
+        return self.add_custom_widget(py_cui.widgets.ScrollTextBlock,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      initial_text)
 
 
     def add_label(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0) -> 'py_cui.widgets.Label':
@@ -317,20 +334,14 @@ def add_label(self, title: str, row: int, column: int, row_span: int = 1, column
             A reference to the created label object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_label           = widgets.Label(id,
-                                            title,
-                                            self._grid,
-                                            row,
-                                            column,
-                                            row_span,
-                                            column_span,
-                                            padx,
-                                            pady,
-                                            self._logger)
-        self._widgets[id]  = new_label
-        self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_label))}')
-        return new_label
+        return self.add_custom_widget(py_cui.widgets.Label,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady)
 
 
     def add_block_label(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, center: bool=True) -> 'py_cui.widgets.BlockLabel':
@@ -361,24 +372,18 @@ def add_block_label(self, title: str, row: int, column: int, row_span: int = 1,
             A reference to the created block label object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_label           = widgets.BlockLabel(id,
-                                                 title,
-                                                 self._grid,
-                                                 row,
-                                                 column,
-                                                 row_span,
-                                                 column_span,
-                                                 padx,
-                                                 pady,
-                                                 center,
-                                                 self._logger)
-        self._widgets[id]  = new_label
-        self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_label))}')
-        return new_label
-
-
-    def add_button(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, command: Optional[Callable[[],Any]]=None) -> 'py_cui.widgets.Button':
+        return self.add_custom_widget(py_cui.widgets.BlockLabel,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      center)
+
+
+    def add_button(self, title: str, row: int, column: int, row_span: int = 1, column_span: int = 1, padx: int = 1, pady: int = 0, command: Callable[[],Any]=None) -> 'py_cui.widgets.Button':
         """Function that adds a new button to the CUI grid
 
         Parameters
@@ -406,30 +411,20 @@ def add_button(self, title: str, row: int, column: int, row_span: int = 1, colum
             A reference to the created button object.
         """
 
-        id = len(self.get_widgets().keys())
-        new_button          = widgets.Button(id,
-                                             title,
-                                             self._grid,
-                                             row,
-                                             column,
-                                             row_span,
-                                             column_span,
-                                             padx,
-                                             pady,
-                                             self._logger,
-                                             command)
-        self._widgets[id]  = new_button
-        if self._selected_widget is None:
-            self.set_selected_widget(id)
-        self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_button))}')
-        return new_button
+        self.add_custom_widget(py_cui.widgets.Button,
+                               title,
+                               row,
+                               column,
+                               row_span,
+                               column_span,
+                               padx,
+                               pady,
+                               command)
 
 
     def add_slider(self, title: str, row: int, column: int, row_span: int=1,
                    column_span: int=1, padx: int=1, pady: int=0,
-                   min_val: int=0, max_val: int=100, step: int=1, init_val: int=0) -> 'py_cui.controls.slider.SliderWidget':     
-                   
-                   
+                   min_val: int=0, max_val: int=100, step: int=1, init_val: int=0) -> 'py_cui.controls.slider.SliderWidget':
         """Function that adds a new label to the CUI grid
 
         Parameters
@@ -457,28 +452,21 @@ def add_slider(self, title: str, row: int, column: int, row_span: int=1,
         init_val = 0 int
             initial value of the slider
 
-
         Returns
         -------
         new_slider : Slider
             A reference to the created slider object.
         """
 
-        id = len(self._widgets.keys())
-        new_slider = controls.slider.SliderWidget(id,
-                                                  title,
-                                                  self._grid,
-                                                  row,
-                                                  column,
-                                                  row_span,
-                                                  column_span,
-                                                  padx,
-                                                  pady,
-                                                  self._logger,
-                                                  min_val,
-                                                  max_val,
-                                                  step,
-                                                  init_val)
-        self._widgets[id] = new_slider
-        self._logger.debug(f'Adding widget {title} w/ ID {id} of type {str(type(new_slider))}')
-        return new_slider
+        return self.add_custom_widget(py_cui.controls.slider.SliderWidget,
+                                      title,
+                                      row,
+                                      column,
+                                      row_span,
+                                      column_span,
+                                      padx,
+                                      pady,
+                                      min_val,
+                                      max_val,
+                                      step,
+                                      init_val)
\ No newline at end of file
diff --git a/py_cui/widgets.py b/py_cui/widgets.py
index f119527..3757055 100644
--- a/py_cui/widgets.py
+++ b/py_cui/widgets.py
@@ -27,8 +27,9 @@
 import py_cui.ui
 import py_cui.colors
 import py_cui.errors
+import py_cui.debug
 
-from typing import Callable, List, Dict, Tuple, Any, Optional
+from typing import Union, Callable, List, Dict, Tuple, Any, Optional
 
 
 class Widget(py_cui.ui.UIElement):
@@ -84,18 +85,22 @@ def __init__(self, id, title: str, grid: 'py_cui.grid.Grid', row: int, column: i
         self.update_height_width()
 
 
-    def add_key_command(self, key: int, command: Callable[[],Any]) -> Any:
+    def add_key_command(self, key: Union[int, List[int]], command: Callable[[],Any]) -> None:
         """Maps a keycode to a function that will be executed when in focus mode
 
         Parameters
         ----------
-        key : py_cui.keys.KEY
+        key : py_cui.keys.KEY_*
             ascii keycode used to map the key
         command : function without args
             a non-argument function or lambda function to execute if in focus mode and key is pressed
         """
 
-        self._key_commands[key] = command
+        if isinstance(key, list):
+            for value in key:
+                self._key_commands[value] = command
+        else:
+            self._key_commands[key] = command
 
 
     def add_mouse_command(self, mouse_event: int, command: Callable[[],Any]) -> None:
@@ -123,12 +128,12 @@ def add_mouse_command(self, mouse_event: int, command: Callable[[],Any]) -> None
         self._mouse_commands[mouse_event] = command
 
 
-    def update_key_command(self, key: int, command: Callable[[],Any]) -> Any:
+    def update_key_command(self, key: Union[int, List[int]], command: Callable[[],Any]) -> Any:
         """Maps a keycode to a function that will be executed when in focus mode, if key is already mapped
 
         Parameters
         ----------
-        key : py_cui.keys.KEY
+        key : py_cui.keys.KEY_*
             ascii keycode used to map the key
         command : function without args
             a non-argument function or lambda function to execute if in focus mode and key is pressed
@@ -165,6 +170,13 @@ def add_text_color_rule(self, regex: str, color: int, rule_type: str, match_type
         self._text_color_rules.append(new_color_rule)
 
 
+    def clear_color_rules(self):
+        """Removes all configured color rules for the widget
+        """
+
+        self._text_color_rules.clear()
+
+
     def get_absolute_start_pos(self) -> Tuple[int,int]:
         """Gets the absolute position of the widget in characters. Override of base class function
 
@@ -306,7 +318,7 @@ def _handle_mouse_press(self, x: int, y: int, mouse_event: int):
         # Retrieve the command function if it exists
         if mouse_event in self._mouse_commands.keys():
             command = self._mouse_commands[mouse_event]
-            
+
             # Identify num of args from callable. This allows for user to create commands that take in x, y
             # coords of the mouse press as input
             num_args = 0
@@ -407,7 +419,7 @@ class BlockLabel(Widget):
         Decides whether or not label should be centered
     """
 
-    def __init__(self, id, title: str,  grid: 'py_cui.grid.Grid', row: int, column: int, row_span: int, column_span: int, padx: int, pady: int, center, logger):
+    def __init__(self, id, title: str,  grid: 'py_cui.grid.Grid', row: int, column: int, row_span: int, column_span: int, padx: int, pady: int, logger: py_cui.debug.PyCUILogger, center: bool):
         """Initializer for blocklabel widget
         """
 
@@ -486,7 +498,7 @@ def _handle_mouse_press(self, x: int, y: int, mouse_event: int):
             if viewport_top <= y and viewport_top + len(self._view_items) - self._top_view >= y:
                 elem_clicked = y - viewport_top + self._top_view
                 self.set_selected_item_index(elem_clicked)
-        
+
             if self.get_selected_item_index() != current and self._on_selection_change is not None:
                 self._process_selection_change_event()
 
@@ -511,7 +523,7 @@ def _handle_key_press(self, key_pressed: int) -> None:
 
         current = self.get_selected_item_index()
         viewport_height = self.get_viewport_height()
-        
+
         if key_pressed == py_cui.keys.KEY_UP_ARROW:
             self._scroll_up()
         if key_pressed == py_cui.keys.KEY_DOWN_ARROW:
@@ -529,6 +541,20 @@ def _handle_key_press(self, key_pressed: int) -> None:
             self._process_selection_change_event()
 
 
+    def add_item(self, item):
+        """Override of add_item function, allows for sticking to bottom
+
+        Parameters
+        ----------
+        item : Object
+            Object to add to the menu. Must have implemented __str__ function
+        """
+
+        super().add_item(item)
+        if self._stick_to_bottom and self._top_view < (len(self._view_items) - self.get_viewport_height() - 1):
+            self._top_view = len(self._view_items) - self.get_viewport_height() - 1
+
+
     def _draw(self) -> None:
         """Overrides base class draw function
         """
@@ -537,6 +563,7 @@ def _draw(self) -> None:
         self._renderer.set_color_mode(self._color)
         self._renderer.draw_border(self)
         counter = self._pady + 1
+
         line_counter = 0
         for item in self._view_items:
             line = str(item)
@@ -671,7 +698,7 @@ def __init__(self, id, title: str, grid: 'py_cui.grid.Grid', row: int, column: i
         self.command = command
         self.set_color(py_cui.MAGENTA_ON_BLACK)
         self.set_help_text('Focus mode on Button. Press Enter to press button, Esc to exit focus mode.')
-        
+
         # By default we will process command on click or double click
         if self.command is not None:
             self.add_mouse_command(py_cui.keys.LEFT_MOUSE_CLICK, self.command)
@@ -773,7 +800,7 @@ def _handle_key_press(self, key_pressed: int) -> None:
             self._move_left()
         elif key_pressed == py_cui.keys.KEY_RIGHT_ARROW:
             self._move_right()
-        elif key_pressed == py_cui.keys.KEY_BACKSPACE:
+        elif key_pressed in py_cui.keys.KEY_BACKSPACE:
             self._erase_char()
         elif key_pressed == py_cui.keys.KEY_DELETE:
             self._delete_char()
@@ -857,7 +884,7 @@ def _handle_mouse_press(self, x: int, y: int, mouse_event: int) -> None:
         """
 
         Widget._handle_mouse_press(self, x, y, mouse_event)
-        
+
         if mouse_event == py_cui.keys.LEFT_MOUSE_CLICK:
             if y >= self._cursor_max_up and y <= self._cursor_max_down:
                 if x >= self._cursor_max_left and x <= self._cursor_max_right:
@@ -901,7 +928,7 @@ def _handle_key_press(self, key_pressed: int) -> None:
         # TODO: Fix this janky operation here
         elif key_pressed == py_cui.keys.KEY_DOWN_ARROW and self._cursor_text_pos_y < len(self._text_lines) - 1:
             self._move_down()
-        elif key_pressed == py_cui.keys.KEY_BACKSPACE:
+        elif key_pressed in py_cui.keys.KEY_BACKSPACE:
             self._handle_backspace()
         elif key_pressed == py_cui.keys.KEY_DELETE:
             self._handle_delete()
diff --git a/requirements_dev.txt b/requirements_dev.txt
index ecfb67a..40461ae 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -4,3 +4,4 @@ wheel
 npdoc2md
 mkdocs
 windows-curses ; platform_system=="Windows"
+black
diff --git a/setup.py b/setup.py
index d22e53f..66af51b 100644
--- a/setup.py
+++ b/setup.py
@@ -15,7 +15,7 @@
     description='A widget and grid based framework for building command line user interfaces in python.',
     long_description=long_description,
     long_description_content_type='text/markdown',
-    version='0.1.4',
+    version='0.1.5',
     author='Jakub Wlodek',
     author_email='jwlodek.dev@gmail.com',
     license='BSD (3-clause)',


Test patch:
diff --git a/tests/test_ui_implementations/test_checkbox_imp.py b/tests/test_ui_implementations/test_checkbox_imp.py
index e0a6596..0902c2b 100644
--- a/tests/test_ui_implementations/test_checkbox_imp.py
+++ b/tests/test_ui_implementations/test_checkbox_imp.py
@@ -5,6 +5,14 @@
 elems = ["Elem0", "Elem1", "Elem2", "Elem3", "Elem4"]
 
 
+def test_is_empty(CHECKBOXMENU):
+    scroll = CHECKBOXMENU
+    assert scroll.is_empty()
+    scroll.add_item_list(elems)
+    assert not scroll.is_empty()
+    scroll.clear()
+
+
 def test_add_item_list(CHECKBOXMENU):
     scroll = CHECKBOXMENU
     scroll.add_item_list(elems)
diff --git a/tests/test_ui_implementations/test_scroll_menu_imp.py b/tests/test_ui_implementations/test_scroll_menu_imp.py
index 89a0fba..694eba3 100644
--- a/tests/test_ui_implementations/test_scroll_menu_imp.py
+++ b/tests/test_ui_implementations/test_scroll_menu_imp.py
@@ -6,6 +6,14 @@
 elems = ["Elem0", "Elem1", "Elem2", "Elem3", "Elem4"]
 
 
+def test_is_empty(SCROLLMENU):
+    scroll = SCROLLMENU
+    assert scroll.is_empty()
+    scroll.add_item_list(elems)
+    assert not scroll.is_empty()
+    scroll.clear()
+
+
 def test_add_item_list(SCROLLMENU):
     scroll = SCROLLMENU
     scroll.add_item_list(elems)