3

Is there a way to show a user that a urwid listbox has additional items above / below the dispalyed section?

I'm thinking of something like a scrollbar that gives an idea of the number of entries.

listbox with vertical scrollbar.

Or a separate bar at the top / bottom of the list box.

transformation of listbox while scrolling down.

If this behavior can not be implemented, what approaches are there to achieve this notification?

During my research, I found this question, which tried to achieve eventually the same. The given answer seems to check if all elements are visible. Unfortunately, this loses its functionality if some elements are hidden at any time because the terminal is not resized.

AFoeee
  • 731
  • 7
  • 24
  • See [`additional_urwid_widgets.IndicativeListBox`](https://github.com/AFoeee/additional_urwid_widgets/wiki/IndicativeListBox), as described in [my second answer](https://stackoverflow.com/questions/52428684/how-to-indicate-that-a-urwid-listbox-has-more-items-than-displayed-at-the-moment#answer-53364291). – AFoeee Nov 20 '18 at 18:06

2 Answers2

1

I think I've found an implementation for the second visualization concept (bars at the top and bottom of the list box).

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import urwid

ENTRIES = [letter for letter in "abcdefghijklmnopqrstuvwxyz"]

PALETTE = [
    ("notifier_active",   "dark cyan",  "light gray"),
    ("notifier_inactive", "black", "dark gray"),
    ("reveal_focus",      "black",      "dark cyan", "standout")
]


class MyListBox(urwid.ListBox):
    def __init__(self, body, on_focus_change=None):
        super().__init__(body)

        self.on_focus_change = on_focus_change

    # Overriden
    def change_focus(self, size, position, offset_inset=0, coming_from=None, cursor_coords=None, snap_rows=None):
        super().change_focus(size,
                             position,
                             offset_inset,
                             coming_from,
                             cursor_coords,
                             snap_rows)

        # Implement a hook to be able to deposit additional logic
        if self.on_focus_change != None:
            self.on_focus_change(size,
                                 position,
                                 offset_inset,
                                 coming_from,
                                 cursor_coords,
                                 snap_rows)


class App(object):
    def __init__(self, entries):
        # Get terminal dimensions
        terminal_cols, terminal_rows = urwid.raw_display.Screen().get_cols_rows()
        list_rows = (terminal_rows - 2) if (terminal_rows > 7) else 5       
        # (available_rows - notifier_rows) OR my preferred minimum size

        # At the beginning, "top" is always visible
        self.notifier_top = urwid.AttrMap(urwid.Text('^', align="center"),
                                          "notifier_inactive")

        # Determine presentation depending on size and number of elements
        self.notifier_bottom = urwid.AttrMap(urwid.Text('v', align="center"),
                                             "notifier_inactive" if (len(entries) <= list_rows) else "notifier_active")

        contents = [urwid.AttrMap(urwid.Button(entry), "", "reveal_focus")
                    for entry in entries]

        self.listbox = MyListBox(urwid.SimpleFocusListWalker(contents),
                                 self.update_notifiers)                   # Pass the hook

        master_pile = urwid.Pile([
            self.notifier_top,
            urwid.BoxAdapter(self.listbox, list_rows),
            self.notifier_bottom,
        ])

        widget = urwid.Filler(master_pile,
                              'top')

        self.loop = urwid.MainLoop(widget,
                                   PALETTE,
                                   unhandled_input=self.handle_input)

    # Implementation for hook
    def update_notifiers(self, size, position, offset_inset, coming_from, cursor_coords, snap_rows):
        # which ends are visible? returns "top", "bottom", both or neither.
        result = self.listbox.ends_visible(size)

        if ("top" in result) and ("bottom" in result):
            self.notifier_top.set_attr_map({None:"notifier_inactive"})
            self.notifier_bottom.set_attr_map({None:"notifier_inactive"})
        elif "top" in result:
            self.notifier_top.set_attr_map({None:"notifier_inactive"})
            self.notifier_bottom.set_attr_map({None:"notifier_active"})
        elif "bottom" in result:
            self.notifier_top.set_attr_map({None:"notifier_active"})
            self.notifier_bottom.set_attr_map({None:"notifier_inactive"})
        else:
            self.notifier_top.set_attr_map({None:"notifier_active"})
            self.notifier_bottom.set_attr_map({None:"notifier_active"})

    def handle_input(self, key):
        if key in ('q', 'Q', 'esc'):
            self.exit()

    def start(self):
        self.loop.run()

    def exit(self):
        raise urwid.ExitMainLoop()


if __name__ == '__main__':
    app = App(ENTRIES)
    app.start()

Basically, I create a subclass of urwid.Listbox and override its change_focus() method to add a hook. Obviously, this method is called internally when the focus changes.

The actual logic uses the result of the ends_visible() method, which returns the currently visible ends of the listbox (top, bottom, both or neither). Depending on that, I modify the presentation of the two surrounding urwid.Text elements.

The code generates the following TUI:

listbox while scrolling down.



I have also written a variant of the code, which is based on the original specification:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import urwid

HEADERS = ["column 1",
           "column 2",
           "column 3",
           "column 4"]

ENTRIES = [["{}1".format(letter),
            "{}2".format(letter),
            "{}3".format(letter),
            "{}4".format(letter)] for letter in "abcdefghijklmnopqrstuvwxyz"]

PALETTE = [
    ("column_headers", "white, bold", ""),
    ("notifier_active",   "dark cyan",  "light gray"),
    ("notifier_inactive", "black", "dark gray"),
    ("reveal_focus",      "black",      "dark cyan", "standout")
]


class SelectableRow(urwid.WidgetWrap):
    def __init__(self, contents, on_select=None):
        self.contents = contents
        self.on_select = on_select

        self._columns = urwid.Columns([urwid.Text(c) for c in contents])
        self._focusable_columns = urwid.AttrMap(self._columns, '', 'reveal_focus')

        super(SelectableRow, self).__init__(self._focusable_columns)

    def selectable(self):
        return True

    def update_contents(self, contents):
        # update the list record inplace...
        self.contents[:] = contents

        # ... and update the displayed items
        for t, (w, _) in zip(contents, self._columns.contents):
            w.set_text(t)

    def keypress(self, size, key):
        if self.on_select and key in ('enter',):
            self.on_select(self)
        return key

    def __repr__(self):
        return '%s(contents=%r)' % (self.__class__.__name__, self.contents)


class MyListBox(urwid.ListBox):
    def __init__(self, body, on_focus_change=None):
        super().__init__(body)

        self.on_focus_change = on_focus_change

    # Overriden
    def change_focus(self, size, position, offset_inset=0, coming_from=None, cursor_coords=None, snap_rows=None):
        super().change_focus(size,
                             position,
                             offset_inset,
                             coming_from,
                             cursor_coords,
                             snap_rows)

        # Implement a hook to be able to deposit additional logic
        if self.on_focus_change != None:
            self.on_focus_change(size,
                                 position,
                                 offset_inset,
                                 coming_from,
                                 cursor_coords,
                                 snap_rows)


class App(object):
    def __init__(self, entries):
        # Get terminal dimensions
        terminal_cols, terminal_rows = urwid.raw_display.Screen().get_cols_rows()
        list_rows = (terminal_rows - 6) if (terminal_rows > 11) else 5       
        # (available_rows - divider_rows - column_headers_row - notifier_rows) OR my preferred minimum size

        column_headers = urwid.AttrMap(urwid.Columns([urwid.Text(c) for c in HEADERS]),
                                       "column_headers")

        # At the beginning, "top" is always visible
        self.notifier_top = urwid.AttrMap(urwid.Text('^', align="center"),
                                          "notifier_inactive")

        # Determine presentation depending on size and number of elements
        self.notifier_bottom = urwid.AttrMap(urwid.Text('v', align="center"),
                                             "notifier_inactive" if (len(entries) <= list_rows) else "notifier_active")

        contents = [SelectableRow(entry) for entry in entries]

        self.listbox = MyListBox(urwid.SimpleFocusListWalker(contents),
                                 self.update_notifiers)                    # Pass the hook

        master_pile = urwid.Pile([
            urwid.Divider(u'─'),
            column_headers,
            urwid.Divider(u'─'),
            self.notifier_top,
            urwid.BoxAdapter(self.listbox, list_rows),
            self.notifier_bottom,
            urwid.Divider(u'─'),
        ])

        widget = urwid.Filler(master_pile,
                              'top')

        self.loop = urwid.MainLoop(widget,
                                   PALETTE,
                                   unhandled_input=self.handle_input)

    # Implementation for hook
    def update_notifiers(self, size, position, offset_inset, coming_from, cursor_coords, snap_rows):
        # which ends are visible? returns "top", "bottom", both or neither.
        result = self.listbox.ends_visible(size)

        if ("top" in result) and ("bottom" in result):
            self.notifier_top.set_attr_map({None:"notifier_inactive"})
            self.notifier_bottom.set_attr_map({None:"notifier_inactive"})
        elif "top" in result:
            self.notifier_top.set_attr_map({None:"notifier_inactive"})
            self.notifier_bottom.set_attr_map({None:"notifier_active"})
        elif "bottom" in result:
            self.notifier_top.set_attr_map({None:"notifier_active"})
            self.notifier_bottom.set_attr_map({None:"notifier_inactive"})
        else:
            self.notifier_top.set_attr_map({None:"notifier_active"})
            self.notifier_bottom.set_attr_map({None:"notifier_active"})

    def handle_input(self, key):
        if key in ('q', 'Q', 'esc'):
            self.exit()

    def start(self):
        self.loop.run()

    def exit(self):
        raise urwid.ExitMainLoop()


if __name__ == '__main__':
    app = App(ENTRIES)
    app.start()

The only real difference is that, I use instances of SelectableRow instead of urwid.Button. (SelectableRow was taken from this answer of user elias.)

Here is the corresponding TUI:

list box while scrolling down (more complex list content)

AFoeee
  • 731
  • 7
  • 24
1

I've implemented a list box that applies the second visualization concept (bars at the top and bottom) by default.

It is called additional_urwid_widgets.IndicativeListBox and can be installed via pip.

Demonstration of list box


For a stand alone example, which illustrates the functionality of the widget, see here.

For more (and simpler) examples, see here.

For a more detailed explanation of the parameters and options, see the corresponding github wiki entry.



Some Examples

Minimal

#! /usr/bin/env python3
# -*- coding: utf-8 -*-

from additional_urwid_widgets import IndicativeListBox    # installed via pip
import urwid                                              # installed via pip

# Color schemes that specify the appearance off focus and on focus.
PALETTE = [("reveal_focus", "black", "light cyan", "standout")]

# The list box is filled with buttons.
body = [urwid.Button(letter) for letter in "abcdefghijklmnopqrstuvwxyz"]

# Wrap the list items into an 'urwid.AttrMap', so that they have an other appearance when focused.
# Instead of an simple list-like object you can/should create a 'urwid.ListWalker'.
attr_body = [urwid.AttrMap(entry, None, "reveal_focus") for entry in body]

ilb = IndicativeListBox(attr_body)

loop = urwid.MainLoop(ilb,
                      PALETTE)
loop.run()

visual output of example 'Minimal'


Display items above/below

#! /usr/bin/env python3
# -*- coding: utf-8 -*-

from additional_urwid_widgets import IndicativeListBox    # installed via pip
import urwid                                              # installed via pip

# Color schemes that specify the appearance off focus and on focus.
PALETTE = [("reveal_focus", "black", "light cyan", "standout")]

# The list box is filled with buttons.
body = [urwid.Button(letter) for letter in "abcdefghijklmnopqrstuvwxyz"]

# Wrap the list items into an 'urwid.AttrMap', so that they have an other appearance when focused.
# Instead of an simple list-like object you can/should create a 'urwid.ListWalker'.
attr_body = [urwid.AttrMap(entry, None, "reveal_focus") for entry in body]

ilb = IndicativeListBox(attr_body,
                        topBar_endCovered_prop=("{} above ...", None, None),
                        bottomBar_endCovered_prop=("{} below ...", None, None))

loop = urwid.MainLoop(ilb,
                      PALETTE)
loop.run()

Visual output of example 'Display items above/below'


In contex with other widgets (also styled)

In this example, ctrl must be additionally pressed so that the list box responds to the input.
This allows the widget to be used in vertical containers (such as urwid.Pile).

#! /usr/bin/env python3
# -*- coding: utf-8 -*-

from additional_urwid_widgets import IndicativeListBox, MODIFIER_KEY    # installed via pip
import urwid                                                            # installed via pip

# Color schemes that specify the appearance off focus and on focus.
PALETTE = [("reveal_focus",              "black",            "light cyan",   "standout"),
           ("ilb_barActive_focus",       "dark cyan",        "light gray"),
           ("ilb_barActive_offFocus",    "light gray",       "dark gray"),
           ("ilb_barInactive_focus",     "light cyan",       "dark gray"),
           ("ilb_barInactive_offFocus",  "black",            "dark gray"),
           ("ilb_highlight_offFocus",    "black",            "dark cyan")]

# The list box is filled with buttons.
body = [urwid.Button(letter) for letter in "abcdefghijklmnopqrstuvwxyz"]

# Wrap the list items into an 'urwid.AttrMap', so that they have an other appearance when focused.
# Instead of an simple list-like object you can/should create a 'urwid.ListWalker'.
attr_body = [urwid.AttrMap(entry, None, "reveal_focus") for entry in body]

ilb = ilb = IndicativeListBox(attr_body,
                              modifier_key=MODIFIER_KEY.CTRL,
                              return_unused_navigation_input=False,
                              topBar_endCovered_prop=("ᐃ", "ilb_barActive_focus", "ilb_barActive_offFocus"),
                              topBar_endExposed_prop=("───", "ilb_barInactive_focus", "ilb_barInactive_offFocus"), 
                              bottomBar_endCovered_prop=("ᐁ", "ilb_barActive_focus", "ilb_barActive_offFocus"), 
                              bottomBar_endExposed_prop=("───", "ilb_barInactive_focus", "ilb_barInactive_offFocus"),
                              highlight_offFocus="ilb_highlight_offFocus")

pile = urwid.Pile([urwid.Text("The listbox responds only if 'ctrl' is pressed."),
                   urwid.Divider(" "),
                   urwid.Button("a button"),
                   urwid.BoxAdapter(ilb, 6),         # Wrap flow widget in box adapter
                   urwid.Button("another button")])


loop = urwid.MainLoop(urwid.Filler(pile, "top"),
                      PALETTE)
loop.run()

Visual output of example 'In contex with other widgets (also styled)'

AFoeee
  • 731
  • 7
  • 24