Repository: d0c-s4vage/lookatme
Pull request #181 (2022-11-18 14:51:25)

Feature 180 add inline html tutors 3.0


Patch:
diff --git a/lookatme/__main__.py b/lookatme/__main__.py
index c1888d2..0adeb13 100644
--- a/lookatme/__main__.py
+++ b/lookatme/__main__.py
@@ -6,8 +6,10 @@ This is the main CLI for lookatme
 
 
 import io
+import logging
 import os
 import tempfile
+import traceback
 
 import click
 import pygments.styles
@@ -23,6 +25,7 @@ from lookatme.schemas import StyleSchema
 
 @click.command("lookatme")
 @click.option("--debug", "debug", is_flag=True, default=False)
+@click.option("--threads", "threads", is_flag=True, default=False)
 @click.option(
     "-l",
     "--log",
@@ -116,6 +119,7 @@ from lookatme.schemas import StyleSchema
 def main(
     tutorial,
     debug,
+    threads,
     log_path,
     theme,
     code_style,
@@ -132,17 +136,18 @@ def main(
 
     See https://lookatme.readthedocs.io/en/v{{VERSION}} for documentation
     """
+    lookatme.config.LOG = lookatme.log.create_log(log_path)
     if debug:
-        lookatme.config.LOG = lookatme.log.create_log(log_path)
+        lookatme.config.LOG.setLevel(logging.DEBUG)
     else:
-        lookatme.config.LOG = lookatme.log.create_null_log()
+        lookatme.config.LOG.setLevel(logging.ERROR)
 
     if len(input_files) == 0:
         input_files = [io.StringIO("")]
 
     if tutorial:
         if tutorial == "all":
-            tutors = ["general", "markdown"]
+            tutors = ["general", "markdown block", "markdown inline"]
         else:
             tutors = [x.strip() for x in tutorial.split(",")]
 
@@ -171,6 +176,7 @@ def main(
         safe=safe,
         no_ext_warn=no_ext_warn,
         ignore_ext_failure=ignore_ext_failure,
+        no_threads=(debug and not threads),
     )
 
     if dump_styles:
@@ -183,10 +189,19 @@ def main(
         number = pres.get_tui().curr_slide.number + 1
         click.echo(f"Error rendering slide {number}: {e}")
         if not debug:
-            click.echo("Rerun with --debug to view the full traceback in logs")
+            click.echo(
+                "Rerun with --debug to run with no threads and more details in the logs"
+            )
+            lookatme.config.get_log().error(
+                f"Error rendering slide {number}", exc_info=True
+            )
         else:
-            lookatme.config.get_log().exception(f"Error rendering slide {number}: {e}")
-            click.echo(f"See {log_path} for traceback")
+            click.echo(f"Error rendering slide {number}: {e}")
+            click.echo("")
+            click.echo(
+                "\n".join("    " + line for line in traceback.format_exc().split("\n"))
+            )
+            click.echo(f"See {log_path} for detailed runtime logs")
         raise click.Abort()
 
 
diff --git a/lookatme/log.py b/lookatme/log.py
index 05c20b3..06d409f 100644
--- a/lookatme/log.py
+++ b/lookatme/log.py
@@ -9,7 +9,13 @@ import logging
 def create_log(log_path):
     """Create a new log that writes to log_path"""
     logging.basicConfig(filename=log_path, level=logging.DEBUG)
-    return logging.getLogger("lookatme")
+    res = logging.getLogger("lookatme")
+
+    stderr_handler = logging.StreamHandler()
+    stderr_handler.setLevel(logging.ERROR)
+    res.addHandler(stderr_handler)
+
+    return res
 
 
 def create_null_log():
diff --git a/lookatme/parser.py b/lookatme/parser.py
index 639c7b8..db343f1 100644
--- a/lookatme/parser.py
+++ b/lookatme/parser.py
@@ -6,25 +6,35 @@ This module defines the parser for the markdown presentation file
 import re
 from collections import defaultdict
 import copy
-from typing import AnyStr, Callable, Dict, List, Tuple
+from typing import cast, AnyStr, Callable, Dict, List, Tuple
 
 import markdown_it
 import markdown_it.token
 
-import lookatme.config
 from lookatme.schemas import MetaSchema
 from lookatme.slide import Slide
 from lookatme.tutorial import tutor
+import lookatme.utils as utils
+
+
+def _set_map_for_inline(token: Dict):
+    start = token["map"][0]
+    for child in token["children"]:
+        child["map"] = [start, start + 1]
+        if child["type"] == "softbreak":
+            start += 1
 
 
 def md_to_tokens(md_text):
     md = markdown_it.MarkdownIt("gfm-like").disable("html_block")
     tokens = md.parse(md_text)
     res = []
-    for token in tokens:
-        token = token.as_dict()
+    for token_tmp in tokens:
+        token = cast(Dict, token_tmp.as_dict())
         if token["type"] in ("heading_open", "heading_close"):
             token["level"] = int(token["tag"].replace("h", ""))
+        if token["type"] == "inline":
+            _set_map_for_inline(token)
         res.append(token)
 
     return res
@@ -38,34 +48,6 @@ def is_hrule(token):
     return token["type"] == "hr"
 
 
-def debug_print_tokens(tokens, level=1):
-    """Print the tokens DFS"""
-
-    def indent(x):
-        return "  " * x
-
-    log = lookatme.config.get_log()
-    log.debug(indent(level) + "DEBUG TOKENS")
-    level += 1
-
-    stack = list(reversed(tokens))
-    while len(stack) > 0:
-        token = stack.pop()
-        if "close" in token["type"]:
-            level -= 1
-
-        log.debug(indent(level) + repr(token))
-
-        if "open" in token["type"]:
-            level += 1
-
-        token_children = token.get("children", None)
-        if isinstance(token_children, list):
-            stack.append({"type": "children_close"})
-            stack += list(reversed(token_children))
-            stack.append({"type": "children_open"})
-
-
 class SlideIsolator:
     def __init__(self):
         self.slide_tokens = []
@@ -132,25 +114,24 @@ class Parser(object):
         """Create a new Parser instance"""
         self._single_slide = single_slide
 
-    def parse(self, input_data):
+    def parse(self, input_data) -> Tuple[Dict, List[Slide], str]:
         """Parse the provided input data into a Presentation object
 
         :param str input_data: The input markdown presentation to parse
-        :returns: Presentation
         """
-        input_data, meta = self.parse_meta(input_data)
-        input_data, slides = self.parse_slides(meta, input_data)
-        return meta, slides
+        no_meta_input_data, meta = self.parse_meta(input_data)
+        slides = self.parse_slides(meta, no_meta_input_data)
+        return meta, slides, no_meta_input_data
 
-    def parse_slides(self, meta, input_data):
+    def parse_slides(self, meta, input_data) -> List[Slide]:
         """Parse the Slide out of the input data
 
         :param dict meta: The parsed meta values
         :param str input_data: The input data string
-        :returns: tuple of (remaining_data, slide)
+        :returns: List[Slide]
         """
         tokens = md_to_tokens(input_data)
-        debug_print_tokens(tokens)
+        utils.debug_print_tokens(tokens)
         num_hrules, hinfo = self._scan_for_smart_split(tokens)
         keep_split_token = True
 
@@ -192,7 +173,7 @@ class Parser(object):
             tokens, slide_split_check, heading_mod, keep_split_token
         )
 
-        return "", slides
+        return slides
 
     def _split_tokens_into_slides(
         self,
@@ -307,10 +288,12 @@ class Parser(object):
             and isinstance(first_heading_contents, list)
             and hinfo["counts"][first_heading["level"]] == 1
         ):
+            map_start = first_heading_contents[0]["map"]
+            map_end = first_heading_contents[-1]["map"]
             hinfo["title"] = (
-                [{"type": "paragraph_open"}]
+                [{"type": "paragraph_open", "map": [map_start[0], map_start[0] + 1]}]
                 + first_heading_contents
-                + [{"type": "paragraph_close"}]
+                + [{"type": "paragraph_close", "map": [map_end[-1] - 1, map_end[-1]]}]
             )
             del hinfo["counts"][first_heading["level"]]
             hinfo["title_level"] = first_heading["level"]
diff --git a/lookatme/pres.py b/lookatme/pres.py
index 7e5e871..281d0c8 100644
--- a/lookatme/pres.py
+++ b/lookatme/pres.py
@@ -49,6 +49,7 @@ class Presentation(object):
         safe=False,
         no_ext_warn=False,
         ignore_ext_failure=False,
+        no_threads=False,
     ):
         """Creates a new Presentation
 
@@ -69,6 +70,7 @@ class Presentation(object):
         self.no_ext_warn = no_ext_warn
         self.ignore_ext_failure = ignore_ext_failure
         self.initial_load_complete = False
+        self.no_threads = no_threads
 
         self.theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme])
 
@@ -110,7 +112,7 @@ class Presentation(object):
                 data = f.read()
 
         parser = Parser(single_slide=self.single_slide)
-        self.meta, self.slides = parser.parse(data)
+        self.meta, self.slides, self.no_meta_source = parser.parse(data)
 
         # only load extensions once! Live editing does not support
         # auto-extension reloading
@@ -162,7 +164,9 @@ class Presentation(object):
 
     def run(self, start_slide=0):
         """Run the presentation!"""
-        self.tui = lookatme.tui.create_tui(self, start_slide=start_slide)
+        self.tui = lookatme.tui.create_tui(
+            self, start_slide=start_slide, no_threads=self.no_threads
+        )
         self.tui.run()
 
     def get_tui(self) -> lookatme.tui.MarkdownTui:
diff --git a/lookatme/render/context.py b/lookatme/render/context.py
index 91e5950..e8f0830 100644
--- a/lookatme/render/context.py
+++ b/lookatme/render/context.py
@@ -3,6 +3,7 @@
 
 
 import contextlib
+import copy
 from dataclasses import dataclass
 from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
 
@@ -11,89 +12,201 @@ import urwid
 import lookatme.config
 import lookatme.utils as utils
 from lookatme.widgets.clickable_text import ClickableText
+from lookatme.render.token_iterator import TokenIterator
 
 
 @dataclass
 class ContainerInfo:
     container: urwid.Widget
     meta: Dict[str, Any]
-
-
-class TokenIterator:
-    def __init__(self, tokens):
-        self.tokens = tokens
-        self.idx = 0
-
-        self.unwind_stack = []
-
-    def peek(self) -> Optional[Dict]:
-        """Return the next token in the token stream, or None if it does
-        not exist
-        """
-        if self.idx >= len(self.tokens):
-            return None
-        return self.tokens[self.idx]
-
-    def _handle_unwind(self, token: Dict[str, str]):
-        if "_open" in token["type"]:
-            close_token_type = token["type"].replace("open", "close")
-            self.unwind_stack.append({"type": close_token_type})
-        elif "_close" in token["type"]:
-            popped = self.unwind_stack.pop()
-            if popped["type"] != token["type"]:
-                raise RuntimeError("Mismatched unwind token stack")
-
-    def next(self):
-        token = self.peek()
-        if token is not None:
-            self.idx += 1
-            self._handle_unwind(token)
-        return token
-
-    def __iter__(self):
-        return self
-
-    def __next__(self):
-        token = self.next()
-        if token is None:
-            raise StopIteration
-        return token
+    source_token: Optional[Dict]
 
 
 class Context:
     """ """
 
     def __init__(self, loop: Optional[urwid.MainLoop]):
-        self.container_stack: List[ContainerInfo] = []
         self.loop = loop
-        self.tag_stack = []
+
+        self.container_stack: List[ContainerInfo] = []
+        self.tag_stack: List[Tuple[str, Dict, bool]] = []
         self.spec_stack = []
+        self.source_stack = []
         self.inline_render_results = []
+        self.token_stack: List[TokenIterator] = []
+        self._unwind_tokens: List[Tuple[bool, Dict]] = []
+        self.validation_states = {}
+
         self.level = 0
         self.in_new_block = True
 
-        self.token_stack: List[TokenIterator] = []
-
         self._log = lookatme.config.get_log()
 
+    def _validate_empty_list(self, stack_name: str, stack: List[Any]):
+        if len(stack) == 0:
+            return
+
+        raise RuntimeError(
+            "The {} stack did not return to empty, {} items remaining".format(
+                stack_name, len(stack)
+            )
+        )
+
+    def _set_validation_state(self, name: str, container: Any):
+        self.validation_states[name] = (container, len(container))
+
+    def clean_state_snapshot(self):
+        self._set_validation_state("container", self.container_stack)
+        self._set_validation_state("tag", self.tag_stack)
+        self._set_validation_state("spec", self.spec_stack)
+        self._set_validation_state("source", self.source_stack)
+        self._set_validation_state("tokens", self.token_stack)
+        self._set_validation_state("inline render results", self.inline_render_results)
+
+    def clean_state_validate(self):
+        """Validate that all stacks are empty, that everything unwound correctly"""
+        errors = []
+        for list_name, (container, expected) in self.validation_states.items():
+            if len(container) != expected:
+                errors.append(
+                    "{} should have been {}, was {}".format(
+                        list_name,
+                        expected,
+                        len(container),
+                    )
+                )
+
+        if self.level != 0:
+            errors.append("Context level did not return to 0, is {}".format(self.level))
+
+        if errors:
+            raise RuntimeError(
+                "Context did not unwind correctly:\n\n{}".format(
+                    "\n".join("    " + error for error in errors)
+                )
+            )
+
+    def fake_token(self, type: str, **kwargs) -> Dict:
+        res = {"type": type, **kwargs}
+        res["map"] = self.tokens.curr["map"]
+        self.tokens._handle_unwind(res)
+        return res
+
+    @property
+    def source(self) -> str:
+        """Return the current markdown source"""
+        if not self.source_stack:
+            raise ValueError("No source has been set!")
+        return self.source_stack[-1]
+
+    def source_push(self, new_source: str):
+        """Push new markdown source onto the source stack"""
+        self.source_stack.append(new_source)
+
+    def source_pop(self) -> str:
+        """Pop the latest source off of the source stack"""
+        if not self.source_stack:
+            raise ValueError("Tried to pop off the source_stack one too many times")
+        return self.source_stack.pop()
+
+    def source_get_token_lines(
+        self,
+        token: Dict,
+        extra: int = 5,
+        with_lines: bool = True,
+        with_marker: bool = True,
+    ) -> List[str]:
+        lines = self.source.split("\n")
+        range_start, range_end = token["map"]
+
+        before_extra = range_start - max(range_start - extra, 0)
+        after_extra = min(len(lines), range_end + extra) - range_end
+
+        source_lines = self.source.split("\n")[
+            range_start - before_extra : range_end + after_extra
+        ]
+
+        if with_lines:
+            max_line_len = len(str(range_end + extra))
+            for idx, line in enumerate(source_lines):
+                source_lines[idx] = "{{line_no:{}}} {{line}}".format(
+                    max_line_len
+                ).format(
+                    line_no=range_start - before_extra + idx + 1,
+                    line=line,
+                )
+
+        if with_marker:
+            for idx, line in enumerate(source_lines):
+                if idx >= before_extra and idx < len(source_lines) - after_extra:
+                    source_lines[idx] = line = "  HERE->  " + line
+                else:
+                    source_lines[idx] = line = "          " + line
+
+        return source_lines
+
+    def _create_unwind_tokens(self, from_idx: int = 0) -> List[Dict]:
+        _, latest_map_end = self.tokens.curr["map"]
+        latest_map = [latest_map_end - 1, latest_map_end]
+
+        res = []
+
+        idx = 0
+        curr_inlines = []
+        while idx < len(self._unwind_tokens):
+            is_inline, token = self._unwind_tokens[idx]
+            idx += 1
+            token["map"] = latest_map
+
+            if is_inline:
+                curr_inlines.append(token)
+                continue
+
+            if curr_inlines:
+                res.append(
+                    {
+                        "type": "inline",
+                        "map": latest_map,
+                        "children": curr_inlines,
+                    }
+                )
+                curr_inlines.clear()
+
+            res.append(token)
+
+        if curr_inlines:
+            res.append(
+                {
+                    "type": "inline",
+                    "map": latest_map,
+                    "children": curr_inlines,
+                }
+            )
+
+        return list(reversed(res))
+
     @property
-    def unwind_tokens(self) -> Iterator[Dict]:
+    def unwind_tokens(self) -> List[Dict]:
         """Generate a list of unwind (close) tokens from the token iterators
         in the stack
         """
-        if not self.token_stack:
-            raise ValueError("Attempted to fetch unwind stack without having a stack")
-        for token in reversed(self.token_stack[-1].unwind_stack):
-            yield token
+        return self._create_unwind_tokens()
+
+    def unwind_tokens_from(self, bookmark: int) -> List[Dict]:
+        return self._create_unwind_tokens(bookmark)
 
     @property
-    def unwind_tokens_consumed(self) -> Iterator[Dict]:
+    def unwind_bookmark(self) -> int:
+        return len(self._unwind_tokens)
+
+    @property
+    def unwind_tokens_consumed(self) -> List[Dict]:
         """Generate a list of unwind (close) tokens from the token iterators
         in the stack
         """
-        for token in self.unwind_tokens:
-            yield token
-        self.token_stack[-1].unwind_stack.clear()
+        res = self.unwind_tokens
+        self._unwind_tokens.clear()
+        return res
 
     @property
     def tokens(self) -> TokenIterator:
@@ -102,27 +215,24 @@ class Context:
             raise ValueError("Attempted to fetch tokens without providing any")
         return self.token_stack[-1]
 
-    @property
-    def token_next(self):
-        """Return the next token in the token iterator, advancing the iterator"""
-        return self.tokens.next()
-
-    def tokens_push(self, tokens: List[Dict]):
+    def tokens_push(self, tokens: List[Dict], inline: bool = False):
         """ """
-        self.token_stack.append(TokenIterator(tokens))
+        self.token_stack.append(
+            TokenIterator(copy.deepcopy(tokens), self._unwind_tokens, inline)
+        )
 
     def tokens_pop(self):
         """ """
-        self.token_stack.pop()
+        return self.token_stack.pop()
 
     @contextlib.contextmanager
-    def use_tokens(self, tokens):
+    def use_tokens(self, tokens, inline: bool = False):
         """Create a context manager for pushing/popping tokens via a with block"""
-        self.tokens_push(tokens)
-        try:
-            yield
-        finally:
-            self.tokens_pop()
+        self.tokens_push(tokens, inline)
+        yield
+        # do not pop tokens when an exception occurrs! We want to have full
+        # context when handlingn errors!
+        self.tokens_pop()
 
     def ensure_new_block(self):
         """Ensure that we are in a new block"""
@@ -149,10 +259,10 @@ class Context:
     def level_inc(self):
         """ """
         self.level += 1
-        try:
-            yield
-        finally:
-            self.level -= 1
+        yield
+        # do not pop tokens when an exception occurrs! We want to have full
+        # context when handlingn errors!
+        self.level -= 1
 
     def log_debug(self, msg):
         indent = "  " * self.level
@@ -236,7 +346,6 @@ class Context:
     def inline_markup_consumed(self):
         """Return and clear the inline markup"""
         res = self.get_inline_markup()
-        self.log_debug(">>>> InlineMarkup: {!r}".format(res))
         self.inline_clear()
         return res
 
@@ -244,30 +353,31 @@ class Context:
     def inline_widgets_consumed(self):
         """Return and clear the inline widgets"""
         res = self.get_inline_widgets()
-        self.log_debug(">>>> InlineWidgets: {!r}".format(res))
         self.inline_clear()
         return res
 
     @property
     def is_literal(self):
-        # walk the tag_stack backwards and see if we are in any <pre> elements
-        for tag_name in reversed(self.tag_stack):
-            if tag_name == "pre":
+        return self.tag_is_ancestor("pre")
+
+    def tag_is_ancestor(self, ancestor_tag_name: str) -> bool:
+        for tag_name, _, _ in reversed(self.tag_stack):
+            if tag_name == ancestor_tag_name:
                 return True
         return False
 
-    def tag_push(self, new_tag, spec=None, text_only_spec=False):
+    def tag_push(self, new_tag: str, token: Dict, spec=None, text_only_spec=False):
         """Push a new tag name onto the stack"""
         if spec is not None:
             self.spec_push(spec, text_only=text_only_spec)
-        self.tag_stack.append((new_tag, spec is not None))
+        self.tag_stack.append((new_tag, token, spec is not None))
 
     def tag_pop(self):
         """Pop the most recent tag off of the tag stack"""
         if not self.tag_stack:
             raise ValueError("Tried to pop off the tag stack one too many times")
 
-        popped_tag, had_spec = self.tag_stack.pop()
+        popped_tag, _, had_spec = self.tag_stack.pop()
 
         if had_spec:
             self.spec_pop()
@@ -280,6 +390,12 @@ class Context:
             return None
         return self.tag_stack[-1][0]
 
+    @property
+    def tag_token(self) -> Dict:
+        if not self.tag_stack:
+            raise ValueError("Tried to get the token for a non-existent tag")
+        return self.tag_stack[-1][1]
+
     @property
     def meta(self) -> Dict[Any, Any]:
         """Return metadata associated with the current container, or None
@@ -302,7 +418,6 @@ class Context:
         else:
             new_item = self.wrap_widget(new_item)
 
-        self.log_debug("Container: {!r}".format(new_item))
         new_meta = {}
         if self.container_stack:
             new_meta.update(self.meta)
@@ -316,7 +431,9 @@ class Context:
         elif self.inline_render_results:
             raise Exception("How do you have render results with no containers?")
 
-        new_info = ContainerInfo(container=new_item, meta=new_meta)
+        new_info = ContainerInfo(
+            container=new_item, meta=new_meta, source_token=self.tokens.curr
+        )
         self.container_stack.append(new_info)
         self.in_new_block = is_new_block
 
@@ -359,10 +476,10 @@ class Context:
     ):
         """Ensure that the container is pushed/popped correctly"""
         self.container_push(new_container, is_new_block, custom_add)
-        try:
-            yield
-        finally:
-            self.container_pop()
+        yield
+        # do not pop tokens when an exception occurrs! We want to have full
+        # context when handlingn errors!
+        self.container_pop()
 
     @contextlib.contextmanager
     def use_container_tmp(self, new_container: urwid.Widget):
@@ -377,13 +494,13 @@ class Context:
         self.container_stack = []
 
         self.container_push(new_container, is_new_block=True)
-        try:
-            yield
-        finally:
-            self.container_pop()
-            self.inline_render_results = tmp_inline_render_results
-            self.container_stack = tmp_container_stack
-            self.in_new_block = tmp_in_new_block
+        yield
+        # do not pop tokens when an exception occurrs! We want to have full
+        # context when handlingn errors!
+        self.container_pop()
+        self.inline_render_results = tmp_inline_render_results
+        self.container_stack = tmp_container_stack
+        self.in_new_block = tmp_in_new_block
 
     def spec_push(self, new_spec, text_only=False):
         """Push a new AttrSpec onto the spec_stack"""
@@ -427,8 +544,8 @@ class Context:
         """Ensure that specs are pushed/popped correctly"""
         if new_spec is not None:
             self.spec_push(new_spec, text_only)
-        try:
-            yield
-        finally:
-            if new_spec is not None:
-                self.spec_pop()
+        yield
+        # do not pop tokens when an exception occurrs! We want to have full
+        # context when handlingn errors!
+        if new_spec is not None:
+            self.spec_pop()
diff --git a/lookatme/render/markdown_block.py b/lookatme/render/markdown_block.py
index 1b700dc..8aca9c2 100644
--- a/lookatme/render/markdown_block.py
+++ b/lookatme/render/markdown_block.py
@@ -45,23 +45,26 @@ def render(token, ctx: Context):
         render_fn(token, ctx)
 
 
-def render_all(ctx: Context):
+def render_all(ctx: Context, and_unwind: bool = False):
     for token in ctx.tokens:
         ctx.log_debug("Rendering block token: {!r}".format(token))
         render(token, ctx)
 
+    if not and_unwind:
+        return
+
     # normally ctx.unwind_tokens will be empty as every "open" token will have
     # a matching "close" token. However, sometimes (like with progressive slides),
     # there will be some tokens missing from the token stream.
     #
     # this is where we artificially close all open tokens
-    for unwind_token in ctx.unwind_tokens:
+    for unwind_token in ctx.unwind_tokens_consumed:
         ctx.log_debug("Rendering unwind block token: {!r}".format(unwind_token))
         render(unwind_token, ctx)
 
 
 @tutor(
-    "markdown",
+    "markdown block",
     "paragraph",
     r"""
     Paragraphs in markdown are simply text with a full empty line between them:
@@ -80,7 +83,7 @@ def render_all(ctx: Context):
 @contrib_first
 def render_paragraph_open(token, ctx: Context):
     """ """
-    next_token = ctx.tokens.peek()
+    next_token = ctx.tokens.at_offset(0)
 
     # don't ensure a new block for paragraphs that contain a single
     # html_inline token!
@@ -104,7 +107,7 @@ def render_paragraph_close(token, ctx: Context):
 @contrib_first
 def render_inline(token, ctx: Context):
     """ """
-    with ctx.use_tokens(token.get("children", [])):
+    with ctx.use_tokens(token.get("children", []), inline=True):
         markdown_inline.render_all(ctx)
 
 
@@ -165,7 +168,7 @@ def render_list_close(_, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown block",
     "ordered lists",
     r"""
     Ordered lists are lines of text prefixed by a `N. ` or `N)`, where `N` is
@@ -189,7 +192,7 @@ def render_list_close(_, ctx: Context):
     """,
 )
 @tutor(
-    "markdown",
+    "markdown block",
     "unordered lists",
     r"""
     Unordered lists are lines of text starting with either `*`, `+`, or `-`.
@@ -212,7 +215,7 @@ def render_list_close(_, ctx: Context):
     """,
 )
 @tutor(
-    "markdown",
+    "markdown block",
     "lists",
     r"""
     Lists can either be ordered or unordered. You can nest lists by indenting
@@ -291,7 +294,7 @@ def render_list_item_close(_, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown block",
     "headings",
     r"""
     Headings are specified by prefixing text with `#` characters:
@@ -322,7 +325,7 @@ def render_heading_open(token: Dict, ctx: Context):
 
     header_spec = utils.spec_from_style(style)
     ctx.spec_push(header_spec)
-    prefix_token = {"type": "text", "content": style["prefix"]}
+    prefix_token = ctx.fake_token("text", content=style["prefix"])
     markdown_inline.render(prefix_token, ctx)
 
 
@@ -333,14 +336,14 @@ def render_heading_close(token: Dict, ctx: Context):
     level = int(token["tag"].replace("h", ""))
     style = config.get_style()["headings"].get(str(level), headings["default"])
 
-    suffix_token = {"type": "text", "content": style["suffix"]}
+    suffix_token = ctx.fake_token("text", content=style["suffix"])
     markdown_inline.render(suffix_token, ctx)
 
     ctx.spec_pop()
 
 
 @tutor(
-    "markdown",
+    "markdown block",
     "block quote",
     r"""
     Block quotes are lines of markdown prefixed with `> `. Block quotes can
@@ -410,7 +413,7 @@ def render_blockquote_close(token: Dict, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown block",
     "code blocks",
     r"""
     Multi-line code blocks are either surrounded by "fences" (three in a row of
@@ -526,8 +529,30 @@ class TableTokenExtractor:
             self.curr_siblings.append(token)
 
 
+def _is_tag_close_with_tag_open_before_line(
+    token: Dict, line_num: int, ctx: Context
+) -> bool:
+    if token["type"] != "inline":
+        return False
+    if len(token["children"]) != 1:
+        return False
+
+    inline_child = token["children"][0]
+    if inline_child["type"] != "html_inline":
+        return False
+
+    if not inline_child["content"].startswith("</"):
+        return False
+
+    # now to see where the current tag open started!
+    if ctx.tag_token["map"][0] < line_num:
+        return True
+
+    return False
+
+
 @tutor(
-    "markdown",
+    "markdown block",
     "tables",
     r"""
     Rows in tables are defined by separating columns with `|` characters. The
@@ -559,18 +584,61 @@ def render_table_open(token: Dict, ctx: Context):
 
     from lookatme.widgets.table import Table
 
+    table_start_line = token["map"][0]
+
     # TODO: are nested tables even possible without using html? let's ignore
     # that edge case for now and assume we're just looking for the first
     # table_close
     table_children = []
+    saw_table_close = False
+    to_inject = None
+    unwind_bookmark = ctx.unwind_bookmark
+
     # consume the tokens until we see a table_close!
-    for token in ctx.tokens:
-        table_children.append(copy.deepcopy(token))
-        if token["type"] == "table_close":
+    for idx, table_token in enumerate(ctx.tokens):
+        if _is_tag_close_with_tag_open_before_line(table_token, table_start_line, ctx):
+            # we still have to process the close tag!
+            to_inject = table_token
+
+            # undo the current td and tr and consume the next two tokens as
+            # well
+            utils.check_token_type(table_children.pop(), "td_open")
+            utils.check_token_type(table_children.pop(), "tr_open")
+
+            # the markdown parser will add empty td_open/inline/close tokens
+            # for the number of columns in the table - need to consume and
+            # ignore all of these
+            for next_token in ctx.tokens:
+                if next_token["type"] in ("td_close", "td_open", "inline"):
+                    continue
+                utils.check_token_type(next_token, "tr_close")
+                break
             break
-    # loop completed without breaking
-    else:
-        table_children += list(ctx.unwind_tokens_consumed)
+
+        table_children.append(copy.deepcopy(table_token))
+        if table_token["type"] == "table_close":
+            saw_table_close = True
+            break
+
+    if not saw_table_close:
+        # don't consume them yet! We may still have to iterate through more
+        # tokens in the next for loop in case we bailed out of the table
+        # early b/c of an html element
+        table_children += list(ctx.unwind_tokens_from(unwind_bookmark))
+
+        # we may break early if we find a an html element that was started
+        # before the table but somehow ended within the table. In that case,
+        # we still need to consume the rest of the table tokens (but discard
+        # them).
+        for token in ctx.tokens:
+            if token["type"] == "table_close":
+                break
+
+        _ = ctx.unwind_tokens_consumed
+
+    if to_inject:
+        token_iter = ctx.tokens
+        token_iter.tokens.insert(token_iter.idx, to_inject)
 
     extractor = TableTokenExtractor()
     extractor.process_tokens(table_children)
diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py
index 798487a..fd9e2ad 100644
--- a/lookatme/render/markdown_inline.py
+++ b/lookatme/render/markdown_inline.py
@@ -6,7 +6,7 @@ interface
 
 import re
 import sys
-from typing import Union
+from typing import Dict, Optional
 
 import urwid
 
@@ -40,11 +40,14 @@ def render(token, ctx: Context):
         return fn(token, ctx)
 
 
-def render_all(ctx: Context):
+def render_all(ctx: Context, and_unwind: bool = False):
     for token in ctx.tokens:
         ctx.log_debug("Rendering inline token: {!r}".format(token))
         render(token, ctx)
 
+    if not and_unwind:
+        return
+
     # normally ctx.unwind_tokens will be empty as every "open" token will have
     # a matching "close" token. However, sometimes (like with progressive slides),
     # there will be some tokens missing from the token stream.
@@ -64,7 +67,7 @@ def render_text(token, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown inline",
     "emphasis",
     r"""
     <TUTOR:EXAMPLE>
@@ -90,7 +93,7 @@ def render_em_close(_, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown inline",
     "strong emphasis",
     r"""
     <TUTOR:EXAMPLE>
@@ -116,7 +119,7 @@ def render_strong_close(_, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown inline",
     "strikethrough",
     r"""
     <TUTOR:EXAMPLE>
@@ -142,7 +145,7 @@ def render_s_close(_, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown inline",
     "links",
     r"""
     Links are inline elements in markdown and have the form `[text](link)`
@@ -173,7 +176,7 @@ def render_link_close(_, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown inline",
     "images",
     r"""
     Vanilla lookatme renders images as links. Some extensions provide ways to
@@ -194,10 +197,12 @@ def render_image(token, ctx: Context):
     attrs = dict(token["attrs"])
     attrs["href"] = attrs.get("src", "")
 
-    render_link_open({"type": "image_open", "attrs": list(attrs.items())}, ctx)
+    fake_token = ctx.fake_token("link_open", attrs=list(attrs.items()))
+    render_link_open(fake_token, ctx)
     with ctx.use_tokens(token["children"]):
         render_all(ctx)
-    render_link_close({}, ctx)
+    fake_token = ctx.fake_token("link_close", attrs=list(attrs.items()))
+    render_link_close(fake_token, ctx)
 
 
 @contrib_first
@@ -205,8 +210,30 @@ def render_image_close(token, ctx: Context):
     return render_link_close(token, ctx)
 
 
+def _prev_token_is_html(ctx: Context) -> bool:
+    prev = ctx.tokens.at_offset(-1)
+    if not prev:
+        return False
+    return prev["type"] == "html_inline"
+
+
+def _next_token_is_html_close(ctx: Context) -> bool:
+    next_token = ctx.tokens.peek()
+    if not next_token:
+        return False
+    return next_token["type"] == "html_inline" and next_token["content"].startswith(
+        "</"
+    )
+
+
 @contrib_first
 def render_softbreak(_, ctx: Context):
+    # do not add spaces between HTML tags!
+    if _prev_token_is_html(ctx):
+        return
+    if _next_token_is_html_close(ctx):
+        return
+
     markup = ctx.get_inline_markup()
     # if the previous line ended with a dash, don't add a space!
     if len(markup) > 0:
@@ -226,7 +253,7 @@ def render_softbreak(_, ctx: Context):
 
 
 @tutor(
-    "markdown",
+    "markdown inline",
     "inline code",
     r"""
     <TUTOR:EXAMPLE>
@@ -244,6 +271,44 @@ def render_code_inline(token, ctx: Context):
         ctx.inline_push((ctx.spec_text, text))
 
 
+@tutor(
+    "markdown inline",
+    "html tags",
+    r"""
+    Many markdown renderers support a limited set of inline html. So does
+    lookatme!
+
+    Lookatme current supports these tags:
+
+    |       tag | note                                                    |
+    |----------:|---------------------------------------------------------|
+    |   `<div>` | Wrap block elements, set background color, etc.         |
+    |     `<u>` | Underline text                                          |
+    |     `<i>` | Italicize text                                          |
+    |     `<b>` | Bold text                                               |
+    |    `<em>` | "em" for emphasis - in lookatme this inverts the colors |
+    | `<blink>` | Make the text blink (not all terminals support this)    |
+    |    `<br>` | Force a newline                                         |
+    |    `<ul>` | Begin an unordered list                                 |
+    |    `<ol>` | Begin an ordered list                                   |
+    |    `<li>` | List item element                                       |
+    |   `<???>` | Treated as an inline element                            |
+
+    Note that all unrecognized tags (including `<span>`) will be treated as
+    inline elements. Style attributes will work, but a new block element
+    will not be created as is done with `<div>`s.
+
+    Each tag supports the `style` attribute features below:
+
+    * `color` - foreground color of the text (`#RRGGBB`)
+    * `background-color` - background color of the text (`#RRGGBB`)
+    * `text-decoration: underline` - underline the text
+    * `font-weight: bold` - bold the text
+    * `font-style: italic` - italicize the text
+
+    See the following slides for specifics and examples of each html element.
+    """,
+)
 @contrib_first
 def render_html_inline(token, ctx: Context):
     tags = Tag.parse(token["content"])
@@ -282,126 +347,336 @@ def render_html_inline(token, ctx: Context):
 
 @contrib_first
 def render_html_tag_default_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
-    ctx.tag_push(tag.name, style_spec)
+    ctx.tag_push(tag.name, token, style_spec)
 
 
 @contrib_first
 def render_html_tag_default_close(
-    _, _tag: Tag, ctx: Context, _style_spec: Union[None, urwid.AttrSpec]
+    _, _tag: Tag, ctx: Context, _style_spec: Optional[urwid.AttrSpec]
 ):
     ctx.tag_pop()
 
 
+@tutor(
+    "markdown inline",
+    "html tag `<u>`",
+    r"""
+    The `<u>` tag can be used to underline all elements within it:
+
+    <TUTOR:EXAMPLE>
+    I <u>made</u> too
+
+    <u>
+    * much
+        * pasta
+    </u>
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_u_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
     style_spec = utils.overwrite_spec(style_spec, utils.spec_from_style("underline"))
-    ctx.tag_push(tag.name, style_spec, text_only_spec=True)
+    ctx.tag_push(tag.name, token, style_spec, text_only_spec=True)
 
 
+@tutor(
+    "markdown inline",
+    "html tag `<i>`",
+    r"""
+    The `<i>` tag can be used to italicize all elements within it:
+
+    <TUTOR:EXAMPLE>
+    <i>but</i> I was so
+
+    <i>
+    | hungry | there's none |
+    |--------|--------------|
+    | left   | for you      |
+    </i>
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_i_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
     style_spec = utils.overwrite_spec(style_spec, utils.spec_from_style("italics"))
-    ctx.tag_push(tag.name, style_spec)
+    ctx.tag_push(tag.name, token, style_spec)
+
+
+@tutor(
+    "markdown inline",
+    "html tag `<b>`",
+    r"""
+    The `<b>` tag can be used to bold all elements within it:
 
+    <TUTOR:EXAMPLE>
+    so I made m<b>ore lasagna</b>
 
+    <b>
+    > just for fun
+    </b>
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_b_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
     style_spec = utils.overwrite_spec(style_spec, utils.spec_from_style("bold"))
-    ctx.tag_push(tag.name, style_spec)
+    ctx.tag_push(tag.name, token, style_spec)
 
 
+@tutor(
+    "markdown inline",
+    "html tag `<em>`",
+    r"""
+    The `<em>` tag can be used to emphasize all elements within it. In
+    lookatme this results in inverted colors
+
+    <TUTOR:EXAMPLE>
+    I j<em>us</em>t <em>n</em>E<em>E</em>d butter<em>!</em>
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_em_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
     style_spec = utils.overwrite_spec(style_spec, utils.spec_from_style("standout"))
-    ctx.tag_push(tag.name, style_spec, text_only_spec=True)
+    ctx.tag_push(tag.name, token, style_spec, text_only_spec=True)
 
 
+@tutor(
+    "markdown inline",
+    "html tag `<blink>`",
+    r"""
+    The `<blink>` tag causes all contents to blink. Not all terminals
+    support this.
+
+    <TUTOR:EXAMPLE>
+    Can you <blink>hear me</blink> now?
+
+    <blink>
+    > YES.
+    > We can hear you.
+    </blink>
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_blink_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
     style_spec = utils.overwrite_spec(style_spec, utils.spec_from_style("blink"))
-    ctx.tag_push(tag.name, style_spec)
+    ctx.tag_push(tag.name, token, style_spec)
+
+
+@tutor(
+    "markdown inline",
+    "html tag `<br>`",
+    r"""
+    The `<br>` inserts a newline wherever it's at. This can be especially
+    useful in tables!
 
+    <TUTOR:EXAMPLE>
+    Newline inserted<br/>here.
 
+    | table                     | of data                   |
+    |---------------------------|---------------------------|
+    | line1<br/>line2<br/>line3 | single item               |
+    | single item               | line1<br/>line2<br/>line3 |
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_br_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
-    ctx.tag_push(tag.name, style_spec)
+    ctx.tag_push(tag.name, token, style_spec)
     ctx.inline_push((ctx.spec_text, "\n"))
 
 
+@tutor(
+    "markdown inline",
+    "html tag `<div>`",
+    r"""
+    The `<div>` tag creates a new visual block. `<div>` tags are also the
+    best option for setting the background color of entire elements:
+
+    <TUTOR:EXAMPLE>
+    <div>text1</div><div>text2</div>
+
+    <div style="background-color: #808020">
+    | table       | of data     |
+    |-------------|-------------|
+    | data        | single item |
+    | single item | data        |
+    </div>
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_div_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
     ctx.ensure_new_block()
     ctx.container_push(urwid.Pile([]), is_new_block=True)
-    ctx.tag_push(tag.name, style_spec)
+    ctx.tag_push(tag.name, token, style_spec)
 
 
 @contrib_first
 def render_html_tag_div_close(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    _, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
-    ctx.container_pop()
     ctx.tag_pop()
+    ctx.container_pop()
+
+
+@tutor(
+    "markdown inline",
+    "html tag `<ol>`",
+    r"""
+    The `<ol>` tag creates a new ordered list. Each child `<li>` element will
+    be turned into an ordered item in the list.
 
+    Using `<ol>` is especially handy if you want to embed a list into
+    a table cell!
 
+    <TUTOR:EXAMPLE>
+    <ol><li>item1</li><li>item2</li></ol>
+
+    | table                                         | with list  |
+    |-----------------------------------------------|------------|
+    | <ol><li>item in table</li><li>item2</li></ol> | other data |
+    | cell                                          | data       |
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_ol_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
-    ctx.tag_push(tag.name, style_spec)
-    markdown_block().render_ordered_list_open({"type": "ordered_list_open"}, ctx)
+    ctx.tag_push(tag.name, token, style_spec)
+    fake_token = ctx.fake_token("ordered_list_open")
+    markdown_block().render_ordered_list_open(fake_token, ctx)
 
 
 @contrib_first
 def render_html_tag_ol_close(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    _, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
     ctx.tag_pop()
-    markdown_block().render_ordered_list_close({"type": "ordered_list_close"}, ctx)
+    fake_token = ctx.fake_token("ordered_list_close")
+    markdown_block().render_ordered_list_close(fake_token, ctx)
+
+
+@tutor(
+    "markdown inline",
+    "html tag `<ul>`",
+    r"""
+    The `<ul>` tag creates a new *un*ordered list. Each child `<li>` element will
+    be turned into an item in the list.
+
+    Using `<ul>` is especially handy if you want to embed a list into
+    a table cell!
 
+    <TUTOR:EXAMPLE>
+    <ul><li>item1</li><li>item2</li></ul>
 
+    | table                                         | with list  |
+    |-----------------------------------------------|------------|
+    | <ul><li>item in table</li><li>item2</li></ul> | other data |
+    | cell                                          | data       |
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_ul_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
-    ctx.tag_push(tag.name, style_spec)
-    markdown_block().render_bullet_list_open({"type": "bullet_list_open"}, ctx)
+    ctx.tag_push(tag.name, token, style_spec)
+    fake_token = ctx.fake_token("bullet_list_open")
+    markdown_block().render_bullet_list_open(fake_token, ctx)
 
 
 @contrib_first
 def render_html_tag_ul_close(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    _, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
     ctx.tag_pop()
-    markdown_block().render_bullet_list_close({"type": "bullet_list_close"}, ctx)
+    fake_token = ctx.fake_token("bullet_list_close")
+    markdown_block().render_bullet_list_close(fake_token, ctx)
+
+
+@tutor(
+    "markdown inline",
+    "html tag `<li>`",
+    r"""
+    The `<li>` tag creates a single list element for both ordered (`<ol>`) and
+    unordered (`<ul>`) lists. When not used as a child of an `<ol>` or `<ul>`
+    tag, the list item will be assumed to be an unordered list item.
 
+    <TUTOR:EXAMPLE>
+    <ol><li>item1</li><li>item2</li></ol>
 
+    Or without the `<ol>`:
+
+    <li>test</li><li>test2</li>
+
+    | table                                | with list  |
+    |--------------------------------------|------------|
+    | <li>item in table</li><li>item2</li> | other data |
+    | cell                                 | data       |
+    </TUTOR:EXAMPLE>
+    """,
+)
 @contrib_first
 def render_html_tag_li_open(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
-    ctx.tag_push(tag.name, style_spec)
-    markdown_block().render_list_item_open({"type": "list_item_open"}, ctx)
+    # if we're not within a ul or ol element, then we need to force one of
+    # those to be rendered first
+    if (
+        not ctx.tokens.unwind_has_type("bullet_list_close")
+        and not ctx.tokens.unwind_has_type("ordered_list_close")
+        and not ctx.tag_is_ancestor("ol")
+        and not ctx.tag_is_ancestor("ul")
+    ):
+        ctx.log_debug("rendering bullet list open")
+        fake_token = ctx.fake_token("bullet_list_open")
+        fake_parent_ul = token.setdefault("fake_parent_ul", fake_token)
+        markdown_block().render_bullet_list_open(fake_parent_ul, ctx)
+
+    ctx.log_debug("rendering li open")
+    ctx.tag_push(tag.name, token, style_spec)
+    fake_token = ctx.fake_token("list_item_open")
+    li_token = token.setdefault("translated_li_token", fake_token)
+    markdown_block().render_list_item_open(li_token, ctx)
 
 
 @contrib_first
 def render_html_tag_li_close(
-    _, tag: Tag, ctx: Context, style_spec: Union[None, urwid.AttrSpec]
+    token: Dict, tag: Tag, ctx: Context, style_spec: Optional[urwid.AttrSpec]
 ):
+    ctx.log_debug("rendering li close")
     ctx.tag_pop()
-    markdown_block().render_list_item_close({"type": "list_item_close"}, ctx)
+    fake_token = ctx.fake_token("list_item_close")
+    li_token = token.setdefault("translated_li_token", fake_token)
+    markdown_block().render_list_item_close(li_token, ctx)
+
+    # if the next token isn't an ol or ul token, then we need to end this
+    # current <li> list
+    next_token = ctx.tokens.peek()
+    if not next_token or (
+        next_token["type"] == "html_inline"
+        and next_token["content"] not in ("</ul>", "</ol>", "</li>", "<li>")
+        and next_token["type"] != "list_item_open"
+    ):
+        ctx.log_debug("rendering bullet list close")
+        fake_token = ctx.fake_token("bullet_list_close")
+        markdown_block().render_bullet_list_close(fake_token, ctx)
diff --git a/lookatme/render/token_iterator.py b/lookatme/render/token_iterator.py
new file mode 100644
index 0000000..14eb695
--- /dev/null
+++ b/lookatme/render/token_iterator.py
@@ -0,0 +1,154 @@
+"""
+This module defines the TokenIterator class.
+"""
+
+from typing import Any, Callable, List, Dict, Optional, Tuple
+import re
+
+
+class TokenIterator:
+    """This clas"""
+
+    def __init__(
+        self,
+        tokens: List[Dict],
+        unwind_tokens: List[Tuple[bool, Dict]],
+        inline: bool = False,
+    ):
+        """Create a new TokenIterator instance that can iterate/work with the
+        provided list of tokens.
+        """
+        self.tokens = tokens
+        self.idx = 0
+        self.inline = inline
+        self.last_map = None
+
+        self._unwind_tokens = unwind_tokens
+
+    def unwind_has_type(self, unwind_type: str) -> bool:
+        for _, token in reversed(self._unwind_tokens):
+            if token["type"] == unwind_type:
+                return True
+        return False
+
+    def at_offset(self, idx_offset: int) -> Optional[Dict]:
+        if not self.tokens:
+            raise ValueError("No tokens")
+
+        target_idx = self.idx + idx_offset
+        if not (0 >= target_idx < len(self.tokens)):
+            return None
+
+        return self.tokens[target_idx]
+
+    @property
+    def curr(self) -> Dict:
+        if not self.tokens:
+            raise ValueError("No tokens")
+        # we haven't started iterating yet
+        if self.idx == 0:
+            return self.tokens[self.idx]
+        return self.tokens[self.idx - 1]
+
+    def peek(self) -> Optional[Dict]:
+        if not self.tokens or self.idx >= len(self.tokens):
+            return None
+        return self.tokens[self.idx]
+
+    def at_offset_v(self, idx_offset: int) -> Dict:
+        res = self.at_offset_v(idx_offset)
+        if res is None:
+            target_idx = self.idx + idx_offset
+            raise ValueError(
+                "Idx offset {} (idx {}) is outside of range [{},{})".format(
+                    idx_offset,
+                    target_idx,
+                    0,
+                    len(self.tokens),
+                )
+            )
+
+        return res
+
+    def _unwind_index_matching(self, comparator: Callable) -> Optional[int]:
+        for idx, unwind_token in enumerate(self._unwind_tokens):
+            if comparator(unwind_token):
+                return idx
+        return None
+
+    def _handle_unwind(self, token: Dict):
+        # we need to recurse into all inline tags so that we can create an
+        # unwind stack for html elements that may not have been closed.
+        #
+        # E.g.:
+        #
+        # <div>           <-- will be a child of an inline token
+        # |h1|h2|
+        # |--|--|
+        # |a |b |
+        # </div>          <-- this will be parsed as a new row in the table,
+        #
+        #         if token["type"] == "inline":
+        #             for child_token in (token.get("children", []) or []):
+        #                 self._handle_unwind(child_token)
+
+        tmp_unwind_token: Dict[str, Any] = {"unwound_token": token}
+
+        if "_open" in token["type"]:
+            close_token_type = token["type"].replace("open", "close")
+            tmp_unwind_token["type"] = close_token_type
+            self._unwind_tokens.append((self.inline, tmp_unwind_token))
+        elif "_close" in token["type"]:
+            # remove the first matching unwind token
+            remove_idx = self._unwind_index_matching(
+                lambda x: x[1]["type"] == token["type"]
+            )
+            if remove_idx is None:
+                return
+            del self._unwind_tokens[remove_idx]
+        elif token["type"] == "html_inline":
+            tag_info = re.match(
+                r"<(?P<close>/)?(?P<tag>[a-z0-9-]+)", token["content"], re.IGNORECASE
+            )
+            if not tag_info:
+                return
+            info = tag_info.groupdict()
+            if info["tag"] == "br":
+                return
+
+            if info["close"]:
+                remove_idx = self._unwind_index_matching(
+                    lambda x: x[1].get("content", "") == token["content"]
+                )
+                if remove_idx is None:
+                    return
+                del self._unwind_tokens[remove_idx]
+            else:
+                tmp_unwind_token["type"] = "html_inline"
+                tmp_unwind_token["content"] = "</{}>".format(info["tag"])
+                self._unwind_tokens.append((self.inline, tmp_unwind_token))
+
+    def next(self) -> Optional[Dict]:
+        if self.idx >= len(self.tokens):
+            return None
+
+        token = self.tokens[self.idx]
+
+        if self.last_map is not None and token.get("map", None) is None:
+            # set the missing map value to be the last line of the last_map
+            token["map"] = [self.last_map[0] - 1, self.last_map[1]]
+
+        self.last_map = token["map"]
+        self.idx += 1
+        self._handle_unwind(token)
+
+        return token
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        token = self.next()
+        if token is None:
+            raise StopIteration
+        return token
diff --git a/lookatme/schemas.py b/lookatme/schemas.py
index 99b106b..1e8c480 100644
--- a/lookatme/schemas.py
+++ b/lookatme/schemas.py
@@ -520,8 +520,8 @@ class StyleSchema(Schema):
     padding = fields.Nested(
         SpacingSchema,
         dump_default={
-            "top": 0,
-            "bottom": 0,
+            "top": 1,
+            "bottom": 1,
             "left": 10,
             "right": 10,
         },
diff --git a/lookatme/tui.py b/lookatme/tui.py
index 3965d4b..280934c 100644
--- a/lookatme/tui.py
+++ b/lookatme/tui.py
@@ -13,6 +13,7 @@ import urwid
 import lookatme.config
 import lookatme.config as config
 import lookatme.parser
+from lookatme.slide import Slide
 import lookatme.render.markdown_block as markdown_block
 from lookatme.contrib import contrib_first, shutdown_contribs
 from lookatme.render.context import Context
@@ -59,17 +60,18 @@ class SlideRenderer(threading.Thread):
 
     def queue_render(self, slide):
         """Queue up a slide to be rendered."""
-        self.events[slide.number].clear()
-        self.queue.put(slide)
+        if self.is_alive():
+            self.events[slide.number].clear()
+            self.queue.put(slide)
+        else:
+            self.render_slide(slide)
 
-    def render_slide(self, slide, force=False):
+    def render_slide(self, slide):
         """Render a slide, blocking until the slide completes. If ``force`` is
         True, rerender the slide even if it is in the cache.
         """
-        if force or slide.number not in self.cache:
-            self.events[slide.number].clear()
-            self.queue.put(slide)
-            self.events[slide.number].wait()
+        if slide.number not in self.cache:
+            self._cache_slide_render(slide)
 
         res = self.cache[slide.number]
         if isinstance(res, Exception):
@@ -87,16 +89,46 @@ class SlideRenderer(threading.Thread):
                 to_render = self.queue.get(timeout=0.05)
             except Empty:
                 continue
+            self._cache_slide_render(to_render)
 
-            slide_num = to_render.number
+    def _cache_slide_render(self, slide: Slide):
+        try:
+            res = self.do_render(slide, slide.number)
+            self.cache[slide.number] = res
+        except Exception as e:
+            self._log.error(f"Error occurred rendering slide {slide.number}")
 
             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()
+                curr_token = self.ctx.tokens.curr
+            except:
+                curr_token = None
+
+            if curr_token:
+                tmp = dict(**curr_token)
+                if "unwound_token" in tmp:
+                    del tmp["unwound_token"]
+
+                self._log.error("Error occurred with token: {}".format(tmp))
+
+                unwound_token = curr_token.get("unwound_token", {})
+                if unwound_token:
+                    self._log.error(
+                        "Token resulted from unwinding {}".format(unwound_token)
+                    )
+                    unwound_context = self.ctx.source_get_token_lines(unwound_token, 10)
+                    self._log.error(
+                        "Unwound context:\n{}".format("\n".join(unwound_context))
+                    )
+
+                source_context = self.ctx.source_get_token_lines(curr_token, 10)
+                self._log.error("Source context:\n{}".format("\n".join(source_context)))
+
+            if self.is_alive():
+                self.cache[slide.number] = e
+            else:
+                raise e
+        finally:
+            self.events[slide.number].set()
 
     def do_render(self, to_render, slide_num):
         """Perform the actual rendering of a slide. This is done by:
@@ -147,6 +179,7 @@ class SlideRenderer(threading.Thread):
         self._log.debug("PRE-Render====================================")
         self._log.debug("")
         self._render_tokens(tokens)
+
         self._log.debug("")
         self._log.debug("FINAL-Render====================================")
         self._log.debug("")
@@ -184,15 +217,20 @@ class SlideRenderer(threading.Thread):
     )
     def _render_tokens(self, tokens):
         tmp_listbox = urwid.ListBox([])
+
+        self.ctx.clean_state_snapshot()
+
         with self.ctx.use_tokens(tokens):
             with self.ctx.use_container(tmp_listbox, is_new_block=True):
-                markdown_block.render_all(self.ctx)
+                markdown_block.render_all(self.ctx, and_unwind=True)
+
+        self.ctx.clean_state_validate()
 
         return tmp_listbox.body
 
 
 class MarkdownTui(urwid.Frame):
-    def __init__(self, pres, start_idx=0):
+    def __init__(self, pres, start_idx=0, no_threads=False):
         """Create a new MarkdownTui"""
         # self.slide_body = urwid.Pile(urwid.SimpleListWalker([urwid.Text("test")]))
         self.slide_body = urwid.ListBox(
@@ -216,8 +254,10 @@ class MarkdownTui(urwid.Frame):
 
         self.root_margins = urwid.Padding(self, left=2, right=2)
         self.root_paddings = urwid.Padding(self.slide_body, left=10, right=10)
+        self.pres = pres
 
         self.ctx = Context(None)
+        self.ctx.source_push(self.pres.no_meta_source)
         self.ctx.spec_push(spec_from_style(config.get_style()["slides"]))
 
         self.root_widget = root_urwid_widget(self.root_margins)
@@ -226,12 +266,13 @@ class MarkdownTui(urwid.Frame):
             screen=screen,
         )
         self.ctx.loop = self.loop
+        self.no_threads = no_threads
 
         # used to track slides that are being rendered
         self.slide_renderer = SlideRenderer(self.ctx)
-        self.slide_renderer.start()
+        if not no_threads:
+            self.slide_renderer.start()
 
-        self.pres = pres
         self.prep_pres(self.pres, start_idx)
 
         urwid.Frame.__init__(
@@ -328,7 +369,7 @@ class MarkdownTui(urwid.Frame):
 
         with self.ctx.use_tokens(title):
             with self.ctx.use_spec(spec):
-                markdown_block.render_all(self.ctx)
+                markdown_block.render_all(self.ctx, and_unwind=True)
         self.slide_title.set_text(self.ctx.inline_markup_consumed)
 
     def update_creation(self):
@@ -444,10 +485,10 @@ class MarkdownTui(urwid.Frame):
         self.loop.run()
 
 
-def create_tui(pres, start_slide=0):
+def create_tui(pres, start_slide=0, no_threads=False):
     """Run the provided presentation
 
     :param int start_slide: 0-based slide index
     """
-    tui = MarkdownTui(pres, start_slide)
+    tui = MarkdownTui(pres, start_slide, no_threads=no_threads)
     return tui
diff --git a/lookatme/tutorial.py b/lookatme/tutorial.py
index 4565ea6..1171776 100644
--- a/lookatme/tutorial.py
+++ b/lookatme/tutorial.py
@@ -101,15 +101,17 @@ class Tutor:
         if file_name is None:
             return "??"
 
-        relpath = file_name.split("lookatme/", 1)[1]
+        relpath = file_name.rsplit("lookatme/", 1)[1]
         _, lineno = inspect.getsourcelines(self.impl_fn)
 
         version = "v" + lookatme.VERSION
+        if version == "v{{VERSION}}":
+            version = "main"
 
         return "[{module}.{fn_name}]({link})".format(
             module=self.impl_fn.__module__,
             fn_name=self.impl_fn.__qualname__,
-            link="https://github.com/d0c-s4vage/lookatme/blob/{version}/{path}#L{lineno}".format(
+            link="https://github.com/d0c-s4vage/lookatme/blob/{version}/lookatme/{path}#L{lineno}".format(
                 version=version,
                 path=relpath,
                 lineno=lineno,
@@ -315,4 +317,8 @@ def get_tutorial_md(groups_or_tutors: List[str]) -> Union[None, str]:
     """
     ).strip()
 
-    return meta + "\n" + "\n\n---\n\n".join(res_slides) + "\n\n---\n\n# End"
+    res = meta + "\n" + "\n\n---\n\n".join(res_slides) + "\n\n---\n\n# End"
+    with open("/tmp/test.md", "w") as f:
+        f.write(res)
+
+    return res
diff --git a/lookatme/utils.py b/lookatme/utils.py
index 0224f7b..a802006 100644
--- a/lookatme/utils.py
+++ b/lookatme/utils.py
@@ -2,13 +2,101 @@
 """
 
 
-from typing import Dict, Union
+from typing import Dict, Optional
 
 import urwid
 
 from lookatme.widgets.smart_attr_spec import SmartAttrSpec
 
 
+def _do_get_packed_widget_list(w: urwid.Widget) -> int:
+    min_width = 250
+    _, orig_rows = w.pack((min_width,))
+    curr_rows = orig_rows
+    seen = set()
+    chunk_size = round(min_width / 2.0)
+
+    last_was_same = True
+    while min_width not in seen and min_width > 0:
+        _, curr_rows = w.pack((min_width,))
+        seen.add(min_width)
+        if curr_rows != orig_rows:
+            min_width += chunk_size
+            if last_was_same and chunk_size == 1:
+                break
+            last_was_same = False
+        else:
+            if not last_was_same and chunk_size == 1:
+                break
+            min_width -= chunk_size
+            last_was_same = True
+        chunk_size = max(round(chunk_size * 0.5), 1)
+
+    return min_width
+
+
+def packed_widget_width(w: urwid.Widget) -> int:
+    """Return the smallest size of the widget without wrapping"""
+    if isinstance(w, urwid.Pile):
+        if len(w.widget_list) == 0:
+            return 0
+        return max(packed_widget_width(x) for x in w.widget_list)
+    elif isinstance(w, urwid.Columns):
+        res = w.dividechars * (len(w.widget_list) - 1)
+        for col_widget, (size_type, size_val, _) in w.contents:
+            if size_type == "given":
+                res += size_val
+            else:
+                res += packed_widget_width(col_widget)
+        return res
+    elif isinstance(w, urwid.AttrMap):
+        return packed_widget_width(w.original_widget)
+    elif isinstance(w, urwid.Text):
+        return max(len(line) for line in w.text.split("\n"))
+
+    return _do_get_packed_widget_list(w)
+
+
+def debug_print_tokens(tokens, level=1):
+    """Print the tokens DFS"""
+    import lookatme.config
+
+    def indent(x):
+        return "  " * x
+
+    log = lookatme.config.get_log()
+    log.debug(indent(level) + "DEBUG TOKENS")
+    level += 1
+
+    stack = list(reversed(tokens))
+    while len(stack) > 0:
+        token = stack.pop()
+        if "close" in token["type"]:
+            level -= 1
+
+        log.debug(indent(level) + repr(token))
+
+        if "open" in token["type"]:
+            level += 1
+
+        token_children = token.get("children", None)
+        if isinstance(token_children, list):
+            stack.append({"type": "children_close"})
+            stack += list(reversed(token_children))
+            stack.append({"type": "children_open"})
+
+
+def check_token_type(token: Optional[Dict], expected_type: str):
+    if token is None:
+        return
+    if token["type"] != expected_type:
+        raise Exception(
+            "Unexpected token type {!r}, expected {!r}".format(
+                token["type"], expected_type
+            )
+        )
+
+
 def core_widget(w) -> urwid.Widget:
     """Resolve a wrapped widget to its core widget value"""
     if isinstance(w, urwid.AttrMap):
@@ -205,7 +293,7 @@ def _default_filter_fn(_x, _y):
     return True
 
 
-def spec_from_stack(spec_stack: list, filter_fn=None) -> Union[None, urwid.AttrSpec]:
+def spec_from_stack(spec_stack: list, filter_fn=None) -> Optional[urwid.AttrSpec]:
     if len(spec_stack) == 0:
         return SmartAttrSpec("", "")
 
diff --git a/lookatme/widgets/table.py b/lookatme/widgets/table.py
index 153f52a..0ea598a 100644
--- a/lookatme/widgets/table.py
+++ b/lookatme/widgets/table.py
@@ -285,7 +285,7 @@ class Table(urwid.Pile):
         row_spec_text, row_spec_general = row_specs
         res = []
         for idx, cell_pile in enumerate(row):
-            # add the pdading between columns if we're not the first column
+            # add the padding between columns if we're not the first column
             if idx > 0:
                 padding_text = " " * self.cell_spacing
                 padding_w = urwid.Text((row_spec_general, padding_text))
@@ -335,8 +335,7 @@ class Table(urwid.Pile):
         for row in self.header_rows + self.body_rows:
             # list of urwid.Piles
             for idx, cell in enumerate(row):
-                rend = cell.render((200,))
-                curr_col_width = self._calc_canvas_width(rend)
+                curr_col_width = utils.packed_widget_width(cell)
                 column_maxes[idx] = max(column_maxes[idx], curr_col_width)
 
         return column_maxes
@@ -377,10 +376,10 @@ class Table(urwid.Pile):
                 cell_container = urwid.Pile([])
                 cell_container.is_header = header
 
-                with self.ctx.use_container_tmp(cell_container):
-                    with self.ctx.use_spec(row_spec_general, text_only=False):
-                        with self.ctx.use_spec(row_spec_text, text_only=True):
-                            with self.ctx.use_tokens(cell["children"]):
+                with self.ctx.use_tokens(cell["children"]):
+                    with self.ctx.use_container_tmp(cell_container):
+                        with self.ctx.use_spec(row_spec_general, text_only=False):
+                            with self.ctx.use_spec(row_spec_text, text_only=True):
                                 markdown_block.render_all(self.ctx)
 
                 # set alignment for all text widgets
diff --git a/presentations/san_diego_python_meetup/2019-12-20.md b/presentations/san_diego_python_meetup/2019-12-20.md
deleted file mode 100644
index a9adbc6..0000000
--- a/presentations/san_diego_python_meetup/2019-12-20.md
+++ /dev/null
@@ -1,316 +0,0 @@
----
-title: 'Markdown Presentation Tool: lookatme'
-author: James Johnson - @d0c_s4vage
-date: 2019-12-20
-extensions:
-  - qrcode
----
-
-# Terminal-Based
-## Markdown
-### Presentations
-
-# Introduction: Me
-
-* I
-    * am a
-        * software developer
-        * security researcher
-        * team leader
-    * enjoy
-        * open-source software development
-        * teaching
-        * parfaits
-
-# Introduction: Me
-
-I can be found on twitter and github:
-
-```qrcode-ex
-columns:
-  - data: "https://twitter.com/d0c_s4vage"
-    caption: "Twitter: @d0c_s4vage"
-  - data: "https://github.com/d0c-s4vage"
-    caption: "GitHub: d0c-s4vage"
-```
-
-# lookatme: A Markdown Presentation Tool
-
-I wrote a cool tool: `lookatme` ([on GitHub](https://github.com/d0c-s4vage/lookatme))
-
-`lookatme` presents Markdown in a terminal
-
-## Installation
-
-lookatme can be installed via pip with:
-
-```bash
-pip install lookatme
-```
-
-# Why?
-
-* I am very familiar with Markdown
-* I enjoy being close to the code and in the terminal
-* Markdown is simple to read and write
-* It lets human-readable slides live alongside code
-    * Presentations as code!
-
-# Why: Part 2.1 - MDP
-
-Existing tools do similar things. E.g., [mdp](https://github.com/visit1985/mdp)
-
-```file
-path: examples/mdp.md
-lang: md
-```
-
-```terminal15
-mdp --invert examples/mdp.md
-```
-
-# Why: Part 2.2
-
-[patat](https://github.com/jaspervdj/patat) is another existing tool that
-presents markdown slides on the command-line:
-
-```file
-path: examples/patat.md
-lang: md
-```
-
-```terminal15
-patat examples/patat.md
-```
-
-# Why: Part 3
-
-I was asked on Twitter why I didn't use a browser-based tool:
-
-> Looks nice. Out of curiosity, any reason why you are not using remarkjs
-> except it runs in a terminal vs a browser?
-
-Although rendering Markdown can look much nicer using GUIs, I wanted to have
-it close to the code and to be able to seamlessly shift between
-
-* presenting concepts
-* displaying source code/CLI commands
-* interactively running commands
-
-# Why: Part 3 - Examples
-
-For example, suppose I was presenting on Flask and I wanted to show a minimal
-flask application running.
-
-It's easy enough to do this from other slide presentation tools, as long as
-you're fine with:
-
-* duplicating your source code in
-    * the slides themselves
-    * the actual code being run
-
-and
-
-* context switching between
-    * running the program in a separate terminal
-    * creating yet another terminal (or browser) to interact with the running program
-    * possibly pulling up an IDE to show/modify the actual source being run
-
-It gets complicated. I don't like complicated
-
-# Why: Part 3 - Flask Example
-
-Source
-
-```terminal7
-bash -c "TERM=xterm-256color vim --clean ./source/minimal_flask.py -c 'colors peachpuff | set number'"
-```
-
-Running
-
-```terminal6
-bash -c "FLASK_APP=./source/minimal_flask.py flask run --reload"
-```
-
-Python3 shell
-
-```terminal6
-python3
-```
-
-
-# Why: Part 4.1
-
-I also wanted to make it easy to create extensions that can have a first-chance
-opportunity to handle specific markdown rendering events.
-
-E.g. below is the code to render code blocks:
-
-```python
-@contrib_first
-def render_code(token, body, stack, loop):
-    """Renders a code block using the Pygments library.
-
-    See :any:`lookatme.tui.SlideRenderer.do_render` for additional argument and
-    return value descriptions.
-    """
-    lang = token.get("lang", "text") or "text"
-    res = pygments_render.render_text(token["text"], lang=lang)
-
-    return [
-        urwid.Divider(),
-        res,
-        urwid.Divider(),
-    ]
-```
-
-# Why: Part 4.2
-
-Below is the code used by file loader to provide an alternative rendering of
-the code blocks:
-
-```python
-def render_code(token, body, stack, loop):
-    """Render the code, ignoring all code blocks except ones with the language
-    set to ``file``.
-    """
-    lang = token["lang"] or ""
-    if lang != "file":
-        raise IgnoredByContrib
-
-    file_info_data = token["text"]
-    file_info = FileSchema().loads(file_info_data)
-
-    # ...
-
-    token["text"] = file_data
-    token["lang"] = file_info["lang"]
-    raise IgnoredByContrib
-```
-
-# Features: Markdown Support: Headings
-
-## Heading 2
-
-### Heading 3
-
-#### Heading 4
-
-# Features: Markdown Support: Lists
-
-* list1
-  * list 2
-    * item 2
-      ```python
-      print("Nested code blocks")
-      ```
-  * list 2
-  * list 2
-    > nested quote
-  * list 2
-
-
-# Features: Markdown Support: Code Blocks
-
-```python
-def this_is_a_function(arg1, arg2):
-    print(f"arg1: {arg1}, arg2: {arg2}")
-
-    return arg1 + arg2
-
-if __name__ == "__main__":
-    this_is_a_function(sys.argv[1], sys.argv[2])
-```
-
-
-# Features: Markdown Support: Quotes
-
-> This is a quote. This is a quote. This is a quote. This is a quote. This
-is a quote. This is a quote. This is a quote. This is a quote. This is a
-quote. This is a quote. This is a quote. This is a quote.
-
-# Features: Markdown Support: Inline
-
-All inline markdown features are supported, with footnotes being the only
-exception.
-
-|                         markdown | rendered                       |
-|---------------------------------:|--------------------------------|
-|                       `*italic*` | *italic*                       |
-|                       `_italic_` | _italic_                       |
-|                       `**bold**` | **bold**                       |
-|                       `__bold__` | __bold__                       |
-|           `***bold underline***` | ***bold underline***           |
-|           `___bold underline___` | ___bold underline___           |
-|              `~~strikethrough~~` | ~~strikethrough~~              |
-| `[CLICK ME](https://google.com)` | [CLICK ME](https://google.com) |
-|                     `` `code` `` | `code`                         |
-
-# Features: Live Reloading
-
-Hi everyone
-
-# Features: Builtin Extensions: Terminal
-
-~~~md
-```terminal8
-python3
-```
-~~~
-
-```terminal8
-python3
-```
-
-# Features: Builtin Extensions: File Loader
-
-~~~md
-```file
-path: 2019-12-20.md
-transform: grep -e "^# " | sort
-lines:
-  end: 10
-```
-~~~
-
-```file
-path: 2019-12-20.md
-lang: md
-transform: grep -e "^# " | sort
-lines:
-  end: 10
-```
-
-# Features: Contrib Extensions: QR Codes
-
-```file
-path: ./examples/lookatme_qrcode.md
-lang: md
-```
-
-```qrcode
-a
-```
-
-# Summary
-
-> Markdown is intended to be as easy-to-read and easy-to-write as is feasible.
->
-> Readability, however, is emphasized above all else. A Markdown-formatted
-> document should be publishable as-is, as plain text, without looking like
-> it’s been marked up with tags or formatting instructions.
-
-[Mark Gruber, Markdown Co-Creator](https://daringfireball.net/projects/markdown/syntax#philosophy)
-
-In keeping with the original Markdown philosophy, here are these slides
-rendered in Github: [slides](https://github.com/d0c-s4vage/lookatme/tree/master/presentations/san_diego_python_meetup/2019-12-20.md)
-
-Not everything carries over, but it's still pretty readable.
-
-# FIN
-
-```qrcode-ex
-columns:
-  - data: https://www.youtube.com/watch?v=oHg5SJYRHA0
-    caption: Questions?
-```
diff --git a/presentations/san_diego_python_meetup/examples/lookatme_qrcode.md b/presentations/san_diego_python_meetup/examples/lookatme_qrcode.md
deleted file mode 100644
index 1677ab3..0000000
--- a/presentations/san_diego_python_meetup/examples/lookatme_qrcode.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-extensions:
-  - qrcode
----
-
-```qrcode
-a
-```
diff --git a/presentations/san_diego_python_meetup/examples/mdp.md b/presentations/san_diego_python_meetup/examples/mdp.md
deleted file mode 100644
index c0c5c28..0000000
--- a/presentations/san_diego_python_meetup/examples/mdp.md
+++ /dev/null
@@ -1,8 +0,0 @@
-%title: MDP Example
-%author: James Johnson
-
--> # Test Title <-
-
-```python
-print("hello")
-```
diff --git a/presentations/san_diego_python_meetup/examples/patat.md b/presentations/san_diego_python_meetup/examples/patat.md
deleted file mode 100644
index e6157ae..0000000
--- a/presentations/san_diego_python_meetup/examples/patat.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-title: Patat Example
-author: James Johnson
----
-
-# Test Title
-
-```python
-print("hello")
-```
diff --git a/presentations/san_diego_python_meetup/source/minimal_flask.py b/presentations/san_diego_python_meetup/source/minimal_flask.py
deleted file mode 100644
index d34381a..0000000
--- a/presentations/san_diego_python_meetup/source/minimal_flask.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import flask
-app = flask.Flask(__name__)
-
-@app.route("/", methods=["GET"])
-def index():
-    return "Hello, World"
-


Test patch:
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index a52c636..1c7b422 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -28,22 +28,12 @@ def test_heading_defaults(style):
     utils.validate_render(
         md_text=r"""
             # H1
-            ## H2
-            ### H3
         """,
         text=[
             "|H1|",
-            "    ",
-            "|H2|",
-            "    ",
-            "|H3|",
         ],
         style_mask=[
             "HHHH",
-            "////",
-            "hhhh",
-            "////",
-            "aaaa",
         ],
         styles={
             "H": style["headings"]["default"],
@@ -197,6 +187,114 @@ def test_table_with_no_body(style):
     )
 
 
+@override_style(
+    {
+        "table": {
+            "column_spacing": 1,
+            "header_divider": {"text": "-"},
+            "header": {"bg": "#121212"},
+        }
+    }
+)
+def test_table_surrounded_by_div(style):
+    """Test table surrounded by div"""
+    utils.validate_render(
+        render_width=7,
+        md_text=r"""
+            Before
+
+            <div style="background-color: #ff0000">
+            | H2 | H1 |
+            |----|----|
+            | A  | B  |
+            | C  | D  |
+            </div>
+
+            After
+        """,
+        text=[
+            "Before ",
+            "       ",
+            "┌─────┐",
+            "│H2 H1│",
+            "│-----│",
+            "│A  B │",
+            "│C  D │",
+            "└─────┘",
+            "       ",
+            "After  ",
+        ],
+        style_mask=[
+            "       ",
+            "       ",
+            ".......",
+            ".HHHHH.",
+            ".DDDDD.",
+            ".EEEEE.",
+            ".OOOOO.",
+            ".......",
+            "       ",
+            "       ",
+        ],
+        styles={
+            "H": style["table"]["header"],
+            "D": {
+                "bg": style["table"]["header"]["bg"],
+                "fg": style["table"]["header_divider"]["fg"],
+            },
+            "E": {"bg": "#ff0000", "fg": style["table"]["even_rows"]["fg"]},
+            "O": style["table"]["odd_rows"],
+            ".": {"bg": "#ff0000", "fg": style["table"]["border"]["tl_corner"]["fg"]},
+            " ": {},
+        },
+    )
+
+
+@override_style(
+    {
+        "table": {
+            "column_spacing": 1,
+            "header_divider": {"text": "-"},
+            "header": {"bg": "#121212"},
+        },
+    }
+)
+def test_table_with_embedded_list(style):
+    """Test table with a an html list in a cell"""
+    utils.validate_render(
+        render_width=6,
+        md_text=r"""
+            | H2                   |
+            |----------------------|
+            | <ol><li>a</li></ol>  |
+        """,
+        text=[
+            "┌────┐",
+            "│H2  │",
+            "│----│",
+            "│1. a│",
+            "└────┘",
+        ],
+        style_mask=[
+            "......",
+            ".HHHH.",
+            ".DDDD.",
+            ".LLEE.",
+            "......",
+        ],
+        styles={
+            "H": style["table"]["header"],
+            "D": {
+                "bg": style["table"]["header"]["bg"],
+                "fg": style["table"]["header_divider"]["fg"],
+            },
+            "E": style["table"]["even_rows"],
+            "L": style["numbering"]["1"],
+            ".": style["table"]["border"]["tl_corner"],
+        },
+    )
+
+
 @override_style(
     {
         "bullets": {
@@ -431,22 +529,22 @@ def test_block_quote(style):
             > Quote text
         """,
         text=[
-            "██━━──               ",
-            "┃  Quote text        ",
-            "┃                    ",
-            "┃  Quote italic text ",
-            "┃                    ",
-            "┃  Quote text        ",
-            "██━━──               ",
+            "██━━──                ",
+            "┃  Quote text         ",
+            "┃                     ",
+            "┃  Quote italic text  ",
+            "┃                     ",
+            "┃  Quote text         ",
+            "██━━──                ",
         ],
         style_mask=[
-            "TTTTTT_______________",
-            "Liiiiiiiiiiiiiiiiiii_",
-            "Liiiiiiiiiiiiiiiiiii_",
-            "Liiiiiiiiiiiiiiiiiii_",
-            "Liiiiiiiiiiiiiiiiiii_",
-            "Liiiiiiiiiiiiiiiiiii_",
-            "BBBBBB_______________",
+            "TTTTTT________________",
+            "Liiiiiiiiiiiiiiiiiiii_",
+            "Liiiiiiiiiiiiiiiiiiii_",
+            "Liiiiiiiiiiiiiiiiiiii_",
+            "Liiiiiiiiiiiiiiiiiiii_",
+            "Liiiiiiiiiiiiiiiiiiii_",
+            "BBBBBB________________",
         ],
         styles={
             "T": style["quote"]["border"]["tl_corner"],
@@ -502,6 +600,7 @@ def test_code(styles):
 def test_empty_codeblock(style):
     """Test that empty code blocks render correctly"""
     utils.validate_render(
+        render_width=1,
         md_text="""
             ```python
 
diff --git a/tests/test_parse.py b/tests/test_parse.py
index 17787b1..74ed466 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -85,7 +85,7 @@ code block
 More text
     """
     parser = Parser()
-    _, slides = parser.parse_slides({}, input_data)
+    slides = parser.parse_slides({}, input_data)
     assert len(slides) == 2
 
 
@@ -109,7 +109,7 @@ p2
 More text
     """
     parser = Parser()
-    _, slides = parser.parse_slides({}, input_data)
+    slides = parser.parse_slides({}, input_data)
     assert len(slides) == 4
 
 
@@ -134,7 +134,7 @@ more text
     """
     parser = Parser()
     meta = {"title": ""}
-    _, slides = parser.parse_slides(meta, input_data)
+    slides = parser.parse_slides(meta, input_data)
     assert len(slides) == 4
     # the title should be a list of tokens!
     assert isinstance(meta["title"], list)
@@ -169,6 +169,6 @@ more text
     """
     parser = Parser()
     meta = {"title": ""}
-    _, slides = parser.parse_slides(meta, input_data)
+    slides = parser.parse_slides(meta, input_data)
     assert len(slides) == 3
     assert meta["title"] == ""
diff --git a/tests/test_table.py b/tests/test_table.py
index cedd077..40028ab 100644
--- a/tests/test_table.py
+++ b/tests/test_table.py
@@ -51,8 +51,9 @@ def _render_table(
     root = urwid.Pile([])
     table = Table(ctx, thead, tbody)
     padding = urwid.Padding(root, width=table.total_width)
-    with ctx.use_container(root, is_new_block=True):
-        ctx.widget_add(table)
+    with ctx.use_tokens([{}]):
+        with ctx.use_container(root, is_new_block=True):
+            ctx.widget_add(table)
 
     content = list(padding.render((table.total_width,), False).content())
     return table, content
diff --git a/tests/test_tutorial.py b/tests/test_tutorial.py
index 6cac1cf..6f401d0 100644
--- a/tests/test_tutorial.py
+++ b/tests/test_tutorial.py
@@ -10,7 +10,8 @@ import lookatme.tutorial as tutorial
 
 def test_real_tutorials_exist():
     assert "general" in tutorial.GROUPED_TUTORIALS
-    assert "markdown" in tutorial.GROUPED_TUTORIALS
+    assert "markdown block" in tutorial.GROUPED_TUTORIALS
+    assert "markdown inline" in tutorial.GROUPED_TUTORIALS
 
 
 def test_tutorial_basic(mocker):
diff --git a/tests/utils.py b/tests/utils.py
index 36e1acd..e34f65e 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -12,6 +12,7 @@ from lookatme.render.context import Context
 import lookatme.config
 import lookatme.schemas
 import lookatme.parser
+import lookatme.utils as utils
 import lookatme.render.markdown_block as markdown_block
 from lookatme.pres import Presentation
 import lookatme.tui
@@ -47,12 +48,17 @@ def _markups_to_vtext(
         text = text.decode()
 
         if spec is None:
-            fg = ""
-            bg = ""
+            fg = "default"
+            bg = "default"
         else:
             fg = spec.foreground
             bg = spec.background
 
+        if fg == "":
+            fg = "default"
+        if bg == "":
+            bg = "default"
+
         if tag_stack[-1][0] == (fg, bg):
             tag_stack[-1][1].append(text)
             continue
@@ -105,13 +111,7 @@ def render_widget(
     w: urwid.Widget, width: Optional[int] = None
 ) -> List[List[Tuple[Optional[urwid.AttrSpec], Any, bytes]]]:
     if width is None:
-        min_width = 300
-        _, orig_rows = w.pack((min_width,))
-        curr_rows = orig_rows
-        while min_width > 0 and curr_rows == orig_rows:
-            min_width -= 1
-            _, curr_rows = w.pack((min_width,))
-        width = min_width + 1
+        width = utils.packed_widget_width(w)
 
     return list(w.render((width,), False).content())
 
@@ -123,9 +123,13 @@ def render_md(
 
     root = urwid.Pile([])
     ctx = Context(None)
+    ctx.clean_state_snapshot()
+
     with ctx.use_tokens(tokens):
         with ctx.use_container(root, is_new_block=True):
-            markdown_block.render_all(ctx)
+            markdown_block.render_all(ctx, and_unwind=True)
+
+    ctx.clean_state_validate()
 
     return render_widget(root, width)