Repository: d0c-s4vage/lookatme
Pull request #189 (2022-12-11 16:03:39)

Codeblocks now have line numbers, highlighting, bg override
Adds the ability to enable line numbers in code blocks and highlight lines

Specifically:
* Curly brace attributes on code blocks are now supported
* the theme can now be set in the YAML metadata:
  ```yaml
  ---
  theme: light
  ---
  ```
* a new `styles.code` YAML metadata value can be used:
  ```markdown
  ---
  styles:
    code:
      style: zenburn
      inline_lang: python
      bg_override: "#aabbcc"
  ---
  ```
* the `--style` CLI argument is removed (should be replaced by the feature described in #190)

See #164
See #89 

d0c-s4vage: Note: this PR includes breaking changes to how syntax highlighting styles are defined:

* the `--style` CLI argument is removed (should be replaced by the feature described in #190)
* the theme can now be set in the YAML metadata
* a new `styles.code` YAML metadata value can be used:

```yaml
styles:
  code:
    style: zenburn
    inline_lang: python
    bg_override: "#aabbcc"
```

Patch:
diff --git a/lookatme/__main__.py b/lookatme/__main__.py
index 0adeb13..a502195 100644
--- a/lookatme/__main__.py
+++ b/lookatme/__main__.py
@@ -50,13 +50,7 @@ from lookatme.schemas import StyleSchema
     "--theme",
     "theme",
     type=click.Choice(["dark", "light"]),
-    default="dark",
-)
-@click.option(
-    "--style",
-    "code_style",
     default=None,
-    type=click.Choice(list(pygments.styles.get_all_styles())),
 )
 @click.option(
     "--dump-styles",
@@ -122,7 +116,6 @@ def main(
     threads,
     log_path,
     theme,
-    code_style,
     dump_styles,
     input_files,
     live_reload,
@@ -151,11 +144,12 @@ def main(
         else:
             tutors = [x.strip() for x in tutorial.split(",")]
 
+        if theme is None:
+            theme = "dark"
         theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme])
         lookatme.config.set_global_style_with_precedence(
             theme_mod,
             {},
-            code_style,
         )
         tutorial_md = lookatme.tutorial.get_tutorial_md(tutors)
         if tutorial_md is None:
@@ -169,7 +163,6 @@ def main(
     pres = Presentation(
         input_files[0],
         theme,
-        code_style,
         live_reload=live_reload,
         single_slide=single_slide,
         preload_extensions=preload_exts,
diff --git a/lookatme/config.py b/lookatme/config.py
index b19dba1..dcb0355 100644
--- a/lookatme/config.py
+++ b/lookatme/config.py
@@ -28,7 +28,8 @@ def get_style() -> Dict:
 
 
 def get_style_with_precedence(
-    theme_mod: ModuleType, direct_overrides: Dict, style_override: str
+    theme_mod: ModuleType,
+    direct_overrides: Dict,
 ) -> Dict[str, Any]:
     """Return the resulting style dict from the provided override values."""
     # style override order:
@@ -37,20 +38,20 @@ def get_style_with_precedence(
     # 2. inline styles from the presentation
     dict_deep_update(styles, direct_overrides)
     # 3. CLI style overrides
-    if style_override is not None:
-        styles["style"] = style_override  # type: ignore
+    # TODO
 
     return styles
 
 
 def set_global_style_with_precedence(
-    theme_mod, direct_overrides, style_override
+    theme_mod,
+    direct_overrides,
 ) -> Dict[str, Any]:
     """Set the lookatme.config.STYLE value based on the provided override
     values
     """
     global STYLE
-    STYLE = get_style_with_precedence(theme_mod, direct_overrides, style_override)
+    STYLE = get_style_with_precedence(theme_mod, direct_overrides)
 
     return STYLE
 
diff --git a/lookatme/pres.py b/lookatme/pres.py
index 281d0c8..3d53997 100644
--- a/lookatme/pres.py
+++ b/lookatme/pres.py
@@ -42,7 +42,6 @@ class Presentation(object):
         self,
         input_stream,
         theme,
-        style_override=None,
         live_reload=False,
         single_slide=False,
         preload_extensions=None,
@@ -62,7 +61,6 @@ class Presentation(object):
             lookatme.config.SLIDE_SOURCE_DIR = os.path.dirname(input_stream.name)
             self.input_filename = input_stream.name
 
-        self.style_override = style_override
         self.live_reload = live_reload
         self.tui = None
         self.single_slide = single_slide
@@ -71,8 +69,7 @@ class Presentation(object):
         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])
+        self.cli_theme = theme
 
         if self.live_reload:
             self.reload_thread = threading.Thread(target=self.reload_watcher)
@@ -133,10 +130,12 @@ class Presentation(object):
                 self.ignore_ext_failure,
             )
 
+        theme = self.meta.get("theme", self.cli_theme or "dark")
+        self.theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme])
+
         self.styles = lookatme.config.set_global_style_with_precedence(
             self.theme_mod,
             self.meta.get("styles", {}),
-            self.style_override,
         )
 
         self.initial_load_complete = True
diff --git a/lookatme/render/markdown_block.py b/lookatme/render/markdown_block.py
index 8aca9c2..67e4e8e 100644
--- a/lookatme/render/markdown_block.py
+++ b/lookatme/render/markdown_block.py
@@ -7,19 +7,20 @@ representations
 import copy
 import pygments
 import pygments.styles
+import pygments.lexers
 import re
 import sys
-from typing import Dict, List, Tuple, Optional, Union
+from typing import Any, Dict, List, Tuple, Optional, Union
 
 import urwid
 
 import lookatme.config as config
 import lookatme.render.markdown_inline as markdown_inline
-import lookatme.render.pygments as pygments_render
 from lookatme.contrib import contrib_first
 from lookatme.render.context import Context
 from lookatme.tutorial import tutor
 from lookatme.widgets.fancy_box import FancyBox
+import lookatme.widgets.codeblock as codeblock
 import lookatme.utils as utils
 
 THIS_MOD = sys.modules[__name__]
@@ -412,6 +413,122 @@ def render_blockquote_close(token: Dict, ctx: Context):
     ctx.container_pop()
 
 
+def _parse_hl_lines(values) -> List:
+    """Parse comma-separated lists of line ranges to highlight"""
+    res = []
+    matches = re.finditer(
+        r"""
+            ,?\s*
+            (
+                (?P<rangeStart>[0-9]+)
+                (\.\.|-)
+                (?P<rangeEnd>[0-9]+)
+                |
+                (?P<singleLine>[0-9]+)
+            )
+        \s*""",
+        values,
+        re.VERBOSE,
+    )
+
+    for match in matches:
+        info = match.groupdict()
+        if info["singleLine"]:
+            val = int(info["singleLine"])
+            res.append(range(val, val + 1))
+        elif info["rangeStart"]:
+            res.append(
+                range(
+                    int(info["rangeStart"]),
+                    int(info["rangeEnd"]) + 1,
+                )
+            )
+
+    return res
+
+
+def _parse_curly_extra(data: str) -> Dict[str, Any]:
+    res = {}
+
+    matches = re.finditer(
+        r"""\s*
+        (
+            (?P<attr>[a-zA-Z-_]+)
+                \s*=\s*
+                (
+                    "(?P<doubleQuoteVal>[^"]*)"
+                    |
+                    '(?P<singleQuoteVal>[^']*)'
+                    |
+                    (?P<plainVal>[a-zA-Z0-9-_,]+)
+                )
+            |
+            (?P<class>\.)?
+            (?P<id>\.)?
+            (?P<classOrIdName>[a-zA-Z0-9-_]+)
+        )
+        \s*
+        """,
+        data,
+        re.VERBOSE,
+    )
+
+    for match in matches:
+        info = match.groupdict()
+
+        if info["classOrIdName"]:
+            val = info["classOrIdName"].lower()
+            if val in codeblock.supported_langs():
+                res["lang"] = info["classOrIdName"]
+            elif val in (
+                "numberlines",
+                "number_lines",
+                "numbers",
+                "line_numbers",
+                "linenumbers",
+                "line_numbers",
+            ):
+                res["line_numbers"] = True
+        elif info["attr"]:
+            attr = info["attr"].lower()
+            val = info["plainVal"] or info["doubleQuoteVal"] or info["singleQuoteVal"]
+            if attr in ("startfrom", "start_from", "line_numberstart", "startlineno"):
+                res["start_line_number"] = int(val)
+            elif attr in ("hl_lines", "hllines", "highlight", "highlight_lines"):
+                res["hl_lines"] = _parse_hl_lines(val)
+
+    return res
+
+
+@tutor(
+    "markdown block",
+    "code blocks - extra attributes",
+    r"""
+    Code blocks can also have additional attributes defined by using curly braces.
+    Values within the curly brace are either css class names or ids (start with a `.`
+    or `#`), or have the form `key=value`.
+
+    The attributes below have specific meanings - all other attributes will be
+    ignored:
+
+    * `.language` - use `language` as the syntax highlighting language
+    * `.numberLines` - add line numbers
+    * `startFrom=X` - start the line numbers from the line `X`
+    * `hllines=ranges` - highlight the line ranges. This should be a comma separated
+      list of either single line numbers, or a line range (e.g. `4-5`).
+
+    <TUTOR:EXAMPLE>
+    ```{.python .numberLines hllines=4-5,7 startFrom="3"}
+    def hello_world():
+        print("Hello, world!\n")
+        print("Hello, world!\n")
+        print("Hello, world!\n")
+        print("Hello, world!\n")
+        print("Hello, world!\n")
+    ```
+    </TUTOR:EXAMPLE>
+    """,
+)
 @tutor(
     "markdown block",
     "code blocks",
@@ -419,7 +536,8 @@ def render_blockquote_close(token: Dict, ctx: Context):
     Multi-line code blocks are either surrounded by "fences" (three in a row of
     either `\`` or `~`), or are lines of text indented at least four spaces.
 
-    Fenced codeblocks let you specify the language of the code:
+    Fenced codeblocks let you specify the language of the code. (See the next
+    slide about additional attributes)
 
     <TUTOR:EXAMPLE>
     ```python
@@ -431,16 +549,15 @@ def render_blockquote_close(token: Dict, ctx: Context):
     ## Style
 
     The syntax highlighting style used to highlight the code block can be
-    specified in the markdown metadata:
+    specified in the markdown metadata, as well as an override for the
+    background color, and the language to use for inline code.
 
-    <TUTOR:STYLE>style</TUTOR:STYLE>
+    <TUTOR:STYLE {{hllines=4,6}}>code</TUTOR:STYLE>
 
     Valid values for the `style` field come directly from pygments. In the
     version of pygments being used as you read this, the list of valid values is:
 
     {pygments_values}
-
-    > **NOTE** This style name is confusing and will be renamed in lookatme v3.0+
     """.format(
         pygments_values=" ".join(pygments.styles.get_all_styles()),
     ),
@@ -455,12 +572,60 @@ def render_fence(token: Dict, ctx: Context):
     ctx.ensure_new_block()
 
     info = token.get("info", None) or "text"
-    lang = info.split()[0]
-    # TODO support line highlighting, etc?
-    text = token["content"]
-    res = pygments_render.render_text(text, lang=lang)
 
-    ctx.widget_add(res)
+    match = re.match(r"^(?P<lang>[^{\s]+)?\s*(\{(?P<curly_extra>[^{]+)\})?", info)
+    lang = "text"
+    line_numbers = False
+    start_line_number = 1
+    hl_lines = []
+
+    if match is not None:
+        full_info = match.groupdict()
+        if full_info["lang"] is not None:
+            lang = full_info["lang"]
+        if full_info["curly_extra"] is not None:
+            curly_extra = _parse_curly_extra(full_info["curly_extra"])
+            lang = curly_extra.get("lang", lang)
+            line_numbers = curly_extra.get("line_numbers", line_numbers)
+            start_line_number = curly_extra.get("start_line_number", start_line_number)
+            hl_lines = curly_extra.get("hl_lines", [])
+
+    curr_spec = ctx.spec_text
+    default_fg = "default"
+    bg_override = config.get_style()["code"]["bg_override"]
+    if curr_spec:
+        default_fg = (
+            default_fg
+            or utils.overwrite_style({"fg": curr_spec.foreground}, {"fg": default_fg})[
+                "fg"
+            ]
+        )
+
+    code = codeblock.CodeBlock(
+        source=token["content"],
+        lang=lang,
+        style_name=config.get_style()["code"]["style"],
+        line_numbers=line_numbers,
+        start_line_number=start_line_number,
+        hl_lines=hl_lines,
+        default_fg=default_fg,
+        bg_override=bg_override,
+    )
+
+    ctx.widget_add(code)
+
+
+#
+#    text = token["content"]
+#    res = pygments_render.render_text(
+#        text,
+#        lang=lang,
+#        line_numbers=line_numbers,
+#        start_line_number=start_line_number,
+#        hl_lines=hl_lines,
+#    )
+#
+#    ctx.widget_add(res)
 
 
 class TableTokenExtractor:
diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py
index fd9e2ad..fe71c06 100644
--- a/lookatme/render/markdown_inline.py
+++ b/lookatme/render/markdown_inline.py
@@ -11,13 +11,14 @@ from typing import Dict, Optional
 import urwid
 
 import lookatme.config as config
-import lookatme.render.pygments as pygments_render
 import lookatme.utils as utils
 from lookatme.contrib import contrib_first
 from lookatme.render.context import Context
 from lookatme.render.markdown_html import Tag
 from lookatme.tutorial import tutor
 from lookatme.widgets.clickable_text import LinkIndicatorSpec
+import lookatme.widgets.codeblock as codeblock
+
 
 THIS_MOD = sys.modules[__name__]
 
@@ -260,15 +261,47 @@ def render_softbreak(_, ctx: Context):
     The `OddOne` class accepts `Table` instances, converts them to raw pointers,
     forces garbage collection to run.
     </TUTOR:EXAMPLE>
+
+    ## Style
+
+    The default language used for syntax highlighting of inline code blocks
+    can be controlled through the markdown metadata. See the
+    `styles.code.inline_lang` option:
+
+    <TUTOR:STYLE {hllines=5}>code</TUTOR:STYLE>
     """,
 )
 @contrib_first
 def render_code_inline(token, ctx: Context):
-    # TODO: add a style for the default programming language for inline text
-    # blocks
-    spec, text = pygments_render.render_text(token["content"], plain=True)[0]
-    with ctx.use_spec(spec):
-        ctx.inline_push((ctx.spec_text, text))
+    inline_lang = config.get_style()["code"]["inline_lang"]
+    code_style = config.get_style()["code"]["style"]
+
+    default_fg = "default"
+    bg_override = config.get_style()["code"]["bg_override"]
+    curr_spec = ctx.spec_text
+    if curr_spec:
+        default_fg = (
+            default_fg
+            or utils.overwrite_style({"fg": curr_spec.foreground}, {"fg": default_fg})[
+                "fg"
+            ]
+        )
+
+    lexer = codeblock.get_lexer(inline_lang)
+    style = codeblock.get_style_cache(
+        default_fg=default_fg, bg_override=bg_override
+    ).get_style(code_style)
+
+    tokens = list(lexer.get_tokens(token["content"]))
+    # split the tokens into distinct lines, and only keep the first line
+    # of tokens
+    tokens = codeblock.tokens_to_lines(tokens)[0]
+
+    markups = codeblock.tokens_to_markup(tokens, style)  # type: ignore
+
+    for spec, text in markups:
+        spec = ctx.spec_text_with(spec)
+        ctx.inline_push((spec, text))
 
 
 @tutor(
diff --git a/lookatme/render/pygments.py b/lookatme/render/pygments.py
deleted file mode 100644
index bace04a..0000000
--- a/lookatme/render/pygments.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""Pygments related rendering
-"""
-
-
-import time
-
-import pygments
-import pygments.lexers
-import pygments.styles
-import pygments.util
-import urwid
-from pygments.formatter import Formatter
-
-import lookatme.config as config
-
-LEXER_CACHE = {}
-STYLE_CACHE = {}
-FORMATTER_CACHE = {}
-
-
-def get_formatter(style_name):
-    style = get_style(style_name)
-
-    formatter, style_bg = FORMATTER_CACHE.get(style_name, (None, None))
-    if formatter is None:
-        style_bg = UrwidFormatter.findclosest(style.background_color.replace("#", ""))
-        formatter = UrwidFormatter(
-            style=style,
-            usebg=(style_bg is not None),
-        )
-        FORMATTER_CACHE[style_name] = (formatter, style_bg)
-    return formatter, style_bg
-
-
-def get_lexer(lang, default="text"):
-    lexer = LEXER_CACHE.get(lang, None)
-    if lexer is None:
-        try:
-            lexer = pygments.lexers.get_lexer_by_name(lang)
-        except pygments.util.ClassNotFound:
-            lexer = pygments.lexers.get_lexer_by_name(default)
-        LEXER_CACHE[lang] = lexer
-    return lexer
-
-
-def get_style(style_name):
-    style = STYLE_CACHE.get(style_name, None)
-    if style is None:
-        style = pygments.styles.get_style_by_name(style_name)
-        STYLE_CACHE[style_name] = style
-    return style
-
-
-def render_text(text, lang="text", style_name=None, plain=False):
-    """Render the provided text with the pygments renderer"""
-    if style_name is None:
-        style_name = config.get_style()["style"]
-
-    lexer = get_lexer(lang)
-    formatter, style_bg = get_formatter(style_name)
-
-    start = time.time()
-    code_tokens = lexer.get_tokens(text)
-    config.get_log().debug(f"Took {time.time()-start}s to render {len(text)} bytes")
-
-    markup = []
-    for x in formatter.formatgenerator(code_tokens):
-        if style_bg:
-            x[0].background = style_bg
-        markup.append(x)
-
-    if markup[-1][1] == "\n":
-        markup = markup[:-1]
-
-    if len(markup) == 0:
-        markup = [(None, "")]
-    elif markup[-1][1].endswith("\n"):
-        markup[-1] = (markup[-1][0], markup[-1][1][:-1])
-
-    if plain:
-        return markup
-    else:
-        return urwid.AttrMap(urwid.Text(markup), urwid.AttrSpec("default", style_bg))
-
-
-class UrwidFormatter(Formatter):
-    """Formatter that returns [(text,attrspec), ...],
-    where text is a piece of text, and attrspec is an urwid.AttrSpec"""
-
-    def __init__(self, **options):
-        """Extra arguments:
-
-        usebold: if false, bold will be ignored and always off
-                default: True
-        usebg: if false, background color will always be 'default'
-                default: True
-        colors: number of colors to use (16, 88, or 256)
-                default: 256"""
-        self.usebold = options.get("usebold", True)
-        self.usebg = options.get("usebg", True)
-        self.style_attrs = {}
-        Formatter.__init__(self, **options)
-
-    @property
-    def style(self):
-        return self._style
-
-    @style.setter
-    def style(self, newstyle):
-        self._style = newstyle
-        self._setup_styles()
-
-    @staticmethod
-    def _distance(col1, col2):
-        r1, g1, b1 = col1
-        r2, g2, b2 = col2
-
-        rd = r1 - r2
-        gd = g1 - g2
-        bd = b1 - b2
-
-        return rd * rd + gd * gd + bd * bd
-
-    @classmethod
-    def findclosest(cls, colstr, colors=256):
-        """Takes a hex string and finds the nearest color to it.
-
-        Returns a string urwid will recognize."""
-
-        rgb = int(colstr, 16)
-        r = (rgb >> 16) & 0xFF
-        g = (rgb >> 8) & 0xFF
-        b = rgb & 0xFF
-
-        dist = 257 * 257 * 3
-        bestcol = urwid.AttrSpec("h0", "default")
-
-        for i in range(colors):
-            curcol = urwid.AttrSpec("h%d" % i, "default", colors=colors)
-            currgb = curcol.get_rgb_values()[:3]
-            curdist = cls._distance((r, g, b), currgb)
-            if curdist < dist:
-                dist = curdist
-                bestcol = curcol
-
-        return bestcol.foreground
-
-    def findclosestattr(
-        self, fgcolstr=None, bgcolstr=None, othersettings="", colors=256
-    ):
-        """Takes two hex colstring (e.g. 'ff00dd') and returns the
-        nearest urwid style."""
-        fg = bg = "default"
-        if fgcolstr:
-            fg = self.findclosest(fgcolstr, colors)
-        if bgcolstr:
-            bg = self.findclosest(bgcolstr, colors)
-        if othersettings:
-            fg = fg + "," + othersettings
-        return urwid.AttrSpec(fg, bg, colors)
-
-    def _setup_styles(self, colors=256):
-        """Fills self.style_attrs with urwid.AttrSpec attributes
-        corresponding to the closest equivalents to the given style."""
-        for ttype, ndef in self.style:
-            fgcolstr = bgcolstr = None
-            othersettings = ""
-            if ndef["color"]:
-                fgcolstr = ndef["color"]
-            if self.usebg and ndef["bgcolor"]:
-                bgcolstr = ndef["bgcolor"]
-            if self.usebold and ndef["bold"]:
-                othersettings = "bold"
-            self.style_attrs[str(ttype)] = self.findclosestattr(
-                fgcolstr, bgcolstr, othersettings, colors
-            )
-
-    def formatgenerator(self, tokensource):
-        """Takes a token source, and generates
-        (tokenstring, urwid.AttrSpec) pairs"""
-        for (ttype, tstring) in tokensource:
-            parts = str(ttype).split(".")
-            while str(ttype) not in self.style_attrs:
-                parts = parts[:-1]
-                ttype = ".".join(parts)
-
-            attr = self.style_attrs[str(ttype)]
-            yield attr, tstring
-
-    def format(self, tokensource, outfile):
-        for (attr, tstring) in self.formatgenerator(tokensource):
-            outfile.write(attr, tstring)
diff --git a/lookatme/schemas.py b/lookatme/schemas.py
index 1e8c480..d090239 100644
--- a/lookatme/schemas.py
+++ b/lookatme/schemas.py
@@ -7,6 +7,7 @@ import datetime
 from typing import cast, Dict
 
 import pygments.styles
+import pygments.lexers
 import yaml
 from marshmallow import INCLUDE, RAISE, Schema, fields, validate
 
@@ -398,6 +399,15 @@ class HeadingsSchema(Schema):
         }
 
 
+class CodeSchema(Schema):
+    style = fields.Str(
+        dump_default="monokai",
+        validate=validate.OneOf(list(pygments.styles.get_all_styles())),
+    )
+    bg_override = fields.Str(dump_default="")
+    inline_lang = fields.Str(dump_default="text")
+
+
 table_border_default = cast(Dict, BorderBoxSchema().dump(None))
 for k, v in table_border_default.items():
     table_border_default[k]["fg"] = "bold,#c0c0c0"
@@ -468,11 +478,6 @@ class StyleSchema(Schema):
         render_module = YamlRender
         unknown = RAISE
 
-    style = fields.Str(
-        dump_default="monokai",
-        validate=validate.OneOf(list(pygments.styles.get_all_styles())),
-    )
-
     title = fields.Nested(
         StyleFieldSchema,
         dump_default={
@@ -497,8 +502,8 @@ class StyleSchema(Schema):
     slides = fields.Nested(
         StyleFieldSchema,
         dump_default={
-            "fg": "default",
-            "bg": "default",
+            "fg": "#ffffff",
+            "bg": "#000000",
         },
     )
     slide_number = fields.Nested(
@@ -526,12 +531,12 @@ class StyleSchema(Schema):
             "right": 10,
         },
     )
-
     headings = fields.Nested(HeadingsSchema, dump_default=HeadingsSchema().dump(None))
     bullets = fields.Nested(BulletsSchema, dump_default=BulletsSchema().dump(None))
     numbering = fields.Nested(
         NumberingSchema, dump_default=NumberingSchema().dump(None)
     )
+    code = fields.Nested(CodeSchema, dump_default=CodeSchema().dump(None))
     table = fields.Nested(TableSchema, dump_default=TableSchema().dump(None))
     quote = fields.Nested(BlockQuoteSchema, dump_default=BlockQuoteSchema().dump(None))
     hrule = fields.Nested(HruleSchema, dump_default=HruleSchema().dump(None))
@@ -578,6 +583,7 @@ class MetaSchema(Schema):
         load_default=datetime.datetime.now().strftime("%Y-%m-%d"),
     )
     author = fields.Str(dump_default="", load_default="")
+    theme = fields.Str(dump_default="dark", load_default="dark")
     styles = fields.Nested(
         StyleSchema,
         dump_default=StyleSchema().dump(None),
diff --git a/lookatme/themes/light.py b/lookatme/themes/light.py
index 76c33e4..69c942f 100644
--- a/lookatme/themes/light.py
+++ b/lookatme/themes/light.py
@@ -13,8 +13,8 @@ theme = {
         "bg": "",
     },
     "slides": {
-        "fg": "#f20,bold",
-        "bg": "",
+        "fg": "#000000",
+        "bg": "#ffffff",
     },
     "quote": {
         "style": {
diff --git a/lookatme/tui.py b/lookatme/tui.py
index 24e33f2..d5c6483 100644
--- a/lookatme/tui.py
+++ b/lookatme/tui.py
@@ -20,6 +20,7 @@ from lookatme.render.context import Context
 from lookatme.tutorial import tutor
 from lookatme.utils import spec_from_style
 from lookatme.widgets.clickable_text import ClickableText
+import lookatme.widgets.codeblock as codeblock
 
 
 def text(style, data, align="left"):
@@ -96,7 +97,9 @@ class SlideRenderer(threading.Thread):
             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}")
+            self._log.error(
+                f"Error occurred rendering slide {slide.number}", exc_info=True
+            )
 
             try:
                 curr_token = self.ctx.tokens.curr
@@ -256,9 +259,7 @@ class MarkdownTui(urwid.Frame):
         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.init_ctx()
 
         self.root_widget = root_urwid_widget(self.root_margins)
         self.loop = urwid.MainLoop(
@@ -430,8 +431,18 @@ class MarkdownTui(urwid.Frame):
         self.update_creation()
         self.update_body()
 
+    def init_ctx(self):
+        self.ctx = Context(None)
+        self.ctx.source_push(self.pres.no_meta_source)
+        self.ctx.spec_push(spec_from_style(config.get_style()["slides"]))
+
     def reload(self):
         """Reload the input, keeping the current slide in focus"""
+        self.init_ctx()
+        self.slide_renderer.ctx = self.ctx
+
+        codeblock.clear_cache()
+
         curr_slide_idx = self.curr_slide.number
         self.slide_renderer.flush_cache()
         self.pres.reload()
diff --git a/lookatme/tutorial.py b/lookatme/tutorial.py
index c7bf9b7..579b6f7 100644
--- a/lookatme/tutorial.py
+++ b/lookatme/tutorial.py
@@ -46,23 +46,23 @@ class Tutor:
     def get_md(self, rendered_example=True) -> str:
         """Get the tutor's markdown text after resolving any special markup
         contained in it.
-            opts. Dict[str, Any]
-              slides. Current can include `{
         """
         slides_md = self.slides_md
         if self.lazy_formatting is not None:
             slides_md = slides_md.format(**self.lazy_formatting())
 
         tag_handlers = {
-            "EXAMPLE": lambda contents: self._handle_show_and_render(
-                contents, rendered_example
+            "EXAMPLE": lambda contents, attrs: self._handle_show_and_render(
+                contents, attrs, rendered_example
             ),
             "STYLE": self._handle_style_yaml,
         }
 
         res_md = []
         last_idx = 0
-        regex = "<(?P<tag>TUTOR:(?P<type>[A-Z_]+))>(?P<inner>.*)</(?P=tag)>"
+        regex = (
+            r"<(?P<tag>TUTOR:(?P<type>[A-Z_]+))(?P<attrs>.*)>(?P<inner>.*)</(?P=tag)>"
+        )
         for match in re.finditer(regex, slides_md, re.MULTILINE | re.DOTALL):
             res_md.append(slides_md[last_idx : match.start()])
             match_groups = match.groupdict()
@@ -73,7 +73,7 @@ class Tutor:
                         match_groups["type"]
                     )
                 )
-            res_md.append(handler(match_groups["inner"]))
+            res_md.append(handler(match_groups["inner"], match_groups["attrs"]))
             last_idx = match.end()
 
         res_md.append(slides_md[last_idx:])
@@ -118,12 +118,14 @@ class Tutor:
             ),
         )
 
-    def _handle_show_and_render(self, contents, rendered_example: bool = True) -> str:
+    def _handle_show_and_render(
+        self, contents, attrs: str, rendered_example: bool = True
+    ) -> str:
         contents = contents.strip()
 
         markdown_example = "\n".join(
             [
-                "~~~markdown",
+                "~~~markdown{}".format(attrs),
                 contents,
                 "~~~",
             ]
@@ -143,11 +145,12 @@ class Tutor:
 
         return "\n\n".join(res)
 
-    def _handle_style_yaml(self, contents: str) -> str:
+    def _handle_style_yaml(self, contents: str, attrs: str) -> str:
         contents = contents.strip()
         style = config.get_style()[contents]
         style = {"styles": {contents: style}}
-        return "```yaml\n---\n{style_yaml}---\n```".format(
+        return "```yaml{attrs}\n---\n{style_yaml}---\n```".format(
+            attrs=attrs,
             style_yaml=yaml.dump(style).encode().decode("unicode-escape"),
         )
 
diff --git a/lookatme/utils.py b/lookatme/utils.py
index a802006..45ac7d2 100644
--- a/lookatme/utils.py
+++ b/lookatme/utils.py
@@ -188,6 +188,14 @@ def get_fg_bg_styles(style):
         raise ValueError("Unsupported style value {!r}".format(style))
 
 
+def extract_hexcolor(spec_style: str) -> str:
+    for part in spec_style.split(","):
+        if part.startswith("#"):
+            return part
+    # TODO
+    return "#ffffff"
+
+
 def overwrite_style(
     orig_style: Dict[str, str], new_style: Dict[str, str]
 ) -> Dict[str, str]:
diff --git a/lookatme/widgets/codeblock.py b/lookatme/widgets/codeblock.py
new file mode 100644
index 0000000..c843be1
--- /dev/null
+++ b/lookatme/widgets/codeblock.py
@@ -0,0 +1,428 @@
+"""
+This module defines an urwid Widget that renders a codeblock
+"""
+
+
+import itertools
+import math
+import re
+from typing import Dict, List, Mapping, Optional, Set, Tuple
+
+
+import pygments
+from pygments.lexer import Lexer
+import pygments.lexers
+import pygments.styles
+from pygments.style import StyleMeta
+import pygments.util
+import pygments.token
+import urwid
+
+
+from lookatme.widgets.smart_attr_spec import SmartAttrSpec
+from lookatme.widgets.line_fill import LineFill
+import lookatme.utils as utils
+
+
+AVAILABLE_LEXERS = set()
+
+
+def supported_langs() -> Set[str]:
+    global AVAILABLE_LEXERS
+
+    if len(AVAILABLE_LEXERS) == 0:
+        AVAILABLE_LEXERS = set(
+            itertools.chain(*[x[1] for x in pygments.lexers.get_all_lexers()])
+        )
+
+    return AVAILABLE_LEXERS
+
+
+LEXER_CACHE: Dict[str, Lexer] = {}
+
+
+def get_lexer(lang, default="text") -> Lexer:
+    lexer = LEXER_CACHE.get(lang, None)
+    if lexer is None:
+        try:
+            lexer = pygments.lexers.get_lexer_by_name(lang)
+        except pygments.util.ClassNotFound:
+            lexer = pygments.lexers.get_lexer_by_name(default)
+        LEXER_CACHE[lang] = lexer
+    return lexer
+
+
+def supported_styles() -> Mapping[str, str]:
+    return pygments.styles.STYLE_MAP
+
+
+class SyntaxHlStyle:
+    """Stores urwid styles for each token type for a specific pygments syntax
+    highlighting style.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        styles: Dict[str, SmartAttrSpec],
+        pygments_style: StyleMeta,
+        default_fg: str,
+        bg_override: Optional[str] = None,
+    ):
+        self.name = name
+        self.styles = styles
+        self.pygments_style = pygments_style
+        self.default_fg = default_fg
+        self.bg_override = bg_override
+
+        bg_color = self.bg_override or self.pygments_style.background_color
+        self.bg_spec = SmartAttrSpec(fg="", bg=bg_color)
+        bg_luminance = self._luminance(bg_color)
+        if bg_luminance > 0.5:
+            hl_color = self._increase_brightness(bg_color, -0.15)
+        else:
+            hl_color = self._increase_brightness(bg_color, 0.15)
+        self.highlight_spec = SmartAttrSpec("bold", hl_color)
+
+        if self.bg_override:
+            self.bg_spec.background = bg_override
+
+        self.line_number_spec = utils.overwrite_spec(
+            SmartAttrSpec(
+                fg=self._to_urwid_color(
+                    self.pygments_style.line_number_color,
+                    self.default_fg,
+                ),
+                bg=self._to_urwid_color(
+                    self.pygments_style.line_number_background_color,
+                    self.bg_spec.background,  # type: ignore
+                ),
+            ),
+            self.get_style_spec("Token.Comment", False),
+        )
+        self.line_number_spec_hl = utils.overwrite_spec(
+            self.line_number_spec, self.highlight_spec
+        )
+
+    def _to_urwid_color(self, val: str, inherit_val: str) -> str:
+        if val in ("inherit", "transparent", None):
+            return inherit_val
+        return val
+
+    def get_line_number_spec(self, do_hl: bool = False) -> SmartAttrSpec:
+        if do_hl:
+            return self.line_number_spec_hl
+        else:
+            return self.line_number_spec
+
+    def get_style_spec(self, token_type: str, highlight: bool) -> SmartAttrSpec:
+        """Attempt to find the closest matching style for the provided token
+        type.
+        """
+        token_type = str(token_type)
+        parts = token_type.split(".")
+        spec = None
+        while len(token_type) > 0:
+            token_type = ".".join(parts)
+            existing_style = self.styles.get(token_type, None)
+            if existing_style is not None:
+                spec = existing_style
+                break
+            parts = parts[:-1]
+
+        if spec is None:
+            spec = self.styles[token_type]
+
+        if highlight:
+            spec = utils.overwrite_spec(spec, self.highlight_spec)
+        else:
+            spec = utils.overwrite_spec(spec, self.bg_spec)
+
+        if "default" in spec.foreground:
+            spec.foreground = utils.overwrite_style(
+                {"fg": spec.foreground}, {"fg": self.default_fg}
+            )["fg"]
+
+        self._ensure_contrast(spec)
+
+        return spec
+
+    def _ensure_contrast(self, spec: urwid.AttrSpec):
+        luminance_fg = self._luminance(utils.extract_hexcolor(spec.foreground))
+        luminance_bg = self._luminance(utils.extract_hexcolor(spec.background))
+
+        if luminance_fg > luminance_bg:
+            contrast_ratio = (luminance_fg + 0.05) / (luminance_bg + 0.05)
+        else:
+            contrast_ratio = (luminance_bg + 0.05) / (luminance_fg + 0.05)
+
+        # w3c recommends a contrast >= 4.5, but most coding color schemes don't
+        # fit this
+        if contrast_ratio >= 3.0:
+            return
+
+        if luminance_bg < 0.5:
+            new_fg = "#ffffff"
+        else:
+            new_fg = "#000000"
+
+        tmp_spec = utils.overwrite_style(
+            {"fg": spec.foreground},
+            {"fg": new_fg},
+        )
+        spec.foreground = tmp_spec["fg"]
+
+    def _luminance(self, color: str) -> float:
+        color = color.strip("#")
+        if len(color) != 6:
+            return 0.0
+
+        red, green, blue = [int(x, 16) for x in re.findall("..", color)]
+        red = math.pow(red / 255.0, 2.2)
+        green = math.pow(green / 255.0, 2.2)
+        blue = math.pow(blue / 255.0, 2.2)
+
+        return red * 0.2126 + green * 0.7152 + blue * 0.0722
+
+    def _increase_brightness(self, color: str, percent: float) -> str:
+        color = color.strip("#")
+        red, green, blue = [int(x, 16) for x in re.findall("..", color)]
+
+        if percent > 0:
+            red += (255.0 - red) * percent
+            green += (255.0 - green) * percent
+            blue += (255.0 - blue) * percent
+        else:
+            red += red * percent
+            green += green * percent
+            blue += blue * percent
+
+        if percent < 0:
+            red = max(0, red)
+            green = max(0, green)
+            blue = max(0, blue)
+        else:
+            red = min(255, red)
+            green = min(255, green)
+            blue = min(255, blue)
+
+        return "#{:02x}{:02x}{:02x}".format(int(red), int(green), int(blue))
+
+
+class StyleCache:
+    """Caches the highlight styles for loaded pygments syntax highlighting
+    styles.
+    """
+
+    def __init__(
+        self,
+        default_fg: Optional[str] = None,
+        bg_override: Optional[str] = None,
+    ):
+        self.default_fg = default_fg or "default"
+        self.bg_override = bg_override
+        self.cache: Dict[str, SyntaxHlStyle] = {}
+
+    def get_style(self, style_name: str) -> SyntaxHlStyle:
+        """Return the highlight style for the specified pygments style name. If
+        the style name isn't found, the "text" style will be used instead.
+        """
+        if style_name not in self.cache:
+            self.cache[style_name] = self.load_style(style_name)
+
+        return self.cache[style_name]
+
+    def is_valid_style(self, style_name: str) -> bool:
+        """Return whether the style name is a valid pygments style"""
+        return style_name in supported_styles()
+
+    def load_style(self, style_name: str) -> SyntaxHlStyle:
+        if not self.is_valid_style(style_name):
+            style_name = "text"
+
+        pygments_style = pygments.styles.get_style_by_name(style_name)
+        style_dict = {}
+        for token_type, style_info in pygments_style:
+            fg_color = style_info.get("color", None)
+            fg = "#" + fg_color if fg_color else "default"
+
+            bg_color = style_info.get("bgcolor", None)
+            bg = "#" + bg_color if bg_color else "default"
+
+            if style_info.get("bold", False):
+                fg += ",bold"  # type: ignore
+            if style_info.get("italics", False):
+                fg += ",italics"  # type: ignore
+            if style_info.get("underline", False):
+                fg += ",underline"  # type: ignore
+
+            style_dict[str(token_type)] = SmartAttrSpec(fg, bg)
+
+        return SyntaxHlStyle(
+            style_name,
+            style_dict,
+            pygments_style,
+            default_fg=self.default_fg,
+            bg_override=self.bg_override,
+        )
+
+
+STYLE_CACHE = None
+
+
+def clear_cache():
+    global STYLE_CACHE
+
+    STYLE_CACHE = None
+    LEXER_CACHE.clear()
+
+
+def get_style_cache(
+    default_fg: Optional[str] = None,
+    bg_override: Optional[str] = None,
+) -> StyleCache:
+    global STYLE_CACHE
+
+    if STYLE_CACHE is None:
+        STYLE_CACHE = StyleCache(default_fg, bg_override)
+
+    return STYLE_CACHE
+
+
+def tokens_to_markup(
+    line: List[Tuple[str, str]], style: SyntaxHlStyle, do_hl: bool = False
+) -> List[Tuple[SmartAttrSpec, str]]:
+    res = []
+    for token_type, token_val in line:
+        spec = style.get_style_spec(token_type, do_hl)
+        res.append((spec, token_val))
+    if len(res) == 0:
+        res.append("")
+    return res
+
+
+def tokens_to_lines(tokens) -> List[List[Tuple[str, str]]]:
+    lines = []
+    curr_line = []
+    token_stack = list(reversed([x for x in tokens]))
+    while len(token_stack) > 0:
+        ttype, tstring = token_stack.pop()
+
+        if "\n" in tstring and tstring != "\n":
+            for line_part in reversed(re.split(r"(\n)", tstring)):
+                if len(line_part) == 0:
+                    continue
+                token_stack.append((ttype, line_part))
+            continue
+
+        if tstring == "\n":
+            lines.append(curr_line)
+            curr_line = []
+            continue
+
+        curr_line.append((ttype, tstring))
+
+    return lines
+
+
+class CodeBlock(urwid.Pile):
+    def __init__(
+        self,
+        source: str,
+        lang: str = "text",
+        style_name: str = "monokai",
+        line_numbers: bool = False,
+        start_line_number: int = 1,
+        hl_lines: Optional[List[range]] = None,
+        default_fg: Optional[str] = None,
+        bg_override: Optional[str] = None,
+    ):
+        self.source = source
+        self.lang = lang
+        self.line_numbers = line_numbers
+        self.start_line_number = start_line_number
+        self.hl_lines = hl_lines or []
+
+        self.style = get_style_cache(
+            default_fg=default_fg,
+            bg_override=bg_override,
+        ).get_style(style_name)
+
+        contents = self._create_contents()
+
+        super().__init__(contents)
+
+    def _create_contents(self) -> List[urwid.Columns]:
+        """Create the contents that will be used in the Pile"""
+        tokens = get_lexer(self.lang).get_tokens(self.source)
+        res = []
+
+        lines = tokens_to_lines(tokens)
+
+        max_line_num_width = len(str(self.start_line_number + len(lines)))
+        line_num_format_str = " {:" + str(max_line_num_width) + "} "
+        line_num_col_width = len(line_num_format_str.format(0))
+
+        for idx, line in enumerate(lines):
+            line_num = idx + self.start_line_number
+            do_hl = self._should_hl_line(line_num)
+            columns = []
+            box_columns = []
+
+            if self.line_numbers:
+                line_num_text = line_num_format_str.format(line_num)
+                line_num_spec = self.style.get_line_number_spec(do_hl)
+                columns.append(
+                    (line_num_col_width, urwid.Text((line_num_spec, line_num_text)))
+                )
+                columns.append(
+                    (
+                        1,
+                        LineFill(
+                            beg_chars="",
+                            fill_char="│",
+                            end_chars="",
+                            fill_spec=line_num_spec,
+                            orientation=LineFill.VERTICAL,
+                        ),
+                    )
+                )
+                columns.append(
+                    (
+                        1,
+                        LineFill(
+                            beg_chars="",
+                            fill_char=" ",
+                            end_chars="",
+                            fill_spec=line_num_spec,
+                            orientation=LineFill.VERTICAL,
+                        ),
+                    )
+                )
+                box_columns.append(1)
+                box_columns.append(2)
+
+            line_text = urwid.Text(tokens_to_markup(line, self.style, do_hl))
+            columns.append(line_text)
+
+            row = urwid.Columns(columns, box_columns=box_columns)
+
+            wrap_spec = self.style.highlight_spec if do_hl else self.style.bg_spec
+            row = urwid.AttrMap(row, {None: wrap_spec})
+
+            res.append(row)
+
+        return res
+
+    def _make_line_column(
+        self, format_str: str, line_num: int, do_hl: bool
+    ) -> urwid.Text:
+        text = format_str.format(line_num)
+        spec = self.style.get_line_number_spec(do_hl)
+        return urwid.Text((spec, text))
+
+    def _should_hl_line(self, line_num: int) -> bool:
+        for hl_range in self.hl_lines:
+            if line_num in hl_range:
+                return True
+        return False


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 8d59ca1..c76b4a6 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -33,7 +33,7 @@ def _get_dumped_style(
     tmpdir,
     theme: Optional[str] = None,
     md_meta_style: Optional[str] = None,
-    cli_style: Optional[str] = None,
+    # cli_style: Optional[str] = None,
 ) -> str:
     cli_args = ["--dump-styles"]
 
@@ -47,20 +47,23 @@ def _get_dumped_style(
                     [
                         "---",
                         "styles:",
-                        "  style: {}".format(md_meta_style),
+                        "  code:",
+                        "    style: {}".format(md_meta_style),
                         "---",
                     ]
                 )
             )
         cli_args += [str(tmpfile)]
-    if cli_style is not None:
-        cli_args += ["--style", cli_style]
+
+    # # TODO once we have better CLI style processing, add this back in!
+    # if cli_style is not None:
+    #     cli_args += ["--code-style", cli_style]
 
     res = run_cmd(*cli_args)
     assert res.exit_code == 0
 
     yaml_data = yaml.safe_load(res.output)
-    return yaml_data["style"]
+    return yaml_data["code"]["style"]
 
 
 def test_style_override_precedence_dark(tmpdir):
@@ -68,18 +71,14 @@ def test_style_override_precedence_dark(tmpdir):
     default_style = _get_dumped_style(tmpdir)
     themed_style = _get_dumped_style(tmpdir, theme="dark")
     themed_and_md = _get_dumped_style(tmpdir, theme="dark", md_meta_style="emacs")
-    themed_and_md_and_cli = _get_dumped_style(
-        tmpdir, theme="dark", md_meta_style="emacs", cli_style="zenburn"
-    )
 
     default = lookatme.schemas.MetaSchema().dump(None)
-    assert default_style == default["styles"]["style"]
+    assert default_style == default["styles"]["code"]["style"]
 
     dark_theme_styles = lookatme.schemas.StyleSchema().dump(dark_theme.theme)
-    assert themed_style == dark_theme_styles["style"]  # type: ignore
+    assert themed_style == dark_theme_styles["code"]["style"]  # type: ignore
 
     assert themed_and_md == "emacs"
-    assert themed_and_md_and_cli == "zenburn"
 
 
 def test_style_override_precedence_light(tmpdir):
@@ -87,18 +86,14 @@ def test_style_override_precedence_light(tmpdir):
     default_style = _get_dumped_style(tmpdir)
     themed_style = _get_dumped_style(tmpdir, theme="light")
     themed_and_md = _get_dumped_style(tmpdir, theme="light", md_meta_style="emacs")
-    themed_and_md_and_cli = _get_dumped_style(
-        tmpdir, theme="light", md_meta_style="emacs", cli_style="zenburn"
-    )
 
     default = lookatme.schemas.MetaSchema().dump(None)
-    assert default_style == default["styles"]["style"]
+    assert default_style == default["styles"]["code"]["style"]
 
     light_theme_styles = lookatme.schemas.StyleSchema().dump(light_theme.theme)
-    assert themed_style == light_theme_styles["style"]  # type: ignore
+    assert themed_style == light_theme_styles["code"]["style"]  # type: ignore
 
     assert themed_and_md == "emacs"
-    assert themed_and_md_and_cli == "zenburn"
 
 
 def test_version():
diff --git a/tests/test_file_loader.py b/tests/test_file_loader.py
index b4ceaab..0e71f5d 100644
--- a/tests/test_file_loader.py
+++ b/tests/test_file_loader.py
@@ -7,7 +7,6 @@ import pytest
 
 import lookatme.config
 import lookatme.contrib.file_loader
-import lookatme.render.pygments
 import tests.utils as utils
 from tests.utils import override_style
 
@@ -50,7 +49,7 @@ def test_file_loader(tmpdir, _):
             "SSSSSSS",
         ],
         styles={
-            "S": {"fg": "g93", "bg": "g15"},
+            "S": {"fg": "#f8f8f2", "bg": "#272822"},
         },
     )
 
@@ -89,7 +88,7 @@ Apples1
             "TTTTTTT",
         ],
         styles={
-            "T": {"fg": "g93", "bg": "g15"},
+            "T": {"fg": "#f8f8f2", "bg": "#272822"},
         },
     )
 
@@ -114,7 +113,7 @@ def test_file_loader_relative(tmpdir, _):
             "SSSSSSS",
         ],
         styles={
-            "S": {"fg": "g93", "bg": "g15"},
+            "S": {"fg": "#f8f8f2", "bg": "#272822"},
         },
     )
 
@@ -135,6 +134,6 @@ def test_file_loader_not_found(_):
             "TTTTTTTTTTTTTT",
         ],
         styles={
-            "T": {"fg": "g93", "bg": "g15"},
+            "T": {"fg": "#f8f8f2", "bg": "#272822"},
         },
     )
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index 1c7b422..66438e8 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -559,7 +559,9 @@ def test_block_quote(style):
 
 @override_style(
     {
-        "code": "monokai",
+        "code": {
+            "style": "monokai",
+        }
     }
 )
 def test_code(styles):
@@ -586,7 +588,7 @@ def test_code(styles):
             "RRRRRRR",
         ],
         styles={
-            "R": {"fg": "#dd8", "bg": "g15"},
+            "R": {"fg": "#e6db74", "bg": "#272822"},
             " ": {},
         },
     )
@@ -594,7 +596,53 @@ def test_code(styles):
 
 @override_style(
     {
-        "code": "monokai",
+        "code": {
+            "style": "monokai",
+        }
+    }
+)
+def test_code_with_highlight_and_numbers(styles):
+    """Test code block rendering"""
+    utils.validate_render(
+        md_text="""
+            ```python {.numberLines startFrom=3 hllines=3-4,6}
+            'Hello'
+            'Hello'
+            'Hello'
+            'Hello'
+            'Hello'
+            ```
+        """,
+        text=[
+            " 3 │ 'Hello'",
+            " 4 │ 'Hello'",
+            " 5 │ 'Hello'",
+            " 6 │ 'Hello'",
+            " 7 │ 'Hello'",
+        ],
+        style_mask=[
+            "lllllrrrrrrr",  # highlighted
+            "lllllrrrrrrr",  # highlighted
+            "LLLLLRRRRRRR",
+            "lllllrrrrrrr",  # highlighted
+            "LLLLLRRRRRRR",
+        ],
+        styles={
+            # normal lines
+            "R": {"fg": "#e6db74", "bg": "#272822"},
+            "L": {"fg": "#75715e", "bg": "#272822"},
+            # highlightled lines
+            "r": {"fg": "#e6db74,bold", "bg": "#474843"},
+            "l": {"fg": "#75715e,bold", "bg": "#474843"},
+        },
+    )
+
+
+@override_style(
+    {
+        "code": {
+            "style": "monokai",
+        },
     }
 )
 def test_empty_codeblock(style):
@@ -613,14 +661,16 @@ def test_empty_codeblock(style):
             "B",
         ],
         styles={
-            "B": {"bg": "g15"},
+            "B": {"bg": "#272822"},
         },
     )
 
 
 @override_style(
     {
-        "code": "monokai",
+        "code": {
+            "style": "monokai",
+        }
     }
 )
 def test_code_preceded_by_text(styles):
@@ -644,8 +694,8 @@ def test_code_preceded_by_text(styles):
             "B______",
         ],
         styles={
-            "B": {"fg": "g93", "bg": "g15"},
-            "_": {"bg": "g15"},
+            "B": {"fg": "#f8f8f2", "bg": "#272822"},
+            "_": {"bg": "#272822"},
             " ": {},
         },
     )
@@ -653,7 +703,9 @@ def test_code_preceded_by_text(styles):
 
 @override_style(
     {
-        "code": "monokai",
+        "code": {
+            "style": "monokai",
+        }
     }
 )
 def test_code_yaml(styles):
@@ -686,12 +738,12 @@ def test_code_yaml(styles):
             "::::VVVVV_____________",
         ],
         styles={
-            "K": {"fg": "#f06", "bg": "g15"},
-            "S": {"fg": "#dd8", "bg": "g15"},
-            "V": {"fg": "#a8f", "bg": "g15"},
-            ":": {"fg": "g93", "bg": "g15"},
-            "-": {"bg": "g15"},
-            "_": {"bg": "g15"},
+            "K": {"fg": "#f92672", "bg": "#272822"},
+            "S": {"fg": "#e6db74", "bg": "#272822"},
+            "V": {"fg": "#ae81ff", "bg": "#272822"},
+            ":": {"fg": "#f8f8f2", "bg": "#272822"},
+            "-": {"bg": "#272822"},
+            "_": {"bg": "#272822"},
         },
     )
 
diff --git a/tests/test_tutorial.py b/tests/test_tutorial.py
index 6f401d0..4fc289a 100644
--- a/tests/test_tutorial.py
+++ b/tests/test_tutorial.py
@@ -44,7 +44,7 @@ def test_tutor(mocker):
         "group",
         "\n".join(
             [
-                "<TUTOR:EXAMPLE>contents</TUTOR:EXAMPLE>",
+                "<TUTOR:EXAMPLE {hllines=1}>contents</TUTOR:EXAMPLE>",
                 "<TUTOR:STYLE>test</TUTOR:STYLE>",
             ]
         ),
@@ -54,7 +54,7 @@ def test_tutor(mocker):
 
     md_text = tutor.get_md()
 
-    md_example = "> ~~~markdown\n> contents\n> ~~~"
+    md_example = "> ~~~markdown {hllines=1}\n> contents\n> ~~~"
     assert md_example in md_text
 
     md_rendered = "\ncontents"