pythonconsole-applicationurwidtuinpyscreen

How do you combine multiple TUI forms to write more complex applications?


I would like to write a program with a Text-based User Interface (TUI) that consists of several forms.

Presentation of the several forms.

Here is my attempt, which uses the library npyscreen but does not return to the first form. The code does also not contain the logic to change the list item.

#! /usr/bin/env python3
# coding:utf8

import npyscreen

# content
headers = ["column 1", "column 2", "column 3", "column 4"]
entries = [["a1", "a2", "a3", "a4"],
           ["b1", "b2", "b3", "b4"],
           ["c1", "c2", "c3", "c4"],
           ["d1", "d2", "d3", "d4"], 
           ["e1", "e2", "e3", "e4"]]


# returns a string in which the segments are padded with spaces.
def format_entry(entry):
    return "{:10} | {:10} | {:10} | {:10}".format(entry[0], entry[1] , entry[2], entry[3])


class SecondForm(npyscreen.Form):
    def on_ok(self):
        self.parentApp.switchFormPrevious()

    # add the widgets of the second form
    def create(self):
        self.col1 = self.add(npyscreen.TitleText, name="column 1:")
        self.col2 = self.add(npyscreen.TitleText, name="column 2:")
        self.col3 = self.add(npyscreen.TitleText, name="column 3:")
        self.col4 = self.add(npyscreen.TitleText, name="column 4:")


class MainForm(npyscreen.Form):    
    def on_ok(self):
        self.parentApp.switchForm(None)

    def changeToSecondForm(self):
        self.parentApp.change_form("SECOND")

    # add the widgets of the main form
    def create(self):
        self.add(npyscreen.FixedText, value=format_entry(headers), editable=False, name="header")

        for i, entry in enumerate(entries):
            self.add(npyscreen.ButtonPress, when_pressed_function=self.changeToSecondForm, name=format_entry(entry))


class TestTUI(npyscreen.NPSAppManaged):
    def onStart(self):
        self.addForm("MAIN", MainForm)
        self.addForm("SECOND", SecondForm, name="Edit row")

    def onCleanExit(self):
        npyscreen.notify_wait("Goodbye!")

    def change_form(self, name):
        self.switchForm(name)


if __name__ == "__main__":
    tui = TestTUI()
    tui.run()

Solution

  • So what follows is my take to this problem, which can be described as an implementation of a master-detail user interface for the console.

    This uses the urwid library, building some custom widgets to achieve the described UI, which has two modes: master view (where the main widget is a pile of records) and the detail view (an overlayed dialog, with the master view behind).

    There are many things that can be improved, including making it look prettier. :)

    Here is the code:

    #!/usr/bin/env python
    # -*- coding: utf-8 -*-
    """
    Sample program demonstrating how to implement widgets for a master-detail UI
    for a list of records using the urwid library (http://urwid.org)
    """
    
    from __future__ import print_function, absolute_import, division
    from functools import partial
    import urwid
    
    
    PALETTE = [
        ('bold', 'bold', ''),
        ('reveal focus', 'black', 'dark cyan', 'standout'),
    ]
    
    
    def show_or_exit(key):
        if key in ('q', 'Q', 'esc'):
            raise urwid.ExitMainLoop()
    
    
    HEADERS = ["Field 1", "Field 2", "Field 3", "Field 4"]
    ENTRIES = [
        ["a1", "a2", "a3", "a4"],
        ["b1", "b2", "b3", "b4"],
        ["c1", "c2", "c3", "c4"],
        ["d1", "d2", "d3", "d4"],
        ["e1", "e2", "e3", "e4"],
        ["e1", "e2", "e3", "e4"],
        ["f1", "f2", "f3", "f4"],
        ["g1", "g2", "g3", "g4"],
        ["h1", "h2", "h3", "h4"],
    ]
    
    
    class SelectableRow(urwid.WidgetWrap):
        def __init__(self, contents, on_select=None):
            self.on_select = on_select
            self.contents = contents
            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 CancelableEdit(urwid.Edit):
        def __init__(self, *args, **kwargs):
            self.on_cancel = kwargs.pop('on_cancel', None)
            super(CancelableEdit, self).__init__(*args, **kwargs)
    
        def keypress(self, size, key):
            if key == 'esc':
                self.on_cancel(self)
            else:
                return super(CancelableEdit, self).keypress(size, key)
    
    
    def build_dialog(title, contents, background, on_save=None, on_cancel=None):
        buttons = urwid.Columns([
            urwid.Button('Save', on_press=on_save),
            urwid.Button('Cancel', on_press=on_cancel),
        ])
        pile = urwid.Pile(
            [urwid.Text(title), urwid.Divider('-')]
            + contents
            + [urwid.Divider(' '), buttons]
        )
        return urwid.Overlay(
            urwid.Filler(urwid.LineBox(pile)),
            urwid.Filler(background),
            'center',
            ('relative', 80),
            'middle',
            ('relative', 80),
        )
    
    
    class App(object):
        def __init__(self, entries):
            self.entries = entries
            self.header = urwid.Text('Welcome to the Master Detail Urwid Sample!')
            self.footer = urwid.Text('Status: ready')
    
            contents = [
                SelectableRow(row, on_select=self.show_detail_view)
                for row in entries
            ]
            listbox = urwid.ListBox(urwid.SimpleFocusListWalker(contents))
    
            # TODO: cap to screen size
            size = len(entries)
    
            self.master_pile = urwid.Pile([
                self.header,
                urwid.Divider(u'─'),
                urwid.BoxAdapter(listbox, size),
                urwid.Divider(u'─'),
                self.footer,
            ])
            self.widget = urwid.Filler(self.master_pile, 'top')
            self.loop = urwid.MainLoop(self.widget, PALETTE, unhandled_input=show_or_exit)
    
        def show_detail_view(self, row):
            self._edits = [
                CancelableEdit('%s: ' % key, value, on_cancel=self.close_dialog)
                for key, value in zip(HEADERS, row.contents)
            ]
            self.loop.widget = build_dialog(
                title='Editing',
                contents=self._edits,
                background=self.master_pile,
                on_save=partial(self.save_and_close_dialog, row),
                on_cancel=self.close_dialog,
            )
            self.show_status('Detail: %r' % row)
    
        def save_and_close_dialog(self, row, btn):
            new_content = [e.edit_text for e in self._edits]
    
            row.update_contents(new_content)
    
            self.show_status('Updated')
            self.loop.widget = self.widget
    
        def close_dialog(self, btn):
            self.loop.widget = self.widget
    
        def show_status(self, mesg):
            self.footer.set_text(str(mesg))
    
        def start(self):
            self.loop.run()
    
    
    if __name__ == '__main__':
        app = App(ENTRIES)
        app.start()
    

    The App class holds the state of the app, keeping track of the main widgets and contains methods that are called upon user actions like hitting the buttons save/cancel.

    The records are updated inplace, in the method update_contents of the SelectableRow widget, which represents a record being displayed in the master list.

    The CancelableEdit widget exists just to be able to react to esc from the dialog window.

    Feel free to ask any further clarifying question, I tried to use decent names and keep the code more or less readable, but I know that there is also a lot going on here and I'm not sure what needs to be explained in detail.

    This was a fun exercise, thanks for giving me the excuse to do it! =)