Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

boxes/helper: Add support for rendering tables. #550

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions tests/ui/test_ui_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,59 @@ def test_private_message_to_self(self, mocker):
('<hr/>', ['[RULER NOT RENDERED]']),
('<img>', ['[IMAGE NOT RENDERED]']),
('<img/>', ['[IMAGE NOT RENDERED]']),
('<table>stuff</table>', ['[TABLE NOT RENDERED]']),
('<table><thead><tr><th>Firstname</th><th>Lastname</th></tr></thead>'
'<tbody><tr><td>John</td><td>Doe</td></tr><tr><td>Mary</td><td>Moe'
'</td></tr></tbody></table>', [
'┌─', '─────────', '─┬─', '────────', '─┐\n',
'│ ', ('table_head', 'Firstname'), ' │ ',
('table_head', 'Lastname'), ' │\n',
'├─', '─────────', '─┼─', '────────', '─┤\n',
'│ ', (None, 'John '), ' │ ', (None, 'Doe '), ' │\n',
'│ ', (None, 'Mary '), ' │ ', (None, 'Moe '), ' │\n',
'└─', '─────────', '─┴─', '────────', '─┘',
]),
('<table><thead><tr><th align="left">Name</th><th align="right">Id'
'</th></tr></thead><tbody><tr><td align="left">Robert</td>'
'<td align="right">1</td></tr><tr><td align="left">Mary</td>'
'<td align="right">100</td></tr></tbody></table>', [
'┌─', '──────', '─┬─', '───', '─┐\n',
'│ ', ('table_head', 'Name '), ' │ ', ('table_head', ' Id'),
' │\n',
'├─', '──────', '─┼─', '───', '─┤\n',
'│ ', (None, 'Robert'), ' │ ', (None, ' 1'), ' │\n',
'│ ', (None, 'Mary '), ' │ ', (None, '100'), ' │\n',
'└─', '──────', '─┴─', '───', '─┘',
]),
('<table><thead><tr><th align="center">Name</th><th align="right">Id'
'</th></tr></thead><tbody><tr><td align="center">Robert</td>'
'<td align="right">1</td></tr><tr><td align="center">Mary</td>'
'<td align="right">100</td></tr></tbody></table>', [
'┌─', '──────', '─┬─', '───', '─┐\n',
'│ ', ('table_head', ' Name '), ' │ ', ('table_head', ' Id'),
' │\n',
'├─', '──────', '─┼─', '───', '─┤\n',
'│ ', (None, 'Robert'), ' │ ', (None, ' 1'), ' │\n',
'│ ', (None, ' Mary '), ' │ ', (None, '100'), ' │\n',
'└─', '──────', '─┴─', '───', '─┘',
]),
neiljp marked this conversation as resolved.
Show resolved Hide resolved
('<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Foo</td>'
'</tr><tr><td>Bar</td></tr><tr><td>Baz</td></tr></tbody></table>', [
'┌─', '────', '─┐\n',
'│ ', ('table_head', 'Name'), ' │\n',
'├─', '────', '─┤\n',
'│ ', (None, 'Foo '), ' │\n',
'│ ', (None, 'Bar '), ' │\n',
'│ ', (None, 'Baz '), ' │\n',
'└─', '────', '─┘',
]),
('<table><thead><tr><th>Column1</th></tr></thead><tbody><tr><td></td>'
'</tr></tbody></table>', [
'┌─', '───────', '─┐\n',
'│ ', ('table_head', 'Column1'), ' │\n',
'├─', '───────', '─┤\n',
'│ ', (None, ' '), ' │\n',
'└─', '───────', '─┘',
]),
('<span class="katex-display">some-math</span>', ['some-math']),
('<span class="katex">some-math</span>', ['some-math']),
('<ul><li>text</li></ul>', ['', ' * ', '', 'text']),
Expand All @@ -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'
])
Expand Down
5 changes: 5 additions & 0 deletions zulipterminal/config/themes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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]

Expand Down
142 changes: 141 additions & 1 deletion zulipterminal/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from threading import Thread
from typing import (
Any, Callable, DefaultDict, Dict, FrozenSet, Iterable, List, Optional, Set,
Tuple, Union,
Tuple, Union, cast,
)

import lxml.html
Expand Down Expand Up @@ -493,3 +493,143 @@ 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 cell items and 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.
preetmishra marked this conversation as resolved.
Show resolved Hide resolved
cells = [[] for _ in range(len(rows) + 1)] # type: List[List[str]]

# Fill up `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 `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,
]

Note: `cast` is used for assisting mypy.
"""
fill = fill_char * fill_width

# Pad the leftmost box-drawing character.
row_strip[0] = cast(str, row_strip[0]) + fill

# Pad the connector box-drawing characters.
for index in range(2, len(row_strip) - 1, 2):
row_strip[index] = fill + cast(str, row_strip[index]) + fill

# Pad the rightmost box-drawing character.
row_strip[-1] = fill + cast(str, row_strip[-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.
if newline:
rcorner += '\n'
border.append(rcorner)
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)
neiljp marked this conversation as resolved.
Show resolved Hide resolved

return table
7 changes: 4 additions & 3 deletions zulipterminal/ui_tools/boxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -392,15 +392,14 @@ def reactions_view(self, reactions: List[Dict[str, Any]]) -> Any:
def soup2markup(self, soup: Any) -> List[Any]:
# Ensure a string is provided, in case the soup finds none
# This could occur if eg. an image is removed or not shown
markup = [''] # type: List[Union[str, Tuple[str, Any]]]
markup = [''] # type: List[Union[str, Tuple[Optional[str], Any]]]
if soup is None: # This is not iterable, so return promptly
return markup
unrendered_tags = { # In pairs of 'tag_name': 'text'
# TODO: Some of these could be implemented
'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?
Expand Down Expand Up @@ -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
Expand Down