Daniel Sheffield 3 лет назад
Родитель
Сommit
435dd5f326

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+*.pyc
+__pycache__

+ 7 - 0
app/__init__.py

@@ -0,0 +1,7 @@
+#
+# Copyright (c) Daniel Sheffield 2021
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+COPYRIGHT = "Copyright (c) Daniel Sheffield 2021"

+ 246 - 0
app/activities/PriceCheck.py

@@ -0,0 +1,246 @@
+#
+# Copyright (c) Daniel Sheffield 2021
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from .. import COPYRIGHT
+from ..widgets import (
+    NoTabCheckBox,
+    AutoCompleteEdit,
+    AutoCompleteFloatEdit,
+    FocusWidget
+)
+from . import show_or_exit
+from decimal import Decimal, InvalidOperation
+from collections import (
+    OrderedDict,
+)
+import numpy as np
+import urwid
+class PriceCheck(FocusWidget):
+
+    def keypress(self, size, key):
+        if isinstance(key, tuple):
+            return
+        
+        if getattr(self.original_widget.original_widget, 'original_widget', None) is None:
+            return super().keypress(size, key)
+        
+        if key == 'tab':
+            self.advance_focus()
+        elif key == 'shift tab':
+            self.advance_focus(reverse=True)
+        else:
+            return super().keypress(size, key)
+
+    def apply_choice(self, name, widget, value):
+        self.apply_changes(name, widget, value)
+        for k,v in self.data.items():
+            if k == name or v:
+                continue
+            options = self.query_manager.unique_suggestions(k, **self.data)
+            if len(options) == 1:
+                self.apply_changes(k, widget, list(options)[0])
+
+    def apply_changes(self, name, widget, value):
+        if name in self.data:
+            
+            self.data.update({
+                name: value,
+            })
+        self.update_historic_prices()
+
+    def clear(self):
+        for k in self.data:
+            if k == 'organic':
+                self.data[k] = 'mixed'
+                continue
+            self.data[k] = ''
+        self.update()
+        return self
+    
+    def update(self):
+        for k in self.edit_fields:
+            if self.data[k] != self.edit_fields[k].get_edit_text():
+                self.edit_fields[k].set_edit_text(self.data[k])
+        
+        if self.data['organic'] != self.organic_checkbox.state:
+            self.organic_checkbox.set_state(self.data['organic'])
+        
+        self.update_historic_prices()
+        
+        return self
+    
+    def update_rating(self, _avg, _min, _max, price=None, quantity=None):
+        if None in (_avg, _min, _max):
+            return
+        current = None if None in (price, quantity or None) else float(price/quantity)
+        size = 14
+        chars = ['|', *['-']*(size - 2), '|' ]
+        rating = [' ']*len(chars)
+        _min, _max = min(_min, current or _min), max(_max, current or _max)
+        ls = np.linspace(_min, _max, len(chars))
+
+        for idx, (e, a) in enumerate(zip(ls, ls[1:])):
+            if e <= _avg < a:
+                for c, (_idx,_) in zip('[{}]'.format(''.join(list(f'{_avg:>5.2f}')[:idx-3+5])), filter(lambda x: idx-3 < x[0], enumerate(chars))):
+                    chars[_idx] = c
+                chars[0] = '|'
+                chars[-1] = '|'
+            
+            if current is not None and e <= current < a:
+                rating[idx] = '^'
+        if current == _max:
+            rating[-1] = '^'
+        self.text_fields['spread'].set_text(f"{_min:>5.2f}{''.join(chars)}{_max:<5.2f}")
+        self.text_fields['rating'].set_text(f"{' '*5}{''.join(rating)}{' '*5}")
+        
+    
+    def update_historic_prices(self):
+        organic = None if self.data['organic'] == 'mixed' else self.data['organic']
+        sort = '$/unit' if self.buttons['sort_price'].state else 'ts'
+        product, unit = self.data['product'] or None, self.data['unit'] or None
+        try: 
+            price = Decimal(self.data['price'])
+        except InvalidOperation:
+            price = None
+        
+        try:
+            quantity = Decimal(self.data['quantity'])
+        except InvalidOperation:
+            quantity = None
+        
+        if None not in (sort, product, unit):
+            self.text_fields['dbview'].set_text(
+                self.query_manager.get_historic_prices(
+                    lambda *args: self.update_rating(*args, price=price, quantity=quantity),
+                    sort, product, unit, organic=organic) 
+            )
+
+    def __init__(self, query_manager, fields,
+        top_pane, left_pane, right_pane, bottom_pane,
+        autocomplete_cb):
+        super().__init__([2,0,1], [
+                [2,0,0,], [2,0,2], [2,2,0], [2,2,2],
+                [3,0,2,], [3,0,3,], [3,0,4,],
+                [3,1,3,],
+                [4,0,0,],
+            ]
+        )
+        self.query_manager = query_manager
+        self.top_pane = top_pane
+        self.left_pane = left_pane
+        self.right_pane = right_pane
+        self.bottom_pane = bottom_pane
+        self.edit_fields = OrderedDict()
+        self.text_fields = OrderedDict()
+        self.data = OrderedDict()
+        self.organic_checkbox = NoTabCheckBox(
+            u"Organic",
+            state='mixed',
+        )
+        urwid.connect_signal(self.organic_checkbox, 'change', lambda w,v: self.apply_changes('organic', w, v))
+        
+        fields = [f for f in fields ]
+        for k in fields:
+            if k in self.right_pane and k != 'unit':
+                ef = AutoCompleteFloatEdit(('bg', k), apply_change_func=lambda name: autocomplete_cb(name, self.data))
+            elif k != 'organic':
+                ef = AutoCompleteEdit(('bg', k), apply_change_func=lambda name: autocomplete_cb(name, self.data))
+            elif k == 'organic':
+                self.data[k] = 'mixed'
+                continue
+            
+            self.data[k] = ''
+            urwid.connect_signal(ef, 'change', lambda w,v,name=k: self.apply_changes(name, w, v))
+            self.edit_fields[k] = ef
+        
+        self.buttons = OrderedDict()
+        group = []
+        for k in top_pane:
+            if k != 'sort':
+                self.buttons.update({ k:  urwid.Button(('streak', f'{k.title()}')) })
+                continue
+            self.buttons.update({ f'{k}_price':  urwid.RadioButton(group, ('streak', u'Best'), state="first True") })
+            self.buttons.update({ f'{k}_date':  urwid.RadioButton(group, ('streak', u'Last'), state="first True") })
+            for button in group:
+                urwid.connect_signal(button, 'postchange', lambda *args: self.update_historic_prices())
+        
+        urwid.connect_signal(self.buttons['clear'], 'click', lambda x: self.clear().update())
+        urwid.connect_signal(self.buttons['exit'], 'click', lambda x: show_or_exit('esc'))
+        
+        dbview, spread, rating = [ urwid.Text('') for i in range(0,3) ]
+        
+        self.text_fields.update({
+            'dbview': dbview,
+            'spread': spread,
+            'rating': rating,
+        })
+        
+        self.clear()
+        
+        header = urwid.Text(u'Price Check', 'center')
+        _copyright = urwid.Text(COPYRIGHT, 'center')
+        
+        banner = urwid.Pile([
+            urwid.Padding(header, 'center', width=('relative', 100)),
+            urwid.Padding(_copyright, 'center', width=('relative', 100)),
+        ])
+        banner = urwid.AttrMap(banner, 'banner')
+        fields = dict([
+            (k, urwid.LineBox(urwid.AttrMap(self.edit_fields[k], 'streak'), title=k.title(), title_align='left')) for k in self.edit_fields
+        ])
+        fields['organic'] = urwid.LineBox(urwid.AttrMap(self.organic_checkbox, 'bg'))
+        fields.update({
+            'dbview': urwid.LineBox(
+                urwid.AttrMap(dbview, 'streak'),
+                title="Historic Prices",
+                title_align='center',
+            ),
+            'div': urwid.Divider(),
+            'spread': spread,
+            'rating': rating,
+        })
+
+        right_pane_widget = (16, urwid.Pile(map(
+            lambda x: fields[x] if x is not None else urwid.Divider(),
+            self.right_pane
+        )))
+        left_pane_widget = (urwid.Pile(map(
+            lambda x: fields[x] if x is not None else urwid.Divider(),
+            self.left_pane
+        )))
+        
+        widget = urwid.Pile([
+            banner,
+            urwid.Divider(),
+            urwid.Columns(
+                [
+                    (9, urwid.Pile([
+                        urwid.Divider(),
+                        urwid.AttrMap(self.buttons['clear'], 'streak'),
+                        urwid.Divider(),
+                    ])),
+                    urwid.LineBox(
+                        urwid.Columns([ v for k,v in self.buttons.items() if 'sort' in k]),
+                        title="Sort price by",
+                        title_align='left',
+                    ),
+                    (9, urwid.Pile([
+                        urwid.Divider(),
+                        urwid.AttrMap(self.buttons['exit'], 'streak'),
+                        urwid.Divider(),
+                    ]))
+                ],
+                dividechars=2,
+            ),
+            urwid.Columns((left_pane_widget, right_pane_widget),
+                dividechars=2,
+            ),
+            *[ fields[c] if c is not None else urwid.Divider() for c in self.bottom_pane ],
+        ])
+        widget = urwid.Filler(widget, 'top') 
+        widget = urwid.AttrMap(widget, 'bg')
+        self.original_widget = widget
+        widget.original_widget.original_widget.set_focus_path([3,0,0])

+ 166 - 0
app/activities/TransactionEditor.py

@@ -0,0 +1,166 @@
+#
+# Copyright (c) Daniel Sheffield 2021
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from .. import COPYRIGHT
+from ..widgets import (
+    NoTabCheckBox,
+    AutoCompleteEdit,
+    AutoCompleteFloatEdit,
+    FocusWidget
+)
+from collections import (
+    OrderedDict,
+)
+
+import urwid
+class TransactionEditor(FocusWidget):
+
+    def keypress(self, size, key):
+        if isinstance(key, tuple):
+            return
+        
+        if getattr(self.original_widget.original_widget, 'original_widget', None) is None:
+            return super().keypress(size, key)
+        
+        if key == 'tab':
+            self.advance_focus()
+        elif key == 'shift tab':
+            self.advance_focus(reverse=True)
+        else:
+            return super().keypress(size, key)
+
+    def _init_data(self, fields):
+        self.data = OrderedDict([
+            (k, '') for k in fields
+        ])
+        self.clear()
+
+    def _apply_choice(self, name, value):
+        self._apply_changes(name, value)
+        for k,v in self.data.items():
+            if k == name or v:
+                continue
+            options = self.query_manager.unique_suggestions(k, **self.data)
+            if len(options) == 1 and k != 'ts':
+                self._apply_changes(k, list(options)[0])
+    
+    def apply_choice(self, name):
+        return  lambda w,x: self._apply_choice(name, x)
+
+    def _apply_changes(self, name, value):
+        self.data.update({
+            name: value,
+        })
+    
+    def apply_changes(self, name):
+        return lambda w,x: self._apply_changes(name, x)
+
+    def apply_organic_state(self, w, state):
+        self.data['organic'] = repr(state).lower()
+
+    def clear(self):
+        for k in self.data:
+            if k in ('ts', 'store',):
+                continue
+            self.data[k] = ''
+        self.organic_checkbox.set_state(False)
+    
+    def update(self):
+        for k in self.edit_fields:
+            self.edit_fields[k].set_edit_text(self.data[k])
+        date, store = self.data['ts'], self.data['store']
+        self.text_fields['dbview'].set_text(
+            self.query_manager.get_session_transactions(date, store) if None not in (
+                date or None, store or None
+            ) else ''
+        )
+        self.organic_checkbox.set_state(True if self.data['organic'] == 'true' else False)
+        return self
+
+    def __init__(self, query_manager, fields,
+        layout, side_pane, bottom_pane,
+        save_and_clear_cb, autocomplete_cb):
+        super().__init__([2,0,0,0], [
+            [4,],
+            [5,],
+            [7,],
+        ])
+        self.organic_checkbox = NoTabCheckBox(
+            u"Organic",
+            on_state_change=self.apply_organic_state
+        )
+        self.query_manager = query_manager
+        self._init_data(fields)
+        self.layout = layout
+        self.side_pane = side_pane
+        self.bottom_pane = bottom_pane
+        self.edit_fields = OrderedDict()
+        self.text_fields = OrderedDict()
+        for k in self.data:
+            if k in self.side_pane and k != 'unit':
+                ef = AutoCompleteFloatEdit(('bg', k), apply_change_func=lambda name: autocomplete_cb(name, self.data))
+            elif k != 'organic':
+                ef = AutoCompleteEdit(('bg', k), apply_change_func=lambda name: autocomplete_cb(name, self.data))
+            else:
+                continue
+            ef.set_edit_text(self.data[k])
+            urwid.connect_signal(ef, 'change', self.apply_changes(k))
+            self.edit_fields[k] = ef
+    
+        header = urwid.Text(u'Fill Transaction', 'center')
+        _copyright = urwid.Text(COPYRIGHT, 'center')
+        done_button = urwid.Button(('streak', u'Done'))
+        urwid.connect_signal(done_button, 'click', lambda w: save_and_clear_cb())
+        banner = urwid.Pile([
+            urwid.Padding(header, 'center', width=('relative', 100)),
+            urwid.Padding(_copyright, 'center', width=('relative', 100)),
+        ])
+        banner = urwid.AttrMap(banner, 'banner')
+        fields = dict([
+            (k, urwid.LineBox(urwid.AttrMap(self.edit_fields[k], 'streak'), title=k.title(), title_align='left')) for k in self.edit_fields
+        ])
+        txn_view = urwid.Text('')
+        self.text_fields.update({'dbview': txn_view})
+        fields.update({
+            'dbview': urwid.LineBox(
+                urwid.AttrMap(txn_view, 'streak'),
+                title="Session Data",
+                title_align='left',
+            )
+        })
+        
+        side_pane_widget = (12, urwid.Pile([
+            fields[r] if r is not None else urwid.Divider() for r in self.side_pane
+        ]))
+        main_pane_widgets = []
+        for i, r in enumerate(self.layout):
+            widgets = []
+            for c in r:
+                if c is not None:
+                    if c != 'organic':
+                        widgets.append(fields[c])
+                    else:
+                        widgets.append(urwid.LineBox(urwid.AttrMap(self.organic_checkbox, 'bg')))
+                else:
+                    widgets.append(urwid.Divider())
+            main_pane_widgets.append(urwid.Columns(widgets))
+
+
+        main_pane_widget = urwid.Pile(main_pane_widgets)
+        
+        widget = urwid.Pile([
+            banner,
+            urwid.Divider(),
+            urwid.Columns((main_pane_widget, side_pane_widget),
+                dividechars=2,
+            ),
+            *[ fields[c] if c is not None else urwid.Divider() for c in self.bottom_pane ],
+            urwid.Divider(),
+            done_button,
+        ])
+        widget = urwid.Filler(widget, 'top') 
+        widget = urwid.AttrMap(widget, 'bg')
+        self.original_widget = widget

+ 13 - 0
app/activities/__init__.py

@@ -0,0 +1,13 @@
+#
+# Copyright (c) Daniel Sheffield 2021
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+import urwid
+def show_or_exit(key):
+    if isinstance(key, tuple):
+        return
+    
+    if key in ('esc',):
+        raise urwid.ExitMainLoop()

+ 2 - 2
db_utils.py → app/db_utils.py

@@ -4,12 +4,12 @@
 # All rights reserved
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-from txn_view import (
+from .txn_view import (
     get_table_statement,
     get_transactions_statement,
     get_session_transactions_statement,
 )
-from price_view import(
+from .price_view import(
     get_historic_prices_statement,
 )
 from dateutil.parser import parse as parse_time

+ 0 - 0
price_view.py → app/price_view.py


+ 0 - 0
txn_view.py → app/txn_view.py


+ 254 - 0
app/widgets.py

@@ -0,0 +1,254 @@
+#
+# Copyright (c) Daniel Sheffield 2021
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+import urwid
+from decimal import Decimal
+from urwid import numedit
+
+class AutoCompleteEdit(urwid.Edit):
+    def __init__(self, name, *args, apply_change_func=None, **kwargs):
+        if isinstance(name, tuple):
+            pallete, title = name
+            self.name = title
+            title = title.title()
+            passthrough = (pallete, u'  ' if title.lower() == 'unit' else u'')
+        else:
+            self.name = name
+            title = name.title()
+            passthrough = u'  ' if name.lower() == 'unit' else u''
+            
+        super().__init__(passthrough, *args, **kwargs)
+        self.apply = apply_change_func
+
+    def keypress(self, size, key):
+        if key == 'enter':
+            self.apply(self.name)
+            return
+        elif key == 'delete':
+            self.set_edit_text('')
+            return
+        
+        return super().keypress(size, key)
+
+
+class AutoCompleteFloatEdit(numedit.FloatEdit):
+    def __init__(self, name, *args, apply_change_func=None, **kwargs):
+        self.last_val = None
+        self.op = '='
+        self.pallete = None
+        if isinstance(name, tuple):
+            self.pallete, self.name = name
+            title = self.name.title()
+            passthrough = (self.pallete, f'{self.op} ')
+        else:
+            self.name = name
+            title = name.title()
+            passthrough = title
+            
+        super(AutoCompleteFloatEdit, self).__init__(passthrough, *args, **kwargs)
+        self.apply = apply_change_func
+
+    def update_caption(self):
+        if self.pallete is not None:
+            self.set_caption((self.pallete, f'{self.op} '))
+        else:
+            self.set_caption(f'{self.op} ')
+
+    def set_op(self, op):
+        self.op = op
+        self.last_val = self.value()
+        self.set_edit_text('')
+        self.update_caption()
+        
+    def calc(self):
+        x = self.last_val
+        op = self.op
+        if op in ('+', '-',):
+            y = self.value() or Decimal(0.0)
+            if op == '+':
+                z = x + y
+            else:
+                z = x - y
+        elif op in ('*', '/'):
+            y = self.value() or Decimal(1.0)
+            if op == '*':
+                z = x * y
+            else:
+                z = x / y
+        else:
+            y = self.value() or Decimal(0.0)
+            z = y
+        
+        self.op = '='
+        self.update_caption()
+        self.set_edit_text(f'{z:.2f}')
+
+    def keypress(self, size, key):
+        if isinstance(key, tuple):
+            return
+        ops = ('+', '-', '*', '/',)
+
+        if key in ops:
+            if self.op in ops:
+                self.calc()
+            self.set_op(key)
+            return
+        elif key == 'enter':
+            if self.get_edit_text() == '' or self.value() == Decimal(0.0):
+                return self.apply(self.name)
+            self.calc()
+            return
+        elif key == '=':
+            self.calc()
+            return
+        elif key == 'delete':
+            self.set_edit_text('')
+            self.op = '='
+            self.update_caption()
+            return
+
+        return super().keypress(size, key)
+
+class NoTabCheckBox(urwid.CheckBox):
+    def keypress(self, size, key):
+        if not isinstance(key, tuple) and key == 'tab':
+            return 
+        else:
+            return super().keypress(size, key)
+
+class FocusWidget(urwid.WidgetPlaceholder):
+
+    def __init__(self, initial_focus, skip_focus):
+        super().__init__(urwid.SolidFill(u'/'))
+        self._initial_focus = tuple([ i for i in initial_focus ])
+        self._skip_focus = tuple([ i for i in skip_focus ])
+    
+    @property
+    def skip_focus(self):
+        return self._skip_focus
+    
+    @property
+    def initial_focus(self):
+        return list(self._initial_focus)
+    
+    @property
+    def container(self):
+        return self.original_widget.original_widget.original_widget
+    
+    def _set_focus_path(self, path):
+        try:
+            self.container.set_focus_path(path)
+            return
+        except IndexError:
+            pass
+        
+        if path[-1] == 0 and len(path) > 1:
+            self._set_focus_path(path[:-1])
+            return
+    
+        raise IndexError
+
+    def iter_focus_paths(self):
+        self._set_focus_path(self.initial_focus)
+        while True:
+            path = self.container.get_focus_path()
+            yield path
+            self.advance_focus()
+            path = self.container.get_focus_path()
+            if path == self.initial_focus:
+                break
+
+    def advance_focus(self, reverse=False):
+
+        path = self.container.get_focus_path()
+                
+        if reverse:
+            paths = [ i for i in self.iter_focus_paths() ]
+            zipped_paths = zip(paths, [
+                *paths[1:], paths[0]
+            ])
+            prev_path = map(lambda x: x[0], filter(
+                lambda x: x[1] == path,
+                zipped_paths
+            ))
+            p = next(prev_path)
+            self._set_focus_path(p)
+            return
+        
+        _iter = [ i for i in enumerate(path) ][::-1]
+
+        for idx, part in _iter:
+            p = [ i for i in path ]
+            if reverse:
+                p[idx] -= 1
+            else:
+                p[idx] += 1
+            
+            try:
+                self._set_focus_path(p)
+                if p in self.skip_focus:
+                    self.advance_focus(reverse=reverse)
+                return
+            except IndexError:
+                path[idx] = 0
+            
+        self.container.set_focus_path(self.initial_focus)
+
+class SuggestionPopup(urwid.Overlay):
+    
+    def __init__(self, under, name, options, apply_cb, esc_cb):
+        self.esc_cb = esc_cb
+        self.under = under
+        body = [urwid.Text(name.title()), urwid.Divider()]
+        for c in options:
+            button = urwid.Button(c)
+            urwid.connect_signal(button, 'click', apply_cb, c)
+            body.append(urwid.AttrMap(button, None, focus_map='reversed'))
+        walker = urwid.SimpleFocusListWalker(body, wrap_around=False)
+        listbox = urwid.ListBox(walker)
+        pad = urwid.Padding(listbox, left=2, right=2)
+        pad = urwid.AttrMap(pad, 'banner')
+        super().__init__(pad, under,
+            align='center', width=('relative', 60),
+            valign='middle', height=('relative', 60),
+            min_width=20, min_height=9)
+
+    def keypress(self, size, key):
+        if key == 'esc':
+            self.esc_cb()
+            return
+
+        if key == 'tab':
+            return
+
+        return super().keypress(size, key)
+
+class ActivityManager(object):
+
+    def __init__(self):
+        self.widgets = dict()
+        self.app = None
+    
+    def add(self, widget, name):
+        self.widgets[name] = widget
+        return widget
+    
+    def get(self, name):
+        if name in self.widgets:
+            return self.widgets[name]
+        raise Exception("Widget {name} not found")
+    
+    def create(self, cls, name, *args, **kwargs):
+        widget = cls(*args, **kwargs)
+        if name is not None:
+            self.add(widget, name)
+        return widget
+    
+    def show(self, widget):
+        if self.app is not None:
+            self.app.original_widget = widget
+            return
+        self.app = widget

+ 70 - 13
grocery_transactions.py

@@ -9,14 +9,15 @@ import time
 import itertools
 import sys
 import urwid
-import pandas as pd
-from db_utils import QueryManager
-from widgets import (
-    TransactionEditor,
+from app.db_utils import QueryManager
+from app.widgets import (
     SuggestionPopup,
-    GroceryTransactionEditor,
     ActivityManager,
 )
+from app.activities import (
+    show_or_exit,
+)  
+from app.activities.TransactionEditor import TransactionEditor
 
 try:
     from db_credentials import HOST, PASSWORD
@@ -28,7 +29,6 @@ except:
 
 try:
     import psycopg2
-    from psycopg2.sql import SQL
     import os
     user = os.getenv('USER')
     conn = psycopg2.connect(f"{host} dbname=das user={user} {password}")
@@ -75,13 +75,6 @@ display_map = {
 }
 display = lambda data, name: display_map[name](data) if name in display_map else data
 
-def show_or_exit(key):
-    if isinstance(key, tuple):
-        return
-    
-    if key in ('esc',):
-        raise urwid.ExitMainLoop()
-
 def _apply_choice_callback(activity_manager, name, widget, value):
     txn = activity_manager.get('transaction')
     txn.apply_choice(name)(widget, value)
@@ -110,6 +103,70 @@ def _save_and_clear_callback(activity_manager):
 args = sys.argv
 log = args[1]
 
+class GroceryTransactionEditor(urwid.WidgetPlaceholder):
+    def __init__(self, activity_manager, cur, log):
+        super().__init__(urwid.SolidFill(u'/'))
+        self.activity_manager = activity_manager
+        self.cur = cur
+        txn = self.activity_manager.get('transaction')
+
+        with open(log, 'r') as f:
+            date = None
+            store = None
+            for line in f.readlines():
+                if date is None and store is None:
+                    if '$store$' in line:
+                        date, store, _= line.split('$store$')
+                        date = date.split("'")[1]
+                else:
+                    assert None not in (date, store,), \
+                        "Both date and store should be set or neither should be set"
+                    if '$store$' in line:
+                        assert date in line and f'$store${store}$store$' in line, \
+                            "Date ({date}) and store ({store}) not found in {line}."\
+                            " Mixing transactions from different dates and stores is not supported"
+                #print(self.cur.mogrify(line))
+                #input()
+                self.cur.execute(line)
+        
+            if None not in (date, store):
+                txn._apply_choice('ts', date)
+                txn._apply_choice('store', store)
+
+        self.activity_manager.show(self)
+        self.activity_manager.show(txn.update())
+
+        self.log = self.open(log)
+    
+    def _open(self, log):
+        with open(log, 'a') as f:
+            yield f
+    
+    def open(self, log):
+        self._to_close = self._open(log)
+        return next(self._to_close)
+    
+    def close(self):
+        if self._to_close is not None:
+            self._to_close.close()
+            self._to_close = None
+        
+    def save(self, data):
+        ts = data['ts']
+        store = data['store']
+        description = data['description']
+        quantity = data['quantity']
+        unit = data['unit']
+        price = data['price']
+        product = data['product']
+        organic = data['organic'] if data['organic'] else 'false'
+        statement = \
+            f"CALL insert_transaction('{ts}', $store${store}$store$, " \
+            f"$descr${description}$descr$, {quantity}, $unit${unit}$unit$, " \
+            f"{price}, $produ${product}$produ$, {organic});\n"
+        self.log.write(statement)
+        self.cur.execute(statement)
+
 cur.execute("BEGIN")
 
 activity_manager = ActivityManager()

+ 6 - 251
price_check.py

@@ -5,28 +5,18 @@
 # All rights reserved
 #
 # THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-import time
 import itertools
-import sys
 import urwid
-import pandas as pd
-import numpy as np
-from widgets import COPYRIGHT
-from db_utils import QueryManager
-from dateutil.parser import parse as parse_time
-from widgets import (
-    NoTabCheckBox,
-    AutoCompleteEdit,
-    AutoCompleteFloatEdit,
+from app import COPYRIGHT
+from app.db_utils import QueryManager
+from app.widgets import (
     SuggestionPopup,
     ActivityManager,
-    FocusWidget,
 )
-from decimal import Decimal, InvalidOperation
-from collections import (
-    OrderedDict,
+from app.activities import (
+    show_or_exit,
 )
-
+from app.activities.PriceCheck import PriceCheck
 try:
     from db_credentials import HOST, PASSWORD
     host = f'host={HOST}'
@@ -37,7 +27,6 @@ except:
 
 try:
     import psycopg2
-    from psycopg2.sql import SQL
     import os
     user = os.getenv('USER')
     conn = psycopg2.connect(f"{host} dbname=das user={user} {password}")
@@ -89,13 +78,6 @@ display_map = {
 }
 display = lambda data, name: display_map[name](data) if name in display_map else data
 
-def show_or_exit(key):
-    if isinstance(key, tuple):
-        return
-    
-    if key in ('esc',):
-        raise urwid.ExitMainLoop()
-
 def _apply_choice_callback(activity_manager, name, widget, value):
     activity = activity_manager.get('price_check')
     activity.apply_choice(name, widget, value)
@@ -124,233 +106,6 @@ class GroceryPriceCheck(urwid.WidgetPlaceholder):
         self.activity_manager.show(self)
         self.activity_manager.show(price_check)
 
-class PriceCheck(FocusWidget):
-
-    def keypress(self, size, key):
-        if isinstance(key, tuple):
-            return
-        
-        if getattr(self.original_widget.original_widget, 'original_widget', None) is None:
-            return super().keypress(size, key)
-        
-        if key == 'tab':
-            self.advance_focus()
-        elif key == 'shift tab':
-            self.advance_focus(reverse=True)
-        else:
-            return super().keypress(size, key)
-
-    def apply_choice(self, name, widget, value):
-        self.apply_changes(name, widget, value)
-        for k,v in self.data.items():
-            if k == name or v:
-                continue
-            options = self.query_manager.unique_suggestions(k, **self.data)
-            if len(options) == 1:
-                self.apply_changes(k, widget, list(options)[0])
-
-    def apply_changes(self, name, widget, value):
-        if name in self.data:
-            
-            self.data.update({
-                name: value,
-            })
-        self.update_historic_prices()
-
-    def clear(self):
-        for k in self.data:
-            if k == 'organic':
-                self.data[k] = 'mixed'
-                continue
-            self.data[k] = ''
-        self.update()
-        return self
-    
-    def update(self):
-        for k in self.edit_fields:
-            if self.data[k] != self.edit_fields[k].get_edit_text():
-                self.edit_fields[k].set_edit_text(self.data[k])
-        
-        if self.data['organic'] != self.organic_checkbox.state:
-            self.organic_checkbox.set_state(self.data['organic'])
-        
-        self.update_historic_prices()
-        
-        return self
-    
-    def update_rating(self, _avg, _min, _max, price=None, quantity=None):
-        if None in (_avg, _min, _max):
-            return
-        current = None if None in (price, quantity or None) else float(price/quantity)
-        size = 14
-        chars = ['|', *['-']*(size - 2), '|' ]
-        rating = [' ']*len(chars)
-        _min, _max = min(_min, current or _min), max(_max, current or _max)
-        ls = np.linspace(_min, _max, len(chars))
-
-        for idx, (e, a) in enumerate(zip(ls, ls[1:])):
-            if e <= _avg < a:
-                for c, (_idx,_) in zip('[{}]'.format(''.join(list(f'{_avg:>5.2f}')[:idx-3+5])), filter(lambda x: idx-3 < x[0], enumerate(chars))):
-                    chars[_idx] = c
-                chars[0] = '|'
-                chars[-1] = '|'
-            
-            if current is not None and e <= current < a:
-                rating[idx] = '^'
-        if current == _max:
-            rating[-1] = '^'
-        self.text_fields['spread'].set_text(f"{_min:>5.2f}{''.join(chars)}{_max:<5.2f}")
-        self.text_fields['rating'].set_text(f"{' '*5}{''.join(rating)}{' '*5}")
-        
-    
-    def update_historic_prices(self):
-        organic = None if self.data['organic'] == 'mixed' else self.data['organic']
-        sort = '$/unit' if self.buttons['sort_price'].state else 'ts'
-        product, unit = self.data['product'] or None, self.data['unit'] or None
-        try: 
-            price = Decimal(self.data['price'])
-        except InvalidOperation:
-            price = None
-        
-        try:
-            quantity = Decimal(self.data['quantity'])
-        except InvalidOperation:
-            quantity = None
-        
-        if None not in (sort, product, unit):
-            self.text_fields['dbview'].set_text(
-                self.query_manager.get_historic_prices(
-                    lambda *args: self.update_rating(*args, price=price, quantity=quantity),
-                    sort, product, unit, organic=organic) 
-            )
-
-    def __init__(self, query_manager, fields,
-        top_pane, left_pane, right_pane, bottom_pane,
-        autocomplete_cb):
-        super().__init__([2,0,1], [
-                [2,0,0,], [2,0,2], [2,2,0], [2,2,2],
-                [3,0,2,], [3,0,3,], [3,0,4,],
-                [3,1,3,],
-                [4,0,0,],
-            ]
-        )
-        self.query_manager = query_manager
-        self.top_pane = top_pane
-        self.left_pane = left_pane
-        self.right_pane = right_pane
-        self.bottom_pane = bottom_pane
-        self.edit_fields = OrderedDict()
-        self.text_fields = OrderedDict()
-        self.data = OrderedDict()
-        self.organic_checkbox = NoTabCheckBox(
-            u"Organic",
-            state='mixed',
-        )
-        urwid.connect_signal(self.organic_checkbox, 'change', lambda w,v: self.apply_changes('organic', w, v))
-        
-        fields = [f for f in fields ]
-        for k in fields:
-            if k in self.right_pane and k != 'unit':
-                ef = AutoCompleteFloatEdit(('bg', k), apply_change_func=lambda name: autocomplete_cb(name, self.data))
-            elif k != 'organic':
-                ef = AutoCompleteEdit(('bg', k), apply_change_func=lambda name: autocomplete_cb(name, self.data))
-            elif k == 'organic':
-                self.data[k] = 'mixed'
-                continue
-            
-            self.data[k] = ''
-            urwid.connect_signal(ef, 'change', lambda w,v,name=k: self.apply_changes(name, w, v))
-            self.edit_fields[k] = ef
-        
-        self.buttons = OrderedDict()
-        group = []
-        for k in top_pane:
-            if k != 'sort':
-                self.buttons.update({ k:  urwid.Button(('streak', f'{k.title()}')) })
-                continue
-            self.buttons.update({ f'{k}_price':  urwid.RadioButton(group, ('streak', u'Best'), state="first True") })
-            self.buttons.update({ f'{k}_date':  urwid.RadioButton(group, ('streak', u'Last'), state="first True") })
-            for button in group:
-                urwid.connect_signal(button, 'postchange', lambda *args: self.update_historic_prices())
-        
-        urwid.connect_signal(self.buttons['clear'], 'click', lambda x: self.clear().update())
-        urwid.connect_signal(self.buttons['exit'], 'click', lambda x: show_or_exit('esc'))
-        
-        dbview, spread, rating = [ urwid.Text('') for i in range(0,3) ]
-        
-        self.text_fields.update({
-            'dbview': dbview,
-            'spread': spread,
-            'rating': rating,
-        })
-        
-        self.clear()
-        
-        header = urwid.Text(u'Price Check', 'center')
-        _copyright = urwid.Text(COPYRIGHT, 'center')
-        
-        banner = urwid.Pile([
-            urwid.Padding(header, 'center', width=('relative', 100)),
-            urwid.Padding(_copyright, 'center', width=('relative', 100)),
-        ])
-        banner = urwid.AttrMap(banner, 'banner')
-        fields = dict([
-            (k, urwid.LineBox(urwid.AttrMap(self.edit_fields[k], 'streak'), title=k.title(), title_align='left')) for k in self.edit_fields
-        ])
-        fields['organic'] = urwid.LineBox(urwid.AttrMap(self.organic_checkbox, 'bg'))
-        fields.update({
-            'dbview': urwid.LineBox(
-                urwid.AttrMap(dbview, 'streak'),
-                title="Historic Prices",
-                title_align='center',
-            ),
-            'div': urwid.Divider(),
-            'spread': spread,
-            'rating': rating,
-        })
-
-        right_pane_widget = (16, urwid.Pile(map(
-            lambda x: fields[x] if x is not None else urwid.Divider(),
-            self.right_pane
-        )))
-        left_pane_widget = (urwid.Pile(map(
-            lambda x: fields[x] if x is not None else urwid.Divider(),
-            self.left_pane
-        )))
-        
-        widget = urwid.Pile([
-            banner,
-            urwid.Divider(),
-            urwid.Columns(
-                [
-                    (9, urwid.Pile([
-                        urwid.Divider(),
-                        urwid.AttrMap(self.buttons['clear'], 'streak'),
-                        urwid.Divider(),
-                    ])),
-                    urwid.LineBox(
-                        urwid.Columns([ v for k,v in self.buttons.items() if 'sort' in k]),
-                        title="Sort price by",
-                        title_align='left',
-                    ),
-                    (9, urwid.Pile([
-                        urwid.Divider(),
-                        urwid.AttrMap(self.buttons['exit'], 'streak'),
-                        urwid.Divider(),
-                    ]))
-                ],
-                dividechars=2,
-            ),
-            urwid.Columns((left_pane_widget, right_pane_widget),
-                dividechars=2,
-            ),
-            *[ fields[c] if c is not None else urwid.Divider() for c in self.bottom_pane ],
-        ])
-        widget = urwid.Filler(widget, 'top') 
-        widget = urwid.AttrMap(widget, 'bg')
-        self.original_widget = widget
-        widget.original_widget.original_widget.set_focus_path([3,0,0])
-
 cur.execute("BEGIN")
 
 activity_manager = ActivityManager()

+ 2 - 2
reconcile.py

@@ -15,8 +15,8 @@ import gnucash
 import sys
 import os
 import psycopg2
-from db_utils import cursor_as_dict
-from txn_view import get_session_transactions_statement as get_statement
+from app.db_utils import cursor_as_dict
+from app.txn_view import get_session_transactions_statement as get_statement
 
 try:
     from db_credentials import HOST, PASSWORD

+ 1 - 0
requirements.txt

@@ -3,3 +3,4 @@ urwid
 psycopg2
 faker
 pandas
+numpy

+ 0 - 472
widgets.py

@@ -1,472 +0,0 @@
-#
-# Copyright (c) Daniel Sheffield 2021
-#
-# All rights reserved
-#
-# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
-import urwid
-from decimal import Decimal
-from urwid import numedit
-from collections import (
-    OrderedDict,
-)
-
-COPYRIGHT = "Copyright (c) Daniel Sheffield 2021"
-
-class AutoCompleteEdit(urwid.Edit):
-    def __init__(self, name, *args, apply_change_func=None, **kwargs):
-        if isinstance(name, tuple):
-            pallete, title = name
-            self.name = title
-            title = title.title()
-            passthrough = (pallete, u'  ' if title.lower() == 'unit' else u'')
-        else:
-            self.name = name
-            title = name.title()
-            passthrough = u'  ' if name.lower() == 'unit' else u''
-            
-        super().__init__(passthrough, *args, **kwargs)
-        self.apply = apply_change_func
-
-    def keypress(self, size, key):
-        if key == 'enter':
-            self.apply(self.name)
-            return
-        elif key == 'delete':
-            self.set_edit_text('')
-            return
-        
-        return super().keypress(size, key)
-
-
-class AutoCompleteFloatEdit(numedit.FloatEdit):
-    def __init__(self, name, *args, apply_change_func=None, **kwargs):
-        self.last_val = None
-        self.op = '='
-        self.pallete = None
-        if isinstance(name, tuple):
-            self.pallete, self.name = name
-            title = self.name.title()
-            passthrough = (self.pallete, f'{self.op} ')
-        else:
-            self.name = name
-            title = name.title()
-            passthrough = title
-            
-        super(AutoCompleteFloatEdit, self).__init__(passthrough, *args, **kwargs)
-        self.apply = apply_change_func
-
-    def update_caption(self):
-        if self.pallete is not None:
-            self.set_caption((self.pallete, f'{self.op} '))
-        else:
-            self.set_caption(f'{self.op} ')
-
-    def set_op(self, op):
-        self.op = op
-        self.last_val = self.value()
-        self.set_edit_text('')
-        self.update_caption()
-        
-    def calc(self):
-        x = self.last_val
-        op = self.op
-        if op in ('+', '-',):
-            y = self.value() or Decimal(0.0)
-            if op == '+':
-                z = x + y
-            else:
-                z = x - y
-        elif op in ('*', '/'):
-            y = self.value() or Decimal(1.0)
-            if op == '*':
-                z = x * y
-            else:
-                z = x / y
-        else:
-            y = self.value() or Decimal(0.0)
-            z = y
-        
-        self.op = '='
-        self.update_caption()
-        self.set_edit_text(f'{z:.2f}')
-
-    def keypress(self, size, key):
-        if isinstance(key, tuple):
-            return
-        ops = ('+', '-', '*', '/',)
-
-        if key in ops:
-            if self.op in ops:
-                self.calc()
-            self.set_op(key)
-            return
-        elif key == 'enter':
-            if self.get_edit_text() == '' or self.value() == Decimal(0.0):
-                return self.apply(self.name)
-            self.calc()
-            return
-        elif key == '=':
-            self.calc()
-            return
-        elif key == 'delete':
-            self.set_edit_text('')
-            self.op = '='
-            self.update_caption()
-            return
-
-        return super().keypress(size, key)
-
-class NoTabCheckBox(urwid.CheckBox):
-    def keypress(self, size, key):
-        if not isinstance(key, tuple) and key == 'tab':
-            return 
-        else:
-            return super().keypress(size, key)
-
-class GroceryTransactionEditor(urwid.WidgetPlaceholder):
-    def __init__(self, activity_manager, cur, log):
-        super().__init__(urwid.SolidFill(u'/'))
-        self.activity_manager = activity_manager
-        self.cur = cur
-        txn = self.activity_manager.get('transaction')
-
-        with open(log, 'r') as f:
-            date = None
-            store = None
-            for line in f.readlines():
-                if date is None and store is None:
-                    if '$store$' in line:
-                        date, store, _= line.split('$store$')
-                        date = date.split("'")[1]
-                else:
-                    assert None not in (date, store,), \
-                        "Both date and store should be set or neither should be set"
-                    if '$store$' in line:
-                        assert date in line and f'$store${store}$store$' in line, \
-                            "Date ({date}) and store ({store}) not found in {line}."\
-                            " Mixing transactions from different dates and stores is not supported"
-                #print(self.cur.mogrify(line))
-                #input()
-                self.cur.execute(line)
-        
-            if None not in (date, store):
-                txn._apply_choice('ts', date)
-                txn._apply_choice('store', store)
-
-        self.activity_manager.show(self)
-        self.activity_manager.show(txn.update())
-
-        self.log = self.open(log)
-    
-    def _open(self, log):
-        with open(log, 'a') as f:
-            yield f
-    
-    def open(self, log):
-        self._to_close = self._open(log)
-        return next(self._to_close)
-    
-    def close(self):
-        if self._to_close is not None:
-            self._to_close.close()
-            self._to_close = None
-        
-    def save(self, data):
-        ts = data['ts']
-        store = data['store']
-        description = data['description']
-        quantity = data['quantity']
-        unit = data['unit']
-        price = data['price']
-        product = data['product']
-        organic = data['organic'] if data['organic'] else 'false'
-        statement = \
-            f"CALL insert_transaction('{ts}', $store${store}$store$, " \
-            f"$descr${description}$descr$, {quantity}, $unit${unit}$unit$, " \
-            f"{price}, $produ${product}$produ$, {organic});\n"
-        self.log.write(statement)
-        self.cur.execute(statement)
-
-class FocusWidget(urwid.WidgetPlaceholder):
-
-    def __init__(self, initial_focus, skip_focus):
-        super().__init__(urwid.SolidFill(u'/'))
-        self._initial_focus = tuple([ i for i in initial_focus ])
-        self._skip_focus = tuple([ i for i in skip_focus ])
-    
-    @property
-    def skip_focus(self):
-        return self._skip_focus
-    
-    @property
-    def initial_focus(self):
-        return list(self._initial_focus)
-    
-    @property
-    def container(self):
-        return self.original_widget.original_widget.original_widget
-    
-    def _set_focus_path(self, path):
-        try:
-            self.container.set_focus_path(path)
-            return
-        except IndexError:
-            pass
-        
-        if path[-1] == 0 and len(path) > 1:
-            self._set_focus_path(path[:-1])
-            return
-    
-        raise IndexError
-
-    def iter_focus_paths(self):
-        self._set_focus_path(self.initial_focus)
-        while True:
-            path = self.container.get_focus_path()
-            yield path
-            self.advance_focus()
-            path = self.container.get_focus_path()
-            if path == self.initial_focus:
-                break
-
-    def advance_focus(self, reverse=False):
-
-        path = self.container.get_focus_path()
-                
-        if reverse:
-            paths = [ i for i in self.iter_focus_paths() ]
-            zipped_paths = zip(paths, [
-                *paths[1:], paths[0]
-            ])
-            prev_path = map(lambda x: x[0], filter(
-                lambda x: x[1] == path,
-                zipped_paths
-            ))
-            p = next(prev_path)
-            self._set_focus_path(p)
-            return
-        
-        _iter = [ i for i in enumerate(path) ][::-1]
-
-        for idx, part in _iter:
-            p = [ i for i in path ]
-            if reverse:
-                p[idx] -= 1
-            else:
-                p[idx] += 1
-            
-            try:
-                self._set_focus_path(p)
-                if p in self.skip_focus:
-                    self.advance_focus(reverse=reverse)
-                return
-            except IndexError:
-                path[idx] = 0
-            
-        self.container.set_focus_path(self.initial_focus)
-
-class TransactionEditor(FocusWidget):
-
-    def keypress(self, size, key):
-        if isinstance(key, tuple):
-            return
-        
-        if getattr(self.original_widget.original_widget, 'original_widget', None) is None:
-            return super().keypress(size, key)
-        
-        if key == 'tab':
-            self.advance_focus()
-        elif key == 'shift tab':
-            self.advance_focus(reverse=True)
-        else:
-            return super().keypress(size, key)
-
-    def _init_data(self, fields):
-        self.data = OrderedDict([
-            (k, '') for k in fields
-        ])
-        self.clear()
-
-    def _apply_choice(self, name, value):
-        self._apply_changes(name, value)
-        for k,v in self.data.items():
-            if k == name or v:
-                continue
-            options = self.query_manager.unique_suggestions(k, **self.data)
-            if len(options) == 1 and k != 'ts':
-                self._apply_changes(k, list(options)[0])
-    
-    def apply_choice(self, name):
-        return  lambda w,x: self._apply_choice(name, x)
-
-    def _apply_changes(self, name, value):
-        self.data.update({
-            name: value,
-        })
-    
-    def apply_changes(self, name):
-        return lambda w,x: self._apply_changes(name, x)
-
-    def apply_organic_state(self, w, state):
-        self.data['organic'] = repr(state).lower()
-
-    def clear(self):
-        for k in self.data:
-            if k in ('ts', 'store',):
-                continue
-            self.data[k] = ''
-        self.organic_checkbox.set_state(False)
-    
-    def update(self):
-        for k in self.edit_fields:
-            self.edit_fields[k].set_edit_text(self.data[k])
-        date, store = self.data['ts'], self.data['store']
-        self.text_fields['dbview'].set_text(
-            self.query_manager.get_session_transactions(date, store) if None not in (
-                date or None, store or None
-            ) else ''
-        )
-        self.organic_checkbox.set_state(True if self.data['organic'] == 'true' else False)
-        return self
-
-    def __init__(self, query_manager, fields,
-        layout, side_pane, bottom_pane,
-        save_and_clear_cb, autocomplete_cb):
-        super().__init__([2,0,0,0], [
-            [4,],
-            [5,],
-            [7,],
-        ])
-        self.organic_checkbox = NoTabCheckBox(
-            u"Organic",
-            on_state_change=self.apply_organic_state
-        )
-        self.query_manager = query_manager
-        self._init_data(fields)
-        self.layout = layout
-        self.side_pane = side_pane
-        self.bottom_pane = bottom_pane
-        self.edit_fields = OrderedDict()
-        self.text_fields = OrderedDict()
-        for k in self.data:
-            if k in self.side_pane and k != 'unit':
-                ef = AutoCompleteFloatEdit(('bg', k), apply_change_func=lambda name: autocomplete_cb(name, self.data))
-            elif k != 'organic':
-                ef = AutoCompleteEdit(('bg', k), apply_change_func=lambda name: autocomplete_cb(name, self.data))
-            else:
-                continue
-            ef.set_edit_text(self.data[k])
-            urwid.connect_signal(ef, 'change', self.apply_changes(k))
-            self.edit_fields[k] = ef
-    
-        header = urwid.Text(u'Fill Transaction', 'center')
-        _copyright = urwid.Text(COPYRIGHT, 'center')
-        done_button = urwid.Button(('streak', u'Done'))
-        urwid.connect_signal(done_button, 'click', lambda w: save_and_clear_cb())
-        banner = urwid.Pile([
-            urwid.Padding(header, 'center', width=('relative', 100)),
-            urwid.Padding(_copyright, 'center', width=('relative', 100)),
-        ])
-        banner = urwid.AttrMap(banner, 'banner')
-        fields = dict([
-            (k, urwid.LineBox(urwid.AttrMap(self.edit_fields[k], 'streak'), title=k.title(), title_align='left')) for k in self.edit_fields
-        ])
-        txn_view = urwid.Text('')
-        self.text_fields.update({'dbview': txn_view})
-        fields.update({
-            'dbview': urwid.LineBox(
-                urwid.AttrMap(txn_view, 'streak'),
-                title="Session Data",
-                title_align='left',
-            )
-        })
-        
-        side_pane_widget = (12, urwid.Pile([
-            fields[r] if r is not None else urwid.Divider() for r in self.side_pane
-        ]))
-        main_pane_widgets = []
-        for i, r in enumerate(self.layout):
-            widgets = []
-            for c in r:
-                if c is not None:
-                    if c != 'organic':
-                        widgets.append(fields[c])
-                    else:
-                        widgets.append(urwid.LineBox(urwid.AttrMap(self.organic_checkbox, 'bg')))
-                else:
-                    widgets.append(urwid.Divider())
-            main_pane_widgets.append(urwid.Columns(widgets))
-
-
-        main_pane_widget = urwid.Pile(main_pane_widgets)
-        
-        widget = urwid.Pile([
-            banner,
-            urwid.Divider(),
-            urwid.Columns((main_pane_widget, side_pane_widget),
-                dividechars=2,
-            ),
-            *[ fields[c] if c is not None else urwid.Divider() for c in self.bottom_pane ],
-            urwid.Divider(),
-            done_button,
-        ])
-        widget = urwid.Filler(widget, 'top') 
-        widget = urwid.AttrMap(widget, 'bg')
-        self.original_widget = widget
-
-class SuggestionPopup(urwid.Overlay):
-    
-    def __init__(self, under, name, options, apply_cb, esc_cb):
-        self.esc_cb = esc_cb
-        self.under = under
-        body = [urwid.Text(name.title()), urwid.Divider()]
-        for c in options:
-            button = urwid.Button(c)
-            urwid.connect_signal(button, 'click', apply_cb, c)
-            body.append(urwid.AttrMap(button, None, focus_map='reversed'))
-        walker = urwid.SimpleFocusListWalker(body, wrap_around=False)
-        listbox = urwid.ListBox(walker)
-        pad = urwid.Padding(listbox, left=2, right=2)
-        pad = urwid.AttrMap(pad, 'banner')
-        super().__init__(pad, under,
-            align='center', width=('relative', 60),
-            valign='middle', height=('relative', 60),
-            min_width=20, min_height=9)
-
-    def keypress(self, size, key):
-        if key == 'esc':
-            self.esc_cb()
-            return
-
-        if key == 'tab':
-            return
-
-        return super().keypress(size, key)
-
-class ActivityManager(object):
-
-    def __init__(self):
-        self.widgets = dict()
-        self.app = None
-    
-    def add(self, widget, name):
-        self.widgets[name] = widget
-        return widget
-    
-    def get(self, name):
-        if name in self.widgets:
-            return self.widgets[name]
-        raise Exception("Widget {name} not found")
-    
-    def create(self, cls, name, *args, **kwargs):
-        widget = cls(*args, **kwargs)
-        if name is not None:
-            self.add(widget, name)
-        return widget
-    
-    def show(self, widget):
-        if self.app is not None:
-            self.app.original_widget = widget
-            return
-        self.app = widget