I would like to write a program with a Text-based User Interface (TUI) that consists of 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()
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! =)
I found myself using Npyscreen and so I found your question. If you're still working on this application, here is your initial code, but returning to the main form this time:
#! /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:")
def afterEditing(self):
self.parentApp.setNextForm("MAIN")
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()
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With