Repository: pimutils/todoman
Pull request #179 (2017-03-06 22:36:48)

UI design tweaks
* Move the list-selector into a separate column.
* Show humanized dates as _"in X days"_ instead of _"X days from now"_.

I wanted to make the list picker colorized, but urwid doesn't support truecolor/24bit. :(


Patch:
diff --git a/todoman/__init__.py b/todoman/__init__.py
index 3e0a23d..39d64a6 100644
--- a/todoman/__init__.py
+++ b/todoman/__init__.py
@@ -1,4 +1,4 @@
-from . import version
+from todoman import version
 
 __version__ = version.version
 __documentation__ = 'https://todoman.rtfd.org/en/latest/'
diff --git a/todoman/cli.py b/todoman/cli.py
index bba067c..c5569ea 100644
--- a/todoman/cli.py
+++ b/todoman/cli.py
@@ -8,10 +8,10 @@ from os.path import expanduser, isdir
 import click
 import click_log
 
-from . import model, ui
-from .configuration import ConfigurationException, load_config
-from .model import cached_property, Database, FileTodo
-from .ui import EditState, TodoEditor
+from todoman import formatters, model
+from todoman.configuration import ConfigurationException, load_config
+from todoman.interactive import EditState, TodoEditor
+from todoman.model import cached_property, Database, FileTodo
 
 TODO_ID_MIN = 1
 with_id_arg = click.argument('id', type=click.IntRange(min=TODO_ID_MIN))
@@ -112,7 +112,7 @@ class AppContext:
 
     @cached_property
     def ui_formatter(self):
-        return ui.DefaultFormatter(
+        return formatters.DefaultFormatter(
             self.config['main']['date_format'],
             self.config['main']['time_format'],
             self.config['main']['dt_separator']
@@ -120,7 +120,7 @@ class AppContext:
 
     @cached_property
     def porcelain_formatter(self):
-        return ui.PorcelainFormatter()
+        return formatters.PorcelainFormatter()
 
     @cached_property
     def formatter(self):
@@ -169,11 +169,11 @@ def cli(click_ctx, color, porcelain, humanize):
         humanize = ctx.config['main']['humanize']
 
     if humanize:
-        ctx.formatter_class = ui.HumanizedFormatter
+        ctx.formatter_class = formatters.HumanizedFormatter
     elif porcelain:
-        ctx.formatter_class = ui.PorcelainFormatter
+        ctx.formatter_class = formatters.PorcelainFormatter
     else:
-        ctx.formatter_class = ui.DefaultFormatter
+        ctx.formatter_class = formatters.DefaultFormatter
 
     color = color or ctx.config['main']['color']
     if color == 'always':
diff --git a/todoman/configuration.py b/todoman/configuration.py
index 667baff..82349f6 100644
--- a/todoman/configuration.py
+++ b/todoman/configuration.py
@@ -5,7 +5,7 @@ import xdg.BaseDirectory
 from configobj import ConfigObj, flatten_errors
 from validate import is_option, Validator, VdtValueError
 
-from . import __documentation__
+from todoman import __documentation__
 
 
 class ConfigurationException(Exception):
diff --git a/todoman/formatters.py b/todoman/formatters.py
new file mode 100644
index 0000000..09c16d2
--- /dev/null
+++ b/todoman/formatters.py
@@ -0,0 +1,232 @@
+import datetime
+import json
+from time import mktime
+
+import click
+import humanize
+import parsedatetime
+from dateutil.tz import tzlocal
+from tabulate import tabulate
+
+
+class DefaultFormatter:
+
+    def __init__(self, date_format, time_format, dt_separator):
+        self.date_format = date_format
+        self.time_format = time_format
+        self.dt_separator = dt_separator
+        self.datetime_format = dt_separator.join(filter(bool, (
+            date_format, time_format
+        )))
+
+        self._localtimezone = tzlocal()
+        self.now = datetime.datetime.now().replace(tzinfo=self._localtimezone)
+
+        self._parsedatetime_calendar = parsedatetime.Calendar()
+
+    def simple_action(self, action, todo):
+        return '{} "{}"'.format(action, todo.summary)
+
+    def compact(self, todo):
+        return self.compact_multiple([todo])
+
+    def compact_multiple(self, todos):
+        table = []
+        for todo in todos:
+            completed = "X" if todo.is_completed else " "
+            percent = todo.percent_complete or ''
+            if percent:
+                percent = " ({}%)".format(percent)
+            priority = self.format_priority_compact(todo.priority)
+
+            due = self.format_datetime(todo.due)
+            if todo.due and todo.due <= self.now and not todo.is_completed:
+                due = click.style(due, fg='red')
+
+            table.append([
+                todo.id,
+                "[{}]".format(completed),
+                priority,
+                due,
+                "{} {}{}".format(
+                    todo.summary,
+                    self.format_database(todo.list),
+                    percent,
+                ),
+            ])
+
+        return tabulate(table, tablefmt='plain')
+
+    def _columnize(self, label, text):
+        rows = []
+
+        lines = text.splitlines()
+        rows.append([label, lines[0]])
+        for line in lines[1:]:
+            rows.append([None, line])
+
+        return rows
+
+    def detailed(self, todo):
+        """
+        Returns a detailed representation of a task.
+
+        :param Todo todo: The todo component.
+        """
+        extra_rows = []
+        if todo.description:
+            extra_rows += self._columnize('Description', todo.description)
+        if todo.location:
+            extra_rows += self._columnize('Location', todo.location)
+
+        if extra_rows:
+            return '{}\n\n{}'.format(
+                self.compact(todo), tabulate(extra_rows, tablefmt='plain')
+            )
+        return self.compact(todo)
+
+    def format_datetime(self, dt):
+        if not dt:
+            return ''
+        elif isinstance(dt, datetime.datetime):
+            return dt.strftime(self.datetime_format)
+        elif isinstance(dt, datetime.date):
+            return dt.strftime(self.date_format)
+
+    def parse_priority(self, priority):
+        if priority is None or priority is '':
+            return None
+        if priority == 'low':
+            return 9
+        elif priority == 'medium':
+            return 5
+        elif priority == 'high':
+            return 4
+        elif priority == 'none':
+            return 0
+        else:
+            raise ValueError('Priority has to be one of low, medium,'
+                             ' high or none')
+
+    def format_priority(self, priority):
+        if not priority:
+            return ''
+        elif 1 <= priority <= 4:
+            return 'high'
+        elif priority == 5:
+            return 'medium'
+        elif 6 <= priority <= 9:
+            return 'low'
+
+    def format_priority_compact(self, priority):
+        if not priority:
+            return ''
+        elif 1 <= priority <= 4:
+            return "!!!"
+        elif priority == 5:
+            return "!!"
+        elif 6 <= priority <= 9:
+            return "!"
+
+    def parse_datetime(self, dt):
+        if not dt:
+            return None
+
+        rv = self._parse_datetime_naive(dt)
+        return rv.replace(tzinfo=self._localtimezone)
+
+    def _parse_datetime_naive(self, dt):
+        try:
+            return datetime.datetime.strptime(dt, self.datetime_format)
+        except ValueError:
+            pass
+
+        try:
+            return datetime.datetime.strptime(dt, self.date_format)
+        except ValueError:
+            pass
+
+        try:
+            return datetime.datetime.combine(
+                self.now.date(),
+                datetime.datetime.strptime(dt, self.time_format).time()
+            )
+        except ValueError:
+            pass
+
+        rv, certainty = self._parsedatetime_calendar.parse(dt)
+        if not certainty:
+            raise ValueError(
+                'Time description not recognized: {}' .format(dt)
+            )
+        return datetime.datetime.fromtimestamp(mktime(rv))
+
+    def format_database(self, database):
+        return '{}@{}'.format(database.color_ansi or '',
+                              click.style(database.name))
+
+
+class HumanizedFormatter(DefaultFormatter):
+
+    def format_datetime(self, dt):
+        if not dt:
+            return ''
+
+        rv = humanize.naturaltime(self.now - dt)
+        if ' from now' in rv:
+            rv = 'in {}'.format(rv[:-9])
+
+        return rv
+
+
+class PorcelainFormatter(DefaultFormatter):
+
+    def __init__(self, *args, **kwargs):
+        pass
+
+    def _todo_as_dict(self, todo):
+        return dict(
+            completed=todo.is_completed,
+            due=self.format_datetime(todo.due),
+            id=todo.id,
+            list=todo.list.name,
+            percent=todo.percent_complete,
+            summary=todo.summary,
+            priority=todo.priority,
+        )
+
+    def compact(self, todo):
+        return json.dumps(self._todo_as_dict(todo), indent=4, sort_keys=True)
+
+    def compact_multiple(self, todos):
+        data = [self._todo_as_dict(todo) for todo in todos]
+        return json.dumps(data, indent=4, sort_keys=True)
+
+    def simple_action(self, action, todo):
+        return self.compact(todo)
+
+    def parse_priority(self, priority):
+        if priority is None:
+            return None
+        try:
+            if int(priority) in range(0, 10):
+                return int(priority)
+            else:
+                raise ValueError('Priority has to be in the range 0-9')
+        except ValueError as e:
+            raise click.BadParameter(e)
+
+    def detailed(self, todo):
+        return self.compact(todo)
+
+    def format_datetime(self, date):
+        if date:
+            return int(date.timestamp())
+        else:
+            return None
+
+    def parse_datetime(self, value):
+        if value:
+            return datetime.datetime.fromtimestamp(value)
+        else:
+            return None
diff --git a/todoman/interactive.py b/todoman/interactive.py
new file mode 100644
index 0000000..84650c1
--- /dev/null
+++ b/todoman/interactive.py
@@ -0,0 +1,195 @@
+import urwid
+
+from todoman import widgets
+
+_palette = [
+    ('error', 'light red', '')
+]
+
+
+class EditState:
+    none = object()
+    saved = object()
+
+
+class TodoEditor:
+    """
+    The UI for a single todo entry.
+    """
+
+    def __init__(self, todo, lists, formatter):
+        """
+        :param model.Todo todo: The todo object which will be edited.
+        """
+        self.current_list = todo.list
+        self.todo = todo
+        self.lists = list(lists)
+        self.formatter = formatter
+        self.saved = EditState.none
+        self._loop = None
+
+        self._msg_text = urwid.Text('')
+
+        due = formatter.format_datetime(todo.due) or ''
+        dtstart = formatter.format_datetime(todo.start) or ''
+        priority = formatter.format_priority(todo.priority)
+
+        self._summary = widgets.ExtendedEdit(parent=self,
+                                             edit_text=todo.summary)
+        self._description = widgets.ExtendedEdit(
+            parent=self,
+            edit_text=todo.description,
+            multiline=True,
+        )
+        self._location = widgets.ExtendedEdit(
+            parent=self,
+            edit_text=todo.location
+        )
+        self._due = widgets.ExtendedEdit(parent=self, edit_text=due)
+        self._dtstart = widgets.ExtendedEdit(parent=self, edit_text=dtstart)
+        self._completed = urwid.CheckBox("", state=todo.is_completed)
+        self._priority = widgets.ExtendedEdit(parent=self, edit_text=priority)
+
+        save_btn = urwid.Button('Save', on_press=self._save)
+        cancel_text = urwid.Text('Hit Ctrl-C to cancel, F1 for help.')
+        buttons = urwid.Columns([(8, save_btn), cancel_text], dividechars=2)
+
+        pile_items = []
+        for label, field in [("Summary", self._summary),
+                             ("Description", self._description),
+                             ("Location", self._location),
+                             ("Due", self._due),
+                             ("Start", self._dtstart),
+                             ("Completed", self._completed),
+                             ("Priority", self._priority),
+                             ]:
+            label = urwid.Text(label + ":", align='right')
+            column = urwid.Columns([(13, label), field], dividechars=1)
+            pile_items.append(('pack', column))
+
+        grid = urwid.Pile(pile_items)
+        spacer = urwid.Divider()
+
+        self._ui_content = items = [grid, spacer, self._msg_text, buttons]
+        left_column = urwid.ListBox(items)
+
+        self._help_text = urwid.Text(
+            '\n\nGlobal:\n'
+            ' F1: Toggle help\n'
+            ' Ctrl-C: Cancel\n'
+            ' Ctrl-S: Save (only works if not a shell shortcut already)\n\n'
+            'In Textfields:\n'
+            + '\n'.join(' {}: {}'.format(k, v) for k, v
+                        in widgets.ExtendedEdit.HELP)
+        )
+
+        list_selector = []
+        for _list in self.lists:
+            urwid.RadioButton(
+                list_selector,
+                _list.name,
+                state=_list.name == self.current_list.name,
+                on_state_change=self._change_current_list,
+                user_data=_list,
+            )
+        right_column = urwid.ListBox([urwid.Text('List:\n')] + list_selector)
+
+        self._ui = urwid.Columns([left_column, right_column])
+
+    def _change_current_list(self, radio_button, new_state, new_list):
+        if new_state:
+            self.current_list = new_list
+
+    def _toggle_help(self):
+        if self._ui_content[-1] is self._help_text:
+            self._ui_content.pop()
+        else:
+            self._ui_content.append(self._help_text)
+        self._loop.draw_screen()
+
+    def message(self, text):
+        self._msg_text.set_text(text)
+
+    def edit(self):
+        """
+        Shows the UI for editing a given todo. Returns True if modifications
+        were saved.
+        """
+        self._loop = urwid.MainLoop(
+            self._ui,
+            palette=_palette,
+            unhandled_input=self._keypress,
+            handle_mouse=False,
+        )
+        try:
+            self._loop.run()
+        except Exception:
+            try:  # Try to leave terminal in usable state
+                self._loop.stop()
+            except Exception:
+                pass
+            raise
+        self._loop = None
+        return self.saved
+
+    def _save(self, btn=None):
+        try:
+            self._save_inner()
+        except Exception as e:
+            self.message(('error', str(e)))
+        else:
+            self.saved = EditState.saved
+            raise urwid.ExitMainLoop()
+
+    def _save_inner(self):
+        self.todo.list = self.current_list
+        self.todo.summary = self.summary
+        self.todo.description = self.description
+        self.todo.location = self.location
+        self.todo.due = self.formatter.parse_datetime(self.due)
+        self.todo.start = self.formatter.parse_datetime(self.dtstart)
+
+        self.todo.is_completed = self._completed.get_state()
+
+        self.todo.priority = self.formatter.parse_priority(self.priority)
+
+        # TODO: categories
+        # TODO: comment
+        # TODO: priority (0: undef. 1: max, 9: min)
+
+        # https://tools.ietf.org/html/rfc5545#section-3.8
+        # geo (lat, lon)
+        # RESOURCE: the main room
+
+    def _cancel(self, btn):
+        raise urwid.ExitMainLoop()
+
+    def _keypress(self, key):
+        if key.lower() == 'f1':
+            self._toggle_help()
+        elif key == 'ctrl s':
+            self._save()
+
+    @property
+    def summary(self):
+        return self._summary.edit_text
+
+    @property
+    def description(self):
+        return self._description.edit_text
+
+    @property
+    def location(self):
+        return self._location.edit_text
+
+    @property
+    def due(self):
+        return self._due.edit_text
+
+    @property
+    def dtstart(self):
+        return self._dtstart.edit_text
+
+    @property
+    def priority(self):
+        return self._priority.edit_text
diff --git a/todoman/ui.py b/todoman/ui.py
deleted file mode 100644
index ff7f884..0000000
--- a/todoman/ui.py
+++ /dev/null
@@ -1,403 +0,0 @@
-import datetime
-import json
-from time import mktime
-
-import click
-import humanize
-import parsedatetime
-import urwid
-from dateutil.tz import tzlocal
-from tabulate import tabulate
-
-
-from . import widgets
-
-_palette = [
-    ('error', 'light red', '')
-]
-
-
-class EditState:
-    none = object()
-    saved = object()
-
-
-class TodoEditor:
-    """
-    The UI for a single todo entry.
-    """
-
-    def __init__(self, todo, lists, formatter):
-        """
-        :param model.Todo todo: The todo object which will be edited.
-        """
-        self.current_list = todo.list
-        self.todo = todo
-        self.lists = list(lists)
-        self.formatter = formatter
-        self.saved = EditState.none
-        self._loop = None
-
-        self._msg_text = urwid.Text('')
-
-        due = formatter.format_datetime(todo.due) or ''
-        dtstart = formatter.format_datetime(todo.start) or ''
-        priority = formatter.format_priority(todo.priority)
-
-        self._summary = widgets.ExtendedEdit(parent=self,
-                                             edit_text=todo.summary)
-        self._description = widgets.ExtendedEdit(
-            parent=self,
-            edit_text=todo.description,
-            multiline=True,
-        )
-        self._location = widgets.ExtendedEdit(
-            parent=self,
-            edit_text=todo.location
-        )
-        self._due = widgets.ExtendedEdit(parent=self, edit_text=due)
-        self._dtstart = widgets.ExtendedEdit(parent=self, edit_text=dtstart)
-        self._completed = urwid.CheckBox("", state=todo.is_completed)
-        self._priority = widgets.ExtendedEdit(parent=self, edit_text=priority)
-
-        save_btn = urwid.Button('Save', on_press=self._save)
-        cancel_text = urwid.Text('Hit Ctrl-C to cancel, F1 for help.')
-        buttons = urwid.Columns([(8, save_btn), cancel_text], dividechars=2)
-
-        pile_items = []
-        for label, field in [("Summary", self._summary),
-                             ("Description", self._description),
-                             ("Location", self._location),
-                             ("Due", self._due),
-                             ("Start", self._dtstart),
-                             ("Completed", self._completed),
-                             ("Priority", self._priority),
-                             ]:
-            label = urwid.Text(label + ":", align='right')
-            column = urwid.Columns([(13, label), field], dividechars=1)
-            pile_items.append(('pack', column))
-
-        grid = urwid.Pile(pile_items)
-        spacer = urwid.Divider()
-
-        self._ui_content = items = [grid, spacer, self._msg_text, buttons]
-        self._ui = urwid.ListBox(items)
-
-        self._help_text = urwid.Text(
-            '\n\nGlobal:\n'
-            ' F1: Toggle help\n'
-            ' Ctrl-C: Cancel\n'
-            ' Ctrl-S: Save (only works if not a shell shortcut already)\n\n'
-            'In Textfields:\n'
-            + '\n'.join(' {}: {}'.format(k, v) for k, v
-                        in widgets.ExtendedEdit.HELP)
-        )
-
-        def change_current_list(radio_button, new_state, new_list):
-            if new_state:
-                self.current_list = new_list
-
-        list_selector = []
-        for _list in self.lists:
-            urwid.RadioButton(list_selector, _list.name,
-                              state=_list.name == self.current_list.name,
-                              on_state_change=change_current_list,
-                              user_data=_list)
-        items.append(urwid.Pile(list_selector))
-
-    def _toggle_help(self):
-        if self._ui_content[-1] is self._help_text:
-            self._ui_content.pop()
-        else:
-            self._ui_content.append(self._help_text)
-        self._loop.draw_screen()
-
-    def message(self, text):
-        self._msg_text.set_text(text)
-
-    def edit(self):
-        """
-        Shows the UI for editing a given todo. Returns True if modifications
-        were saved.
-        """
-        self._loop = urwid.MainLoop(
-            self._ui,
-            palette=_palette,
-            unhandled_input=self._keypress,
-            handle_mouse=False,
-        )
-        try:
-            self._loop.run()
-        except Exception:
-            try:  # Try to leave terminal in usable state
-                self._loop.stop()
-            except Exception:
-                pass
-            raise
-        self._loop = None
-        return self.saved
-
-    def _save(self, btn=None):
-        try:
-            self._save_inner()
-        except Exception as e:
-            self.message(('error', str(e)))
-        else:
-            self.saved = EditState.saved
-            raise urwid.ExitMainLoop()
-
-    def _save_inner(self):
-        self.todo.list = self.current_list
-        self.todo.summary = self.summary
-        self.todo.description = self.description
-        self.todo.location = self.location
-        self.todo.due = self.formatter.parse_datetime(self.due)
-        self.todo.start = self.formatter.parse_datetime(self.dtstart)
-
-        self.todo.is_completed = self._completed.get_state()
-
-        self.todo.priority = self.formatter.parse_priority(self.priority)
-
-        # TODO: categories
-        # TODO: comment
-        # TODO: priority (0: undef. 1: max, 9: min)
-
-        # https://tools.ietf.org/html/rfc5545#section-3.8
-        # geo (lat, lon)
-        # RESOURCE: the main room
-
-    def _cancel(self, btn):
-        raise urwid.ExitMainLoop()
-
-    def _keypress(self, key):
-        if key.lower() == 'f1':
-            self._toggle_help()
-        elif key == 'ctrl s':
-            self._save()
-
-    @property
-    def summary(self):
-        return self._summary.edit_text
-
-    @property
-    def description(self):
-        return self._description.edit_text
-
-    @property
-    def location(self):
-        return self._location.edit_text
-
-    @property
-    def due(self):
-        return self._due.edit_text
-
-    @property
-    def dtstart(self):
-        return self._dtstart.edit_text
-
-    @property
-    def priority(self):
-        return self._priority.edit_text
-
-
-class DefaultFormatter:
-
-    def __init__(self, date_format, time_format, dt_separator):
-        self.date_format = date_format
-        self.time_format = time_format
-        self.dt_separator = dt_separator
-        self.datetime_format = dt_separator.join(filter(bool, (
-            date_format, time_format
-        )))
-
-        self._localtimezone = tzlocal()
-        self.now = datetime.datetime.now().replace(tzinfo=self._localtimezone)
-
-        self._parsedatetime_calendar = parsedatetime.Calendar()
-
-    def simple_action(self, action, todo):
-        return '{} "{}"'.format(action, todo.summary)
-
-    def compact(self, todo):
-        return self.compact_multiple([todo])
-
-    def compact_multiple(self, todos):
-        table = []
-        for todo in todos:
-            completed = "X" if todo.is_completed else " "
-            percent = todo.percent_complete or ''
-            if percent:
-                percent = " ({}%)".format(percent)
-            priority = self.format_priority_compact(todo.priority)
-
-            due = self.format_datetime(todo.due)
-            if todo.due and todo.due <= self.now and not todo.is_completed:
-                due = click.style(due, fg='red')
-
-            table.append([
-                todo.id,
-                "[{}]".format(completed),
-                priority,
-                due,
-                "{} {}{}".format(
-                    todo.summary,
-                    self.format_database(todo.list),
-                    percent,
-                ),
-            ])
-
-        return tabulate(table, tablefmt='plain')
-
-    def detailed(self, todo):
-        """
-        Returns a detailed representation of a task.
-
-        :param Todo todo: The todo component.
-        """
-        rv = self.compact_multiple([todo])
-        if todo.description:
-            rv = "{}\n\nDescription: {}".format(rv, todo.description)
-        if todo.location:
-            rv = "{}\n\nLocation: {}".format(rv, todo.location)
-        return rv
-
-    def format_datetime(self, dt):
-        if not dt:
-            return ''
-        elif isinstance(dt, datetime.datetime):
-            return dt.strftime(self.datetime_format)
-        elif isinstance(dt, datetime.date):
-            return dt.strftime(self.date_format)
-
-    def parse_priority(self, priority):
-        if priority is None or priority is '':
-            return None
-        if priority == 'low':
-            return 9
-        elif priority == 'medium':
-            return 5
-        elif priority == 'high':
-            return 4
-        elif priority == 'none':
-            return 0
-        else:
-            raise ValueError('Priority has to be one of low, medium,'
-                             ' high or none')
-
-    def format_priority(self, priority):
-        if not priority:
-            return ''
-        elif 1 <= priority <= 4:
-            return 'high'
-        elif priority == 5:
-            return 'medium'
-        elif 6 <= priority <= 9:
-            return 'low'
-
-    def format_priority_compact(self, priority):
-        if not priority:
-            return ''
-        elif 1 <= priority <= 4:
-            return "!!!"
-        elif priority == 5:
-            return "!!"
-        elif 6 <= priority <= 9:
-            return "!"
-
-    def parse_datetime(self, dt):
-        if not dt:
-            return None
-
-        rv = self._parse_datetime_naive(dt)
-        return rv.replace(tzinfo=self._localtimezone)
-
-    def _parse_datetime_naive(self, dt):
-        try:
-            return datetime.datetime.strptime(dt, self.datetime_format)
-        except ValueError:
-            pass
-
-        try:
-            return datetime.datetime.strptime(dt, self.date_format)
-        except ValueError:
-            pass
-
-        try:
-            return datetime.datetime.combine(
-                self.now.date(),
-                datetime.datetime.strptime(dt, self.time_format).time()
-            )
-        except ValueError:
-            pass
-
-        rv, certainty = self._parsedatetime_calendar.parse(dt)
-        if not certainty:
-            raise ValueError(
-                'Time description not recognized: {}' .format(dt)
-            )
-        return datetime.datetime.fromtimestamp(mktime(rv))
-
-    def format_database(self, database):
-        return '{}@{}'.format(database.color_ansi or '',
-                              click.style(database.name))
-
-
-class HumanizedFormatter(DefaultFormatter):
-
-    def format_datetime(self, dt):
-        if not dt:
-            return ''
-        return humanize.naturaltime(self.now - dt)
-
-
-class PorcelainFormatter(DefaultFormatter):
-
-    def __init__(self, *args, **kwargs):
-        pass
-
-    def _todo_as_dict(self, todo):
-        return dict(
-            completed=todo.is_completed,
-            due=self.format_datetime(todo.due),
-            id=todo.id,
-            list=todo.list.name,
-            percent=todo.percent_complete,
-            summary=todo.summary,
-            priority=todo.priority,
-        )
-
-    def compact(self, todo):
-        return json.dumps(self._todo_as_dict(todo), indent=4, sort_keys=True)
-
-    def compact_multiple(self, todos):
-        data = [self._todo_as_dict(todo) for todo in todos]
-        return json.dumps(data, indent=4, sort_keys=True)
-
-    def simple_action(self, action, todo):
-        return self.compact(todo)
-
-    def parse_priority(self, priority):
-        if priority is None:
-            return None
-        try:
-            if int(priority) in range(0, 10):
-                return int(priority)
-            else:
-                raise ValueError('Priority has to be in the range 0-9')
-        except ValueError as e:
-            raise click.BadParameter(e)
-
-    def detailed(self, todo):
-        return self.compact(todo)
-
-    def format_datetime(self, date):
-        if date:
-            return int(date.timestamp())
-        else:
-            return None
-
-    def parse_datetime(self, value):
-        if value:
-            return datetime.datetime.fromtimestamp(value)
-        else:
-            return None


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 3d02040..4ed93b4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,7 +9,7 @@ from dateutil.tz import tzlocal
 from hypothesis import HealthCheck, settings, Verbosity
 
 from todoman import model
-from todoman.ui import DefaultFormatter
+from todoman.formatters import DefaultFormatter
 
 
 @pytest.fixture
@@ -73,14 +73,18 @@ def now_for_tz():
 @pytest.fixture
 def todo_factory(default_database):
     def inner(
+            description=None,
             due=None,
+            location=None,
             priority=None,
             summary='YARR!',
               ):
         todo = model.FileTodo()
         todo.list = list(default_database.lists())[0]
 
+        todo.description = description
         todo.due = due
+        todo.location = location
         todo.priority = priority
         todo.summary = summary
 
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index be5a0f1..caa6011 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -6,7 +6,7 @@ from todoman.cli import cli
 
 
 @pytest.mark.parametrize('interval', [
-    (65, 'a minute from now'),
+    (65, 'in a minute'),
     (-10800, '3 hours ago'),
 ])
 @pytest.mark.parametrize('tz', ['CET', 'HST'])
@@ -52,3 +52,23 @@ def test_format_date(default_formatter):
 def test_format_datetime(default_formatter):
     assert default_formatter.format_datetime(datetime(2017, 3, 4, 17, 00)) == \
         '2017-03-04 17:00'
+
+
+def test_detailed_format(runner, todo_factory):
+    todo_factory(
+        description='Test detailed formatting\n'
+        'This includes multiline descriptions\n'
+        'Blah!',
+        location='Over the hills, and far away',
+    )
+
+    result = runner.invoke(cli, ['show', '1'])
+    expected = (
+        '1  [ ]      YARR! @default\n\n'
+        'Description  Test detailed formatting\n'
+        '             This includes multiline descriptions\n'
+        '             Blah!\n'
+        'Location     Over the hills, and far away'
+    )
+    assert not result.exception
+    assert result.output.strip() == expected
diff --git a/tests/test_model.py b/tests/test_model.py
index 2cbf104..789dd3e 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -4,9 +4,7 @@ import icalendar
 
 from dateutil.tz.tz import tzoffset
 
-from todoman.model import Database
-from todoman.model import FileTodo
-from todoman.model import List
+from todoman.model import Database, FileTodo, List
 
 
 def test_querying(create, tmpdir):
diff --git a/tests/test_ui.py b/tests/test_ui.py
index 960d916..4669a89 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -5,7 +5,7 @@ import pytz
 from freezegun import freeze_time
 from urwid import ExitMainLoop
 
-from todoman.ui import TodoEditor
+from todoman.interactive import TodoEditor
 
 
 def test_todo_editor_priority(default_database, todo_factory,