Repository: python-cmd2/cmd2

Issue:
If a tab completion hint is very large it seems to crash 
There appears to be a bug in [ArgparseCompleter._format_completions()](https://github.com/python-cmd2/cmd2/blob/master/cmd2/argparse_completer.py#L479) where `fill_width` can go negative when `token_width` is large relative to the terminal width.

This was reported by @anselor.  

Eric, please fill in additional details, in particular how to reproduce.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31daa079..44331511 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,8 @@
 ## 1.1.0 (TBD, 2020)
 * Bug Fixes
     * Fixed issue where subcommand usage text could contain a subcommand alias instead of the actual name
+    * Fixed bug in `ArgparseCompleter` where `fill_width` could become negative if `token_width` was large 
+      relative to the terminal width.
 * Enhancements
     * Made `ipy` consistent with `py` in the following ways
         * `ipy` returns whether any of the commands run in it returned True to stop command loop
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index 61f173cc..6acb5abc 100644
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -23,6 +23,7 @@ from .argparse_custom import (
     CompletionItem,
     generate_range_error,
 )
+from .table_creator import Column, SimpleTable
 from .utils import CompletionError, basic_complete
 
 # If no descriptive header is supplied, then this will be used instead
@@ -467,29 +468,35 @@ class ArgparseCompleter:
 
             # If a metavar was defined, use that instead of the dest field
             destination = action.metavar if action.metavar else action.dest
+
+            desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_HEADER, None)
+            if desc_header is None:
+                desc_header = DEFAULT_DESCRIPTIVE_HEADER
+
+            # Calculate needed widths for the token and description columns of the table
             token_width = ansi.style_aware_wcswidth(destination)
-            completions_with_desc = []
+            desc_width = ansi.style_aware_wcswidth(desc_header)
 
             for item in completions:
-                item_width = ansi.style_aware_wcswidth(item)
-                if item_width > token_width:
-                    token_width = item_width
+                token_width = max(ansi.style_aware_wcswidth(item), token_width)
+                desc_width = max(ansi.style_aware_wcswidth(item.description), desc_width)
 
-            term_size = shutil.get_terminal_size()
-            fill_width = int(term_size.columns * .6) - (token_width + 2)
-            for item in completions:
-                entry = '{: <{token_width}}{: <{fill_width}}'.format(item, item.description,
-                                                                     token_width=token_width + 2,
-                                                                     fill_width=fill_width)
-                completions_with_desc.append(entry)
+            # Create a table that's over half the width of the terminal.
+            # This will force readline to place each entry on its own line.
+            min_width = int(shutil.get_terminal_size().columns * 0.6)
+            base_width = SimpleTable.base_width(2)
+            initial_width = base_width + token_width + desc_width
 
-            desc_header = getattr(action, ATTR_DESCRIPTIVE_COMPLETION_HEADER, None)
-            if desc_header is None:
-                desc_header = DEFAULT_DESCRIPTIVE_HEADER
-            header = '\n{: <{token_width}}{}'.format(destination.upper(), desc_header, token_width=token_width + 2)
+            if initial_width < min_width:
+                desc_width += (min_width - initial_width)
+
+            cols = list()
+            cols.append(Column(destination.upper(), width=token_width))
+            cols.append(Column(desc_header, width=desc_width))
 
-            self._cmd2_app.completion_header = header
-            self._cmd2_app.display_matches = completions_with_desc
+            hint_table = SimpleTable(cols, divider_char=None)
+            self._cmd2_app.completion_header = hint_table.generate_header()
+            self._cmd2_app.display_matches = [hint_table.generate_data_row([item, item.description]) for item in completions]
 
         return completions
 
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 73f7aa79..65047cc8 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -1147,7 +1147,7 @@ class Cmd(cmd.Cmd):
 
             # Print the header if one exists
             if self.completion_header:
-                sys.stdout.write('\n' + self.completion_header)
+                sys.stdout.write('\n\n' + self.completion_header)
 
             # Call readline's display function
             # rl_display_match_list(strings_array, number of completion matches, longest match length)
@@ -1176,7 +1176,7 @@ class Cmd(cmd.Cmd):
             # Print the header if one exists
             if self.completion_header:
                 # noinspection PyUnresolvedReferences
-                readline.rl.mode.console.write('\n' + self.completion_header)
+                readline.rl.mode.console.write('\n\n' + self.completion_header)
 
             # Display matches using actual display function. This also redraws the prompt and line.
             orig_pyreadline_display(matches_to_display)
diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py
index 70b7c1eb..b35cade6 100644
--- a/cmd2/table_creator.py
+++ b/cmd2/table_creator.py
@@ -5,6 +5,7 @@ This API is built upon two core classes: Column and TableCreator
 The general use case is to inherit from TableCreator to create a table class with custom formatting options.
 There are already implemented and ready-to-use examples of this below TableCreator's code.
 """
+import copy
 import functools
 import io
 from collections import deque
@@ -103,7 +104,7 @@ class TableCreator:
         :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab,
                           then it will be converted to one space.
         """
-        self.cols = cols
+        self.cols = copy.copy(cols)
         self.tab_width = tab_width
 
     @staticmethod
@@ -465,8 +466,9 @@ class TableCreator:
                 if cell_index == len(self.cols) - 1:
                     row_buf.write(post_line)
 
-            # Add a newline if this is not the last row
-            row_buf.write('\n')
+            # Add a newline if this is not the last line
+            if line_index < total_lines - 1:
+                row_buf.write('\n')
 
         return row_buf.getvalue()
 
@@ -480,6 +482,9 @@ class SimpleTable(TableCreator):
     Implementation of TableCreator which generates a borderless table with an optional divider row after the header.
     This class can be used to create the whole table at once or one row at a time.
     """
+    # Spaces between cells
+    INTER_CELL = 2 * SPACE
+
     def __init__(self, cols: Sequence[Column], *, tab_width: int = 4, divider_char: Optional[str] = '-') -> None:
         """
         SimpleTable initializer
@@ -487,32 +492,68 @@ class SimpleTable(TableCreator):
         :param cols: column definitions for this table
         :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab,
                           then it will be converted to one space.
-        :param divider_char: optional character used to build the header divider row. If provided, its value must meet the
-                             same requirements as fill_char in TableCreator.generate_row() or exceptions will be raised.
-                             Set this to None if you don't want a divider row. (Defaults to dash)
+        :param divider_char: optional character used to build the header divider row. Set this to None if you don't
+                             want a divider row. Defaults to dash. (Cannot be a line breaking character)
+        :raises: TypeError if fill_char is more than one character (not including ANSI style sequences)
+        :raises: ValueError if text or fill_char contains an unprintable character
         """
+        if divider_char is not None:
+            if len(ansi.strip_style(divider_char)) != 1:
+                raise TypeError("Divider character must be exactly one character long")
+
+            divider_char_width = ansi.style_aware_wcswidth(divider_char)
+            if divider_char_width == -1:
+                raise (ValueError("Divider character is an unprintable character"))
+
         super().__init__(cols, tab_width=tab_width)
         self.divider_char = divider_char
-        self.empty_data = [EMPTY for _ in self.cols]
 
-    def generate_header(self) -> str:
+    @classmethod
+    def base_width(cls, num_cols: int) -> int:
         """
-        Generate header with an optional divider row
+        Utility method to calculate the display width required for a table before data is added to it.
+        This is useful when determining how wide to make your columns to have a table be a specific width.
+
+        :param num_cols: how many columns the table will have
+        :return: base width
+        :raises: ValueError if num_cols is less than 1
         """
+        if num_cols < 1:
+            raise ValueError("Column count cannot be less than 1")
+
+        data_str = SPACE
+        data_width = ansi.style_aware_wcswidth(data_str) * num_cols
+
+        tbl = cls([Column(data_str)] * num_cols)
+        data_row = tbl.generate_data_row([data_str] * num_cols)
+
+        return ansi.style_aware_wcswidth(data_row) - data_width
+
+    def total_width(self) -> int:
+        """Calculate the total display width of this table"""
+        base_width = self.base_width(len(self.cols))
+        data_width = sum(col.width for col in self.cols)
+        return base_width + data_width
+
+    def generate_header(self) -> str:
+        """Generate table header with an optional divider row"""
         header_buf = io.StringIO()
 
         # Create the header labels
-        if self.divider_char is None:
-            inter_cell = 2 * SPACE
-        else:
-            inter_cell = SPACE * ansi.style_aware_wcswidth(2 * self.divider_char)
-        header = self.generate_row(inter_cell=inter_cell)
+        header = self.generate_row(inter_cell=self.INTER_CELL)
         header_buf.write(header)
 
-        # Create the divider. Use empty strings for the row_data.
+        # Create the divider if necessary
         if self.divider_char is not None:
-            divider = self.generate_row(row_data=self.empty_data, fill_char=self.divider_char,
-                                        inter_cell=(2 * self.divider_char))
+            total_width = self.total_width()
+            divider_char_width = ansi.style_aware_wcswidth(self.divider_char)
+
+            # Make divider as wide as table and use padding if width of
+            # divider_char does not divide evenly into table width.
+            divider = self.divider_char * (total_width // divider_char_width)
+            divider += SPACE * (total_width % divider_char_width)
+
+            header_buf.write('\n')
             header_buf.write(divider)
         return header_buf.getvalue()
 
@@ -523,11 +564,7 @@ class SimpleTable(TableCreator):
         :param row_data: data with an entry for each column in the row
         :return: data row string
         """
-        if self.divider_char is None:
-            inter_cell = 2 * SPACE
-        else:
-            inter_cell = SPACE * ansi.style_aware_wcswidth(2 * self.divider_char)
-        return self.generate_row(row_data=row_data, inter_cell=inter_cell)
+        return self.generate_row(row_data=row_data, inter_cell=self.INTER_CELL)
 
     def generate_table(self, table_data: Sequence[Sequence[Any]], *,
                        include_header: bool = True, row_spacing: int = 1) -> str:
@@ -548,6 +585,8 @@ class SimpleTable(TableCreator):
         if include_header:
             header = self.generate_header()
             table_buf.write(header)
+            if len(table_data) > 0:
+                table_buf.write('\n')
 
         for index, row_data in enumerate(table_data):
             if index > 0 and row_spacing > 0:
@@ -555,6 +594,8 @@ class SimpleTable(TableCreator):
 
             row = self.generate_data_row(row_data)
             table_buf.write(row)
+            if index < len(table_data) - 1:
+                table_buf.write('\n')
 
         return table_buf.getvalue()
 
@@ -586,6 +627,35 @@ class BorderedTable(TableCreator):
             raise ValueError("Padding cannot be less than 0")
         self.padding = padding
 
+    @classmethod
+    def base_width(cls, num_cols: int, *, column_borders: bool = True, padding: int = 1) -> int:
+        """
+        Utility method to calculate the display width required for a table before data is added to it.
+        This is useful when determining how wide to make your columns to have a table be a specific width.
+
+        :param num_cols: how many columns the table will have
+        :param column_borders: if True, borders between columns will be included in the calculation (Defaults to True)
+        :param padding: number of spaces between text and left/right borders of cell
+        :return: base width
+        :raises: ValueError if num_cols is less than 1
+        """
+        if num_cols < 1:
+            raise ValueError("Column count cannot be less than 1")
+
+        data_str = SPACE
+        data_width = ansi.style_aware_wcswidth(data_str) * num_cols
+
+        tbl = cls([Column(data_str)] * num_cols, column_borders=column_borders, padding=padding)
+        data_row = tbl.generate_data_row([data_str] * num_cols)
+
+        return ansi.style_aware_wcswidth(data_row) - data_width
+
+    def total_width(self) -> int:
+        """Calculate the total display width of this table"""
+        base_width = self.base_width(len(self.cols), column_borders=self.column_borders, padding=self.padding)
+        data_width = sum(col.width for col in self.cols)
+        return base_width + data_width
+
     def generate_table_top_border(self):
         """Generate a border which appears at the top of the header and data section"""
         pre_line = '╔' + self.padding * '═'
@@ -643,10 +713,7 @@ class BorderedTable(TableCreator):
                                  inter_cell=inter_cell, post_line=post_line)
 
     def generate_header(self) -> str:
-        """
-        Generate header
-        :return: header string
-        """
+        """Generate table header"""
         pre_line = '║' + self.padding * SPACE
 
         inter_cell = self.padding * SPACE
@@ -659,7 +726,9 @@ class BorderedTable(TableCreator):
         # Create the bordered header
         header_buf = io.StringIO()
         header_buf.write(self.generate_table_top_border())
+        header_buf.write('\n')
         header_buf.write(self.generate_row(pre_line=pre_line, inter_cell=inter_cell, post_line=post_line))
+        header_buf.write('\n')
         header_buf.write(self.generate_header_bottom_border())
 
         return header_buf.getvalue()
@@ -699,13 +768,17 @@ class BorderedTable(TableCreator):
             top_border = self.generate_table_top_border()
             table_buf.write(top_border)
 
+        table_buf.write('\n')
+
         for index, row_data in enumerate(table_data):
             if index > 0:
                 row_bottom_border = self.generate_row_bottom_border()
                 table_buf.write(row_bottom_border)
+                table_buf.write('\n')
 
             row = self.generate_data_row(row_data)
             table_buf.write(row)
+            table_buf.write('\n')
 
         table_buf.write(self.generate_table_bottom_border())
         return table_buf.getvalue()
@@ -797,9 +870,12 @@ class AlternatingTable(BorderedTable):
             top_border = self.generate_table_top_border()
             table_buf.write(top_border)
 
+        table_buf.write('\n')
+
         for row_data in table_data:
             row = self.generate_data_row(row_data)
             table_buf.write(row)
+            table_buf.write('\n')
 
         table_buf.write(self.generate_table_bottom_border())
         return table_buf.getvalue()
diff --git a/examples/table_creation.py b/examples/table_creation.py
index 85bfc3f0..6325b200 100755
--- a/examples/table_creation.py
+++ b/examples/table_creation.py
@@ -53,7 +53,7 @@ data_list.append(["John Jones",
 
 def ansi_print(text):
     """Wraps style_aware_write so style can be stripped if needed"""
-    ansi.style_aware_write(sys.stdout, text + '\n')
+    ansi.style_aware_write(sys.stdout, text + '\n\n')
 
 
 def main():


Test patch:
diff --git a/tests/test_table_creator.py b/tests/test_table_creator.py
index ed53efa6..0d2edfb2 100644
--- a/tests/test_table_creator.py
+++ b/tests/test_table_creator.py
@@ -70,14 +70,14 @@ def test_column_alignment():
     header = tc.generate_row()
     assert header == ('Col 1                               Three     \n'
                       '              Col 2                 line      \n'
-                      '                             Col 3  header    \n')
+                      '                             Col 3  header    ')
 
     # Create a data row
     row_data = ["Val 1", "Val 2", "Val 3", "Three\nline\ndata"]
     row = tc.generate_row(row_data=row_data)
     assert row == ('Val 1                               Three     \n'
                    '              Val 2                 line      \n'
-                   '                             Val 3  data      \n')
+                   '                             Val 3  data      ')
 
 
 def test_wrap_text():
@@ -93,13 +93,13 @@ def test_wrap_text():
                    'that will \n'
                    'wrap      \n'
                    'Not wrap  \n'
-                   ' 1  2   3 \n')
+                   ' 1  2   3 ')
 
     # Test preserving a multiple space sequence across a line break
     row_data = ['First      last one']
     row = tc.generate_row(row_data=row_data)
     assert row == ('First     \n'
-                   ' last one \n')
+                   ' last one ')
 
 
 def test_wrap_text_max_lines():
@@ -110,31 +110,31 @@ def test_wrap_text_max_lines():
     row_data = ['First line last line']
     row = tc.generate_row(row_data=row_data)
     assert row == ('First line\n'
-                   'last line \n')
+                   'last line ')
 
     # Test having to truncate the last word because it's too long for the final line
     row_data = ['First line last lineextratext']
     row = tc.generate_row(row_data=row_data)
     assert row == ('First line\n'
-                   'last line…\n')
+                   'last line…')
 
     # Test having to truncate the last word because it fits the final line but there is more text not being included
     row_data = ['First line thistxtfit extra']
     row = tc.generate_row(row_data=row_data)
     assert row == ('First line\n'
-                   'thistxtfi…\n')
+                   'thistxtfi…')
 
     # Test having to truncate the last word because it fits the final line but there are more lines not being included
     row_data = ['First line thistxtfit\nextra']
     row = tc.generate_row(row_data=row_data)
     assert row == ('First line\n'
-                   'thistxtfi…\n')
+                   'thistxtfi…')
 
     # Test having space left on the final line and adding an ellipsis because there are more lines not being included
     row_data = ['First line last line\nextra line']
     row = tc.generate_row(row_data=row_data)
     assert row == ('First line\n'
-                   'last line…\n')
+                   'last line…')
 
 
 def test_wrap_long_word():
@@ -148,7 +148,7 @@ def test_wrap_long_word():
     # Test header row
     header = tc.generate_row()
     assert header == ('LongColumn            \n'
-                      'Name        Col 2     \n')
+                      'Name        Col 2     ')
 
     # Test data row
     row_data = list()
@@ -162,7 +162,7 @@ def test_wrap_long_word():
     row = tc.generate_row(row_data=row_data)
     expected = (ansi.RESET_ALL + ansi.fg.green + "LongerThan" + ansi.RESET_ALL + "  Word      \n"
                 + ansi.RESET_ALL + ansi.fg.green + "10" + ansi.fg.reset + ansi.RESET_ALL + '        ' + ansi.RESET_ALL + '  LongerThan\n'
-                '            10        \n')
+                '            10        ')
     assert row == expected
 
 
@@ -192,7 +192,7 @@ def test_wrap_long_word_max_data_lines():
 
     row = tc.generate_row(row_data=row_data)
     assert row == ('LongerThan  LongerThan  LongerThan  A LongerT…\n'
-                   '10FitsLast  10FitsLas…  10RunsOve…            \n')
+                   '10FitsLast  10FitsLas…  10RunsOve…            ')
 
 
 def test_wrap_long_char_wider_than_max_width():
@@ -203,7 +203,7 @@ def test_wrap_long_char_wider_than_max_width():
     column_1 = Column("Col 1", width=1)
     tc = TableCreator([column_1])
     row = tc.generate_row(row_data=['深'])
-    assert row == '…\n'
+    assert row == '…'
 
 
 def test_generate_row_exceptions():
@@ -237,12 +237,12 @@ def test_tabs():
 
     row = tc.generate_row(fill_char='\t', pre_line='\t',
                           inter_cell='\t', post_line='\t')
-    assert row == '  Col  1                Col 2  \n'
+    assert row == '  Col  1                Col 2  '
 
 
-def test_simple_table():
-    column_1 = Column("Col 1", width=15)
-    column_2 = Column("Col 2", width=15)
+def test_simple_table_creation():
+    column_1 = Column("Col 1", width=16)
+    column_2 = Column("Col 2", width=16)
 
     row_data = list()
     row_data.append(["Col 1 Row 1", "Col 2 Row 1"])
@@ -252,46 +252,76 @@ def test_simple_table():
     st = SimpleTable([column_1, column_2])
     table = st.generate_table(row_data)
 
-    assert table == ('Col 1            Col 2          \n'
-                     '--------------------------------\n'
-                     'Col 1 Row 1      Col 2 Row 1    \n'
+    assert table == ('Col 1             Col 2           \n'
+                     '----------------------------------\n'
+                     'Col 1 Row 1       Col 2 Row 1     \n'
                      '\n'
-                     'Col 1 Row 2      Col 2 Row 2    \n')
+                     'Col 1 Row 2       Col 2 Row 2     ')
 
     # Custom divider
     st = SimpleTable([column_1, column_2], divider_char='─')
     table = st.generate_table(row_data)
 
-    assert table == ('Col 1            Col 2          \n'
-                     '────────────────────────────────\n'
-                     'Col 1 Row 1      Col 2 Row 1    \n'
+    assert table == ('Col 1             Col 2           \n'
+                     '──────────────────────────────────\n'
+                     'Col 1 Row 1       Col 2 Row 1     \n'
                      '\n'
-                     'Col 1 Row 2      Col 2 Row 2    \n')
+                     'Col 1 Row 2       Col 2 Row 2     ')
 
     # No divider
     st = SimpleTable([column_1, column_2], divider_char=None)
     table = st.generate_table(row_data)
 
-    assert table == ('Col 1            Col 2          \n'
-                     'Col 1 Row 1      Col 2 Row 1    \n'
+    assert table == ('Col 1             Col 2           \n'
+                     'Col 1 Row 1       Col 2 Row 1     \n'
                      '\n'
-                     'Col 1 Row 2      Col 2 Row 2    \n')
+                     'Col 1 Row 2       Col 2 Row 2     ')
 
     # No row spacing
     st = SimpleTable([column_1, column_2])
     table = st.generate_table(row_data, row_spacing=0)
-    assert table == ('Col 1            Col 2          \n'
-                     '--------------------------------\n'
-                     'Col 1 Row 1      Col 2 Row 1    \n'
-                     'Col 1 Row 2      Col 2 Row 2    \n')
+    assert table == ('Col 1             Col 2           \n'
+                     '----------------------------------\n'
+                     'Col 1 Row 1       Col 2 Row 1     \n'
+                     'Col 1 Row 2       Col 2 Row 2     ')
 
     # No header
     st = SimpleTable([column_1, column_2])
     table = st.generate_table(row_data, include_header=False)
 
-    assert table == ('Col 1 Row 1      Col 2 Row 1    \n'
+    assert table == ('Col 1 Row 1       Col 2 Row 1     \n'
+                     '\n'
+                     'Col 1 Row 2       Col 2 Row 2     ')
+
+    # Wide custom divider (divider needs no padding)
+    st = SimpleTable([column_1, column_2], divider_char='深')
+    table = st.generate_table(row_data)
+
+    assert table == ('Col 1             Col 2           \n'
+                     '深深深深深深深深深深深深深深深深深\n'
+                     'Col 1 Row 1       Col 2 Row 1     \n'
                      '\n'
-                     'Col 1 Row 2      Col 2 Row 2    \n')
+                     'Col 1 Row 2       Col 2 Row 2     ')
+
+    # Wide custom divider (divider needs padding)
+    column_2 = Column("Col 2", width=17)
+    st = SimpleTable([column_1, column_2], divider_char='深')
+    table = st.generate_table(row_data)
+
+    assert table == ('Col 1             Col 2            \n'
+                     '深深深深深深深深深深深深深深深深深 \n'
+                     'Col 1 Row 1       Col 2 Row 1      \n'
+                     '\n'
+                     'Col 1 Row 2       Col 2 Row 2      ')
+
+    # Invalid divider character
+    with pytest.raises(TypeError) as excinfo:
+        SimpleTable([column_1, column_2], divider_char='too long')
+    assert "Divider character must be exactly one character long" in str(excinfo.value)
+
+    with pytest.raises(ValueError) as excinfo:
+        SimpleTable([column_1, column_2], divider_char='\n')
+    assert "Divider character is an unprintable character" in str(excinfo.value)
 
     # Invalid row spacing
     st = SimpleTable([column_1, column_2])
@@ -300,7 +330,29 @@ def test_simple_table():
     assert "Row spacing cannot be less than 0" in str(excinfo.value)
 
 
-def test_bordered_table():
+def test_simple_table_width():
+    # Base width
+    for num_cols in range(1, 10):
+        assert SimpleTable.base_width(num_cols) == (num_cols - 1) * 2
+
+    # Invalid num_cols value
+    with pytest.raises(ValueError) as excinfo:
+        SimpleTable.base_width(0)
+    assert "Column count cannot be less than 1" in str(excinfo.value)
+
+    # Total width
+    column_1 = Column("Col 1", width=16)
+    column_2 = Column("Col 2", width=16)
+
+    row_data = list()
+    row_data.append(["Col 1 Row 1", "Col 2 Row 1"])
+    row_data.append(["Col 1 Row 2", "Col 2 Row 2"])
+
+    st = SimpleTable([column_1, column_2])
+    assert st.total_width() == 34
+
+
+def test_bordered_table_creation():
     column_1 = Column("Col 1", width=15)
     column_2 = Column("Col 2", width=15)
 
@@ -317,7 +369,7 @@ def test_bordered_table():
                      '║ Col 1 Row 1     │ Col 2 Row 1     ║\n'
                      '╟─────────────────┼─────────────────╢\n'
                      '║ Col 1 Row 2     │ Col 2 Row 2     ║\n'
-                     '╚═════════════════╧═════════════════╝\n')
+                     '╚═════════════════╧═════════════════╝')
 
     # No column borders
     bt = BorderedTable([column_1, column_2], column_borders=False)
@@ -328,7 +380,7 @@ def test_bordered_table():
                      '║ Col 1 Row 1      Col 2 Row 1     ║\n'
                      '╟──────────────────────────────────╢\n'
                      '║ Col 1 Row 2      Col 2 Row 2     ║\n'
-                     '╚══════════════════════════════════╝\n')
+                     '╚══════════════════════════════════╝')
 
     # No header
     bt = BorderedTable([column_1, column_2])
@@ -337,7 +389,7 @@ def test_bordered_table():
                      '║ Col 1 Row 1     │ Col 2 Row 1     ║\n'
                      '╟─────────────────┼─────────────────╢\n'
                      '║ Col 1 Row 2     │ Col 2 Row 2     ║\n'
-                     '╚═════════════════╧═════════════════╝\n')
+                     '╚═════════════════╧═════════════════╝')
 
     # Non-default padding
     bt = BorderedTable([column_1, column_2], padding=2)
@@ -348,7 +400,7 @@ def test_bordered_table():
                      '║  Col 1 Row 1      │  Col 2 Row 1      ║\n'
                      '╟───────────────────┼───────────────────╢\n'
                      '║  Col 1 Row 2      │  Col 2 Row 2      ║\n'
-                     '╚═══════════════════╧═══════════════════╝\n')
+                     '╚═══════════════════╧═══════════════════╝')
 
     # Invalid padding
     with pytest.raises(ValueError) as excinfo:
@@ -356,7 +408,45 @@ def test_bordered_table():
     assert "Padding cannot be less than 0" in str(excinfo.value)
 
 
-def test_alternating_table():
+def test_bordered_table_width():
+    # Default behavior (column_borders=True, padding=1)
+    assert BorderedTable.base_width(1) == 4
+    assert BorderedTable.base_width(2) == 7
+    assert BorderedTable.base_width(3) == 10
+
+    # No column borders
+    assert BorderedTable.base_width(1, column_borders=False) == 4
+    assert BorderedTable.base_width(2, column_borders=False) == 6
+    assert BorderedTable.base_width(3, column_borders=False) == 8
+
+    # No padding
+    assert BorderedTable.base_width(1, padding=0) == 2
+    assert BorderedTable.base_width(2, padding=0) == 3
+    assert BorderedTable.base_width(3, padding=0) == 4
+
+    # Extra padding
+    assert BorderedTable.base_width(1, padding=3) == 8
+    assert BorderedTable.base_width(2, padding=3) == 15
+    assert BorderedTable.base_width(3, padding=3) == 22
+
+    # Invalid num_cols value
+    with pytest.raises(ValueError) as excinfo:
+        BorderedTable.base_width(0)
+    assert "Column count cannot be less than 1" in str(excinfo.value)
+
+    # Total width
+    column_1 = Column("Col 1", width=15)
+    column_2 = Column("Col 2", width=15)
+
+    row_data = list()
+    row_data.append(["Col 1 Row 1", "Col 2 Row 1"])
+    row_data.append(["Col 1 Row 2", "Col 2 Row 2"])
+
+    bt = BorderedTable([column_1, column_2])
+    assert bt.total_width() == 37
+
+
+def test_alternating_table_creation():
     column_1 = Column("Col 1", width=15)
     column_2 = Column("Col 2", width=15)
 
@@ -372,7 +462,7 @@ def test_alternating_table():
                      '╠═════════════════╪═════════════════╣\n'
                      '║ Col 1 Row 1     │ Col 2 Row 1     ║\n'
                      '\x1b[100m║ \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m │ \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m ║\x1b[49m\n'
-                     '╚═════════════════╧═════════════════╝\n')
+                     '╚═════════════════╧═════════════════╝')
 
     # Other bg colors
     at = AlternatingTable([column_1, column_2], bg_odd=ansi.bg.bright_blue, bg_even=ansi.bg.green)
@@ -382,7 +472,7 @@ def test_alternating_table():
                      '╠═════════════════╪═════════════════╣\n'
                      '\x1b[104m║ \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104m │ \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104m ║\x1b[49m\n'
                      '\x1b[42m║ \x1b[49m\x1b[0m\x1b[42mCol 1 Row 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42m │ \x1b[49m\x1b[0m\x1b[42mCol 2 Row 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42m ║\x1b[49m\n'
-                     '╚═════════════════╧═════════════════╝\n')
+                     '╚═════════════════╧═════════════════╝')
 
     # No column borders
     at = AlternatingTable([column_1, column_2], column_borders=False)
@@ -392,7 +482,7 @@ def test_alternating_table():
                      '╠══════════════════════════════════╣\n'
                      '║ Col 1 Row 1      Col 2 Row 1     ║\n'
                      '\x1b[100m║ \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m  \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m ║\x1b[49m\n'
-                     '╚══════════════════════════════════╝\n')
+                     '╚══════════════════════════════════╝')
 
     # No header
     at = AlternatingTable([column_1, column_2])
@@ -400,7 +490,7 @@ def test_alternating_table():
     assert table == ('╔═════════════════╤═════════════════╗\n'
                      '║ Col 1 Row 1     │ Col 2 Row 1     ║\n'
                      '\x1b[100m║ \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m │ \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m ║\x1b[49m\n'
-                     '╚═════════════════╧═════════════════╝\n')
+                     '╚═════════════════╧═════════════════╝')
 
     # Non-default padding
     at = AlternatingTable([column_1, column_2], padding=2)
@@ -410,7 +500,7 @@ def test_alternating_table():
                      '╠═══════════════════╪═══════════════════╣\n'
                      '║  Col 1 Row 1      │  Col 2 Row 1      ║\n'
                      '\x1b[100m║  \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m  │  \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m  ║\x1b[49m\n'
-                     '╚═══════════════════╧═══════════════════╝\n')
+                     '╚═══════════════════╧═══════════════════╝')
 
     # Invalid padding
     with pytest.raises(ValueError) as excinfo: