Repository: d0c-s4vage/lookatme
Pull request #179 (2022-11-10 06:09:28)

3.0 - Handle softbreaks as spaces
This PR deals with softbreaks correctly (or how I think is more correct). Softbreaks should be spaces, not newlines, unless the previous line ended with a dash that was preceded by a non-space, which would make us *not* insert a space but eat the newline.


Patch:
diff --git a/bin/ci b/bin/ci
index ce01284..8178ca1 100755
--- a/bin/ci
+++ b/bin/ci
@@ -108,6 +108,13 @@ function run_all {
         black
     if [ $? -ne 0 ] ; then
         log "Can't run all, did you forget to use a virtual environment?"
+        log "Run these commands if you did forget:"
+        log
+        log "    python3 -m virtualenv venv3"
+        log "    . venv3/bin/activate"
+        log "    pip install -r requirements.test.txt -r requirements.txt"
+        log
+        log "Then try running bin/ci again!"
         exit 1
     fi
 
diff --git a/lookatme/render/context.py b/lookatme/render/context.py
index 2d650dc..91e5950 100644
--- a/lookatme/render/context.py
+++ b/lookatme/render/context.py
@@ -159,7 +159,8 @@ class Context:
         self._log.debug(indent + msg)
 
     def inline_push(
-        self, inline_result: Union[urwid.Widget, str, Tuple[urwid.AttrSpec, str]]
+        self,
+        inline_result: Union[urwid.Widget, str, Tuple[Optional[urwid.AttrSpec], str]],
     ):
         """Push a new inline render result onto the stack. Either a widget, or
         text markup
@@ -169,6 +170,8 @@ class Context:
         if isinstance(inline_result, tuple) and len(inline_result) == 2:
             if len(inline_result[1]) == 0:
                 return
+            if inline_result[0] is None:
+                inline_result = inline_result[1]
 
         self.in_new_block = False
         self.inline_render_results.append(inline_result)
diff --git a/lookatme/render/markdown_block.py b/lookatme/render/markdown_block.py
index 5716a3b..1b700dc 100644
--- a/lookatme/render/markdown_block.py
+++ b/lookatme/render/markdown_block.py
@@ -314,12 +314,12 @@ def render_list_item_close(_, ctx: Context):
 @contrib_first
 def render_heading_open(token: Dict, ctx: Context):
     """ """
+    ctx.ensure_new_block()
+
     headings = config.get_style()["headings"]
     level = token["level"]
     style = config.get_style()["headings"].get(str(level), headings["default"])
 
-    ctx.ensure_new_block()
-
     header_spec = utils.spec_from_style(style)
     ctx.spec_push(header_spec)
     prefix_token = {"type": "text", "content": style["prefix"]}
@@ -337,7 +337,6 @@ def render_heading_close(token: Dict, ctx: Context):
     markdown_inline.render(suffix_token, ctx)
 
     ctx.spec_pop()
-    ctx.ensure_new_block()
 
 
 @tutor(
@@ -450,13 +449,15 @@ def render_fence(token: Dict, ctx: Context):
     See :any:`lookatme.tui.SlideRenderer.do_render` for additional argument and
     return value descriptions.
     """
+    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([urwid.Divider(), res, urwid.Divider()])
+    ctx.widget_add(res)
 
 
 class TableTokenExtractor:
@@ -628,337 +629,3 @@ def render_hr(token, ctx: Context):
         ctx.widget_add(urwid.Text(" "))
         ctx.widget_add(urwid.AttrMap(div, hrule_spec))
         ctx.widget_add(urwid.Text(" "))
-
-
-# @contrib_first
-# def render_blankline(token, ctx: Context):
-#     """Render a newline
-#
-#     See :any:`lookatme.tui.SlideRenderer.do_render` for argument and return
-#     value descriptions.
-#     """
-#     return [urwid.Divider()]
-#
-#
-# # @contrib_first
-# # def render_hrule(token, ctx: Context):
-# #     """Render a newline
-# #
-# #     See :any:`lookatme.tui.SlideRenderer.do_render` for argument and return
-# #     value descriptions.
-# #     """
-# #     hrule_conf = config.get_style()["hrule"]
-# #     div = urwid.Divider(hrule_conf['char'], top=1, bottom=1)
-# #     return urwid.Pile([urwid.AttrMap(div, utils.spec_from_style(hrule_conf['style']))])
-#
-#
-# @contrib_first
-# def render_heading(token, ctx: Context):
-#     """Render markdown headings, using the defined styles for the styling and
-#     prefix/suffix.
-#
-#     See :any:`lookatme.tui.SlideRenderer.do_render` for argument and return
-#     value descriptions.
-#
-#     Below are the default stylings for headings:
-#
-#     .. code-block:: yaml
-#
-#         headings:
-#           '1':
-#             bg: default
-#             fg: '#9fc,bold'
-#             prefix: "██ "
-#             suffix: ""
-#           '2':
-#             bg: default
-#             fg: '#1cc,bold'
-#             prefix: "▓▓▓ "
-#             suffix: ""
-#           '3':
-#             bg: default
-#             fg: '#29c,bold'
-#             prefix: "▒▒▒▒ "
-#             suffix: ""
-#           '4':
-#             bg: default
-#             fg: '#66a,bold'
-#             prefix: "░░░░░ "
-#             suffix: ""
-#           default:
-#             bg: default
-#             fg: '#579,bold'
-#             prefix: "░░░░░ "
-#             suffix: ""
-#
-#     :returns: A list of urwid Widgets or a single urwid Widget
-#     """
-#     headings = config.get_style()["headings"]
-#     level = token["level"]
-#     style = config.get_style()["headings"].get(str(level), headings["default"])
-#
-#     header_spec = utils.spec_from_style(style)
-#     with ctx.use_spec(header_spec):
-#         prefix_token = {"type": "text", "text": style["prefix"]}
-#         suffix_token = {"type": "text", "text": style["suffix"]}
-#         markdown_inline.render_all([prefix_token] + token["children"] + [suffix_token], ctx)
-#
-#     return [urwid.Divider()] + ctx.inline_widgets_consumed + [urwid.Divider()]
-#
-#
-# @contrib_first
-# def render_table(token, ctx: Context):
-#     """Renders a table using the :any:`Table` widget.
-#
-#     See :any:`lookatme.tui.SlideRenderer.do_render` for argument and return
-#     value descriptions.
-#
-#     The table widget makes use of the styles below:
-#
-#     .. code-block:: yaml
-#
-#         table:
-#           column_spacing: 3
-#           header_divider: "─"
-#
-#     :returns: A list of urwid Widgets or a single urwid Widget
-#     """
-#     from lookatme.widgets.table import Table
-#
-#     table_header = None
-#     table_body = None
-#
-#     for child_token in token["children"]:
-#         if child_token["type"] == "table_head":
-#             table_header = child_token
-#         elif child_token["type"] == "table_body":
-#             table_body = child_token
-#         else:
-#             raise NotImplementedError("Unsupported table child token: {!r}".format(child_token["type"]))
-#
-#     table = Table(header=table_header, body=table_body, ctx=ctx)
-#     padding = urwid.Padding(table, width=table.total_width + 2, align="center")
-#
-#     def table_changed(*args, **kwargs):
-#         padding.width = table.total_width + 2
-#
-#     urwid.connect_signal(table, "change", table_changed)
-#
-#     return padding
-#
-#
-# @contrib_first
-# def render_list(token, ctx: Context):
-#     """Handles the indentation when starting rendering a new list. List items
-#     themselves (with the bullets) are rendered by the
-#     :any:`render_list_item_start` function.
-#
-#     See :any:`lookatme.tui.SlideRenderer.do_render` for argument and return
-#     value descriptions.
-#     """
-#     res = []
-#     # Consume any queued inline widgets from previous tokens!
-#     res += ctx.inline_widgets_consumed
-#
-#     list_container = urwid.Pile([])
-#
-#     level = 1
-#     prev_meta = get_meta(ctx.container)
-#     in_list = prev_meta.get("is_list", False)
-#     if in_list:
-#         level = prev_meta["level"] + 1
-#
-#     meta = get_meta(list_container)
-#     meta["is_list"] = True
-#     meta["level"] = level
-#     meta["ordered"] = token["ordered"]
-#     meta["item_count"] = 0
-#     meta["list_start_token"] = token
-#     meta["max_list_marker_width"] = token.get("max_list_marker_width", 2)
-#     meta["ordered"] = token["ordered"]
-#
-#     with ctx.use_container(list_container):
-#         render_all(token["children"], ctx)
-#
-#     meta = get_meta(list_container)
-#     meta["list_start_token"]["max_list_marker_width"] = meta["max_list_marker_width"]
-#
-#     widgets = []
-#     if not in_list:
-#         widgets.append(urwid.Divider())
-#         widgets.append(ctx.wrap_widget(urwid.Padding(list_container, left=2)))
-#         widgets.append(urwid.Divider())
-#         return res + widgets
-#     return res + [list_container]
-#
-#
-# @contrib_first
-# def render_list_item(token, ctx: Context):
-#     """Render the start of a list item. This function makes use of two
-#     different styles, one each for unordered lists (bullet styles) and ordered
-#     lists (numbering styles):
-#
-#     .. code-block:: yaml
-#
-#         bullets:
-#           '1': "•"
-#           '2': "⁃"
-#           '3': "◦"
-#           default: "•"
-#         numbering:
-#           '1': "numeric"
-#           '2': "alpha"
-#           '3': "roman"
-#           default: "numeric"
-#
-#     See :any:`lookatme.tui.SlideRenderer.do_render` for argument and return
-#     value descriptions.
-#     """
-#     meta = get_meta(ctx.container)
-#     list_level = meta["level"]
-#     curr_count = _inc_item_count(ctx.container)
-#     pile = urwid.Pile(urwid.SimpleFocusListWalker([]))
-#
-#     if meta["ordered"]:
-#         numbering = config.get_style()["numbering"]
-#         list_marker_type = numbering.get(str(list_level), numbering["default"])
-#         sequence = {
-#             "numeric": lambda x: str(x),
-#             "alpha": lambda x: chr(ord("a") + x - 1),
-#             "roman": lambda x: int_to_roman(x),
-#         }[list_marker_type]
-#         list_marker = sequence(curr_count) + "."
-#     else:
-#         bullets = config.get_style()["bullets"]
-#         list_marker = bullets.get(str(list_level), bullets["default"])
-#
-#     marker_text = list_marker + " "
-#     if len(marker_text) > meta["max_list_marker_width"]:
-#         meta["max_list_marker_width"] = len(marker_text)
-#     marker_col_width = meta["max_list_marker_width"]
-#
-#     res = urwid.Columns([
-#         (marker_col_width, urwid.Text((ctx.spec_text_with(utils.spec_from_style("bold")), marker_text))),
-#         pile,
-#     ])
-#     res = ctx.wrap_widget(res)
-#
-#     with ctx.use_container(pile):
-#         render_all(token["children"], ctx)
-#
-#     pile_or_listbox_add(pile, ctx.inline_widgets_consumed)
-#
-#     return res
-#
-#
-# @contrib_first
-# def render_block_text(token, ctx: Context):
-#     """Render block text
-#     """
-#     markdown_inline.render_all(token["children"], ctx)
-#     # let the inline render results continue!
-#     # return ctx.inline_widgets_consumed
-#     return []
-#
-#
-# @contrib_first
-# def render_htmlblock(token, ctx: Context):
-#     """Render block html
-#     """
-#     LookatmeHTMLParser(ctx).feed(token["children"])
-#     return ctx.inline_widgets_consumed
-#
-#
-# # @contrib_first
-# # def render_paragraph(token, ctx: Context):
-# #     markdown_inline.render_all(token["children"], ctx)
-# #
-# #     res = []
-# #     if not _is_list(ctx.container):
-# #         res.append(urwid.Divider())
-# #     res += ctx.inline_widgets_consumed
-# #
-# #     return res
-#
-#
-# # @contrib_first
-# # def render_paragraph_close(token, ctx: Context):
-# #     #markdown_inline.render_all(token["children"], ctx)
-# #     return [urwid.Divider()]
-# #
-# # @contrib_first
-# # def render_inline(token, ctx: Context):
-# #     markdown_inline.render_all(token["children"], ctx)
-# #     return ctx.inline_widgets_consumed
-# #
-# #
-# # @contrib_first
-# # def render_block_quote(token, ctx: Context):
-# #     """
-# #
-# #     This function makes use of the styles:
-# #
-# #     .. code-block:: yaml
-# #
-# #         quote:
-# #           top_corner: "┌"
-# #           bottom_corner: "└"
-# #           side: "╎"
-# #           style:
-# #             bg: default
-# #             fg: italics,#aaa
-# #
-# #     See :any:`lookatme.tui.SlideRenderer.do_render` for additional argument and
-# #     return value descriptions.
-# #     """
-# #     pile = urwid.Pile([])
-# #
-# #     styles = config.get_style()["quote"]
-# #
-# #     quote_side = styles["side"]
-# #     quote_top_corner = styles["top_corner"]
-# #     quote_bottom_corner = styles["bottom_corner"]
-# #     quote_style = styles["style"]
-# #
-# #     with ctx.use_container(pile):
-# #         with ctx.use_spec(utils.spec_from_style(quote_style)):
-# #             for child_token in token["children"]:
-# #                 render(child_token, ctx)
-# #
-# #     # remove leading/trailing divider if they were added to the pile
-# #     if isinstance(pile.contents[0][0], urwid.Divider):
-# #         pile.contents = pile.contents[1:]
-# #     if isinstance(pile.contents[-1][0], urwid.Divider):
-# #         pile.contents = pile.contents[:-1]
-# #
-# #     return [
-# #         urwid.LineBox(
-# #             urwid.AttrMap(
-# #                 urwid.Padding(pile, left=2),
-# #                 utils.spec_from_style(quote_style),
-# #             ),
-# #             lline=quote_side, rline="",
-# #             tline=" ", trcorner="", tlcorner=quote_top_corner,
-# #             bline=" ", brcorner="", blcorner=quote_bottom_corner,
-# #         ),
-# #     ]
-#
-#
-# @contrib_first
-# def render_block_code(token, ctx: Context):
-#     """Renders a code block using the Pygments library.
-#
-#     See :any:`lookatme.tui.SlideRenderer.do_render` for additional argument and
-#     return value descriptions.
-#     """
-#     info = token.get("info", None) or "text"
-#     lang = info.split()[0]
-#     # TODO support line highlighting, etc?
-#     text = token["text"]
-#     res = pygments_render.render_text(text, lang=lang)
-#
-#     return [
-#         urwid.Divider(),
-#         res,
-#         urwid.Divider()
-#     ]
diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py
index 83822dd..798487a 100644
--- a/lookatme/render/markdown_inline.py
+++ b/lookatme/render/markdown_inline.py
@@ -4,6 +4,7 @@ interface
 """
 
 
+import re
 import sys
 from typing import Union
 
@@ -69,6 +70,12 @@ def render_text(token, ctx: Context):
     <TUTOR:EXAMPLE>
     The donut jumped *under* the crane.
     </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Emphasis can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>emphasis</TUTOR:STYLE>
     """,
 )
 @contrib_first
@@ -84,11 +91,17 @@ def render_em_close(_, ctx: Context):
 
 @tutor(
     "markdown",
-    "double emphasis",
+    "strong emphasis",
     r"""
     <TUTOR:EXAMPLE>
     They jumped **over** the wagon
     </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Strong emphasis can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>strong_emphasis</TUTOR:STYLE>
     """,
 )
 @contrib_first
@@ -109,6 +122,12 @@ def render_strong_close(_, ctx: Context):
     <TUTOR:EXAMPLE>
     I lost my ~~mind~~ keyboard and couldn't type anymore.
     </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Strikethrough can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>strikethrough</TUTOR:STYLE>
     """,
 )
 @contrib_first
@@ -188,7 +207,22 @@ def render_image_close(token, ctx: Context):
 
 @contrib_first
 def render_softbreak(_, ctx: Context):
-    ctx.inline_push((ctx.spec_text, "\n"))
+    markup = ctx.get_inline_markup()
+    # if the previous line ended with a dash, don't add a space!
+    if len(markup) > 0:
+        prev_markup = markup[-1]
+        if isinstance(prev_markup, str):
+            prev_text = prev_markup
+        elif isinstance(prev_markup, tuple) and len(prev_markup) == 2:
+            prev_text = prev_markup[1]
+        else:
+            raise Exception("Unknown condition handling softbreak")
+
+        # if the previous text was a dash-split, then don't add a space
+        if re.match(r".*[^\s]-$", prev_text.split("\n")[-1]) is not None:
+            return
+
+    ctx.inline_push((ctx.spec_text, " "))
 
 
 @tutor(
@@ -371,210 +405,3 @@ def render_html_tag_li_close(
 ):
     ctx.tag_pop()
     markdown_block().render_list_item_close({"type": "list_item_close"}, ctx)
-
-
-# def render_no_change(text, ctx: Context):
-#     """Render inline markdown text with no changes
-#     """
-#     ctx.inline_push((ctx.spec_text, text))
-#
-#
-# @contrib_first
-# def render_inline_html(token, ctx: Context):
-#     """Renders inline html as plaintext
-#
-#     :returns: list of `urwid Text markup <http://urwid.org/manual/displayattributes.html#text-markup>`_
-#         tuples.
-#     """
-#     raise NotImplementedError("render_inline_html is not implemented")
-#
-#
-# @contrib_first
-# def render_text(token, ctx: Context):
-#     """Renders plain text (does nothing)
-#
-#     :returns: list of `urwid Text markup <http://urwid.org/manual/displayattributes.html#text-markup>`_
-#         tuples.
-#     """
-#     data = token["text"]
-#     new_data = lookatme.parser.unobscure_html_tags(data)
-#     if new_data != data:
-#         tokens = LookatmeHTMLParser(ctx).parse(new_data)
-#         render_all(tokens, ctx)
-#         return
-#
-#     if ctx.is_literal:
-#         text = token["text"].replace("\r", "").replace("\n", " ")
-#     else:
-#         text = token["text"]
-#     ctx.inline_push((ctx.spec_text, text))
-#
-#
-# @contrib_first
-# def render_footnote_ref(token, ctx: Context):
-#     """Renders a footnote
-#
-#     :returns: list of `urwid Text markup <http://urwid.org/manual/displayattributes.html#text-markup>`_
-#         tuples.
-#     """
-#     raise NotImplementedError("render_footnote_ref is not implemented")
-#
-#
-# @contrib_first
-# def render_image(token, ctx: Context):
-#     """Renders an image as a link. This would be a cool extension to render
-#     referenced images as scaled-down ansii pixel blocks.
-#
-#     :returns: list of `urwid Text markup <http://urwid.org/manual/displayattributes.html#text-markup>`_
-#         tuples.
-#     """
-#     #raise NotImplementedError("render_image is not implemented")
-#
-#
-# @contrib_first
-# def render_link(token, ctx: Context):
-#     """Renders a link. This function does a few special things to make the
-#     clickable links happen. All text in lookatme is rendered using the
-#     :any:`ClickableText` class. The ``ClickableText`` class looks for
-#     ``urwid.AttrSpec`` instances that are actually ``LinkIndicatorSpec`` instances
-#     within the Text markup. If an AttrSpec is an instance of ``LinkIndicator``
-#     spec in the Text markup, ClickableText knows to handle clicks on that
-#     section of the text as a link.
-#
-#     Example token:
-#
-#     ..:code:
-#
-#         {'type': 'link', 'link': 'https://google.com', 'children': [{'type': 'text', 'text': 'blah'}], 'title': None}
-#     """
-#     plain_spec = utils.spec_from_style(config.get_style()["link"])
-#     link_spec = LinkIndicatorSpec(token["link"], token["link"], plain_spec)
-#
-#     with ctx.use_spec(link_spec):
-#         render_all(token["children"], ctx)
-#
-#
-# @contrib_first
-# def render_strong(token, ctx: Context):
-#     """Renders double emphasis. Handles both ``**word**`` and ``__word__``
-#     """
-#     with ctx.use_spec(utils.spec_from_style("underline")):
-#         render_all(token["children"], ctx)
-#
-#
-# @contrib_first
-# def render_emphasis(token, ctx: Context):
-#     """Renders double emphasis. Handles both ``*word*`` and ``_word_``
-#
-#     :returns: list of `urwid Text markup <http://urwid.org/manual/displayattributes.html#text-markup>`_
-#         tuples.
-#     """
-#     with ctx.use_spec(utils.spec_from_style("italics")):
-#         render_all(token["children"], ctx)
-#
-#
-# @contrib_first
-# def render_codespan(token, ctx: Context):
-#     """Renders inline code using the pygments renderer. This function also makes
-#     use of the coding style:
-#
-#     .. code-block:: yaml
-#
-#         style: monokai
-#
-#     :returns: list of `urwid Text markup <http://urwid.org/manual/displayattributes.html#text-markup>`_
-#         tuples.
-#     """
-#     spec, text = pygments_render.render_text(" " + token["content"] + " ", plain=True)[0]
-#     with ctx.use_spec(spec):
-#         ctx.inline_push((ctx.spec_text, text))
-#
-#
-# @contrib_first
-# def render_linebreak(token, ctx: Context):
-#     """Renders a line break
-#     """
-#     ctx.inline_push((ctx.spec_general, "\n"))
-#
-#
-# @contrib_first
-# def render_strikethrough(token, ctx: Context):
-#     """Renders strikethrough text (``~~text~~``)
-#     """
-#     with ctx.use_spec(utils.spec_from_style("strikethrough")):
-#         render_all(token["children"], ctx)
-#
-# @contrib_first
-# def render_html_tag(token, ctx: Context):
-#     """
-#     """
-#     style_spec = None
-#     if len(token["style"]) > 0:
-#         style_spec = utils.spec_from_style({
-#             "fg": token["style"].get("color", ""),
-#             "bg": token["style"].get("background-color", ""),
-#         })
-#
-#     fn = getattr(THIS_MOD, "render_html_tag_{}".format(token["tag"]), None)
-#     if fn is None:
-#         fn = render_html_tag_default
-#
-#     with ctx.use_spec(style_spec):
-#         fn(token, ctx)
-#
-# @contrib_first
-# def render_html_tag_default(token, ctx: Context):
-#     """
-#     """
-#     render_all(token["children"], ctx)
-#
-# @contrib_first
-# def render_html_tag_b(token, ctx: Context):
-#     with ctx.use_spec(utils.spec_from_style("bold")):
-#         render_all(token["children"], ctx)
-#
-# @contrib_first
-# def render_html_tag_i(token, ctx: Context):
-#     with ctx.use_spec(utils.spec_from_style("italics")):
-#         render_all(token["children"], ctx)
-#
-# @contrib_first
-# def render_html_tag_blink(token, ctx: Context):
-#     with ctx.use_spec(utils.spec_from_style("blink"), text_only=True):
-#         render_all(token["children"], ctx)
-#
-# @contrib_first
-# def render_html_tag_em(token, ctx: Context):
-#     with ctx.use_spec(utils.spec_from_style("standout"), text_only=True):
-#         render_all(token["children"], ctx)
-#
-# @contrib_first
-# def render_html_tag_u(token, ctx: Context):
-#     with ctx.use_spec(utils.spec_from_style("underline")):
-#         render_all(token["children"], ctx)
-#
-# @contrib_first
-# def render_html_tag_br(token, ctx: Context):
-#     render_text({"text": "\n"}, ctx)
-#
-# @contrib_first
-# def render_html_tag_div(token, ctx: Context):
-#     ctx.inline_flush()
-#     with ctx.use_spec(utils.spec_from_style("underline")):
-#         render_all(token["children"], ctx)
-#
-# @contrib_first
-# def render_html_tag_ul(token, ctx: Context):
-#     token["type"] = "list"
-#     token["ordered"] = False
-#
-#     for li in token["children"]:
-#         if li["tag"] != "li":
-#             continue
-#         li["type"] = "list_item"
-#
-#     import lookatme.render.markdown_block as block
-#
-#     ctx.inline_flush()
-#     with ctx.container_to_inline():
-#         block.render_all([token], ctx)
diff --git a/lookatme/tutorial.py b/lookatme/tutorial.py
index a0fdba4..4565ea6 100644
--- a/lookatme/tutorial.py
+++ b/lookatme/tutorial.py
@@ -74,7 +74,7 @@ class Tutor:
                     )
                 )
             res_md.append(handler(match_groups["inner"]))
-            last_idx = match.end() + 1
+            last_idx = match.end()
 
         res_md.append(slides_md[last_idx:])
 


Test patch:
diff --git a/tests/test_file_loader.py b/tests/test_file_loader.py
index 2a3ee25..b4ceaab 100644
--- a/tests/test_file_loader.py
+++ b/tests/test_file_loader.py
@@ -44,18 +44,13 @@ def test_file_loader(tmpdir, _):
             ```
         """,
         text=[
-            "       ",
             "'hello'",
-            "       ",
         ],
         style_mask=[
-            "       ",
             "SSSSSSS",
-            "       ",
         ],
         styles={
             "S": {"fg": "g93", "bg": "g15"},
-            " ": {},
         },
     )
 
@@ -84,22 +79,17 @@ Apples1
             ```
         """,
         text=[
-            "       ",
             "Apples1",
             "Apples2",
             "Apples3",
-            "       ",
         ],
         style_mask=[
-            "       ",
             "TTTTTTT",
             "TTTTTTT",
             "TTTTTTT",
-            "       ",
         ],
         styles={
             "T": {"fg": "g93", "bg": "g15"},
-            " ": {},
         },
     )
 
@@ -118,18 +108,13 @@ def test_file_loader_relative(tmpdir, _):
             ```
         """,
         text=[
-            "       ",
             "'hello'",
-            "       ",
         ],
         style_mask=[
-            "       ",
             "SSSSSSS",
-            "       ",
         ],
         styles={
             "S": {"fg": "g93", "bg": "g15"},
-            " ": {},
         },
     )
 
@@ -144,17 +129,12 @@ def test_file_loader_not_found(_):
             ```
         """,
         text=[
-            "              ",
             "File not found",
-            "              ",
         ],
         style_mask=[
-            "              ",
             "TTTTTTTTTTTTTT",
-            "              ",
         ],
         styles={
             "T": {"fg": "g93", "bg": "g15"},
-            " ": {},
         },
     )
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index ba6d787..a52c636 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -37,7 +37,6 @@ def test_heading_defaults(style):
             "|H2|",
             "    ",
             "|H3|",
-            "    ",
         ],
         style_mask=[
             "HHHH",
@@ -45,7 +44,6 @@ def test_heading_defaults(style):
             "hhhh",
             "////",
             "aaaa",
-            "////",
         ],
         styles={
             "H": style["headings"]["default"],
@@ -100,7 +98,6 @@ def test_heading_levels(style):
             ">>H2<<  ",
             "        ",
             "[[[H3]]]",
-            "        ",
         ],
         style_mask=[
             "HHHH////",
@@ -108,7 +105,6 @@ def test_heading_levels(style):
             "hhhhhh//",
             "////////",
             "aaaaaaaa",
-            "////////",
         ],
         styles={
             "H": style["headings"]["1"],
@@ -429,17 +425,17 @@ def test_block_quote(style):
     utils.validate_render(
         md_text="""
             > Quote text
+            >
             > Quote italic text
-            > Quote italic text
-            > Quote italic text
+            >
             > Quote text
         """,
         text=[
             "██━━──               ",
             "┃  Quote text        ",
+            "┃                    ",
             "┃  Quote italic text ",
-            "┃  Quote italic text ",
-            "┃  Quote italic text ",
+            "┃                    ",
             "┃  Quote text        ",
             "██━━──               ",
         ],
@@ -480,20 +476,16 @@ def test_code(styles):
             ```
         """,
         text=[
-            "       ",
             "'Hello'",
             "'Hello'",
             "'Hello'",
             "'Hello'",
-            "       ",
         ],
         style_mask=[
-            "       ",
             "RRRRRRR",
             "RRRRRRR",
             "RRRRRRR",
             "RRRRRRR",
-            "       ",
         ],
         styles={
             "R": {"fg": "#dd8", "bg": "g15"},
@@ -517,16 +509,44 @@ def test_empty_codeblock(style):
         """,
         text=[
             " ",
-            " ",
-            " ",
         ],
         style_mask=[
-            " ",
             "B",
-            " ",
         ],
         styles={
             "B": {"bg": "g15"},
+        },
+    )
+
+
+@override_style(
+    {
+        "code": "monokai",
+    }
+)
+def test_code_preceded_by_text(styles):
+    """Test that code blocks preceded by a line of text render correctly"""
+    utils.validate_render(
+        md_text="""
+            Testing
+
+            ```text
+            a
+            ```
+        """,
+        text=[
+            "Testing",
+            "       ",
+            "a      ",
+        ],
+        style_mask=[
+            "       ",
+            "       ",
+            "B______",
+        ],
+        styles={
+            "B": {"fg": "g93", "bg": "g15"},
+            "_": {"bg": "g15"},
             " ": {},
         },
     )
@@ -551,24 +571,20 @@ def test_code_yaml(styles):
             ```
         """,
         text=[
-            "                      ",
             "test: a value         ",
             'test2: "another value"',
             "array:                ",
             "  - item1             ",
             "  - item2             ",
             "  - item3             ",
-            "                      ",
         ],
         style_mask=[
-            "                      ",
             "KKKK::VVVVVVV_________",
             "KKKKK::SSSSSSSS:SSSSSS",
             "KKKKK:________________",
             "::::VVVVV_____________",
             "::::VVVVV_____________",
             "::::VVVVV_____________",
-            "                      ",
         ],
         styles={
             "K": {"fg": "#f06", "bg": "g15"},
@@ -577,7 +593,6 @@ def test_code_yaml(styles):
             ":": {"fg": "g93", "bg": "g15"},
             "-": {"bg": "g15"},
             "_": {"bg": "g15"},
-            " ": {},
         },
     )
 
@@ -619,18 +634,22 @@ def test_emphasis(styles):
     utils.validate_render(
         md_text="""
             *emphasis*
+
             _emphasis_
         """,
         text=[
             "emphasis",
+            "        ",
             "emphasis",
         ],
         style_mask=[
             "EEEEEEEE",
+            "        ",
             "EEEEEEEE",
         ],
         styles={
             "E": styles["emphasis"],
+            " ": {},
         },
     )
 
@@ -647,18 +666,22 @@ def test_strong_emphasis(styles):
     utils.validate_render(
         md_text="""
             **strong emphasis**
+
             __strong emphasis__
         """,
         text=[
             "strong emphasis",
+            "               ",
             "strong emphasis",
         ],
         style_mask=[
             "EEEEEEEEEEEEEEE",
+            "               ",
             "EEEEEEEEEEEEEEE",
         ],
         styles={
             "E": styles["strong_emphasis"],
+            " ": {},
         },
     )
 
@@ -679,20 +702,29 @@ def test_emphasis_strong_emphasis(styles):
     utils.validate_render(
         md_text="""
             *em **strong emphasis** em*
+
             ***strong emphasis***
+
             _em __strong emphasis__ em_
+
             ___strong emphasis___
         """,
         text=[
             "em strong emphasis em",
+            "                     ",
             "strong emphasis      ",
+            "                     ",
             "em strong emphasis em",
+            "                     ",
             "strong emphasis      ",
         ],
         style_mask=[
             "EEEDDDDDDDDDDDDDDDEEE",
+            "                     ",
             "DDDDDDDDDDDDDDD      ",
+            "                     ",
             "EEEDDDDDDDDDDDDDDDEEE",
+            "                     ",
             "DDDDDDDDDDDDDDD      ",
         ],
         styles={
@@ -777,3 +809,64 @@ def test_image_as_link(styles):
             "L": styles["link"],
         },
     )
+
+
+def test_softbreak(tmpdir, mocker):
+    utils.setup_lookatme(tmpdir, mocker)
+    utils.validate_render(
+        md_text="""
+            hello
+            world
+        """,
+        text=[
+            "hello world",
+        ],
+        style_mask=[
+            "XXXXXXXXXXX",
+        ],
+        styles={
+            "X": {},
+        },
+    )
+
+
+def test_softbreak_with_dash_in_word(tmpdir, mocker):
+    utils.setup_lookatme(tmpdir, mocker)
+    utils.validate_render(
+        md_text="""
+            hello-
+            world
+            hello world
+            hello world
+        """,
+        text=[
+            "hello-world hello world hello world",
+        ],
+        style_mask=[
+            "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
+        ],
+        styles={
+            "X": {},
+        },
+    )
+
+
+def test_softbreak_with_dash_with_preceding_space(tmpdir, mocker):
+    utils.setup_lookatme(tmpdir, mocker)
+    utils.validate_render(
+        md_text="""
+            hello -
+            world
+            hello world
+            hello world
+        """,
+        text=[
+            "hello - world hello world hello world",
+        ],
+        style_mask=[
+            "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
+        ],
+        styles={
+            "X": {},
+        },
+    )