Repository: d0c-s4vage/lookatme
Pull request #35 (2019-12-02 06:34:17)

Develop


Patch:
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..f016df2
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,20 @@
+language: python
+python:
+  - "3.6"
+  - "3.7"
+install:
+    - export VERSION=$(sed 's/^v//' <<<"${TRAVIS_TAG:-0.0.0+$(sed 's^[/_-]^.^g' <<<$TRAVIS_BRANCH)}")
+    - find . -type f -exec sed -i "s/{{VERSION}}/${VERSION}/g" {} \;
+    - pip install tox
+
+script: tox
+
+deploy:
+  provider: pypi
+  password:
+    secure: "MlhilHXHs+bSXZcOHEuVE+jaioTq5vQAKi7FJmEzVq93WCfl78O0RkDVF4xwLxBmyGeRXvhkP36rCWwDj/PcKAfSfURk+kpgvA1SuEU+7wuigpUXeLIH2wrVntQ7z7TeVChF5Uvm3RZI/OyuzQHMO52wNnSv1ypNf7C5zE2OxvE="
+  skip_cleanup: true
+  skip_existing: true
+  user: d0c.s4vage
+  on:
+    tags: true
diff --git a/lookatme/__init__.py b/lookatme/__init__.py
index 55fa636..46de538 100644
--- a/lookatme/__init__.py
+++ b/lookatme/__init__.py
@@ -1,6 +1,1 @@
-"""
-This 
-"""
-
-
 VERSION = __version__ = "{{VERSION}}"
diff --git a/lookatme/parser.py b/lookatme/parser.py
index 2aa401e..6fb86d3 100644
--- a/lookatme/parser.py
+++ b/lookatme/parser.py
@@ -89,11 +89,11 @@ class Parser(object):
                 break
 
         if not found_first:
-            return input_data, {}
+            return input_data, MetaSchema().load({})
         
         new_input = input_data[skipped_chars:]
         if len(yaml_data) == 0:
-            return new_input, {}
+            return new_input, MetaSchema().load({})
 
         yaml_data = "\n".join(yaml_data)
         data = MetaSchema().loads(yaml_data)
diff --git a/lookatme/render/markdown_block.py b/lookatme/render/markdown_block.py
index 66b4bf4..593d330 100644
--- a/lookatme/render/markdown_block.py
+++ b/lookatme/render/markdown_block.py
@@ -47,16 +47,21 @@ def _list_level(item):
 
 @contrib_first
 def render_heading(token, body, stack, loop):
-    if token["level"] == 1:
-        return urwid.Text(("bold", token["text"]), align="center")
-        return urwid.Padding(
-            #urwid.BigText(("bold", token["text"]), font=urwid.HalfBlock5x4Font()),
-            urwid.Text(token["text"]),
-            width="clip",
-            align="center",
-        )
-    else:
-        return urwid.Text(("bold", token["text"]), align="center")
+    headings = config.STYLE["headings"]
+    level = token["level"]
+    style = config.STYLE["headings"].get(str(level), headings["default"])
+
+    prefix = styled_text(style["prefix"], style)
+    suffix = styled_text(style["suffix"], style)
+
+    rendered = render_text(text=token["text"])
+    styled_rendered = styled_text(rendered, style, supplement_style=True)
+
+    return [
+        urwid.Divider(),
+        urwid.Text([prefix] + styled_text(rendered, style) + [suffix]),
+        urwid.Divider(),
+    ]
 
 
 @contrib_first
@@ -143,16 +148,24 @@ render_paragraph = render_text
 def render_block_quote_start(token, body, stack, loop):
     pile = urwid.Pile([])
     stack.append(pile)
+
+    styles = config.STYLE["quote"]
+
+    quote_side = styles["side"]
+    quote_top_corner = styles["top_corner"]
+    quote_bottom_corner = styles["bottom_corner"]
+    quote_style = styles["style"]
+
     return [
         urwid.Divider(),
         urwid.LineBox(
             urwid.AttrMap(
                 urwid.Padding(pile, left=2),
-                spec_from_style(config.STYLE["quote_style"]),
+                spec_from_style(quote_style),
             ),
-            lline="╎", rline="",
-            tline=" ", trcorner="", #tlcorner="",
-            bline=" ", brcorner="", #blcorner="",
+            lline=quote_side, rline="",
+            tline=" ", trcorner="", tlcorner=quote_top_corner,
+            bline=" ", brcorner="", blcorner=quote_bottom_corner,
         ),
         urwid.Divider(),
     ]
diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py
index 649833a..bfe0a06 100644
--- a/lookatme/render/markdown_inline.py
+++ b/lookatme/render/markdown_inline.py
@@ -55,8 +55,8 @@ text = render_no_change
 escape = render_no_change
 
 
-def autolink(link, is_email=False):
-    return render_no_change(link)
+def autolink(link_uri, is_email=False):
+    return link(link_uri, None, link_uri)
 
 
 def inline_html(html):
@@ -64,15 +64,16 @@ def inline_html(html):
 
 
 def footnote_ref(key, index):
+    __import__('pdb').set_trace()
     return render_no_change(key)
 
 
-def image(link, title, text):
-    return render_no_change(text or "")
+def image(link_uri, title, text):
+    return link(link_uri, title, text)
 
 
-def link(link, title, text):
-    return render_no_change(title)
+def link(link_uri, title, text):
+    return [styled_text(text, config.STYLE["link"])]
 
 
 @expanded_styles
diff --git a/lookatme/schemas.py b/lookatme/schemas.py
index 754e897..010ccad 100644
--- a/lookatme/schemas.py
+++ b/lookatme/schemas.py
@@ -2,6 +2,8 @@
 Defines all schemas used in lookatme
 """
 
+
+import datetime
 from marshmallow import Schema, fields, validate
 import pygments.styles
 import yaml
@@ -35,7 +37,7 @@ class YamlRender:
 
 
 class BulletsSchema(Schema):
-    default = fields.Str(required=True, default="•")
+    default = fields.Str(default="•")
 
     class Meta:
         include = {
@@ -52,16 +54,72 @@ class BulletsSchema(Schema):
         }
 
 
-class TableSchema(Schema):
-    header_divider = fields.Str(default="─")
-    column_spacing = fields.Int(default=3)
+class StyleFieldSchema(Schema):
+    fg = fields.Str(default="")
+    bg = fields.Str(default="")
 
 
-class StyleFieldSchema(Schema):
+class HeadingStyleSchema(Schema):
+    prefix = fields.Str()
+    suffix = fields.Str()
     fg = fields.Str(default="")
     bg = fields.Str(default="")
 
 
+class BlockQuoteSchema(Schema):
+    side = fields.Str(default="╎")
+    top_corner = fields.Str(default="┌")
+    bottom_corner = fields.Str(default="└")
+    style = fields.Nested(StyleFieldSchema, default=StyleFieldSchema().dump({
+        "fg": "italics,#aaa",
+        "bg": "default",
+    }))
+
+
+class HeadingsSchema(Schema):
+    default = fields.Nested(HeadingStyleSchema, default={
+        "fg": "#579,bold",
+        "bg": "default",
+        "prefix": "░ ",
+        "suffix": " ░",
+    })
+
+    class Meta:
+        include = {
+            "1": fields.Nested(HeadingStyleSchema, default={
+                "fg": "#9fc,bold",
+                "bg": "default",
+                "prefix": "░ ",
+                "suffix": " ░",
+            }),
+            "2": fields.Nested(HeadingStyleSchema, default={
+                "fg": "#1cc,bold",
+                "bg": "default",
+                "prefix": "░░ ",
+                "suffix": " ░░",
+            }),
+            "3": fields.Nested(HeadingStyleSchema, default={
+                "fg": "#29c,bold",
+                "bg": "default",
+                "prefix": "░░░ ",
+                "suffix": " ░░░",
+            }),
+            "4": fields.Nested(HeadingStyleSchema, default={
+                "fg": "#66a,bold",
+                "bg": "default",
+                "prefix": "░░░░ ",
+                "suffix": " ░░░░",
+            }),
+            "5": fields.Nested(HeadingStyleSchema),
+            "6": fields.Nested(HeadingStyleSchema),
+        }
+
+
+class TableSchema(Schema):
+    header_divider = fields.Str(default="─")
+    column_spacing = fields.Int(default=3)
+
+
 class StyleSchema(Schema):
     """Styles schema for themes and style overrides within presentations
     """
@@ -72,12 +130,14 @@ class StyleSchema(Schema):
         default="monokai",
         validate=validate.OneOf(list(pygments.styles.get_all_styles())),
     )
+    headings = fields.Nested(HeadingsSchema, default=HeadingsSchema().dump(HeadingsSchema()))
     bullets = fields.Nested(BulletsSchema, default=BulletsSchema().dump(BulletsSchema()))
     table = fields.Nested(TableSchema, default=TableSchema().dump(TableSchema()))
-    quote_style = fields.Nested(StyleFieldSchema, default=StyleFieldSchema().dump({
-        "fg": "italics,#aaa",
+    quote = fields.Nested(BlockQuoteSchema, default=BlockQuoteSchema().dump(BlockQuoteSchema()))
+    link = fields.Nested(StyleFieldSchema, default={
+        "fg": "#228,underline",
         "bg": "default",
-    }))
+    })
 
 
 class MetaSchema(Schema):
@@ -86,8 +146,11 @@ class MetaSchema(Schema):
     class Meta:
         render_module = YamlRender
 
-    title = fields.Str()
-    date = fields.Date()
-    author = fields.Str()
-    styles = fields.Nested(StyleSchema, default={})
-    extensions = fields.List(fields.Str(), default=[])
+    title = fields.Str(default="", missing="")
+    date = fields.Date(
+        default=datetime.datetime.now(),
+        missing=datetime.datetime.now(),
+    )
+    author = fields.Str(default="", missing="")
+    styles = fields.Nested(StyleSchema, default={}, missing={})
+    extensions = fields.List(fields.Str(), default=[], missing={})
diff --git a/lookatme/tui.py b/lookatme/tui.py
index 7f8169a..0566f06 100644
--- a/lookatme/tui.py
+++ b/lookatme/tui.py
@@ -14,7 +14,7 @@ import urwid
 import lookatme.config
 import lookatme.contrib
 import lookatme.render.markdown_block as lam_md
-from lookatme.utils import *
+from lookatme.utils import pile_add
 
 
 palette = [
@@ -34,6 +34,7 @@ class SlideRenderer(threading.Thread):
     def __init__(self, loop):
         threading.Thread.__init__(self)
         self.events = defaultdict(threading.Event)
+        self.keep_running = threading.Event()
         self.queue = Queue()
         self.loop = loop
         self.cache = {}
@@ -64,7 +65,10 @@ class SlideRenderer(threading.Thread):
         self.queue.put(slide)
         self.events[slide.number].wait()
 
-        return self.cache[slide.number]
+        res = self.cache[slide.number]
+        if isinstance(res, Exception):
+            raise res
+        return res
 
     def get_slide(self, slide_number):
         """Fetch the slide from the cache
@@ -80,39 +84,53 @@ class SlideRenderer(threading.Thread):
         new_meta = copy.deepcopy(meta)
         new_meta.update(existing_meta)
         setattr(item2, "meta", new_meta)
+
+    def stop(self):
+        self.keep_running.clear()
     
     def run(self):
         """Run the main render thread
         """
-        while True:
+        self.keep_running.set()
+        while self.keep_running.is_set():
             to_render = self.queue.get()
             slide_num = to_render.number
 
-            self._log.debug(f"Rendering slide {slide_num}")
-            start = time.time()
-
-            tmp_pile = urwid.Pile([])
-            stack = [tmp_pile]
-            for token in to_render.tokens:
-                self._log.debug(f"{'  '*len(stack)}Rendering token {token}")
+            try:
+                res = self._do_render(to_render, slide_num)
+                self.cache[slide_num] = res
+            except Exception as e:
+                self.cache[slide_num] = e
+            finally:
+                self.events[slide_num].set()
 
-                last_stack = stack[-1]
-                last_stack_len = len(stack)
-
-                #render_token = getattr(lam_md, f"render_{token['type']}", lambda *args: None)
-                render_token = getattr(lam_md, f"render_{token['type']}")
-                res = render_token(token, stack[-1], stack, self.loop)
-                if len(stack) > last_stack_len:
-                    self._propagate_meta(last_stack, stack[-1])
-                if res is None:
-                    continue
-                pile_add(last_stack, res)
+    def _do_render(self, to_render, slide_num):
+        """Perform the actual rendering of a slide
+        """
+        self._log.debug(f"Rendering slide {slide_num}")
+        start = time.time()
+
+        tmp_pile = urwid.Pile([])
+        stack = [tmp_pile]
+        for token in to_render.tokens:
+            self._log.debug(f"{'  '*len(stack)}Rendering token {token}")
+
+            last_stack = stack[-1]
+            last_stack_len = len(stack)
+
+            #render_token = getattr(lam_md, f"render_{token['type']}", lambda *args: None)
+            render_token = getattr(lam_md, f"render_{token['type']}")
+            res = render_token(token, stack[-1], stack, self.loop)
+            if len(stack) > last_stack_len:
+                self._propagate_meta(last_stack, stack[-1])
+            if res is None:
+                continue
+            pile_add(last_stack, res)
 
-            total = time.time() - start
-            self._log.debug(f"Rendered slide {slide_num} in {total}")
+        total = time.time() - start
+        self._log.debug(f"Rendered slide {slide_num} in {total}")
 
-            self.cache[slide_num] = tmp_pile.contents
-            self.events[slide_num].set()
+        return tmp_pile.contents
 
 
 class MarkdownTui(urwid.Frame):
diff --git a/lookatme/utils.py b/lookatme/utils.py
index 989105a..46402b1 100644
--- a/lookatme/utils.py
+++ b/lookatme/utils.py
@@ -16,10 +16,14 @@ def dict_deep_update(to_update, new_vals):
             to_update[key] = value
 
 
-def spec_from_style(style_dict):
-    """Create an urwid.AttrSpec from a {fg:"", bg:""} style dict
+def spec_from_style(styles):
+    """Create an urwid.AttrSpec from a {fg:"", bg:""} style dict. If styles
+    is a string, it will be used as the foreground
     """
-    return urwid.AttrSpec(style_dict.get("fg", ""), style_dict.get("bg", ""))
+    if isinstance(styles, str):
+        return urwid.AttrSpec(styles, "")
+    else:
+        return urwid.AttrSpec(styles.get("fg", ""), styles.get("bg", ""))
 
 
 def get_fg_bg_styles(style):
@@ -42,7 +46,70 @@ def get_fg_bg_styles(style):
         raise ValueError("Unsupported style value {!r}".format(style))
 
 
-def styled_text(text, new_styles, old_styles=None):
+def overwrite_spec(orig_spec, new_spec):
+    if orig_spec is None:
+        orig_spec = urwid.AttrSpec("", "")
+    if new_spec is None:
+        new_spec = urwid.AttrSpec("", "")
+
+    fg_orig = orig_spec.foreground.split(",")
+    fg_orig_color = orig_spec._foreground_color()
+    fg_orig.remove(fg_orig_color)
+
+    bg_orig = orig_spec.background.split(",")
+    bg_orig_color = orig_spec._background()
+    bg_orig.remove(bg_orig_color)
+
+    fg_new = new_spec.foreground.split(",")
+    fg_new_color = new_spec._foreground_color()
+    fg_new.remove(fg_new_color)
+
+    bg_new = new_spec.background.split(",")
+    bg_new_color = new_spec._background()
+    bg_new.remove(bg_new_color)
+
+    if fg_new_color == "default":
+        fg_orig.append(fg_orig_color)
+    else:
+        fg_new.append(fg_new_color)
+
+    if bg_new_color == "default":
+        bg_orig.append(bg_orig_color)
+    else:
+        bg_new.append(bg_new_color)
+
+    return urwid.AttrSpec(
+        ",".join(set(fg_orig + fg_new)),
+        ",".join(set(bg_orig + bg_new)),
+    )
+
+
+def flatten_text(text, new_spec=None):
+    """Return a flattend list of tuples that can be used as the first argument
+    to a new urwid.Text().
+
+    :param urwid.Text text: The text to flatten
+    :param urwid.AttrSpec new_spec: A new spec to merge with existing styles
+    :returns: list of tuples
+    """
+    text, chunk_stylings = text.get_text()
+
+    res = []
+    total_len = 0
+    for spec, chunk_len in chunk_stylings:
+        split_text = text[total_len:total_len + chunk_len]
+        total_len += chunk_len
+
+        split_text_spec = overwrite_spec(new_spec, spec)
+        res.append((split_text_spec, split_text))
+
+    if len(text[total_len:]) > 0:
+        res.append((new_spec, text[total_len:]))
+
+    return res
+
+
+def styled_text(text, new_styles, old_styles=None, supplement_style=False):
     """Return a styled text tuple that can be used within urwid.Text.
 
     .. note::
@@ -52,9 +119,8 @@ def styled_text(text, new_styles, old_styles=None):
         caller.
     """
     if isinstance(text, urwid.Text):
-        if len(text.attrib) > 0:
-            old_styles = text.attrib[0][0]
-        text = text.text
+        new_spec = spec_from_style(new_styles)
+        return flatten_text(text, new_spec)
     elif (isinstance(text, tuple)
             and isinstance(text[0], urwid.AttrSpec)
             and isinstance(text[1], urwid.Text)):
diff --git a/lookatme/widgets/table.py b/lookatme/widgets/table.py
index 204a33c..b77ff2c 100644
--- a/lookatme/widgets/table.py
+++ b/lookatme/widgets/table.py
@@ -29,7 +29,7 @@ class Table(urwid.Pile):
 
         if headers is not None:
             self.num_columns = len(headers)
-        elif len(rows) == 0:
+        elif headers is None:
             self.num_columns = len(rows[0])
         else:
             raise ValueError(
@@ -39,15 +39,18 @@ class Table(urwid.Pile):
         def header_modifier(cell):
             return urwid.Text(styled_text(cell, "bold"), align=cell.align)
 
-        self.rend_headers = self.create_cells(
-            [self.table_headers], modifier=header_modifier
-        )
+        if self.table_headers is not None:
+            self.rend_headers = self.create_cells(
+                [self.table_headers], modifier=header_modifier
+            )
+        else:
+            self.rend_headers = []
         self.rend_rows = self.create_cells(self.table_rows)
 
-        column_maxes = self.calc_column_maxes()
+        self.column_maxes = self.calc_column_maxes()
 
         cell_spacing = config.STYLE["table"]["column_spacing"]
-        self.total_width = sum(column_maxes.values()) + (
+        self.total_width = sum(self.column_maxes.values()) + (
             cell_spacing * (self.num_columns - 1)
         )
 
@@ -55,19 +58,20 @@ class Table(urwid.Pile):
         final_rows = []
 
         # put headers in Columns
-        header_columns = []
-        for idx, header in enumerate(self.rend_headers[0]):
-            header_with_div = urwid.Pile([
-                header,
-                urwid.Divider(config.STYLE["table"]["header_divider"]),
-            ])
-            header_columns.append((column_maxes[idx], header_with_div))
-        final_rows.append(urwid.Columns(header_columns, cell_spacing))
+        if self.table_headers is not None:
+            header_columns = []
+            for idx, header in enumerate(self.rend_headers[0]):
+                header_with_div = urwid.Pile([
+                    header,
+                    urwid.Divider(config.STYLE["table"]["header_divider"]),
+                ])
+                header_columns.append((self.column_maxes[idx], header_with_div))
+            final_rows.append(urwid.Columns(header_columns, cell_spacing))
 
         for row_idx, rend_row in enumerate(self.rend_rows):
             row_columns = []
             for idx, rend_cell in enumerate(rend_row):
-                row_columns.append((column_maxes[idx], rend_cell))
+                row_columns.append((self.column_maxes[idx], rend_cell))
 
             column_row = urwid.Columns(row_columns, cell_spacing)
             final_rows.append(column_row)
@@ -103,4 +107,3 @@ class Table(urwid.Pile):
             res.append(rend_row)
 
         return res
-        return res
diff --git a/setup.py b/setup.py
index 4c15458..39ad478 100644
--- a/setup.py
+++ b/setup.py
@@ -13,8 +13,9 @@ with open(req_path, "r") as f:
 
 
 setup(
-    name='lookatme', version='{{VERSION}}',
-    description='A command-line presentation tool',
+    name='lookatme',
+    version='{{VERSION}}',
+    description='An interactive, command-line presentation tool',
     author='James Johnson',
     author_email='d0c.s4vage@gmail.com',
     packages=find_packages(exclude=["docs", ".gitignore", "README.md"]),
diff --git a/tox.ini b/tox.ini
index 72620d1..f23d1c4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,12 +1,13 @@
 [tox]
-envlist = py36
+envlist = py3
 
 [testenv]
 # install pytest in the virtualenv where commands will be executed
 deps = -r requirements.txt
     pytest
+    pytest-xdist
     pytest-mock
     pytest-cov
 commands =
     # NOTE: you can run any command line tool here - not just tests
-    pytest --cov lookatme --cov-report term {posargs}
+    pytest --cov lookatme --cov-report term-missing {posargs}


Test patch:
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
new file mode 100644
index 0000000..f7a98af
--- /dev/null
+++ b/tests/test_markdown.py
@@ -0,0 +1,293 @@
+"""
+Test basic markdown renderings
+"""
+
+
+import urwid
+
+
+import lookatme.config
+import lookatme.render.markdown_block
+import lookatme.render.pygments
+from lookatme.parser import Parser
+import lookatme.tui
+
+
+from tests.utils import spec_and_text, row_text
+
+
+def render_markdown(markdown, height=50):
+    """Returns the rendered canvas contents of the markdown
+    """
+    loop = urwid.MainLoop(urwid.Pile([]))
+    renderer = lookatme.tui.SlideRenderer(loop)
+    renderer.start()
+
+    parser = Parser()
+    _, slides = parser.parse_slides(markdown)
+
+    renderer.stop()
+    pile_contents = renderer.render_slide(slides[0], force=True)
+    renderer.join()
+
+    pile = urwid.Pile([urwid.Text("testing")])
+    pile.contents = pile_contents
+    return list(pile.render((height,)).content())
+
+
+TEST_STYLE = {
+    "style": "monokai",
+    "headings": {
+        "default": {
+            "fg": "bold",
+            "bg": "",
+            "prefix": "|",
+            "suffix": "|",
+        },
+    },
+}
+
+
+def test_headings(mocker):
+    """Test basic header rendering
+    """
+    mocker.patch.object(lookatme.config, "LOG")
+    fake_config = mocker.patch.object(lookatme.render.markdown_block, "config")
+    fake_config.STYLE = TEST_STYLE
+
+    rendered = render_markdown("""
+# H1
+## H2
+### H3""")
+
+    # three lines for the headings plus an extra line of padding after each
+    # and one line of padding before the first one
+    assert len(rendered) == 7
+
+    stripped_rows = [
+        b"",
+        b"|H1|",
+        b"",
+        b"|H2|",
+        b"",
+        b"|H3|",
+        b"",
+    ]
+    for idx, row in enumerate(rendered):
+        stripped_row_text = row_text(row).strip()
+        assert stripped_row_text == stripped_rows[idx]
+
+
+def test_table(mocker):
+    """Test basic table rendering
+    """
+    import lookatme.widgets.table
+
+    mocker.patch.object(lookatme.config, "LOG")
+    fake_config = mocker.patch.object(lookatme.render.markdown_block, "config")
+    mocker.patch.object(lookatme.widgets.table, "config", fake_config)
+    fake_config.STYLE = {
+        "table": {
+            "column_spacing": 1,
+            "header_divider": "-",
+        },
+    }
+
+    rendered = render_markdown("""
+| H1      |   H2   |     H3 |
+|:--------|:------:|-------:|
+| 1value1 | value2 | value3 |
+| 1       | 2      | 3      |
+""")
+
+    assert len(rendered) == 4
+
+    stripped_rows = [
+        b"H1        H2       H3",
+        b"------- ------ ------",
+        b"1value1 value2 value3",
+        b"1          2        3",
+    ]
+    for idx, row in enumerate(rendered):
+        stripped_row_text = row_text(row).strip()
+        assert stripped_row_text == stripped_rows[idx]
+
+
+def test_lists(mocker):
+    """Test list rendering
+    """
+    import lookatme.widgets.table
+
+    mocker.patch.object(lookatme.config, "LOG")
+    fake_config = mocker.patch.object(lookatme.render.markdown_block, "config")
+    mocker.patch.object(lookatme.widgets.table, "config", fake_config)
+    fake_config.STYLE = {
+        "bullets": {
+            "default": "*",
+            "1": "-",
+            "2": "=",
+            "3": "^",
+        },
+    }
+
+    rendered = render_markdown("""
+* list 1
+  * list 2
+    * list 3
+      * list 4
+  * list 2
+    * list 3
+    * list 3
+
+* list 2
+""")
+
+    # seven list items plus a pre and post Divider()
+    assert len(rendered) == 10
+
+    stripped_rows = [
+        b'',
+        b'  - list 1',
+        b'      = list 2',
+        b'          ^ list 3',
+        b'              * list 4',
+        b'      = list 2',
+        b'          ^ list 3',
+        b'          ^ list 3',
+        b'  - list 2',
+        b'',
+    ]
+    for idx, row in enumerate(rendered):
+        stripped_row_text = row_text(row).rstrip()
+        assert stripped_row_text == stripped_rows[idx]
+
+
+def test_block_quote(mocker):
+    """Test block quote rendering
+    """
+    mocker.patch.object(lookatme.config, "LOG")
+    fake_config = mocker.patch.object(lookatme.render.markdown_block, "config")
+    fake_config.STYLE = {
+        "quote": {
+            "style": {
+                "fg": "",
+                "bg": "",
+            },
+            "side": ">",
+            "top_corner": "-",
+            "bottom_corner": "=",
+        },
+    }
+
+    rendered = render_markdown("""
+> this is a quote
+""")
+
+    assert len(rendered) == 5
+
+    stripped_rows = [
+        b'',
+        b'-',
+        b'>  this is a quote',
+        b'=',
+        b'',
+    ]
+    for idx, row in enumerate(rendered):
+        stripped_row_text = row_text(row).rstrip()
+        assert stripped_row_text == stripped_rows[idx]
+
+def test_code(mocker):
+    """Test code block rendering
+    """
+    mocker.patch.object(lookatme.config, "LOG")
+    fake_config = mocker.patch.object(lookatme.render.pygments, "config")
+    fake_config.STYLE = {
+        "style": "monokai",
+    }
+
+    rendered = render_markdown("""
+```python
+def some_fn(*args, **kargs):
+    pass```
+    """)
+
+    assert len(rendered) == 5
+
+    stripped_rows = [
+        b'',
+        b'def some_fn(*args, **kargs):',
+        b'    pass',
+        b'',
+        b'',
+    ]
+    for idx, row in enumerate(rendered):
+        stripped_row_text = row_text(row).rstrip()
+        assert stripped_row_text == stripped_rows[idx]
+
+
+def test_inline(mocker):
+    """Test inline markdown
+    """
+    mocker.patch.object(lookatme.config, "LOG")
+    fake_config = mocker.patch.object(lookatme.render.pygments, "config")
+    mocker.patch.object(lookatme.render.markdown_inline, "config", fake_config)
+    fake_config.STYLE = {
+        "style": "monokai",
+        "link": {
+            "fg": "underline",
+            "bg": "default",
+        },
+    }
+
+    rendered = render_markdown("*emphasis*")
+    assert len(rendered) == 1
+    assert rendered[0][0][0].foreground == "default,italics"
+    assert row_text(rendered[0]).strip() == b"emphasis"
+
+    rendered = render_markdown("**emphasis**")
+    assert len(rendered) == 1
+    assert rendered[0][0][0].foreground == "default,underline"
+    assert row_text(rendered[0]).strip() == b"emphasis"
+
+    rendered = render_markdown("_emphasis_")
+    assert len(rendered) == 1
+    assert rendered[0][0][0].foreground == "default,italics"
+    assert row_text(rendered[0]).strip() == b"emphasis"
+
+    rendered = render_markdown("__emphasis__")
+    assert len(rendered) == 1
+    assert rendered[0][0][0].foreground == "default,underline"
+    assert row_text(rendered[0]).strip() == b"emphasis"
+
+    rendered = render_markdown("`inline code`")
+    assert len(rendered) == 1
+    assert row_text(rendered[0]).rstrip() == b" inline code"
+
+    rendered = render_markdown("~~strikethrough~~")
+    assert len(rendered) == 1
+    assert rendered[0][0][0].foreground == "default,strikethrough"
+    assert row_text(rendered[0]).rstrip() == b"strikethrough"
+
+    rendered = render_markdown("[link](http://domain.tld)")
+    assert len(rendered) == 1
+    assert rendered[0][0][0].foreground == "default,underline"
+    assert row_text(rendered[0]).rstrip() == b"link"
+
+    rendered = render_markdown("http://domain.tld")
+    assert len(rendered) == 1
+    assert rendered[0][0][0].foreground == "default,underline"
+    assert row_text(rendered[0]).rstrip() == b"http://domain.tld"
+
+    rendered = render_markdown("![link](http://domain.tld)")
+    assert len(rendered) == 1
+    assert rendered[0][0][0].foreground == "default,underline"
+    assert row_text(rendered[0]).rstrip() == b"link"
+
+#      rendered = render_markdown("""
+#  test[^1]
+#  [^1]: This is a foot note
+#  """)
+#      assert len(rendered) == 1
+#      assert rendered[0][0][0].foreground == "default,underline"
+#      assert row_text(rendered[0]).rstrip() == b"link"
+#  
diff --git a/tests/test_parse.py b/tests/test_parse.py
index 78740c7..5a778f2 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -3,6 +3,9 @@ This module tests that markdown presentations can be correctly parsed
 """
 
 
+import datetime
+
+
 from lookatme.parser import Parser
 
 
@@ -26,11 +29,11 @@ remaining
     parser = Parser()
     input_data, meta = parser.parse_meta(input_data)
     assert input_data.strip() == "remaining"
-    assert meta.title == title
-    assert meta.date.year == 9999
-    assert meta.date.month == 1
-    assert meta.date.day == 1
-    assert meta.author == author
+    assert meta["title"] == title
+    assert meta["date"].year == 9999
+    assert meta["date"].month == 1
+    assert meta["date"].day == 1
+    assert meta["author"] == author
 
 
 def test_parse_metadata_empty():
@@ -46,9 +49,12 @@ remaining
     parser = Parser()
     input_data, meta = parser.parse_meta(input_data)
     assert input_data.strip() == "remaining"
-    assert meta.title is None
-    assert meta.date is None
-    assert meta.author is None
+    now = datetime.datetime.now()
+    assert meta["title"] == ""
+    assert meta["date"].year == now.year
+    assert meta["date"].month == now.month
+    assert meta["date"].day == now.day
+    assert meta["author"] == ""
 
 
 def test_parse_slides():
diff --git a/tests/test_schemas.py b/tests/test_schemas.py
new file mode 100644
index 0000000..1136364
--- /dev/null
+++ b/tests/test_schemas.py
@@ -0,0 +1,26 @@
+"""
+Test tha lookatme YAML schemas behave as expected
+"""
+
+
+from lookatme.schemas import *
+
+
+def test_meta_schema():
+    """Test the meta schema
+    """
+    title = "TITLE"
+    author = "AUTHOR"
+    date = "2019-01-01"
+    yaml_text = f"""
+title: {title}
+author: {author}
+date: {date}
+    """
+
+    schema = MetaSchema().loads(yaml_text)
+    assert schema["title"] == title
+    assert schema["author"] == author
+    assert schema["date"].year == 2019
+    assert schema["date"].month == 1
+    assert schema["date"].day == 1
diff --git a/tests/test_table.py b/tests/test_table.py
new file mode 100644
index 0000000..3f70002
--- /dev/null
+++ b/tests/test_table.py
@@ -0,0 +1,157 @@
+"""
+This module tests the Table widget in lookatme/widgets/table.py
+"""
+
+
+import lookatme.widgets.table
+
+
+from tests.utils import *
+
+
+def test_basic_render(mocker):
+    """Test that a Table widget renders correctly
+    """
+    column_spacing = 3
+
+    fake_config = mocker.patch.object(lookatme.widgets.table, "config")
+    fake_config.STYLE = {
+        "style": "monokai",
+        "table": {
+            "column_spacing": column_spacing,
+            "header_divider": "&",
+        },
+    }
+
+    headers = ["H1", "H2", "H3"]
+    aligns = ["left", "center", "right"]
+    rows = [
+        ["1", "22", "333"],
+        ["*1*", "~~22~~", "**333**"],
+    ]
+
+    table = lookatme.widgets.table.Table(rows, headers=headers, aligns=aligns)
+    canvas = table.render((20,))
+    content = list(canvas.content())
+
+    # four rows:
+    #  1 headers
+    #  2 DIVIDER
+    #  3 row1
+    #  4 row2
+    assert len(content) == 4
+
+    header_row = content[0]
+    spec, text = spec_and_text(header_row[0])
+    assert "bold" in spec.foreground
+    assert text == b"H1"
+    spec, text = spec_and_text(header_row[2])
+    assert "bold" in spec.foreground
+    assert text == b"H2"
+    spec, text = spec_and_text(header_row[5])
+    assert "bold" in spec.foreground
+    assert text == b"H3"
+
+    divider_row = content[1]
+    spec, text = spec_and_text(divider_row[0])
+    # no styling applied to the divider
+    assert spec is None
+    assert text == b"&&"
+    spec, text = spec_and_text(divider_row[2])
+    # no styling applied to the divider
+    assert spec is None
+    assert text == b"&&"
+    spec, text = spec_and_text(divider_row[4])
+    # no styling applied to the divider
+    assert spec is None
+    assert text == b"&&&"
+
+    content_row1 = content[2]
+    spec, text = spec_and_text(content_row1[0])
+    # no styling applied to this row
+    assert spec is None
+    assert text == b"1 "
+    spec, text = spec_and_text(content_row1[2])
+    # no styling applied to this row
+    assert spec is None
+    assert text == b"22"
+    spec, text = spec_and_text(content_row1[4])
+    # no styling applied to this row
+    assert spec is None
+    assert text == b"333"
+
+    content_row1 = content[3]
+    spec, text = spec_and_text(content_row1[0])
+    # no styling applied to this row
+    assert "italics" in spec.foreground
+    assert text == b"1"
+    spec, text = spec_and_text(content_row1[3])
+    # no styling applied to this row
+    assert "strikethrough" in spec.foreground
+    assert text == b"22"
+    spec, text = spec_and_text(content_row1[5])
+    # no styling applied to this row
+    assert "underline" in spec.foreground
+    assert text == b"333"
+
+
+def test_table_no_headers(mocker):
+    """This situation could never happen as parsed from Markdown. See
+    https://stackoverflow.com/a/17543474.
+    
+    However this situation could happen manually when using the Table() class
+    directly.
+    """
+    fake_config = mocker.patch.object(lookatme.widgets.table, "config")
+    fake_config.STYLE = {
+        "style": "monokai",
+        "table": {
+            "column_spacing": 3,
+            "header_divider": "&",
+        },
+    }
+
+    headers = None
+    aligns = ["left", "center", "right"]
+    rows = [
+        ["1", "22", "333"],
+        ["*1*", "~~22~~", "**333**"],
+    ]
+
+    table = lookatme.widgets.table.Table(rows, headers=headers, aligns=aligns)
+    canvas = table.render((20,))
+    content = list(canvas.content())
+
+    assert len(content) == 2
+
+
+def test_ignored_extra_column(mocker):
+    """Test that extra columns beyond header values are ignored
+    """
+    fake_config = mocker.patch.object(lookatme.widgets.table, "config")
+    fake_config.STYLE = {
+        "style": "monokai",
+        "table": {
+            "column_spacing": 3,
+            "header_divider": "&",
+        },
+    }
+
+    headers = ["H1", "H2", "H3"]
+    aligns = ["left", "center", "right"]
+    rows = [
+        ["1", "2", "3"],
+        ["1", "2", "3", "4"],
+        ["1", "2", "3", "4", "5"],
+    ]
+
+    table = lookatme.widgets.table.Table(rows, headers=headers, aligns=aligns)
+    canvas = table.render((20,))
+    content = list(canvas.content())
+
+    # number of rows of output
+    assert len(content) == 5
+    assert b"4" not in row_text(content[-2])
+
+    assert b"4" not in row_text(content[-1])
+    assert b"5" not in row_text(content[-1])
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..1dc9c8d
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,22 @@
+"""
+Defines utilities for testing lookatme
+"""
+
+
+import pytest
+
+
+def spec_and_text(item):
+    """``item`` should be an item from a rendered widget, a tuple of the form
+
+    .. code-block:: python
+
+        (spec, ?, text)
+    """
+    return item[0], item[2]
+
+
+def row_text(rendered_row):
+    """Return all text joined together from the rendered row
+    """
+    return b"".join(x[-1] for x in rendered_row)