diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py
index 40832fba0d4..33f56da88ad 100644
--- a/tests/ui/test_ui_tools.py
+++ b/tests/ui/test_ui_tools.py
@@ -1397,7 +1397,59 @@ def test_private_message_to_self(self, mocker):
('
', ['[RULER NOT RENDERED]']),
('', ['[IMAGE NOT RENDERED]']),
('', ['[IMAGE NOT RENDERED]']),
- ('', ['[TABLE NOT RENDERED]']),
+ ('Firstname | Lastname |
'
+ 'John | Doe |
Mary | Moe'
+ ' |
', [
+ '┌─', '─────────', '─┬─', '────────', '─┐\n',
+ '│ ', ('table_head', 'Firstname'), ' │ ',
+ ('table_head', 'Lastname'), ' │\n',
+ '├─', '─────────', '─┼─', '────────', '─┤\n',
+ '│ ', (None, 'John '), ' │ ', (None, 'Doe '), ' │\n',
+ '│ ', (None, 'Mary '), ' │ ', (None, 'Moe '), ' │\n',
+ '└─', '─────────', '─┴─', '────────', '─┘',
+ ]),
+ ('Name | Id'
+ ' |
---|
Robert | '
+ '1 |
Mary | '
+ '100 |
', [
+ '┌─', '──────', '─┬─', '───', '─┐\n',
+ '│ ', ('table_head', 'Name '), ' │ ', ('table_head', ' Id'),
+ ' │\n',
+ '├─', '──────', '─┼─', '───', '─┤\n',
+ '│ ', (None, 'Robert'), ' │ ', (None, ' 1'), ' │\n',
+ '│ ', (None, 'Mary '), ' │ ', (None, '100'), ' │\n',
+ '└─', '──────', '─┴─', '───', '─┘',
+ ]),
+ ('Name | Id'
+ ' |
---|
Robert | '
+ '1 |
Mary | '
+ '100 |
', [
+ '┌─', '──────', '─┬─', '───', '─┐\n',
+ '│ ', ('table_head', ' Name '), ' │ ', ('table_head', ' Id'),
+ ' │\n',
+ '├─', '──────', '─┼─', '───', '─┤\n',
+ '│ ', (None, 'Robert'), ' │ ', (None, ' 1'), ' │\n',
+ '│ ', (None, ' Mary '), ' │ ', (None, '100'), ' │\n',
+ '└─', '──────', '─┴─', '───', '─┘',
+ ]),
+ ('', [
+ '┌─', '────', '─┐\n',
+ '│ ', ('table_head', 'Name'), ' │\n',
+ '├─', '────', '─┤\n',
+ '│ ', (None, 'Foo '), ' │\n',
+ '│ ', (None, 'Bar '), ' │\n',
+ '│ ', (None, 'Baz '), ' │\n',
+ '└─', '────', '─┘',
+ ]),
+ ('', [
+ '┌─', '───────', '─┐\n',
+ '│ ', ('table_head', 'Column1'), ' │\n',
+ '├─', '───────', '─┤\n',
+ '│ ', (None, ' '), ' │\n',
+ '└─', '───────', '─┘',
+ ]),
('some-math', ['some-math']),
('some-math', ['some-math']),
('', ['', ' * ', '', 'text']),
@@ -1417,7 +1469,10 @@ def test_private_message_to_self(self, mocker):
'link_sametext', 'link_sameimage', 'link_differenttext',
'link_userupload', 'link_api', 'link_serverrelative_same',
'listitem', 'listitems',
- 'br', 'br2', 'hr', 'hr2', 'img', 'img2', 'table', 'math', 'math2',
+ 'br', 'br2', 'hr', 'hr2', 'img', 'img2', 'table_default',
+ 'table_with_left_and_right_alignments',
+ 'table_with_center_and_right_alignments', 'table_with_single_column',
+ 'table_with_the_bare_minimum', 'math', 'math2',
'ul', 'strikethrough_del', 'inline_image', 'inline_ref',
'emoji', 'preview-twitter', 'zulip_extra_emoji', 'custom_emoji'
])
diff --git a/zulipterminal/config/themes.py b/zulipterminal/config/themes.py
index 4f528efb96a..a339c202d44 100644
--- a/zulipterminal/config/themes.py
+++ b/zulipterminal/config/themes.py
@@ -79,6 +79,7 @@
('starred', 'light red, bold', ''),
('category', 'light blue, bold', ''),
('unread_count', 'yellow', ''),
+ ('table_head', 'white, bold', ''),
],
'gruvbox': [
# default colorscheme on 16 colors, gruvbox colorscheme
@@ -137,6 +138,8 @@
None, LIGHTBLUE, BLACK),
('unread_count', 'yellow', 'black',
None, YELLOW, BLACK),
+ ('table_head', 'white, bold', 'black',
+ None, WHITEBOLD, BLACK),
],
'light': [
(None, 'black', 'white'),
@@ -166,6 +169,7 @@
('starred', 'light red, bold', 'white'),
('category', 'dark gray, bold', 'light gray'),
('unread_count', 'dark blue, bold', 'white'),
+ ('table_head', 'black, bold', 'white'),
],
'blue': [
(None, 'black', 'light blue'),
@@ -195,6 +199,7 @@
('starred', 'light red, bold', 'dark blue'),
('category', 'light gray, bold', 'light blue'),
('unread_count', 'yellow', 'light blue'),
+ ('table_head', 'black, bold', 'light blue'),
]
} # type: Dict[str, ThemeSpec]
diff --git a/zulipterminal/helper.py b/zulipterminal/helper.py
index e226451406d..81b05b82b13 100644
--- a/zulipterminal/helper.py
+++ b/zulipterminal/helper.py
@@ -493,3 +493,148 @@ def notify(title: str, html_text: str) -> None:
if command:
res = subprocess.run(shlex.split(command), stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT)
+
+
+def parse_html_table(table_element: Any) -> Tuple[List[str], List[List[str]]]:
+ """
+ Parses an HTML table to extract the cell items and the column alignments.
+
+ The table cells are stored in `cells` in a row-wise manner.
+ cells = [[row0, row0, row0],
+ [row1, row1, row1],
+ [row2, row2, row2]]
+ """
+ headers = table_element.thead.tr.find_all('th')
+ rows = table_element.tbody.find_all('tr')
+ column_alignments = []
+
+ # Add +1 to count the header row as well.
+ cells = [[] for _ in range(len(rows) + 1)] # type: List[List[str]]
+
+ # Fill up the `cells` with the header/0th row and extract alignments.
+ for header in headers:
+ cells[0].append(header.text)
+ column_alignments.append(header.get(('align'), 'left'))
+
+ # Fill up the `cells` with body rows.
+ for index, row in enumerate(rows, start=1):
+ for tdata in row.find_all('td'):
+ cells[index].append(tdata.text)
+ return (column_alignments, cells)
+
+
+StyledTableData = List[Union[str, Tuple[Optional[str], str]]]
+
+
+def pad_row_strip(row_strip: StyledTableData, fill_char: str=' ',
+ fill_width: int=1) -> StyledTableData:
+ """
+ Returns back a padded row strip.
+
+ This only pads the box-drawing unicode characters. In particular, all the
+ connector characters are padded both sides, the leftmost character is
+ padded right and the rightmost is padded left.
+
+ The structure of `row_strip` for a table with three columns:
+ row_strip = [
+ leftmost_char,
+ cell_content,
+ connector_char,
+ cell_content,
+ connector_char,
+ cell_content,
+ rightmost_char,
+ ]
+ """
+ fill = fill_char * fill_width
+
+ # [1] Add "type: ignore", to suppress "unsupported operand types" error,
+ # for row_strip[index] and fill concatenation(s) - `row_strip` is of type
+ # "StyledTableData" and fill is of type "str".
+
+ # Pad the leftmost box-drawing character.
+ row_strip[0] = row_strip[0] + fill # type: ignore # Refer to [1].
+
+ # Pad the connector box-drawing characters.
+ for index in range(2, len(row_strip) - 1, 2):
+ row_strip[index] = fill + row_strip[index] + fill # type: ignore
+ # Refer to [1].
+
+ # Pad the rightmost box-drawing character.
+ row_strip[-1] = fill + row_strip[-1] # type: ignore # Refer to [1].
+ return row_strip
+
+
+def row_with_styled_content(row: List[str], column_alignments: List[str],
+ column_widths: List[int], vertical_bar: str,
+ row_style: Optional[str]=None) -> StyledTableData:
+ """
+ Constructs styled row strip, for markup table, using unicode characters
+ and row elements.
+ """
+ aligner = {'center': str.center, 'left': str.ljust, 'right': str.rjust}
+ row_strip = [vertical_bar, ] # type: StyledTableData
+ for column_num, cell in enumerate(row):
+ aligned_text = aligner[column_alignments[column_num]](
+ cell, column_widths[column_num]
+ )
+ row_strip.extend([(row_style, aligned_text), vertical_bar])
+ row_strip.pop() # Remove the extra vertical_bar.
+ row_strip.append(vertical_bar + '\n')
+ return pad_row_strip(row_strip)
+
+
+def row_with_only_border(lcorner: str, line: str, connector: str, rcorner: str,
+ column_widths: List[int],
+ newline: bool=True) -> StyledTableData:
+ """
+ Given left corner, line, connecter and right corner unicode character,
+ constructs a border row strip for markup table.
+ """
+ border = [lcorner, ] # type: StyledTableData
+ for width in column_widths:
+ border.extend([line * width, connector])
+ border.pop() # Remove the extra connector.
+ border.append(rcorner)
+ if newline:
+ border[-1] += '\n' # type: ignore # Suppress "unsupported operand
+ # types" error - `border` is of type "StyledTableData" and `fill` is of
+ # type "str".
+ return pad_row_strip(border, fill_char=line)
+
+
+def render_table(table_element: Any) -> StyledTableData:
+ """
+ A helper function for rendering a markup table in the MessageBox.
+ """
+ column_alignments, cells = parse_html_table(table_element)
+
+ # Calculate the width required for each column.
+ column_widths = [
+ len(max(column, key=lambda string: len(string)))
+ for column in zip(*cells)
+ ]
+
+ top_border = row_with_only_border(u'┌', u'─', u'┬', u'┐', column_widths)
+ middle_border = row_with_only_border(u'├', u'─', u'┼', u'┤', column_widths)
+ bottom_border = row_with_only_border(u'└', u'─', u'┴', u'┘', column_widths,
+ newline=False)
+
+ # Construct the table, row-by-row.
+ table = [] # type: StyledTableData
+
+ # Add the header/0th row and the borders that surround it to the table.
+ table.extend(top_border)
+ table.extend(row_with_styled_content(cells.pop(0), column_alignments,
+ column_widths, u'│',
+ row_style='table_head'))
+ table.extend(middle_border)
+
+ # Add the body rows to the table followed by the bottom-most border in the
+ # end.
+ for row in cells:
+ table.extend(row_with_styled_content(row, column_alignments,
+ column_widths, u'│'))
+ table.extend(bottom_border)
+
+ return table
diff --git a/zulipterminal/ui_tools/boxes.py b/zulipterminal/ui_tools/boxes.py
index 8f5c39ae346..23dc89d0bd1 100644
--- a/zulipterminal/ui_tools/boxes.py
+++ b/zulipterminal/ui_tools/boxes.py
@@ -14,7 +14,7 @@
from zulipterminal.config.keys import is_command_key, keys_for_command
from zulipterminal.emoji_names import EMOJI_NAMES
from zulipterminal.helper import (
- Message, match_emoji, match_groups, match_stream, match_user,
+ Message, match_emoji, match_groups, match_stream, match_user, render_table,
)
from zulipterminal.urwid_types import urwid_Size
@@ -400,7 +400,6 @@ def soup2markup(self, soup: Any) -> List[Any]:
'br': '', # No indicator of absence
'hr': 'RULER',
'img': 'IMAGE',
- 'table': 'TABLE'
}
unrendered_div_classes = { # In pairs of 'div_class': 'text'
# TODO: Support embedded content & twitter preview?
@@ -496,6 +495,8 @@ def soup2markup(self, soup: Any) -> List[Any]:
# TODO: Support nested lists
markup.append(' * ')
markup.extend(self.soup2markup(element))
+ elif element.name == 'table':
+ markup.extend(render_table(element))
else:
markup.extend(self.soup2markup(element))
return markup