From 5b1ffaeda27645981e590d6f25b7c82c7830c39f Mon Sep 17 00:00:00 2001 From: Preet Mishra Date: Tue, 31 Mar 2020 02:26:09 +0530 Subject: [PATCH] boxes/helper/themes: Add support for rendering tables. * Add support for rendering tables, in the MessageBox, using helper functions, parse_html_table, render_table, style_row and unicode_border that parse and return the table contents. * Add `table_head` in themes, for default, gruvbox, light and white, to specify a style for table headings. Tests amended and added for different column alignments. --- tests/ui/test_ui_tools.py | 40 ++++++++- zulipterminal/config/themes.py | 5 ++ zulipterminal/helper.py | 138 ++++++++++++++++++++++++++++++++ zulipterminal/ui_tools/boxes.py | 5 +- 4 files changed, 184 insertions(+), 4 deletions(-) diff --git a/tests/ui/test_ui_tools.py b/tests/ui/test_ui_tools.py index 40832fba0d4..b0fd31682c2 100644 --- a/tests/ui/test_ui_tools.py +++ b/tests/ui/test_ui_tools.py @@ -1397,7 +1397,41 @@ def test_private_message_to_self(self, mocker): ('
', ['[RULER NOT RENDERED]']), ('', ['[IMAGE NOT RENDERED]']), ('', ['[IMAGE NOT RENDERED]']), - ('stuff
', ['[TABLE NOT RENDERED]']), + ('' + '
FirstnameLastname
JohnDoe
MaryMoe' + '
', [ + '┌─', '─────────', '─┬─', '────────', '─┐\n', + '│ ', ('table_head', 'Firstname'), ' │ ', + ('table_head', 'Lastname'), ' │\n', + '├─', '─────────', '─┼─', '────────', '─┤\n', + '│ ', (None, 'John '), ' │ ', (None, 'Doe '), ' │\n', + '│ ', (None, 'Mary '), ' │ ', (None, 'Moe '), ' │\n', + '└─', '─────────', '─┴─', '────────', '─┘', + ]), + ('' + '' + '
NameId' + '
Robert1
Mary100
', [ + '┌─', '──────', '─┬─', '───', '─┐\n', + '│ ', ('table_head', 'Name '), ' │ ', ('table_head', ' Id'), + ' │\n', + '├─', '──────', '─┼─', '───', '─┤\n', + '│ ', (None, 'Robert'), ' │ ', (None, ' 1'), ' │\n', + '│ ', (None, 'Mary '), ' │ ', (None, '100'), ' │\n', + '└─', '──────', '─┴─', '───', '─┘', + ]), + ('' + '' + '
NameId' + '
Robert1
Mary100
', [ + '┌─', '──────', '─┬─', '───', '─┐\n', + '│ ', ('table_head', ' Name '), ' │ ', ('table_head', ' Id'), + ' │\n', + '├─', '──────', '─┼─', '───', '─┤\n', + '│ ', (None, 'Robert'), ' │ ', (None, ' 1'), ' │\n', + '│ ', (None, ' Mary '), ' │ ', (None, '100'), ' │\n', + '└─', '──────', '─┴─', '───', '─┘', + ]), ('some-math', ['some-math']), ('some-math', ['some-math']), ('', ['', ' * ', '', 'text']), @@ -1417,7 +1451,9 @@ 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', '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..06ce620416c 100644 --- a/zulipterminal/helper.py +++ b/zulipterminal/helper.py @@ -493,3 +493,141 @@ 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) + + +TABLE_ROW = List[Union[str, Tuple[Optional[str], str]]] + + +def pad_row_strip(row_strip: TABLE_ROW, fill_char: str=' ', + fill_width: int=1) -> TABLE_ROW: + """ + 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 two columns: + row_strip = [ + leftmost_char, + cell_content, + connector_char, + cell_content, + connector_char, + rightmost_char, + ] + """ + fill = fill_char * fill_width + + # Pad the leftmost box-drawing character. + row_strip[0] = row_strip[0] + fill # type: ignore + + # 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 + + # Pad the rightmost box-drawing character. + row_strip[-1] = fill + row_strip[-1] # type: ignore + return row_strip + + +def row_with_styled_content(row: List[str], column_alignments: List[str], + column_widths: List[int], + row_style: Optional[str]=None) -> TABLE_ROW: + """ + Constructs styled row strip, for markup table, using unicode characters + and row elements. + """ + aligner = {'center': str.center, 'left': str.ljust, 'right': str.rjust} + vertical_bar = u'│' + row_strip = [vertical_bar, ] # type: TABLE_ROW + 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) -> TABLE_ROW: + """ + Given left corner, line, connecter and right corner unicode character, + constructs a border row strip for markup table. + """ + border = [lcorner, ] # type: TABLE_ROW + 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 + return pad_row_strip(border, fill_char=line) + + +def render_table(table_element: Any) -> TABLE_ROW: + """ + 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: List[Union[str, Tuple[Optional[str], str]]] + + # 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, + 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)) + 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