Repository: urwid/urwid (language: python)

Issue:
How to make any widget scrollable?
I have a bunch of fixed, multi-line, non-focusable widgets combined in a
GridfFlow, and the resulting canvas is larger than any reasonably sized
terminal. Is there any way I can scroll the whole canvas up and down?

If not, I was thinking that it would be nice to have a `Scrollable` widget that
wraps another widget. It would implement a `keypress` method that catches the
keys `up` and `down`, keeps track of the scrolling position and a `render`
method that renders the whole wrapped widget and extracts the part of the canvas
at the current scroll position.

That would make it possible to scroll through arbitrarily complex widgets line
by line. Eventually, it could also implement vertical scrolling and scroll bars.

Is that a good approach or is there a better way to do it?


Discussion / hints:
I went ahead and implemented a `Scrollable` and a `ScrollBar` class:
https://github.com/rndusr/stig/blob/dev/stig/tui/scroll.py

The behaviour when changing focus inside a `Scrollable` can be a little
unexpected, depending on the layout, but it should be possible to fix
that. Otherwise it works quite well for me.

I'd be happy to make adjustments and eventually submit a pull request if there
is interest.
I could definitely use this. Could you provide examples on how to use these for a urwid newbie?
`Scrollable` and `ScrollBar` just wrap around other widgets. Scrollable should just work with any fixed or flow widget.

Widgets supplied to ScrollBar need the non-standard methods `rows_max` (returns maximum/total number of rows for a given size) and `get_scrollpos` (returns current scroll position). `Scrollable` provides these methods.

Here is a very simple example:

```python
text1 = '''
Not
a
very
long
Text.
'''

text2 = '''
Another
not
very
long
Text.
'''

main = urwid.Pile([('pack', urwid.Text(text1)),
                   ('pack', urwid.Text(text2))])
mainloop = urwid.MainLoop(ScrollBar(Scrollable(main)))
mainloop.run()
```

You can also have a look at the tests:
https://github.com/rndusr/stig/blob/dev/tests/tui_test/scroll_test.py

If you want to add a ScrollBar to a ListBox, you have to monkey patch the ListBox class like this:
https://github.com/rndusr/stig/blob/dev/stig/tui/urwidpatches.py#L124

Then you should be able to do `ScrollBar(PatchedListBox(...))`.

Have fun! :)

Thanks! This should get me on the right track.
The links in your earlier comments were broken, but I did find the code from https://github.com/rndusr/stig/blob/master/stig/tui/scroll.py

There were a couple of fixes I needed to do before the scrollbar worked correctly. You may want to upstream these:

```
253c253
<     def __init__(self, widget, thumb_char='\u2588', trough_char=' ',
---
>     def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ',
303c303
<         top_weight = pos / max(1, posmax)
---
>         top_weight = float(pos) / max(1, posmax)
304a305
>
```

Thanks again!
That's because I'm using it in a python3-only project and didn't notice the lacking python2 compatibility.

I fixed it anyway in case others want to use it. Thanks for the note.

I also find this functionality very useful.
As indicated [here](https://github.com/rndusr/stig/issues/53#issuecomment-367365660), it seems mostly ready except for some missing optimizations (although it is unclear how easily implemented those are).

I would suggest to start with a PR for `Scrollable` and `ScrollBar`, in order to continue with explicit code.
What do you think?
Hi there, I'm interested in this functionality as well. @rndusr anything I can do to help here?
Thanks for the offer. I don't see an easy way the remaining issues can be
resolved without redesigning some parts of urwid. But I'd be willing to keep
working on this and make a PR if anyone finds a solution.

One issue is figuring out if the widget we want to make scrollable should get a
keypress.
https://github.com/rndusr/stig/blob/master/stig/tui/scroll.py#L112

Another issue is moving the cursor when making a container with multiple Edit
widgets scrollable. An Edit can also have multiple lines.
https://github.com/rndusr/stig/blob/master/stig/tui/scroll.py#L37

I wish I would've kept my experiment setup so I could provide some code that
demonstrates the issues more clearly.

Just tossing out ideas:
- For the former, what if the render method accepted an offset `(x, y)` tuple? I don't think that can be done in a backwards-compatible way, though...
- For the latter, could we patch the Edits to return the keystroke if no action can be taken? (Ex: Return "up" if the cursor is already at line 0 of the Edit)
To be honest, I don't remember the details.

I'll try to find some time this week to make new demos.

Here's the demo code for the keypress issue:
https://gist.github.com/rndusr/7e139a5709a44db2debc4d1702c53444

Here's the demo for the other known issue:
https://gist.github.com/rndusr/10f11864ff1b8d62907cf9a5abdff418

It's not a real dealbreaker, but this behaviour can be really unintuitive in my
opinion.

@rndusr 
Your `ScrollBar` looks fantastic and I'm looking into using it in https://github.com/rr-/pq-cli.git, however it seems there is a problem when you combine it with `urwid.Overlay`:

```
import random

import urwid

from scroll import Scrollable, ScrollBar


texts = [
    """Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.""",
    "test",
]


def linebox():
    return urwid.LineBox(
        ScrollBar(
            Scrollable(
                urwid.Pile(
                    [
                        urwid.Columns(
                            [
                                urwid.Text(random.choice(texts)),
                                urwid.Text(random.choice(texts)),
                            ]
                        )
                        for _ in range(25)
                    ]
                )
            )
        )
    )


columns = urwid.Columns(
    [
        ("weight", 1, urwid.Pile([linebox(), linebox()])),
        ("weight", 2, urwid.Filler(urwid.Text("derp"))),
    ]
)


def unhandled_keypress(key):
    if key == "ctrl x":
        raise urwid.ExitMainLoop()


widget = urwid.Pile([columns, (1, urwid.Filler(urwid.Text("derp")))])
loop = urwid.MainLoop(widget, unhandled_input=unhandled_keypress)
loop.widget = urwid.Overlay(
    urwid.LineBox(urwid.Filler(urwid.Text("herp"))),
    loop.widget,
    "center",
    10,
    "middle",
    10,
)
loop.run()
```

I get either `cviews do not fill gaps in shard_tail!` or `cviews overflow gaps in shard_tail!`. I'm not currently sure if it's bug in urwid, or the `ScrollBar` implementation. Tested on 188x41 terminal size.

If I change urwid sources to `break` rather than throw these `CanvasError`s, this is what I end up getting (perhaps it might tell you something):
![20181108_232221_cez](https://user-images.githubusercontent.com/1045476/48231026-2a1a1580-e3ad-11e8-966f-80d67fa4d25c.png)

Edit: Looks like it doesn't concern only overlays - I managed to get following behavior that involves vertical axis, unfortunately I haven't been able to come up with a SSCCE to demonstrate this bug:
![20181108_232803_aem](https://user-images.githubusercontent.com/1045476/48231328-04d9d700-e3ae-11e8-8ff6-b2a6fd67b1b8.png)

It's probably a bug in my implementation. It's kind of hacked into urwid. As
mentioned earlier, there are other issues, too.

I'm not very motivated to look into this as the maintainer doesn't seem
interested in a PR. But if you find a solution, feel free to make a PR in stig.

Hi @rndusr , what do you recommand to do when using : http://urwid.org/tutorial/index.html#horizontal-menu, to display urwid.Text([u'  You chose ', self.caption, u'\n']) taking as much as space as possible of the screen and scrollable ?

Thanks a lot in advance
I don't understand you question.

Firstly, from the screenshots it looks like it already does take as much screen
space as the menu.

Secondly, what do you want to scroll? "You chose Idle" fits in a one-line
terminal, and even then there is nothing to scroll.

Maybe you can post mockup screenshots to show what you want?

@rndusr The box size is 10x24. I changed it to 80x24, so it's ok when browsing the menu but when I select an item, I want to display a text (or a log file) scrollable in full size.

How to achieve that ?

Mockup for reference: https://s3.amazonaws.com/assets.mockflow.com/app/wireframepro/company/C88ac345e101c4bd481d46a50faad3b3e/projects/M3f341fbb2161da52621d9f5b32fb72c31562138537608/pages/f3c80246efda4eae819a3a8cbeea4ccd/image/f3c80246efda4eae819a3a8cbeea4ccd.png
Should be as simple as this:

    urwid.ScrollBar(urwid.Text(...))

I'm not sure if you need to wrap the ScrollBar into a Frame or something if
you're want it to be the top-level widget. I never got the hang of urwid's
sizing modes: http://urwid.org/manual/widgets.html#box-flow-and-fixed-widgets

@rndusr There is no default ScrollBar, right ?

Because I checked on the official repo and there is none.

I have to use yours (https://github.com/rndusr/stig/blob/master/stig/tui/scroll.py) , right ?


Thanks a lot for the answers
Yes, unfortunately, you have to copy the code.

It's not in urwid because of the minor bugs I mentioned earlier that I don't
know how to fix.

@rndusr Ok thanks I will give it a try
I tried and when using it :
`ScrollBar(urwid.Text(commands[self.caption]['command'](**parameters)))`

I get the following exception:
> ValueError: Not a box widget: <Text flow widget '\nOK, processing command...\n\n-- Status of application --\nOK -'>

Sorry, my mistake. You need to wrap the Text in a Scrollable first:

    ScrollBar(Scrollable(urwid.Text(...)))

@rndusr Thanks, I will test it.
@rndusr It still does not work :/
My code:
`response = ScrollBar(Scrollable(urwid.Text(commands[self.caption]['command'](**parameters))))`
Stack trace:
`AttributeError: 'ScrollBar' object has no attribute 'rows'`
Looks like ScrollBar is a box widget and you're wrapping it in a widget that
expects a flow widget.

http://urwid.org/manual/widgets.html#box-flow-and-fixed-widgets

@rndusr Indeed, that was it, thanks a lot, it's awesome !
Really love this functionality https://github.com/rndusr/stig/blob/master/stig/tui/scroll.py wish we could get it into urwid master
@rndusr Sorry to ask is dragging the scrollbar supported? If so is there any short working example? I saw there's `mouse_event()` defined in the `ScrollBar` class but from your given example I cannot drag the scrollbar. From what I have found is that `ScrollBar.mouse_event()` does get invoked when I click the scroll bar.

```python
import urwid
from scrollable.py import ScrollBar, Scrollable

text1 = '''
Not
a
very
long
Text.
''' * 100    # now it is very long

text2 = '''
Another
not
very
long
Text.
'''

main = urwid.Pile([('pack', urwid.Text(text1)),
                   ('pack', urwid.Text(text2))])
mainloop = urwid.MainLoop(ScrollBar(Scrollable(main)))
mainloop.run()
```
Since I don't need mouse support at all it's probably not implemented. IIRC, you
can move the scroll bar by clicking on it because that was relatively simple.

It looks like this thread went dead. Is anyone working on getting Scrollbar into master?

On the other hand maybe there is or could be an utility package with addons that will contain it? I think it's a common practice.
Just a note for others looking for this feature. It has since been added to [panwid](https://github.com/tonycpsu/panwid) which is packaged in pip, and Debian/Ubuntu.
Now that urwid is under active development again 🎉, is it time to resurrect this feature request? @rndusr 's scrollbar works well and should be added to the core library, especially if the "minor bugs" that @rndusr mentions above can be addressed. (I don't think I've experienced any bugs using this scrollbar in the [toot ](https://github.com/ihabunek/toot)client UI, but YMMV)  Tagging @penguinolog for visibility
The slightly improved [Scrollable.py](https://github.com/markqvist/NomadNet/blob/master/nomadnet/vendor/Scrollable.py) from [Nomad Network](https://github.com/markqvist/NomadNet/) now works in Urwid 2.4.2.

I don't know if *all* the bugs mentioned by @rndUsr above has been fixed, but I did fix a number of bugs and made some improvements to navigation and cursor placement during my use of it for Nomad Network. All in all, it's been working very well all the time it's been used in the program.

It should more or less be a drop in for use in Urwid itself.
Thanks! Did you have any crash bugs with 2.4.2 (or any post -2.1.2 release) that needed fixing? I ask because I'm getting a report of an intermittent crash in toot that uses an older version of scrollable.py , not sure it's at all related, but thought I might get lucky..
Not *yet*. Nomadnet has been running stable with Urwid 2.1.2 and it's own version of Scrollable for a long time, but I just updated it to 2.4.2 today, so it's a bit uncertain at this point. Have been monkeying around with it, and it seems all good so far, but not making any guarantees yet ;)

Feel free to grab it from the nomadnet repo and try it out though.
Looks like I'll make port myself, because it will require some changes to land in `widgets`.

Since re-license permission was received, it will require only some extra tests.
Premission link: https://github.com/markqvist/NomadNet/pull/46#issuecomment-1892712616

Fix patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index 803cee996..45e098443 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,5 @@
 recursive-include examples *.py *.tac
 recursive-include docs *.rst *.py *.py.xdotool *.png *.html *.sh Makefile
 include COPYING CHANGELOG README.rst
+include requirements.txt classifiers.txt
 include urwid/display/_web.js urwid/display/_web.css
diff --git a/docs/manual/widgets.rst b/docs/manual/widgets.rst
index 2c3c4aaae..c9983f6a2 100644
--- a/docs/manual/widgets.rst
+++ b/docs/manual/widgets.rst
@@ -508,10 +508,9 @@ List Walker Interface
 List Walker API Version 1
 ~~~~~~~~~~~~~~~~~~~~~~~~~
 
-This API will remain available and is still the least restrictive option for
-the programmer.  Your class should subclass :class:`ListWalker`.
-Whenever the focus or content changes you are responsible for
-calling :meth:`ListWalker._modified`.
+This API will remain available and is still the least restrictive option fo the programmer.
+Your class should subclass :class:`ListWalker`.
+Whenever the focus or content changes you are responsible for calling :meth:`ListWalker._modified`.
 
 .. currentmodule:: MyV1ListWalker
 
@@ -578,7 +577,59 @@ When this is defined it will be used by :meth:`ListBox.__iter__` and
 
 .. currentmodule:: urwid
 
+Scrollable Widgets
+==================
+Scrollable widgets can scroll long content which normally not fit into the screen resolution.
+Scrolling is normally supported using keyboard positioning keys and mouse wheel.
+
+Comparing to the :class:`ListBox`,
+:class:`Scrollable` handles fixed and flow widgets directly instead of using list of small widgets.
+
+:class:`ListBox` should be used to scroll between widgets, which can be multiline by itself.
+:class:`Scrollable` should be used to scroll over widget lines.
+
+:class:`ScrollBar` provide visual scrollbar with scrolling position information.
+
+.. currentmodule:: Scrollable
+
+:class:`Scrollable` implements special container for making widget scrollable via :class:`ScrollBar` widget.
+
+Scrollable API
+--------------
+
+Widget pretending to be scrolled via :class:`ScrollBar` should subclass :class:`Widget`
+and implement positioning API:
 
+.. py:method:: get_scrollpos(size=None, focus=False)
+
+    Get scrolling position
+
+    :param size: widget render size. If `size` is not given, the currently rendered number of rows is returned.
+    :type size: tuple[int, int] | None
+    :param focus: widget is focused
+    :type focus: bool
+    :return: the index of the first visible row.
+    :rtype: int
+
+.. py:method:: set_scrollpos(position: SupportsInt)
+
+    Set scrolling position.
+
+    If `position` is positive it is interpreted as lines from the top.
+    If `position` is negative it is interpreted as lines from the bottom.
+
+.. py:method:: rows_max(size=None, focus=False)
+
+    Get the total number of rows `widget` can render.
+
+    :param size: widget render size. If `size` is not given, the currently rendered number of rows is returned.
+    :type size: tuple[int, int] | None
+    :param focus: widget is focused
+    :type focus: bool
+    :return: the number of rows for `size`
+    :rtype: int
+
+.. currentmodule:: urwid
 
 Custom Widgets
 ==============
diff --git a/docs/reference/widget.rst b/docs/reference/widget.rst
index ec8740fac..82ebe450d 100644
--- a/docs/reference/widget.rst
+++ b/docs/reference/widget.rst
@@ -219,3 +219,19 @@ Terminal
 
 .. autoclass:: Terminal
 
+
+Helper Widget Classes
+---------------------
+.. currentmodule:: urwid.widget
+
+Scrollable
+~~~~~~~~~~
+
+.. autoclass:: Scrollable
+
+ScrollBar
+~~~~~~~~~
+
+.. autoclass:: ScrollBar
+
+.. currentmodule:: urwid
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 0e5507910..b84008b5e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,7 @@ keywords = ["curses", "ui", "widget", "scroll", "listbox", "user interface", "te
 license={text="LGPL-2.1-only"}  # Use SPDX classifier
 readme = {file = "README.rst", content-type = "text/x-rst"}
 authors=[{name="Ian Ward", email="ian@excess.org"}]
-dynamic = ["classifiers", "version"]  # "dependencies",
+dynamic = ["classifiers", "version", "dependencies"]
 
 [project.urls]
 "Homepage" = "https://urwid.org/"
@@ -41,7 +41,7 @@ exclude = [
 namespaces = false
 
 [tool.setuptools.dynamic]
-#dependencies = {file = ["requirements.txt"]}
+dependencies = {file = ["requirements.txt"]}
 classifiers = {file = ["classifiers.txt"]}
 
 [project.optional-dependencies]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 000000000..85b288032
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,1 @@
+typing-extensions
\ No newline at end of file
diff --git a/urwid/canvas.py b/urwid/canvas.py
index 9d07da1a0..240e6825d 100644
--- a/urwid/canvas.py
+++ b/urwid/canvas.py
@@ -40,7 +40,7 @@
 )
 
 if typing.TYPE_CHECKING:
-    from collections.abc import Sequence
+    from collections.abc import Iterable, Sequence
 
     from .widget import Widget
 
@@ -1209,7 +1209,7 @@ def cview_trim_cols(cv, cols: int):
     return cv[:2] + (cols,) + cv[3:]
 
 
-def CanvasCombine(canvas_info):
+def CanvasCombine(canvas_info: Iterable[tuple[Canvas, typing.Any, bool]]) -> CompositeCanvas:
     """Stack canvases in l vertically and return resulting canvas.
 
     :param canvas_info: list of (canvas, position, focus) tuples:
@@ -1245,7 +1245,7 @@ def CanvasCombine(canvas_info):
     return combined_canvas
 
 
-def CanvasOverlay(top_c, bottom_c, left: int, top: int):
+def CanvasOverlay(top_c, bottom_c, left: int, top: int) -> CompositeCanvas:
     """
     Overlay canvas top_c onto bottom_c at position (left, top).
     """
@@ -1258,7 +1258,7 @@ def CanvasOverlay(top_c, bottom_c, left: int, top: int):
     return overlayed_canvas
 
 
-def CanvasJoin(canvas_info):
+def CanvasJoin(canvas_info: Iterable[tuple[Canvas, typing.Any, bool, int]]) -> CompositeCanvas:
     """
     Join canvases in l horizontally. Return result.
 
diff --git a/urwid/widget/__init__.py b/urwid/widget/__init__.py
index b2d3f250c..558ff5fbf 100644
--- a/urwid/widget/__init__.py
+++ b/urwid/widget/__init__.py
@@ -34,6 +34,7 @@
 from .pile import Pile, PileError, PileWarning
 from .popup import PopUpLauncher, PopUpTarget
 from .progress_bar import ProgressBar
+from .scrollable import Scrollable, ScrollableError, ScrollBar
 from .solid_fill import SolidFill
 from .text import Text, TextError
 from .widget import (
@@ -151,6 +152,9 @@
     "ProgressBar",
     "WidgetContainerListContentsMixin",
     "WidgetWarning",
+    "Scrollable",
+    "ScrollableError",
+    "ScrollBar",
 )
 
 # Backward compatibility
diff --git a/urwid/widget/scrollable.py b/urwid/widget/scrollable.py
new file mode 100644
index 000000000..e8071a995
--- /dev/null
+++ b/urwid/widget/scrollable.py
@@ -0,0 +1,593 @@
+# Copyright (C) 2024 Urwid developers
+#    This library is free software; you can redistribute it and/or
+#    modify it under the terms of the GNU Lesser General Public
+#    License as published by the Free Software Foundation; either
+#    version 2.1 of the License, or (at your option) any later version.
+#
+#    This library is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+#    Lesser General Public License for more details.
+#
+#    You should have received a copy of the GNU Lesser General Public
+#    License along with this library; if not, write to the Free Software
+#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+# Urwid web site: https://urwid.org/
+#
+# Copyright (C) 2017-2024 rndusr (https://github.com/rndusr)
+# Re-licensed from gpl-3.0 with author permission.
+# Permission comment link: https://github.com/markqvist/NomadNet/pull/46#issuecomment-1892712616
+
+from __future__ import annotations
+
+import contextlib
+import enum
+import typing
+
+from typing_extensions import Protocol, runtime_checkable
+
+from .constants import Sizing
+from .widget_decoration import WidgetDecoration, WidgetError
+
+if typing.TYPE_CHECKING:
+    from collections.abc import Iterator
+
+    from typing_extensions import Literal
+
+    from urwid import Canvas, CompositeCanvas
+
+    from .widget import Widget
+
+
+__all__ = ["Scrollable", "ScrollBar", "ScrollableError", "Blocks"]
+
+
+class ScrollableError(WidgetError):
+    """Scrollable specific widget errors."""
+
+
+# Scroll actions
+SCROLL_LINE_UP = "line up"
+SCROLL_LINE_DOWN = "line down"
+SCROLL_PAGE_UP = "page up"
+SCROLL_PAGE_DOWN = "page down"
+SCROLL_TO_TOP = "to top"
+SCROLL_TO_END = "to end"
+
+# Scrollbar positions
+SCROLLBAR_LEFT = "left"
+SCROLLBAR_RIGHT = "right"
+
+
+class Blocks(str, enum.Enum):
+    """Standard normal width blocks."""
+
+    FULL = "█"
+    DARK_SHADE = "▓"
+    MEDIUM_SHADE = "▒"
+    LITE_SHADE = "░"
+
+
+@runtime_checkable
+class SupportsScroll(Protocol):
+    """Protocol for scroll supporting widget.
+
+    Due to protocol can not inherit non-protocol bases, require also several obligatory Widget methods.
+    """
+
+    # Base widget methods (from Widget)
+    def sizing(self) -> frozenset[Sizing]:
+        ...
+
+    def selectable(self) -> bool:
+        ...
+
+    def pack(self, size: tuple[int, int], focus: bool = False) -> tuple[int, int]:
+        ...
+
+    @property
+    def base_widget(self) -> Widget:
+        raise NotImplementedError
+
+    def keypress(self, size: tuple[int, int], key: str) -> str | None:
+        ...
+
+    def mouse_event(
+        self,
+        size: tuple[int, int],
+        event: str,
+        button: int,
+        col: int,
+        row: int,
+        focus: bool,
+    ) -> bool | None:
+        ...
+
+    def render(self, size: tuple[int, int], focus: bool = False) -> Canvas:
+        ...
+
+    # Scroll specific methods
+    def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
+        ...
+
+    def set_scrollpos(self, position: typing.SupportsInt) -> None:
+        ...
+
+    def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
+        ...
+
+
+class Scrollable(WidgetDecoration):
+    def sizing(self) -> frozenset[Sizing]:
+        return frozenset((Sizing.BOX,))
+
+    def selectable(self) -> bool:
+        return True
+
+    def __init__(self, widget: Widget, force_forward_keypress: bool = False) -> None:
+        """Box widget that makes a fixed or flow widget vertically scrollable
+
+        .. note::
+            Focusable widgets are handled, including switching focus, but possibly not intuitively,
+            depending on the arrangement of widgets.
+
+            When switching focus to a widget that is ouside of the visible part of the original widget,
+            the canvas scrolls up/down to the focused widget.
+
+            It would be better to scroll until the next focusable widget is in sight first.
+            But for that to work we must somehow obtain a list of focusable rows in the original canvas.
+        """
+        if not widget.sizing() & frozenset((Sizing.FIXED, Sizing.FLOW)):
+            raise ValueError(f"Not a fixed or flow widget: {widget!r}")
+
+        self._trim_top = 0
+        self._scroll_action = None
+        self._forward_keypress = None
+        self._old_cursor_coords = None
+        self._rows_max_cached = 0
+        self.force_forward_keypress = force_forward_keypress
+        super().__init__(widget)
+
+    def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas:
+        from urwid import canvas
+
+        maxcol, maxrow = size
+
+        def automove_cursor() -> None:
+            ch = 0
+            last_hidden = False
+            first_visible = False
+            for pwi, (w, _o) in enumerate(ow.contents):
+                wcanv = w.render((maxcol,))
+                wh = wcanv.rows()
+                if wh:
+                    ch += wh
+
+                if not last_hidden and ch >= self._trim_top:
+                    last_hidden = True
+
+                elif last_hidden:
+                    if not first_visible:
+                        first_visible = True
+
+                    if not w.selectable():
+                        continue
+
+                    ow.focus_item = pwi
+
+                    st = None
+                    nf = ow.get_focus()
+                    if hasattr(nf, "key_timeout"):
+                        st = nf
+                    elif hasattr(nf, "original_widget"):
+                        no = nf.original_widget
+                        if hasattr(no, "original_widget"):
+                            st = no.original_widget
+                        elif hasattr(no, "key_timeout"):
+                            st = no
+
+                    if st and hasattr(st, "key_timeout") and callable(getattr(st, "keypress", None)):
+                        st.keypress(None, None)
+
+                    break
+
+        # Render complete original widget
+        ow = self._original_widget
+        ow_size = self._get_original_widget_size(size)
+        canv_full = ow.render(ow_size, focus)
+
+        # Make full canvas editable
+        canv = canvas.CompositeCanvas(canv_full)
+        canv_cols, canv_rows = canv.cols(), canv.rows()
+
+        if canv_cols <= maxcol:
+            pad_width = maxcol - canv_cols
+            if pad_width > 0:
+                # Canvas is narrower than available horizontal space
+                canv.pad_trim_left_right(0, pad_width)
+
+        if canv_rows <= maxrow:
+            fill_height = maxrow - canv_rows
+            if fill_height > 0:
+                # Canvas is lower than available vertical space
+                canv.pad_trim_top_bottom(0, fill_height)
+
+        if canv_cols <= maxcol and canv_rows <= maxrow:
+            # Canvas is small enough to fit without trimming
+            return canv
+
+        self._adjust_trim_top(canv, size)
+
+        # Trim canvas if necessary
+        trim_top = self._trim_top
+        trim_end = canv_rows - maxrow - trim_top
+        trim_right = canv_cols - maxcol
+        if trim_top > 0:
+            canv.trim(trim_top)
+        if trim_end > 0:
+            canv.trim_end(trim_end)
+        if trim_right > 0:
+            canv.pad_trim_left_right(0, -trim_right)
+
+        # Disable cursor display if cursor is outside of visible canvas parts
+        if canv.cursor is not None:
+            _curscol, cursrow = canv.cursor
+            if cursrow >= maxrow or cursrow < 0:
+                canv.cursor = None
+
+        # Figure out whether we should forward keypresses to original widget
+        if canv.cursor is not None:
+            # Trimmed canvas contains the cursor, e.g. in an Edit widget
+            self._forward_keypress = True
+        elif canv_full.cursor is not None:
+            # Full canvas contains the cursor, but scrolled out of view
+            self._forward_keypress = False
+
+            # Reset cursor position on page/up down scrolling
+            if getattr(ow, "automove_cursor_on_scroll", False):
+                with contextlib.suppress(Exception):
+                    automove_cursor()
+
+        else:
+            # Original widget does not have a cursor, but may be selectable
+
+            # FIXME: Using ow.selectable() is bad because the original
+            # widget may be selectable because it's a container widget with
+            # a key-grabbing widget that is scrolled out of view.
+            # ow.selectable() returns True anyway because it doesn't know
+            # how we trimmed our canvas.
+            #
+            # To fix this, we need to resolve ow.focus and somehow
+            # ask canv whether it contains bits of the focused widget.  I
+            # can't see a way to do that.
+            self._forward_keypress = ow.selectable()
+
+        return canv
+
+    def keypress(self, size: tuple[int, int], key: str) -> str | None:
+        from urwid.command_map import Command
+
+        # Maybe offer key to original widget
+        if self._forward_keypress or self.force_forward_keypress:
+            ow = self._original_widget
+            ow_size = self._get_original_widget_size(size)
+
+            # Remember previous cursor position if possible
+            if hasattr(ow, "get_cursor_coords"):
+                self._old_cursor_coords = ow.get_cursor_coords(ow_size)
+
+            key = ow.keypress(ow_size, key)
+            if key is None:
+                return None
+
+        # Handle up/down, page up/down, etc
+        command_map = self._command_map
+        if command_map[key] == Command.UP:
+            self._scroll_action = SCROLL_LINE_UP
+        elif command_map[key] == Command.DOWN:
+            self._scroll_action = SCROLL_LINE_DOWN
+
+        elif command_map[key] == Command.PAGE_UP:
+            self._scroll_action = SCROLL_PAGE_UP
+        elif command_map[key] == Command.PAGE_DOWN:
+            self._scroll_action = SCROLL_PAGE_DOWN
+
+        elif command_map[key] == Command.MAX_LEFT:  # 'home'
+            self._scroll_action = SCROLL_TO_TOP
+        elif command_map[key] == Command.MAX_RIGHT:  # 'end'
+            self._scroll_action = SCROLL_TO_END
+
+        else:
+            return key
+
+        self._invalidate()
+        return None
+
+    def mouse_event(
+        self,
+        size: tuple[int, int],
+        event: str,
+        button: int,
+        col: int,
+        row: int,
+        focus: bool,
+    ) -> bool | None:
+        ow = self._original_widget
+        if hasattr(ow, "mouse_event"):
+            ow_size = self._get_original_widget_size(size)
+            row += self._trim_top
+            return ow.mouse_event(ow_size, event, button, col, row, focus)
+
+        return False
+
+    def _adjust_trim_top(self, canv: Canvas, size: tuple[int, int]) -> None:
+        """Adjust self._trim_top according to self._scroll_action"""
+        action = self._scroll_action
+        self._scroll_action = None
+
+        _maxcol, maxrow = size
+        trim_top = self._trim_top
+        canv_rows = canv.rows()
+
+        if trim_top < 0:
+            # Negative trim_top values use bottom of canvas as reference
+            trim_top = canv_rows - maxrow + trim_top + 1
+
+        if canv_rows <= maxrow:
+            self._trim_top = 0  # Reset scroll position
+            return
+
+        def ensure_bounds(new_trim_top: int) -> int:
+            return max(0, min(canv_rows - maxrow, new_trim_top))
+
+        if action == SCROLL_LINE_UP:
+            self._trim_top = ensure_bounds(trim_top - 1)
+        elif action == SCROLL_LINE_DOWN:
+            self._trim_top = ensure_bounds(trim_top + 1)
+
+        elif action == SCROLL_PAGE_UP:
+            self._trim_top = ensure_bounds(trim_top - maxrow + 1)
+        elif action == SCROLL_PAGE_DOWN:
+            self._trim_top = ensure_bounds(trim_top + maxrow - 1)
+
+        elif action == SCROLL_TO_TOP:
+            self._trim_top = 0
+        elif action == SCROLL_TO_END:
+            self._trim_top = canv_rows - maxrow
+
+        else:
+            self._trim_top = ensure_bounds(trim_top)
+
+        # If the cursor was moved by the most recent keypress, adjust trim_top
+        # so that the new cursor position is within the displayed canvas part.
+        # But don't do this if the cursor is at the top/bottom edge so we can still scroll out
+        if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor is not None:
+            self._old_cursor_coords = None
+            _curscol, cursrow = canv.cursor
+            if cursrow < self._trim_top:
+                self._trim_top = cursrow
+            elif cursrow >= self._trim_top + maxrow:
+                self._trim_top = max(0, cursrow - maxrow + 1)
+
+    def _get_original_widget_size(self, size: tuple[int, int]) -> tuple[int] | tuple[()]:
+        ow = self._original_widget
+        sizing = ow.sizing()
+        if Sizing.FLOW in sizing:
+            return (size[0],)
+        if Sizing.FIXED in sizing:
+            return ()
+        raise ScrollableError(f"{ow!r} sizing is not supported")
+
+    def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
+        """Current scrolling position.
+
+        Lower limit is 0, upper limit is the maximum number of rows with the given maxcol minus maxrow.
+
+        ..note::
+            The returned value may be too low or too high if the position has
+            changed but the widget wasn't rendered yet.
+        """
+        return self._trim_top
+
+    def set_scrollpos(self, position: typing.SupportsInt) -> None:
+        """Set scrolling position
+
+        If `position` is positive it is interpreted as lines from the top.
+        If `position` is negative it is interpreted as lines from the bottom.
+
+        Values that are too high or too low values are automatically adjusted during rendering.
+        """
+        self._trim_top = int(position)
+        self._invalidate()
+
+    def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
+        """Return the number of rows for `size`
+
+        If `size` is not given, the currently rendered number of rows is returned.
+        """
+        if size is not None:
+            ow = self._original_widget
+            ow_size = self._get_original_widget_size(size)
+            sizing = ow.sizing()
+            if Sizing.FIXED in sizing:
+                self._rows_max_cached = ow.pack(ow_size, focus)[1]
+            elif Sizing.FLOW in sizing:
+                self._rows_max_cached = ow.rows(ow_size, focus)
+            else:
+                raise ScrollableError(f"Not a flow/box widget: {self._original_widget!r}")
+        return self._rows_max_cached
+
+
+class ScrollBar(WidgetDecoration):
+    def sizing(self):
+        return frozenset((Sizing.BOX,))
+
+    def selectable(self):
+        return True
+
+    def __init__(
+        self,
+        widget: SupportsScroll,
+        thumb_char: str = Blocks.FULL,
+        trough_char: str = " ",
+        side: Literal["left", "right"] = SCROLLBAR_RIGHT,
+        width: int = 1,
+    ) -> None:
+        """Box widget that adds a scrollbar to `widget`
+
+        `widget` must be a box widget with the following methods:
+          - `get_scrollpos` takes the arguments `size` and `focus` and returns the index of the first visible row.
+          - `set_scrollpos` (optional; needed for mouse click support) takes the index of the first visible row.
+          - `rows_max` takes `size` and `focus` and returns the total number of rows `widget` can render.
+
+        `thumb_char` is the character used for the scrollbar handle.
+        `trough_char` is used for the space above and below the handle.
+        `side` must be 'left' or 'right'.
+        `width` specifies the number of columns the scrollbar uses.
+        """
+        if Sizing.BOX not in widget.sizing():
+            raise ValueError(f"Not a box widget: {widget!r}")
+        if not isinstance(widget, SupportsScroll):
+            raise TypeError(f"Not a scrollable widget: {widget!r}")
+
+        super().__init__(widget)
+        self._thumb_char = thumb_char
+        self._trough_char = trough_char
+        self.scrollbar_side = side
+        self.scrollbar_width = max(1, width)
+        self._original_widget_size = (0, 0)
+
+    def render(self, size: tuple[int, int], focus: bool = False) -> CompositeCanvas:
+        from urwid import canvas
+
+        maxcol, maxrow = size
+
+        sb_width = self._scrollbar_width
+        ow_size = (max(0, maxcol - sb_width), maxrow)
+        sb_width = maxcol - ow_size[0]
+
+        ow = self._original_widget
+        ow_base = self.scrolling_base_widget
+        ow_rows_max = ow_base.rows_max(size, focus)
+        if ow_rows_max <= maxrow:
+            # Canvas fits without scrolling - no scrollbar needed
+            self._original_widget_size = size
+            return ow.render(size, focus)
+        ow_rows_max = ow_base.rows_max(ow_size, focus)
+
+        ow_canv = ow.render(ow_size, focus)
+        self._original_widget_size = ow_size
+
+        pos = ow_base.get_scrollpos(ow_size, focus)
+        posmax = ow_rows_max - maxrow
+
+        # Thumb shrinks/grows according to the ratio of
+        # <number of visible lines> / <number of total lines>
+        thumb_weight = min(1, maxrow // max(1, ow_rows_max))
+        thumb_height = max(1, round(thumb_weight * maxrow))
+
+        # Thumb may only touch top/bottom if the first/last row is visible
+        top_weight = float(pos) / max(1, posmax)
+        top_height = int((maxrow - thumb_height) * top_weight)
+        if top_height == 0 and top_weight > 0:
+            top_height = 1
+
+        # Bottom part is remaining space
+        bottom_height = maxrow - thumb_height - top_height
+
+        # Create scrollbar canvas
+        # Creating SolidCanvases of correct height may result in
+        # "cviews do not fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" exceptions.
+        # Stacking the same SolidCanvas is a workaround.
+        # https://github.com/urwid/urwid/issues/226#issuecomment-437176837
+        top = canvas.SolidCanvas(self._trough_char, sb_width, 1)
+        thumb = canvas.SolidCanvas(self._thumb_char, sb_width, 1)
+        bottom = canvas.SolidCanvas(self._trough_char, sb_width, 1)
+        sb_canv = canvas.CanvasCombine(
+            (
+                *((top, None, False) for _ in range(top_height)),
+                *((thumb, None, False) for _ in range(thumb_height)),
+                *((bottom, None, False) for _ in range(bottom_height)),
+            ),
+        )
+
+        combinelist = [(ow_canv, None, True, ow_size[0]), (sb_canv, None, False, sb_width)]
+
+        if self._scrollbar_side != SCROLLBAR_LEFT:
+            return canvas.CanvasJoin(combinelist)
+
+        return canvas.CanvasJoin(reversed(combinelist))
+
+    @property
+    def scrollbar_width(self) -> int:
+        """Columns the scrollbar uses"""
+        return max(1, self._scrollbar_width)
+
+    @scrollbar_width.setter
+    def scrollbar_width(self, width: typing.SupportsInt) -> None:
+        self._scrollbar_width = max(1, int(width))
+        self._invalidate()
+
+    @property
+    def scrollbar_side(self) -> Literal["left", "right"]:
+        """Where to display the scrollbar; must be 'left' or 'right'"""
+        return self._scrollbar_side
+
+    @scrollbar_side.setter
+    def scrollbar_side(self, side: Literal["left", "right"]) -> None:
+        if side not in {SCROLLBAR_LEFT, SCROLLBAR_RIGHT}:
+            raise ValueError(f'scrollbar_side must be "left" or "right", not {side!r}')
+        self._scrollbar_side = side
+        self._invalidate()
+
+    @property
+    def scrolling_base_widget(self) -> SupportsScroll:
+        """Nearest `original_widget` that is compatible with the scrolling API"""
+
+        def orig_iter(w: Widget) -> Iterator[Widget]:
+            while hasattr(w, "original_widget"):
+                w = w.original_widget
+                yield w
+            yield w
+
+        w = self
+
+        for w in orig_iter(self):
+            if isinstance(w, SupportsScroll):
+                return w
+
+        raise ScrollableError(f"Not compatible to be wrapped by ScrollBar: {w!r}")
+
+    def keypress(self, size: tuple[int, int], key: str) -> str | None:
+        return self._original_widget.keypress(self._original_widget_size, key)
+
+    def mouse_event(
+        self,
+        size: tuple[int, int],
+        event: str,
+        button: int,
+        col: int,
+        row: int,
+        focus: bool,
+    ) -> bool | None:
+        ow = self._original_widget
+        ow_size = self._original_widget_size
+        handled = False
+        if hasattr(ow, "mouse_event"):
+            handled = ow.mouse_event(ow_size, event, button, col, row, focus)
+
+        if not handled and hasattr(ow, "set_scrollpos"):
+            if button == 4:  # scroll wheel up
+                pos = ow.get_scrollpos(ow_size)
+                newpos = pos - 1
+                if newpos < 0:
+                    newpos = 0
+                ow.set_scrollpos(newpos)
+                return True
+            if button == 5:  # scroll wheel down
+                pos = ow.get_scrollpos(ow_size)
+                ow.set_scrollpos(pos + 1)
+                return True
+
+        return False
diff --git a/urwid/widget/widget.py b/urwid/widget/widget.py
index 423ecd363..c61fe60d1 100644
--- a/urwid/widget/widget.py
+++ b/urwid/widget/widget.py
@@ -284,58 +284,6 @@ class Widget(metaclass=WidgetMeta):
        widget renders the same size regardless of the value of the *focus*
        parameter.
 
-
-    .. method:: keypress(size, key)
-
-       :param size: See :meth:`Widget.render` for details
-       :type size: widget size
-       :param key: a single keystroke value; see :ref:`keyboard-input`
-       :type key: str
-
-       :returns: ``None`` if *key* was handled by this widget or
-                 *key* (the same value passed) if *key* was not handled
-                 by this widget
-
-       Container widgets will typically call the :meth:`keypress` method on
-       whichever of their children is set as the focus.
-
-       The standard widgets use :attr:`_command_map` to
-       determine what action should be performed for a given *key*. You may
-       modify these values to your liking globally, at some level in the
-       widget hierarchy or on individual widgets. See :class:`CommandMap`
-       for the defaults.
-
-       In your own widgets you may use whatever logic you like: filtering or
-       translating keys, selectively passing along events etc.
-
-
-
-    .. method:: mouse_event(size, event, button, col, row, focus)
-
-       :param size: See :meth:`Widget.render` for details.
-       :type size: widget size
-       :param event: Values such as ``'mouse press'``, ``'ctrl mouse press'``,
-                     ``'mouse release'``, ``'meta mouse release'``,
-                     ``'mouse drag'``; see :ref:`mouse-input`
-       :type event: mouse event
-       :param button: 1 through 5 for press events, often 0 for release events
-                      (which button was released is often not known)
-       :type button: int
-       :param col: Column of the event, 0 is the left edge of this widget
-       :type col: int
-       :param row: Row of the event, 0 it the top row of this widget
-       :type row: int
-       :param focus: Set to ``True`` if this widget or one of its children
-                     is in focus
-       :type focus: bool
-
-       :returns: ``True`` if the event was handled by this widget, ``False``
-                 otherwise
-
-       Container widgets will typically call the :meth:`mouse_event` method on
-       whichever of their children is at the position (*col*, *row*).
-
-
     .. method:: get_cursor_coords(size)
 
        .. note::


Test patch:
diff --git a/tests/test_scrollable.py b/tests/test_scrollable.py
new file mode 100644
index 000000000..542c5b116
--- /dev/null
+++ b/tests/test_scrollable.py
@@ -0,0 +1,193 @@
+from __future__ import annotations
+
+import unittest
+
+import urwid
+from urwid.widget import scrollable
+
+LGPL_HEADER = """
+Copyright (C) <year>  <name of author>
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public
+License along with this library; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+"""
+
+
+class TestScrollable(unittest.TestCase):
+    def test_basic(self):
+        """Test basic init and scroll."""
+        long_content = urwid.Text(LGPL_HEADER)
+        reduced_size = (80, 5)
+        content_size = long_content.pack()
+
+        widget = urwid.widget.Scrollable(long_content)
+        self.assertEqual(frozenset((urwid.BOX,)), widget.sizing())
+
+        cropped_content_canvas = urwid.CompositeCanvas(long_content.render((reduced_size[0],)))
+        cropped_content_canvas.trim_end(content_size[1] - reduced_size[1])
+        top_decoded = cropped_content_canvas.decoded_text
+
+        self.assertEqual(
+            top_decoded,
+            widget.render(reduced_size).decoded_text,
+        )
+
+        for key_1, key_2 in (("down", "up"), ("page down", "page up"), ("end", "home")):
+            widget.keypress(reduced_size, key_1)
+
+            self.assertNotEqual(top_decoded, widget.render(reduced_size).decoded_text)
+
+            widget.keypress(reduced_size, key_2)
+
+            self.assertEqual(top_decoded, widget.render(reduced_size).decoded_text)
+
+    def test_negative(self):
+        with self.assertRaises(ValueError):
+            urwid.widget.Scrollable(urwid.SolidFill(" "))
+
+
+class TestScrollBar(unittest.TestCase):
+    def test_basic(self):
+        """Test basic init and scroll.
+
+        Unlike `Scrollable`, `ScrollBar` can be also scrolled with a mouse wheel.
+        """
+
+        long_content = urwid.Text(LGPL_HEADER)
+        reduced_size = (40, 5)
+        widget = urwid.widget.ScrollBar(urwid.widget.Scrollable(long_content))
+
+        self.assertEqual(frozenset((urwid.BOX,)), widget.sizing())
+
+        top_position_rendered = (
+            "                                       █",
+            "Copyright (C) <year>  <name of author>  ",
+            "                                        ",
+            "This library is free software; you can  ",
+            "redistribute it and/or                  ",
+        )
+        pos_1_down_rendered = (
+            "Copyright (C) <year>  <name of author>  ",
+            "                                       █",
+            "This library is free software; you can  ",
+            "redistribute it and/or                  ",
+            "modify it under the terms of the GNU    ",
+        )
+
+        self.assertEqual(top_position_rendered, widget.render(reduced_size).decoded_text)
+
+        widget.keypress(reduced_size, "down")
+
+        self.assertEqual(pos_1_down_rendered, widget.render(reduced_size).decoded_text)
+
+        widget.keypress(reduced_size, "up")
+
+        self.assertEqual(top_position_rendered, widget.render(reduced_size).decoded_text)
+
+        widget.keypress(reduced_size, "page down")
+
+        self.assertEqual(
+            (
+                "redistribute it and/or                  ",
+                "modify it under the terms of the GNU   █",
+                "Lesser General Public                   ",
+                "License as published by the Free        ",
+                "Software Foundation; either             ",
+            ),
+            widget.render(reduced_size).decoded_text,
+        )
+
+        widget.keypress(reduced_size, "page up")
+
+        self.assertEqual(top_position_rendered, widget.render(reduced_size).decoded_text)
+
+        widget.keypress(reduced_size, "end")
+
+        self.assertEqual(
+            (
+                "not, write to the Free Software         ",
+                "Foundation, Inc., 51 Franklin Street,   ",
+                "Fifth Floor, Boston, MA  02110-1301     ",
+                "USA                                     ",
+                "                                       █",
+            ),
+            widget.render(reduced_size).decoded_text,
+        )
+
+        widget.keypress(reduced_size, "home")
+
+        self.assertEqual(top_position_rendered, widget.render(reduced_size).decoded_text)
+
+        widget.mouse_event(reduced_size, "mouse press", 5, 1, 1, False)
+
+        self.assertEqual(pos_1_down_rendered, widget.render(reduced_size).decoded_text)
+
+        widget.mouse_event(reduced_size, "mouse press", 4, 1, 1, False)
+
+        self.assertEqual(top_position_rendered, widget.render(reduced_size).decoded_text)
+
+    def test_alt_symbols(self):
+        long_content = urwid.Text(LGPL_HEADER)
+        reduced_size = (40, 5)
+        widget = urwid.widget.ScrollBar(
+            urwid.widget.Scrollable(long_content),
+            trough_char=scrollable.Blocks.LITE_SHADE,
+        )
+
+        self.assertEqual(
+            (
+                "                                       █",
+                "Copyright (C) <year>  <name of author> ░",
+                "                                       ░",
+                "This library is free software; you can ░",
+                "redistribute it and/or                 ░",
+            ),
+            widget.render(reduced_size).decoded_text,
+        )
+
+    def test_fixed(self):
+        """Test with fixed wrapped widget."""
+        widget = urwid.widget.ScrollBar(
+            urwid.widget.Scrollable(urwid.BigText("1", urwid.HalfBlockHeavy6x5Font())),
+            trough_char=scrollable.Blocks.LITE_SHADE,
+            thumb_char=scrollable.Blocks.DARK_SHADE,
+        )
+        reduced_size = (8, 3)
+
+        self.assertEqual(
+            (
+                " ▐█▌   ▓",
+                " ▀█▌   ░",
+                "  █▌   ░",
+            ),
+            widget.render(reduced_size).decoded_text,
+        )
+
+        widget.keypress(reduced_size, "page down")
+
+        self.assertEqual(
+            (
+                "  █▌   ░",
+                "  █▌   ░",
+                " ███▌  ▓",
+            ),
+            widget.render(reduced_size).decoded_text,
+        )
+
+    def test_negative(self):
+        with self.assertRaises(ValueError):
+            urwid.widget.ScrollBar(urwid.Text(" "))
+
+        with self.assertRaises(TypeError):
+            urwid.widget.ScrollBar(urwid.SolidFill(" "))