|
@@ -0,0 +1,537 @@
|
|
|
+#
|
|
|
+# Copyright (c) Daniel Sheffield 2023
|
|
|
+#
|
|
|
+# All rights reserved
|
|
|
+#
|
|
|
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
|
|
|
+from xml.etree.ElementTree import fromstring, ParseError
|
|
|
+from markdown import markdown
|
|
|
+from itertools import chain, product
|
|
|
+from decimal import Decimal, InvalidOperation
|
|
|
+from collections import OrderedDict
|
|
|
+from typing import List, Tuple, Union, Iterable, Callable
|
|
|
+from urwid import (
|
|
|
+ connect_signal,
|
|
|
+ AttrMap,
|
|
|
+ Button,
|
|
|
+ Columns,
|
|
|
+ Divider,
|
|
|
+ Edit,
|
|
|
+ Filler,
|
|
|
+ LineBox,
|
|
|
+ Padding,
|
|
|
+ Pile,
|
|
|
+ RadioButton,
|
|
|
+ Text,
|
|
|
+)
|
|
|
+from urwid.numedit import FloatEdit
|
|
|
+
|
|
|
+from .. import COPYRIGHT
|
|
|
+from ..widgets import (
|
|
|
+ AutoCompleteEdit,
|
|
|
+ AutoCompleteFloatEdit,
|
|
|
+ FocusWidget,
|
|
|
+ AutoCompletePopUp,
|
|
|
+ NoTabCheckBox,
|
|
|
+ FlowBarGraphWithVScale,
|
|
|
+)
|
|
|
+from ..data.QueryManager import QueryManager
|
|
|
+from . import ActivityManager, show_or_exit
|
|
|
+from .Rating import Rating
|
|
|
+import yaml
|
|
|
+def change_style(style, representer):
|
|
|
+ def new_representer(dumper, data):
|
|
|
+ scalar = representer(dumper, data)
|
|
|
+ scalar.style = style
|
|
|
+ return scalar
|
|
|
+ return new_representer
|
|
|
+
|
|
|
+import yaml
|
|
|
+from yaml.representer import SafeRepresenter
|
|
|
+class folded_str(str): pass
|
|
|
+
|
|
|
+class literal_str(str): pass
|
|
|
+
|
|
|
+# represent_str does handle some corner cases, so use that
|
|
|
+# instead of calling represent_scalar directly
|
|
|
+represent_folded_str = change_style('>', SafeRepresenter.represent_str)
|
|
|
+represent_literal_str = change_style('|', SafeRepresenter.represent_str)
|
|
|
+yaml.add_representer(folded_str, represent_folded_str)
|
|
|
+yaml.add_representer(literal_str, represent_literal_str)
|
|
|
+def depth_first_elements(tree):
|
|
|
+ for e in tree:
|
|
|
+ for y in depth_first_elements(e):
|
|
|
+ yield y
|
|
|
+ yield tree
|
|
|
+
|
|
|
+def get_products_from_xhtml(md: str):
|
|
|
+ try:
|
|
|
+ xhtml = fromstring(
|
|
|
+f"""<root>
|
|
|
+{md}
|
|
|
+</root>
|
|
|
+""")
|
|
|
+ except ParseError:
|
|
|
+ return
|
|
|
+ for e in filter(lambda x: x.tag == 'strong', depth_first_elements(xhtml)):
|
|
|
+ yield e.text
|
|
|
+
|
|
|
+def to_numbered_field(x):
|
|
|
+ if len(x[0].split('#', 1)) > 1:
|
|
|
+ name, idx = x[0].split('#', 1)
|
|
|
+ idx = int(idx)
|
|
|
+ else:
|
|
|
+ name, idx = x[0], 0
|
|
|
+
|
|
|
+ return (name, int(idx)), x[1]
|
|
|
+
|
|
|
+def to_unnumbered_field(x):
|
|
|
+ return x[0][0], x[1]
|
|
|
+
|
|
|
+def in_same_row(name):
|
|
|
+ if len(name.split('#', 1)) > 1:
|
|
|
+ _, row = name.split('#', 1)
|
|
|
+ return lambda x: x[0][1] == int(row)
|
|
|
+
|
|
|
+def unzip(iter: List[Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]]) -> Tuple[
|
|
|
+ List[AutoCompleteEdit], List[FloatEdit], List[AutoCompleteEdit]
|
|
|
+]:
|
|
|
+ return zip(*iter)
|
|
|
+
|
|
|
+def extract_values(x: Union[List[AutoCompleteEdit], List[FloatEdit]]) -> Iterable[str]:
|
|
|
+ if isinstance(x, list) or isinstance(x, tuple):
|
|
|
+ if len(x) == 0:
|
|
|
+ return []
|
|
|
+ return ( v.get_edit_text() for v in x )
|
|
|
+ raise Exception(f"Unsupported type: {type(x)}")
|
|
|
+
|
|
|
+def to_named_value(name: str) -> Callable[[str], Tuple[str,str]]:
|
|
|
+ return lambda e: (f'{name}#{e[0]}', e[1])
|
|
|
+
|
|
|
+def blank_ingredients_row(idx: int) -> Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]:
|
|
|
+ return (
|
|
|
+ AutoCompleteEdit(('bg', f'product#{idx}')),
|
|
|
+ FloatEdit(('bg', f'')),
|
|
|
+ AutoCompleteEdit(('bg', f'unit#{idx}'))
|
|
|
+ )
|
|
|
+
|
|
|
+class RecipeEditor(FocusWidget):
|
|
|
+
|
|
|
+ def keypress(self, size, key):
|
|
|
+ if isinstance(key, tuple):
|
|
|
+ return
|
|
|
+
|
|
|
+ if getattr(self._w.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)
|
|
|
+ elif key == 'ctrl delete':
|
|
|
+ self.clear()
|
|
|
+ elif key == 'ctrl w':
|
|
|
+ self.save()
|
|
|
+ else:
|
|
|
+ return super().keypress(size, key)
|
|
|
+
|
|
|
+ def apply_choice(self, name, value):
|
|
|
+ self.apply_changes(name, value)
|
|
|
+
|
|
|
+ data = dict(filter(
|
|
|
+ in_same_row(name),
|
|
|
+ map(to_numbered_field, self.data.items())
|
|
|
+ ))
|
|
|
+
|
|
|
+ for k,v in data.items():
|
|
|
+ if f'{k[0]}#{k[1]}' == name or v:
|
|
|
+ continue
|
|
|
+ _data = dict(map(lambda x: (x[0][0], x[1]), data.items()))
|
|
|
+ options = self.query_manager.unique_suggestions(k[0], **_data)
|
|
|
+ if len(options) == 1:
|
|
|
+ self.apply_changes(f'{k[0]}#{k[1]}', list(options)[0])
|
|
|
+
|
|
|
+ def apply_changes(self, name, value):
|
|
|
+ self.data = {
|
|
|
+ name: value if name != 'organic' else {
|
|
|
+ 'yes': True, 'no': False,
|
|
|
+ True: True, False: False,
|
|
|
+ 'mixed': '',
|
|
|
+ }[value],
|
|
|
+ }
|
|
|
+
|
|
|
+ @property
|
|
|
+ def data(self):
|
|
|
+ zipped = zip(
|
|
|
+ ['product', 'quantity', 'unit'],
|
|
|
+ map(extract_values, unzip(self.ingredients)),
|
|
|
+ )
|
|
|
+ ret = dict(chain(
|
|
|
+ *[ map(to_named_value(n), enumerate(l)) for n,l in zipped ],
|
|
|
+ [ ('organic', self.organic.state) ]
|
|
|
+ ))
|
|
|
+ return ret
|
|
|
+
|
|
|
+ @data.setter
|
|
|
+ def data(self, _data: dict):
|
|
|
+ for k,v in _data.items():
|
|
|
+ if len(k.split('#')) > 1:
|
|
|
+ name, idx = k.split('#', 1)
|
|
|
+ w = self.ingredients[int(idx)][ next(( pos for pos, n in zip(
|
|
|
+ [0, 1, 2],
|
|
|
+ ['product', 'quantity', 'unit']
|
|
|
+ ) if n == name ))]
|
|
|
+ w.set_edit_text(v)
|
|
|
+ if k == 'organic':
|
|
|
+ self.organic.set_state(v)
|
|
|
+ @property
|
|
|
+ def components(self):
|
|
|
+ return self._components
|
|
|
+
|
|
|
+ @components.setter
|
|
|
+ def components(self, _data: dict):
|
|
|
+ self._components = _data
|
|
|
+
|
|
|
+ def clear(self):
|
|
|
+ self.ingredients = []
|
|
|
+ self.add_ingredient()
|
|
|
+ self.organic.set_state('mixed')
|
|
|
+ self.instructions.set_edit_text('')
|
|
|
+ self.fname.set_edit_text('')
|
|
|
+ self.notice.set_text('')
|
|
|
+ return self.update()
|
|
|
+
|
|
|
+ def init_ingredients(self):
|
|
|
+ right_pane = LineBox(Pile([AttrMap(
|
|
|
+ AutoCompletePopUp(
|
|
|
+ ingredient[0],
|
|
|
+ self.apply_choice,
|
|
|
+ lambda: self.activity_manager.show(self.update(ingredient[0]))
|
|
|
+ ), 'streak') for ingredient in self.ingredients]),
|
|
|
+ title='Product',
|
|
|
+ title_align='left'
|
|
|
+ )
|
|
|
+ left_pane = LineBox(
|
|
|
+ Pile([AttrMap(
|
|
|
+ ingredient[1],
|
|
|
+ 'streak'
|
|
|
+ ) for ingredient in self.ingredients]),
|
|
|
+ title='Quantity',
|
|
|
+ title_align='left'
|
|
|
+ )
|
|
|
+ middle_pane = LineBox(
|
|
|
+ Pile([AttrMap(AutoCompletePopUp(
|
|
|
+ ingredient[2],
|
|
|
+ self.apply_choice,
|
|
|
+ lambda: self.activity_manager.show(self.update(ingredient[2]))
|
|
|
+ ), 'streak') for ingredient in self.ingredients]),
|
|
|
+ title='Unit',
|
|
|
+ title_align='left'
|
|
|
+ )
|
|
|
+ gutter = Pile([
|
|
|
+ *[ Divider() for _ in product(
|
|
|
+ range(1), self.ingredients[:-1]
|
|
|
+ )],
|
|
|
+ Divider(),
|
|
|
+ Divider(),
|
|
|
+ self.buttons['add'],
|
|
|
+ ])
|
|
|
+ return left_pane, middle_pane, right_pane, gutter
|
|
|
+
|
|
|
+ def add_ingredient(self):
|
|
|
+ self.ingredients.append(
|
|
|
+ blank_ingredients_row(len(self.ingredients))
|
|
|
+ )
|
|
|
+ l, m, r, gutter = self.init_ingredients()
|
|
|
+ self.components['left_pane'][1].original_widget.contents = list(l.original_widget.contents)
|
|
|
+ self.components['middle_pane'][1].original_widget.contents = list(m.original_widget.contents)
|
|
|
+ self.components['right_pane'].original_widget.contents = list(r.original_widget.contents)
|
|
|
+ self.components['gutter'][1].contents = list(gutter.contents)
|
|
|
+ for idx, widget in enumerate(self.ingredients):
|
|
|
+ connect_signal(widget[0], 'postchange', lambda w,_: self.update(w))
|
|
|
+ connect_signal(widget[0], 'apply', lambda w, name: self.autocomplete_callback(
|
|
|
+ w, self.autocomplete_options(name, dict(map(
|
|
|
+ to_unnumbered_field,
|
|
|
+ filter(in_same_row(name), map(to_numbered_field, self.data.items())
|
|
|
+ ))))
|
|
|
+ ))
|
|
|
+ connect_signal(widget[1], 'postchange', lambda w,_: self.update(w))
|
|
|
+ connect_signal(widget[2], 'postchange', lambda w,_: self.update(w))
|
|
|
+ connect_signal(widget[2], 'apply', lambda w, name: self.autocomplete_callback(
|
|
|
+ w, self.autocomplete_options(name, dict(map(
|
|
|
+ to_unnumbered_field,
|
|
|
+ filter(in_same_row(name), map(to_numbered_field, self.data.items())
|
|
|
+ ))))
|
|
|
+ ))
|
|
|
+
|
|
|
+ def save(self):
|
|
|
+ yml = dict()
|
|
|
+ yml['ingredients'] = list(map(lambda x: ' '.join(x), filter(
|
|
|
+ lambda x: None not in map(lambda x: x or None, x), [
|
|
|
+ list(map(lambda x: x.get_edit_text(), x)) for x in self.ingredients
|
|
|
+ ])))
|
|
|
+ if self.feeds:
|
|
|
+ n, d = self.feeds.as_integer_ratio()
|
|
|
+ yml['feeds'] = float(self.feeds) if d != 1 else n
|
|
|
+ else:
|
|
|
+ yml['feeds'] = None
|
|
|
+ yml['instructions'] = literal_str('\n'.join(map(
|
|
|
+ lambda x: x.strip(),
|
|
|
+ self.instructions.get_text()[0].splitlines()
|
|
|
+ )).strip())
|
|
|
+ fname = self.fname.get_edit_text()
|
|
|
+ if not fname:
|
|
|
+ return
|
|
|
+ with open(fname, 'w') as f:
|
|
|
+ yaml.dump(yml, f)
|
|
|
+
|
|
|
+
|
|
|
+ def update(self, widget = None):
|
|
|
+ data = self.data
|
|
|
+ organic = None if data['organic'] == 'mixed' else data['organic']
|
|
|
+ sort = 'ts'
|
|
|
+ self.notices[None] = set()
|
|
|
+ for r in filter(
|
|
|
+ lambda x: next(filter(lambda x: x is widget or widget is None, x), None),
|
|
|
+ self.ingredients
|
|
|
+ ):
|
|
|
+ product, quantity, unit = map(lambda x: x.get_edit_text(), r)
|
|
|
+ if product not in self.notices:
|
|
|
+ self.notices[product] = set()
|
|
|
+ self.found[product] = '>'
|
|
|
+
|
|
|
+ try:
|
|
|
+ quantity = Decimal(quantity)
|
|
|
+ except InvalidOperation:
|
|
|
+ quantity = None
|
|
|
+
|
|
|
+ if None in (sort or None, product or None, unit or None, quantity or None):
|
|
|
+ continue
|
|
|
+
|
|
|
+ df = self.query_manager.get_historic_prices_data(unit, sort=sort, product=product, organic=organic)
|
|
|
+ if not df.empty:
|
|
|
+ distinct = df['quantity'].unique()
|
|
|
+ if len(distinct) == 1 and None in distinct:
|
|
|
+ self.notices[product].add(f"Product '{product}' has no conversion to '{unit}'")
|
|
|
+ continue
|
|
|
+ df = df.dropna()
|
|
|
+ self.found[product] = '='
|
|
|
+
|
|
|
+ else:
|
|
|
+ if organic is None:
|
|
|
+ self.notices[product].add(f"Product '{product}' not found in database")
|
|
|
+ continue
|
|
|
+
|
|
|
+ df = self.query_manager.get_historic_prices_data(unit, sort=sort, product=product, organic=None)
|
|
|
+ if df.empty:
|
|
|
+ self.notices[product].add(f"Product '{product}' not found in database")
|
|
|
+ continue
|
|
|
+ distinct = df['quantity'].unique()
|
|
|
+ if len(distinct) == 1 and None in distinct:
|
|
|
+ self.notices[product].add(f"Product '{product}' has no conversion to '{unit}'")
|
|
|
+ continue
|
|
|
+ df = df.dropna()
|
|
|
+ self.notices[product].add(f"Using {'non-' if organic else ''}organic {product}")
|
|
|
+ self.found[product] = '~'
|
|
|
+
|
|
|
+ assert len(df['avg'].unique()) == 1, f"There should be only one average price: {df['avg'].unique()}"
|
|
|
+
|
|
|
+ _last, _avg, _min, _max = list(
|
|
|
+ map(Decimal,df[['last', 'avg','min','max']].iloc[0])
|
|
|
+ )
|
|
|
+ self.prices[product] = [
|
|
|
+ i*quantity for i in (_last, _min, _avg, _max)
|
|
|
+ ]
|
|
|
+
|
|
|
+ for k in list(self.notices):
|
|
|
+ if k not in [
|
|
|
+ *map(lambda x: x[0].get_edit_text(), self.ingredients),
|
|
|
+ None
|
|
|
+ ]:
|
|
|
+ del self.notices[k]
|
|
|
+ for k in list(self.prices):
|
|
|
+ if k not in map(lambda x: x[0].get_edit_text(), self.ingredients):
|
|
|
+ del self.prices[k]
|
|
|
+ for k in list(self.found):
|
|
|
+ if k not in [
|
|
|
+ *map(lambda x: x[0].get_edit_text(), self.ingredients),
|
|
|
+ ''
|
|
|
+ ]:
|
|
|
+ del self.found[k]
|
|
|
+
|
|
|
+ price = [
|
|
|
+ sum([self.prices[p][i] for p in self.prices]) for i in range(4)
|
|
|
+ ]
|
|
|
+ price_as = next(filter(
|
|
|
+ lambda x: self.price_as[x[1]].state,
|
|
|
+ enumerate(self.price_as)
|
|
|
+ ))[0]
|
|
|
+ not_found = next(
|
|
|
+ filter(lambda x: x == '>', self.found.values()), next(
|
|
|
+ filter(lambda x: x == '~', self.found.values()), next(
|
|
|
+ filter(lambda x: x == '=', self.found.values()),
|
|
|
+ None,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ self.price.set_text(
|
|
|
+ f'Cost: {not_found}{price[price_as]}'
|
|
|
+ f', Per Serve: {not_found}{price[price_as]/self.feeds}'
|
|
|
+ )
|
|
|
+ ingredients = list(filter(lambda x: x, map(lambda x: x[0].get_edit_text(), self.ingredients)))
|
|
|
+ parsed_products = list(get_products_from_xhtml(markdown(self.instructions.get_edit_text())))
|
|
|
+ fname = self.fname.get_edit_text()
|
|
|
+ if not fname:
|
|
|
+ self.notice.set_text('No file name set')
|
|
|
+ return self
|
|
|
+ if not parsed_products:
|
|
|
+ self.notice.set_text('Failed to parse recipe instructions')
|
|
|
+ return self
|
|
|
+ products = set(parsed_products)
|
|
|
+ for product in products - set(ingredients):
|
|
|
+ if product not in self.notices:
|
|
|
+ self.notices[product] = set()
|
|
|
+ self.notices[product].add(f"Product '{product}' not found in list of ingredients")
|
|
|
+ for ingredient in set(ingredients) - products:
|
|
|
+ if product not in self.notices:
|
|
|
+ self.notices[product] = set()
|
|
|
+ self.notices[ingredient].add(f"Ingredient '{ingredient}' is not used")
|
|
|
+ if len(set(ingredients)) != len(ingredients):
|
|
|
+ self.notices[None].add(f"Some ingredients listed more than once")
|
|
|
+
|
|
|
+ self.notice.set_text('\n'.join(sorted(filter(lambda x: x, [
|
|
|
+ '\n'.join(sorted(v)) for v in self.notices.values()
|
|
|
+ ]))) or 'None')
|
|
|
+
|
|
|
+ return self
|
|
|
+
|
|
|
+ def __init__(self,
|
|
|
+ activity_manager: ActivityManager,
|
|
|
+ query_manager: QueryManager,
|
|
|
+ fname: str,
|
|
|
+ recipe: dict,
|
|
|
+ ):
|
|
|
+ self.notices = dict()
|
|
|
+ button_group = []
|
|
|
+ self.price_as = OrderedDict([
|
|
|
+ ('last', RadioButton(button_group, ('streak', 'Last'), state="first True")),
|
|
|
+ ('min', RadioButton(button_group, ('streak', 'Min'), state="first True")),
|
|
|
+ ('avg', RadioButton(button_group, ('streak', 'Avg'), state="first True")),
|
|
|
+ ('max', RadioButton(button_group, ('streak', 'Max'), state="first True")),
|
|
|
+ ])
|
|
|
+ for b in self.price_as.values():
|
|
|
+ connect_signal(b, 'postchange', lambda w,_: self.update(w))
|
|
|
+ self.fname = Edit('', fname)
|
|
|
+ self.prices = dict()
|
|
|
+ self.found = dict()
|
|
|
+ self.components = dict()
|
|
|
+ self.buttons = {
|
|
|
+ 'clear': Button(('streak', 'Clear')),
|
|
|
+ 'exit': Button(('streak', 'Exit')),
|
|
|
+ 'add': Button(('streak', 'Add')),
|
|
|
+ 'save': Button(('streak', 'Save')),
|
|
|
+ }
|
|
|
+ self.ingredients: List[Tuple[AutoCompleteEdit, FloatEdit, AutoCompleteEdit]] = [
|
|
|
+ (
|
|
|
+ AutoCompleteEdit(('bg', f'product#{idx}'), edit_text=ingredient[0]),
|
|
|
+ FloatEdit(('bg', f''), default=ingredient[1]),
|
|
|
+ AutoCompleteEdit(('bg', f'unit#{idx}'), edit_text=ingredient[2]),
|
|
|
+ ) for idx, ingredient in enumerate(recipe['ingredients'])
|
|
|
+ ] if len(recipe['ingredients']) else [ ]
|
|
|
+
|
|
|
+ self.organic = NoTabCheckBox(('bg', "Organic"), state='mixed')
|
|
|
+ self.instructions = Edit('', edit_text=recipe['instructions'] or u'', multiline=True, allow_tab=True)
|
|
|
+ self.feeds = Decimal(recipe['feeds'])
|
|
|
+ self.price = Text('')
|
|
|
+ self.notice = Text('')
|
|
|
+ feeds = FloatEdit(f'Serves: ', self.feeds)
|
|
|
+
|
|
|
+ bottom_pane = [
|
|
|
+ self.organic,
|
|
|
+ LineBox(self.instructions, title=f'Instructions'),
|
|
|
+ feeds,
|
|
|
+ Columns([('weight', 2, self.price), *[(9, w) for _,w in self.price_as.items()], Divider()]),
|
|
|
+ LineBox(self.notice, title='Errors', title_align='left'),
|
|
|
+ ]
|
|
|
+ self.activity_manager = activity_manager
|
|
|
+ self.query_manager = query_manager
|
|
|
+ self.autocomplete_options = lambda name, data: self.query_manager.unique_suggestions(name.split('#', 1)[0], **data)
|
|
|
+ self.autocomplete_callback = lambda widget, options: len(options) > 0 and widget._emit('open', options)
|
|
|
+ connect_signal(self.organic, 'postchange', lambda *_: self.update())
|
|
|
+
|
|
|
+ connect_signal(self.buttons['save'], 'click', lambda _: self.save())
|
|
|
+ connect_signal(self.buttons['add'], 'click', lambda _: self.add_ingredient())
|
|
|
+ connect_signal(self.buttons['clear'], 'click', lambda _: self.clear())
|
|
|
+ connect_signal(self.buttons['exit'], 'click', lambda _: show_or_exit('esc'))
|
|
|
+ connect_signal(self.instructions, 'postchange', lambda w,_: self.update(w))
|
|
|
+
|
|
|
+ header = Text(u'Recipe Editor', 'center')
|
|
|
+ _copyright = Text(COPYRIGHT, 'center')
|
|
|
+
|
|
|
+ banner = Pile([
|
|
|
+ Padding(header, 'center', width=('relative', 100)),
|
|
|
+ Padding(_copyright, 'center', width=('relative', 100)),
|
|
|
+ ])
|
|
|
+ banner = AttrMap(banner, 'banner')
|
|
|
+
|
|
|
+ left_pane, middle_pane, right_pane, gutter = self.init_ingredients()
|
|
|
+
|
|
|
+
|
|
|
+ self.components = {
|
|
|
+ 'top_pane': Columns([
|
|
|
+ (9, Pile([
|
|
|
+ Divider(),
|
|
|
+ AttrMap(self.buttons['clear'], 'streak'),
|
|
|
+ Divider(),
|
|
|
+ ])),
|
|
|
+ Divider(),
|
|
|
+ (60, LineBox(Columns([
|
|
|
+ self.fname, (8, self.buttons['save'])
|
|
|
+ ]), title='Recipe')),
|
|
|
+ Divider(),
|
|
|
+ (9, Pile([
|
|
|
+ Divider(),
|
|
|
+ AttrMap(self.buttons['exit'], 'streak'),
|
|
|
+ Divider(),
|
|
|
+ ]))
|
|
|
+ ], dividechars=1),
|
|
|
+ 'bottom_pane': Pile(bottom_pane),
|
|
|
+ 'right_pane': right_pane,
|
|
|
+ 'middle_pane': (15, middle_pane),
|
|
|
+ 'left_pane': (12, left_pane),
|
|
|
+ 'gutter': (8, gutter)
|
|
|
+ }
|
|
|
+ self.add_ingredient()
|
|
|
+
|
|
|
+ widget = Pile([
|
|
|
+ banner,
|
|
|
+ Divider(),
|
|
|
+ self.components['top_pane'],
|
|
|
+ Columns([
|
|
|
+ self.components['left_pane'],
|
|
|
+ self.components['middle_pane'],
|
|
|
+ self.components['right_pane'],
|
|
|
+ (1,Divider()),
|
|
|
+ self.components['gutter'],
|
|
|
+ ], dividechars=0),
|
|
|
+ self.components['bottom_pane'],
|
|
|
+ ])
|
|
|
+ widget = Filler(widget, 'top')
|
|
|
+ widget = AttrMap(widget, 'bg')
|
|
|
+ super().__init__(widget, [
|
|
|
+ 'instructions',
|
|
|
+ 'ingredients', #'quantity', 'units',
|
|
|
+ 'add',
|
|
|
+ 'organic',
|
|
|
+ 'clear', 'fname', 'save', 'exit',
|
|
|
+ ], lambda: chain(
|
|
|
+ self.buttons.items(),
|
|
|
+ chain(*[product(['ingredients',], [*x[1:],x[0]]) for x in self.ingredients]),
|
|
|
+ [
|
|
|
+ ('fname', self.fname),
|
|
|
+ ('instructions', self.instructions),
|
|
|
+ ('organic', self.organic)
|
|
|
+ ],
|
|
|
+ )
|
|
|
+ )
|
|
|
+ self.update()
|