Repository: pimutils/todoman
Pull request #206 (2017-03-22 21:48:16)

Implement a priority widget
* [x] Tests
* [x] A bit more manual testing of corner cases
* [ ] Intuitiveness (it's not obvious that arrow keys switch, but `<` and `>` make it look like a button).



Patch:
diff --git a/todoman/formatters.py b/todoman/formatters.py
index 7073f65..34b2389 100644
--- a/todoman/formatters.py
+++ b/todoman/formatters.py
@@ -112,7 +112,7 @@ class DefaultFormatter:
 
     def format_priority(self, priority):
         if not priority:
-            return ''
+            return 'none'
         elif 1 <= priority <= 4:
             return 'high'
         elif priority == 5:
diff --git a/todoman/interactive.py b/todoman/interactive.py
index bd6fbb3..79a78d8 100644
--- a/todoman/interactive.py
+++ b/todoman/interactive.py
@@ -22,27 +22,10 @@ class TodoEditor:
         self.formatter = formatter
         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)
+        self._status = urwid.Text('')
+        self._init_basic_fields()
+        self._init_list_selector()
+        self._init_help_text()
 
         save_btn = urwid.Button('Save', on_press=self._save)
         cancel_text = urwid.Text('Hit Ctrl-C to cancel, F1 for help.')
@@ -64,19 +47,51 @@ class TodoEditor:
         grid = urwid.Pile(pile_items)
         spacer = urwid.Divider()
 
-        self._ui_content = items = [grid, spacer, self._msg_text, buttons]
-        left_column = urwid.ListBox(items)
+        self.left_column = urwid.ListBox([
+            grid,
+            spacer,
+            self._status,
+            buttons,
+        ])
+        right_column = urwid.ListBox(
+            [urwid.Text('List:\n')] + self.list_selector
+        )
 
-        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)
+        self._ui = urwid.Columns([self.left_column, right_column])
+
+    def _init_basic_fields(self):
+        self._summary = widgets.ExtendedEdit(
+            parent=self,
+            edit_text=self.todo.summary,
+        )
+        self._description = widgets.ExtendedEdit(
+            parent=self,
+            edit_text=self.todo.description,
+            multiline=True,
+        )
+        self._location = widgets.ExtendedEdit(
+            parent=self,
+            edit_text=self.todo.location,
+        )
+        self._due = widgets.ExtendedEdit(
+            parent=self,
+            edit_text=self.formatter.format_datetime(self.todo.due),
+        )
+        self._dtstart = widgets.ExtendedEdit(
+            parent=self,
+            edit_text=self.formatter.format_datetime(self.todo.start),
+        )
+        self._completed = urwid.CheckBox(
+            "",
+            state=self.todo.is_completed
+        )
+        self._priority = widgets.PrioritySelector(
+            parent=self,
+            priority=self.todo.priority,
+            formatter_function=self.formatter.format_priority,
         )
 
+    def _init_list_selector(self):
         self.list_selector = []
         for _list in self.lists:
             urwid.RadioButton(
@@ -86,25 +101,37 @@ class TodoEditor:
                 on_state_change=self._change_current_list,
                 user_data=_list,
             )
-        right_column = urwid.ListBox(
-            [urwid.Text('List:\n')] + self.list_selector
-        )
 
-        self._ui = urwid.Columns([left_column, right_column])
+    def _init_help_text(self):
+        self._help_text = urwid.Text(
+            '\n\n'
+            'Global:\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) +
+            '\n\n'
+            'In Priority Selector:\n'
+            + '\n'.join(' {}: {}'.format(k, v) for k, v
+                        in widgets.PrioritySelector.HELP)
+        )
 
     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()
+        if self.left_column.body.contents[-1] is self._help_text:
+            self.left_column.body.contents.pop()
         else:
-            self._ui_content.append(self._help_text)
+            self.left_column.body.contents.append(self._help_text)
         self._loop.draw_screen()
 
-    def message(self, text):
-        self._msg_text.set_text(text)
+    def set_status(self, text):
+        self._status.set_text(text)
 
     def edit(self):
         """Shows the UI for editing a given todo."""
@@ -128,7 +155,7 @@ class TodoEditor:
         try:
             self._save_inner()
         except Exception as e:
-            self.message(('error', str(e)))
+            self.set_status(('error', str(e)))
         else:
             raise urwid.ExitMainLoop()
 
@@ -139,22 +166,16 @@ class TodoEditor:
         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)
+        self.todo.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()
@@ -183,4 +204,4 @@ class TodoEditor:
 
     @property
     def priority(self):
-        return self._priority.edit_text
+        return self._priority.priority
diff --git a/todoman/widgets.py b/todoman/widgets.py
index 82dda5b..4ccc733 100644
--- a/todoman/widgets.py
+++ b/todoman/widgets.py
@@ -122,3 +122,53 @@ class ExtendedEdit(urwid.Edit):
         new_text = click.edit(self.get_edit_text())
         if new_text is not None:
             self.set_edit_text(new_text.strip())
+
+
+class PrioritySelector(urwid.Button):
+
+    HELP = [
+        ('left', 'Lower Priority'),
+        ('right', 'Higher Priority'),
+    ]
+
+    RANGES = [
+        [0],
+        [9, 8, 7, 6],
+        [5],
+        [1, 2, 3, 4],
+    ]
+
+    def __init__(self, parent, priority, formatter_function):
+        self._parent = parent
+        self._label = urwid.SelectableIcon("", 0)
+        urwid.WidgetWrap.__init__(self, self._label)
+
+        self._priority = priority
+        self._formatter = formatter_function
+        self._set_label()
+
+    def _set_label(self):
+        self.set_label(self._formatter(self._priority))
+
+    def _update_label(self, delta=0):
+        for i, r in enumerate(PrioritySelector.RANGES):
+            if self._priority in r:
+                self._priority = PrioritySelector.RANGES[
+                    (i + delta) % len(PrioritySelector.RANGES)
+                ][0]
+                self._set_label()
+                return
+
+    def keypress(self, size, key):
+        if key in ['right', 'enter']:
+            self._update_label(1)
+            return
+        if key == 'left':
+            self._update_label(-1)
+            return
+
+        return super().keypress(size, key)
+
+    @property
+    def priority(self):
+        return self._priority


Test patch:
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index 04607e2..10fb018 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -28,8 +28,8 @@ def test_humanized_date(runner, create, interval, now_for_tz, tz):
 
 
 def test_format_priority(default_formatter):
-    assert default_formatter.format_priority(None) == ''
-    assert default_formatter.format_priority(0) == ''
+    assert default_formatter.format_priority(None) == 'none'
+    assert default_formatter.format_priority(0) == 'none'
     assert default_formatter.format_priority(5) == 'medium'
     for i in range(1, 5):
         assert default_formatter.format_priority(i) == 'high'
diff --git a/tests/test_ui.py b/tests/test_ui.py
index 9bfb3a4..9376a7c 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -1,4 +1,5 @@
 from datetime import datetime
+from unittest import mock
 
 import pytest
 import pytz
@@ -14,9 +15,9 @@ def test_todo_editor_priority(default_database, todo_factory,
     lists = list(default_database.lists())
 
     editor = TodoEditor(todo, lists, default_formatter)
-    assert editor._priority.edit_text == 'high'
+    assert editor._priority.label == 'high'
 
-    editor._priority.edit_text = ''
+    editor._priority.keypress(10, 'right')
     with pytest.raises(ExitMainLoop):  # Look at editor._msg_text if this fails
         editor._keypress('ctrl s')
 
@@ -87,3 +88,44 @@ def test_todo_editor_due(default_database, todo_factory, default_formatter):
         editor._keypress('ctrl s')
 
     assert todo.due == datetime(2017, 3, 10, 12, tzinfo=tz)
+
+
+def test_toggle_help(default_database, default_formatter, todo_factory):
+    todo = todo_factory()
+    lists = list(default_database.lists())
+
+    editor = TodoEditor(todo, lists, default_formatter)
+    editor._loop = mock.MagicMock()
+    assert editor._help_text not in editor.left_column.body.contents
+
+    editor._keypress('f1')
+    # Help text is made visible
+    assert editor._help_text in editor.left_column.body.contents
+
+    # Called event_loop.draw_screen
+    assert editor._loop.draw_screen.call_count == 1
+    assert editor._loop.draw_screen.call_args == mock.call()
+
+    editor._keypress('f1')
+    # Help text is made visible
+    assert editor._help_text not in editor.left_column.body.contents
+
+    # Called event_loop.draw_screen
+    assert editor._loop.draw_screen.call_count == 2
+    assert editor._loop.draw_screen.call_args == mock.call()
+
+
+def test_show_save_errors(default_database, default_formatter, todo_factory):
+    todo = todo_factory()
+    lists = list(default_database.lists())
+
+    editor = TodoEditor(todo, lists, default_formatter)
+    # editor._loop = mock.MagicMock()
+
+    editor._due.set_edit_text('not a date')
+    editor._keypress('ctrl s')
+
+    assert(
+        editor.left_column.body.contents[2].get_text()[0] ==
+        'Time description not recognized: not a date'
+    )
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index bd6c295..70067e7 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -1,6 +1,6 @@
 from unittest import mock
 
-from todoman.widgets import ExtendedEdit
+from todoman.widgets import ExtendedEdit, PrioritySelector
 
 # We ignore `size` when testing keypresses, because it's not used anywhere.
 # Just pass any number when writing tests, unless we start using the value.
@@ -158,3 +158,43 @@ def test_extended_edit_editor():
     assert edit.call_count == 1
     assert edit.call_args == mock.call(BASE_STRING)
     assert extended_edit.get_edit_text() == 'Sheep!'
+
+
+def test_priority_selector(default_formatter):
+    selector = PrioritySelector(None, 5, default_formatter.format_priority)
+
+    assert selector.label == 'medium'
+    assert selector.priority == 5
+
+    selector.keypress(10, 'right')
+    assert selector.label == 'high'
+    assert selector.priority == 1
+
+    selector.keypress(10, 'left')
+    selector.keypress(10, 'left')
+    assert selector.label == 'low'
+    assert selector.priority == 9
+
+    selector.keypress(10, 'right')
+    assert selector.label == 'medium'
+    assert selector.priority == 5
+
+    # Spin the whoel way around:
+    for i in PrioritySelector.RANGES:
+        selector.keypress(10, 'right')
+
+    assert selector.label == 'medium'
+    assert selector.priority == 5
+
+    # Now the other way
+    for i in PrioritySelector.RANGES:
+        selector.keypress(10, 'left')
+
+    assert selector.label == 'medium'
+    assert selector.priority == 5
+
+    # Should do nothing:
+    selector.keypress(10, 'd')
+    selector.keypress(10, '9')
+    assert selector.label == 'medium'
+    assert selector.priority == 5