Repository: d0c-s4vage/lookatme
Pull request #195 (2022-12-14 14:57:06)

This PR adds a scrollbar to the slide contents when needed.
See #192 

![image](https://user-images.githubusercontent.com/5090146/207629689-b27b877d-e73b-4fc8-b1ec-2a224211def3.png)


d0c-s4vage: Still need to add some unit tests and update the tutorial to mention it

Patch:
diff --git a/lookatme/log.py b/lookatme/log.py
index 06d409f..6db99bc 100644
--- a/lookatme/log.py
+++ b/lookatme/log.py
@@ -15,6 +15,8 @@ def create_log(log_path):
     stderr_handler.setLevel(logging.ERROR)
     res.addHandler(stderr_handler)
 
+    logging.getLogger("markdown_it").setLevel(logging.CRITICAL)
+
     return res
 
 
diff --git a/lookatme/pres.py b/lookatme/pres.py
index 3d53997..e4ce20a 100644
--- a/lookatme/pres.py
+++ b/lookatme/pres.py
@@ -4,6 +4,7 @@ Defines Presentation specific objects
 
 
 import os
+import sys
 import threading
 import time
 
@@ -29,9 +30,7 @@ from lookatme.tutorial import tutor
      |-----------------------|-----------------|--------------------------------|
      | basic markdown slides | in the terminal | anywhere with markdown support |
 
-    > **NOTE** `l | j | right arrow` advance the slides
-    >
-    > **NOTE** `q` quits
+    Press `l`, `j`, or `right arrow` to go to the next slide, or `q` to quit.
     """,
     order=0,
 )
diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py
index fe71c06..2ff780a 100644
--- a/lookatme/render/markdown_inline.py
+++ b/lookatme/render/markdown_inline.py
@@ -227,6 +227,11 @@ def _next_token_is_html_close(ctx: Context) -> bool:
     )
 
 
+@contrib_first
+def render_hardbreak(_, ctx: Context):
+    ctx.inline_push((ctx.spec_text, "\n"))
+
+
 @contrib_first
 def render_softbreak(_, ctx: Context):
     # do not add spaces between HTML tags!
diff --git a/lookatme/schemas.py b/lookatme/schemas.py
index d090239..0a9db4e 100644
--- a/lookatme/schemas.py
+++ b/lookatme/schemas.py
@@ -471,6 +471,39 @@ class TableSchema(Schema):
     )
 
 
+class ScrollbarGutterSchema(Schema):
+    """Schema for the slider on the scrollbar"""
+
+    fill = fields.Str(dump_default="▕")
+    fg = fields.Str(dump_default="#2c2c2c")
+    bg = fields.Str(dump_default="")
+
+
+class ScrollbarSliderSchema(Schema):
+    """Schema for the slider on the scrollbar"""
+
+    top_chars = fields.List(
+        fields.Str(), dump_default=["⡀", "⣀", "⣠", "⣤", "⣦", "⣶", "⣾", "⣿"]
+    )
+    fill = fields.Str(dump_default="⣿")
+    bottom_chars = fields.List(
+        fields.Str(), dump_default=["⠈", "⠉", "⠋", "⠛", "⠻", "⠿", "⡿", "⣿"]
+    )
+    fg = fields.Str(dump_default="#4c4c4c")
+    bg = fields.Str(dump_default="")
+
+
+class ScrollbarSchema(Schema):
+    """Schema for the scroll bar"""
+
+    gutter = fields.Nested(
+        ScrollbarGutterSchema, dump_default=ScrollbarGutterSchema().dump(None)
+    )
+    slider = fields.Nested(
+        ScrollbarSliderSchema, dump_default=ScrollbarSliderSchema().dump(None)
+    )
+
+
 class StyleSchema(Schema):
     """Styles schema for themes and style overrides within presentations"""
 
@@ -568,6 +601,9 @@ class StyleSchema(Schema):
             "bg": "default",
         },
     )
+    scrollbar = fields.Nested(
+        ScrollbarSchema, dump_default=ScrollbarSchema().dump(None)
+    )
 
 
 class MetaSchema(Schema):
diff --git a/lookatme/tui.py b/lookatme/tui.py
index d5c6483..fd6a74a 100644
--- a/lookatme/tui.py
+++ b/lookatme/tui.py
@@ -21,6 +21,8 @@ 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
+from lookatme.widgets.scrollbar import Scrollbar
+from lookatme.widgets.scroll_monitor import ScrollMonitor
 
 
 def text(style, data, align="left"):
@@ -53,7 +55,7 @@ class SlideRenderer(threading.Thread):
         self._log = lookatme.config.get_log().getChild("RENDER")
 
     def flush_cache(self):
-        """Clea everything out of the queue and the cache."""
+        """Clear everything out of the queue and the cache."""
         # clear all pending items
         with self.queue.mutex:
             self.queue.queue.clear()
@@ -203,7 +205,7 @@ class SlideRenderer(threading.Thread):
         |----------------------------------:|---------------------|
         |                            Tables | Footnotes           |
         |                          Headings | *Images             |
-        |                        Paragraphs | Inline HTML         |
+        |                        Paragraphs |                     |
         |                      Block quotes |                     |
         |                     Ordered lists |                     |
         |                   Unordered lists |                     |
@@ -212,6 +214,7 @@ class SlideRenderer(threading.Thread):
         |                   Double emphasis |                     |
         |                   Single Emphasis |                     |
         |                     Strikethrough |                     |
+        |                       Inline HTML |                     |
         |                             Links |                     |
 
         \*Images may be supported through extensions
@@ -235,10 +238,10 @@ class SlideRenderer(threading.Thread):
 class MarkdownTui(urwid.Frame):
     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(
             urwid.SimpleFocusListWalker([urwid.Text("test")])
         )
+        self.slide_body_scrollbar = Scrollbar(self.slide_body)
         self.slide_title = ClickableText([""], align="center")
         self.top_spacing = urwid.Filler(self.slide_title, top=0, bottom=0)
         self.top_spacing_box = urwid.BoxAdapter(self.top_spacing, 1)
@@ -257,6 +260,9 @@ 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.scrolled_root_paddings = ScrollMonitor(
+            self.root_paddings, self.slide_body_scrollbar
+        )
         self.pres = pres
 
         self.init_ctx()
@@ -269,6 +275,8 @@ class MarkdownTui(urwid.Frame):
         self.ctx.loop = self.loop
         self.no_threads = no_threads
 
+        self._slide_focus_cache = {}
+
         # used to track slides that are being rendered
         self.slide_renderer = SlideRenderer(self.ctx.clone())
         if not no_threads:
@@ -278,7 +286,7 @@ class MarkdownTui(urwid.Frame):
 
         urwid.Frame.__init__(
             self,
-            self.root_paddings,
+            self.scrolled_root_paddings,
             self.top_spacing_box,
             self.bottom_spacing_box,
         )
@@ -322,8 +330,6 @@ class MarkdownTui(urwid.Frame):
         contents
         ```
 
-        <!-- stop -->
-
         ## 2. Metadata
 
         Set the title explicitly through YAML metadata at the start of the slide:
@@ -338,8 +344,6 @@ class MarkdownTui(urwid.Frame):
         Slide contents
         ```
 
-        <!-- stop -->
-
         > **NOTE** Metadata and styling will be covered later in this tutorial
         >
         > **NOTE** `h | k | delete | backspace | left arrow` reverse the slides
@@ -396,18 +400,35 @@ class MarkdownTui(urwid.Frame):
     def update_body(self):
         """Render the provided slide body"""
         rendered = self.slide_renderer.render_slide(self.curr_slide)
+
         self.slide_body.body = rendered
 
+        self._restore_slide_scroll_state()
+
+        scroll_style = config.get_style()["scrollbar"]
+
+        self.slide_body_scrollbar.gutter_spec = spec_from_style(scroll_style["gutter"])
+        self.slide_body_scrollbar.gutter_fill_char = scroll_style["gutter"]["fill"]
+
+        self.slide_body_scrollbar.slider_top_chars = scroll_style["slider"]["top_chars"]
+        self.slide_body_scrollbar.slider_bottom_chars = scroll_style["slider"][
+            "bottom_chars"
+        ]
+        self.slide_body_scrollbar.slider_spec = spec_from_style(scroll_style["slider"])
+        self.slide_body_scrollbar.slider_fill_char = scroll_style["slider"]["fill"]
+
     def update_slide_settings(self):
         """Update the slide margins and paddings"""
+        style = config.get_style()
+
         # reset the base spec from the slides settings
         self.ctx.spec_pop()
-        self.ctx.spec_push(spec_from_style(config.get_style()["slides"]))
+        self.ctx.spec_push(spec_from_style(style["slides"]))
         # re-wrap the root widget with the new styles
         self.loop.widget = self.ctx.wrap_widget(self.root_widget)
 
-        margin = config.get_style()["margin"]
-        padding = config.get_style()["padding"]
+        margin = style["margin"]
+        padding = style["padding"]
 
         self.root_margins.left = margin["left"]
         self.root_margins.right = margin["right"]
@@ -438,6 +459,8 @@ class MarkdownTui(urwid.Frame):
 
     def reload(self):
         """Reload the input, keeping the current slide in focus"""
+        self._cache_slide_scroll_state()
+
         self.init_ctx()
         self.slide_renderer.ctx = self.ctx
 
@@ -449,6 +472,22 @@ class MarkdownTui(urwid.Frame):
         self.prep_pres(self.pres, curr_slide_idx)
         self.update()
 
+    @tutor(
+        "general",
+        "Navigation and Keybindings",
+        r"""
+        Slides are navigated using vim direction keys, arrow keys, and page up/page down:
+
+        |                key(s) | action                  |
+        |----------------------:|-------------------------|
+        | `l` `j` `right arrow` | Next slide              |
+        |  `h` `k` `left arrow` | Previous slide          |
+        |      up / down arrows | Scroll by line          |
+        |   page up / page down | Scroll by pages         |
+        |                   `r` | Reload                  |
+        """,
+        order=0.1,
+    )
     def keypress(self, size, key):
         """Handle keypress events"""
         self._log.debug(f"KEY: {key}")
@@ -479,9 +518,23 @@ class MarkdownTui(urwid.Frame):
         if new_slide_num == self.curr_slide.number:
             return
 
+        self._cache_slide_scroll_state()
         self.curr_slide = self.pres.slides[new_slide_num]
         self.update()
 
+    def _cache_slide_scroll_state(self):
+        self._slide_focus_cache[self.curr_slide.number] = (
+            self.slide_body.offset_rows,
+            self.slide_body.focus_position,
+        )
+
+    def _restore_slide_scroll_state(self):
+        offset_rows, focus_pos = self._slide_focus_cache.setdefault(
+            self.curr_slide.number, (0, 0)
+        )
+        self.slide_body.set_focus(focus_pos)
+        self.slide_body.offset_rows = offset_rows
+
     def _get_key(self, size, key):
         """Resolve the key that was pressed."""
         try:
diff --git a/lookatme/tutorial.py b/lookatme/tutorial.py
index 2330bbe..fc72eec 100644
--- a/lookatme/tutorial.py
+++ b/lookatme/tutorial.py
@@ -27,7 +27,7 @@ class Tutor:
         group: str,
         slides_md: str,
         impl_fn: Callable,
-        order: int,
+        order: float,
         lazy_formatting: Optional[Callable] = None,
     ):
         """Create a new Tutor
@@ -202,7 +202,7 @@ def tutor(
     group: str,
     name: str,
     slides_md: str,
-    order: int = 99999,
+    order: float = 99999.0,
     lazy_formatting: Optional[Callable] = None,
 ):
     """Define tutorial slides by using this as a decorator on a function!"""
diff --git a/lookatme/widgets/scroll_monitor.py b/lookatme/widgets/scroll_monitor.py
new file mode 100644
index 0000000..b4f560f
--- /dev/null
+++ b/lookatme/widgets/scroll_monitor.py
@@ -0,0 +1,54 @@
+import urwid
+
+
+from lookatme.widgets.scrollbar import Scrollbar
+from lookatme.tutorial import tutor
+
+
+@tutor(
+    "general",
+    "scrolling",
+    r"""
+    The scrollbar that kkjjj
+
+    Scroll with:
+
+    | amount | up key   | down key   |
+    |-------:|----------|------------|
+    |   line | up arrow | down arrow |
+    |   page | page up  | page down  |
+
+    Try scrolling through the numbers 0-49 below:
+
+    {numbers}
+    """.format(
+        numbers="\n\n    ".join(str(n) for n in range(50))
+    ),
+    order=0.2,
+)
+class ScrollMonitor(urwid.Frame):
+    def __init__(self, main_widget: urwid.Widget, scrollbar: Scrollbar):
+        self.main_widget = main_widget
+        self.scrollbar = scrollbar
+        super().__init__(self.main_widget)
+
+    def render(self, size, focus: bool = True):
+        if not self.scrollbar.should_display(size):
+            return super().render(size, focus)
+
+        width, height = size
+
+        main_canvas = super().render((width - 1, height), focus)
+        if not isinstance(main_canvas, urwid.CompositeCanvas):
+            main_canvas = urwid.CompositeCanvas(main_canvas)
+
+        scroll_canvas = self.scrollbar.render((1, height), focus)
+        if not isinstance(scroll_canvas, urwid.CompositeCanvas):
+            scroll_canvas = urwid.CompositeCanvas(scroll_canvas)
+
+        return urwid.CanvasJoin(
+            [
+                (main_canvas, None, True, main_canvas.cols()),
+                (scroll_canvas, None, False, scroll_canvas.cols()),
+            ]
+        )
diff --git a/lookatme/widgets/scrollbar.py b/lookatme/widgets/scrollbar.py
new file mode 100644
index 0000000..e611668
--- /dev/null
+++ b/lookatme/widgets/scrollbar.py
@@ -0,0 +1,188 @@
+import math
+from typing import List, Optional, Tuple
+
+
+import urwid
+from urwid.signals import connect_signal
+
+
+import lookatme.config as config
+from lookatme.widgets.smart_attr_spec import SmartAttrSpec
+
+
+class Scrollbar(urwid.Widget):
+    """A scrollbar that reflects the current scroll state of a list box.
+    Non-selectable, non-interactive, informative only. Especially useful as
+    an overlay or a box column so that you can have additional padding around
+    your ListBox.
+    """
+
+    _sizing = frozenset([urwid.BOX])
+    _selectable = False
+    ignore_focus = True
+
+    def __init__(self, listbox: urwid.ListBox):
+        super().__init__()
+
+        self.slider_top_chars = ["⡀", "⣀", "⣠", "⣤", "⣦", "⣶", "⣾", "⣿"]
+        self.slider_bottom_chars = ["⠈", "⠉", "⠋", "⠛", "⠻", "⠿", "⡿", "⣿"]
+
+        self.slider_fill_char = "⣿"
+        self.slider_spec = SmartAttrSpec("#4c4c4c", "")
+        self.gutter_fill_char = "▕"
+        self.gutter_spec = SmartAttrSpec("#2c2c2c", "")
+
+        self._listbox_widget_size_cache: Optional[
+            Tuple[List[Tuple[int, int, int]], Tuple, bool, int]
+        ] = None
+
+        self.listbox = listbox
+        connect_signal(self.listbox.body, "modified", self._invalidate)
+        self.listbox.render_orig = self.listbox.render
+        self.listbox.render = self._listbox_render
+
+    def _listbox_invalidate(self):
+        self.listbox._invalidate_orig()
+        self._invalidate()
+
+    def _listbox_render(self, size, focus: bool = False):
+        self.listbox._last_size = size
+        self.listbox._last_focus = focus
+        res = self.listbox.render_orig(size, focus)
+        self._invalidate()
+        return res
+
+    def render(self, size: Tuple[int, int], focus: bool = False):
+        """Please use `needs_scrollbar()` if manually deciding to render the
+        Scrollbar (e.g. if you're overlaying the rendered results onto a canvas)
+        """
+        if not self.slider_top_chars:
+            self.slider_top_chars.append(self.slider_fill_char)
+        if not self.slider_bottom_chars:
+            self.slider_bottom_chars.append(self.slider_fill_char)
+
+        before, visible, after, total = self._get_listbox_visible_scroll_range()
+        scroll_percent = before / float(before + after)
+        scroll_percent = max(0.0, scroll_percent)
+
+        _, height = size
+        total_chars = height
+
+        slider_height = max(height * float(visible) / total, 2.0)
+        fill_count = slider_height
+
+        slider_end_idx_f = (
+            slider_height + (total_chars - slider_height) * scroll_percent
+        )
+        slider_end_val = slider_end_idx_f - math.floor(slider_end_idx_f)
+
+        if slider_end_val == 0.0:
+            slider_end_char = ""
+        else:
+            fill_count -= slider_end_val
+            slider_end_char = self.slider_bottom_chars[
+                int(slider_end_val // (1.0 / len(self.slider_bottom_chars)))
+            ]
+
+        slider_start_idx_f = slider_end_idx_f - slider_height
+        slider_start_val = 1.0 - (slider_start_idx_f - math.floor(slider_start_idx_f))
+
+        if slider_start_val == 1.0:
+            slider_start_char = ""
+        else:
+            fill_count -= slider_start_val
+            slider_start_char = self.slider_top_chars[
+                int(slider_start_val // (1.0 / len(self.slider_top_chars)))
+            ]
+
+        slider_start_idx = math.floor(slider_start_idx_f)
+        slider_end_idx = math.ceil(slider_end_idx_f)
+
+        slider_chars = "{}{}{}".format(
+            slider_start_char,
+            self.slider_fill_char * round(fill_count),
+            slider_end_char,
+        )
+
+        scroll_text = [
+            (
+                self.gutter_spec,
+                "\n".join(self.gutter_fill_char * slider_start_idx),
+            ),
+            (self.slider_spec, "\n".join(slider_chars)),
+            (
+                self.gutter_spec,
+                "\n".join(self.gutter_fill_char * (total_chars - slider_end_idx)),
+            ),
+        ]
+
+        return urwid.Text(scroll_text).render((1,), False)
+
+    def _get_listbox_visible_scroll_range(self) -> Tuple[int, int, int, int]:
+        """Return a tuple containing: (visible_start_idx, visible_stop_idx, total_rows)"""
+        # we're assuming that the 1 char for the scrollbar is already taken
+        # out
+        size = self.listbox._last_size  # type: ignore
+        height = size[1]
+
+        widget_sizes = self._get_listbox_widget_sizes(size, self.listbox._last_focus)
+        # using calculate_visible has turned out to be the most reliable way
+        # to determine the bounds of the current view of the ListBox. Other
+        # ways had problems when the screen is resized (e.g., scroll to the
+        middle, top, bottom = self.listbox.calculate_visible(size, True)
+
+        # from urwid's source:
+        #         *middle*
+        #             (*row offset*(when +ve) or *inset*(when -ve),
+        #             *focus widget*, *focus position*, *focus rows*,
+        #             *cursor coords* or ``None``)
+        row_offset, focus_widget, focus_pos, focus_rows, cursor = middle
+        top_trim, top_widgets = top
+
+        if len(top_widgets) > 0:
+            # from urwid's source:
+            #         *top*
+            #             (*# lines to trim off top*,
+            #             list of (*widget*, *position*, *rows*) tuples above focus
+            #             in order from bottom to top)
+            top_w, top_pos, top_rows = top_widgets[-1]
+            before = widget_sizes[top_pos][1] + top_trim
+        else:
+            before = widget_sizes[focus_pos][1] + abs(row_offset)
+
+        total = widget_sizes[-1][-1]
+        after = total - before - height
+
+        return before, height, after, total
+
+    def _get_listbox_widget_sizes(self, size, focus):
+        curr_body_id = id(self.listbox.body)
+        if self._listbox_widget_size_cache is not None:
+            (
+                cached_widget_sizes,
+                cached_size,
+                cached_focus,
+                cached_body_id,
+            ) = self._listbox_widget_size_cache
+            if (
+                True
+                and cached_size == size
+                and cached_focus == focus
+                and cached_body_id == curr_body_id
+            ):
+                return cached_widget_sizes
+
+        widget_sizes = []
+        total = 0
+        for widget in self.listbox.body:
+            w_rows = widget.rows((size[0],))
+            widget_sizes.append((w_rows, total, total + w_rows))
+            total += w_rows
+
+        self._listbox_widget_size_cache = (widget_sizes, size, focus, curr_body_id)
+
+        return widget_sizes
+
+    def should_display(self, size, focus: bool = False):
+        # will return ['top', 'bottom'] if both ends of the content are visible
+        return len(self.listbox.ends_visible(size, focus)) != 2


Test patch:
diff --git a/tests/test_slides.py b/tests/test_slides.py
index 21bf9ad..a66038b 100644
--- a/tests/test_slides.py
+++ b/tests/test_slides.py
@@ -259,3 +259,118 @@ Hello world
             "_": style["slides"],
         },
     )
+
+
+@override_style(
+    {
+        "slides": {
+            "fg": "#ff0000",
+            "bg": "#00ffff",
+        },
+        "title": {
+            "fg": "#111100",
+            "bg": "#001111",
+        },
+        "slide_number": {
+            "fg": "#222200",
+            "bg": "#002222",
+        },
+        "author": {
+            "fg": "#333300",
+            "bg": "#003333",
+        },
+        "date": {
+            "fg": "#444400",
+            "bg": "#004444",
+        },
+        "margin": {
+            "top": 0,
+            "bottom": 0,
+            "left": 0,
+            "right": 0,
+        },
+        "padding": {
+            "top": 1,
+            "bottom": 1,
+            "left": 1,
+            "right": 1,
+        },
+        "scrollbar": {
+            "slider": {
+                "top_chars": ["*", "X"],
+                "fill": "X",
+                "bottom_chars": ["*", "X"],
+                "fg": "#334455",
+                "bg": "#534453",
+            },
+            "gutter": {
+                "fill": "-",
+                "fg": "#994455",
+                "bg": "#594459",
+            },
+        },
+    },
+    complete=True,
+)
+def test_slide_scrollbar(style):
+    """Test that styles are applied correctly to the different slide elements"""
+    meta = {
+        "title": "title",
+        "styles": style,
+        "date": "DATE",
+        "author": "me",
+    }
+    md = r"""
+---
+{}
+---
+Hi\
+Hi\
+Hi\
+Hi\
+Hi\
+Hi\
+Hi\
+Hi\
+    """.format(
+        yaml.dump(meta)
+    )
+
+    utils.validate_render(
+        as_slide=True,
+        render_width=30,
+        render_height=9,  # should be four lines not in view
+        md_text=md,
+        text=[
+            "             title            ",
+            "                              ",
+            " Hi                          X",
+            " Hi                          X",
+            " Hi                          X",
+            " Hi                          *",
+            " Hi                          -",
+            "                              ",
+            "me DATE            slide 1 / 1",
+        ],
+        style_mask=[
+            "_____________TTTTT____________",
+            "______________________________",
+            "__ss_________________________L",
+            "__ss_________________________L",
+            "__ss_________________________L",
+            "__ss_________________________L",
+            "__ss_________________________G",
+            "______________________________",
+            "AA_DDDD____________SSSSSSSSSSS",
+        ],
+        styles={
+            "T": style["title"],
+            "s": style["slides"],
+            "A": style["author"],
+            "D": style["date"],
+            "S": style["slide_number"],
+            "_": style["slides"],
+            "L": style["scrollbar"]["slider"],
+            "G": style["scrollbar"]["gutter"],
+        },
+    )