Bläddra i källkod

add recipe UI with autocomplete

Daniel Sheffield 2 år sedan
förälder
incheckning
0f8bc8b9ef
3 ändrade filer med 309 tillägg och 1 borttagningar
  1. 219 0
      app/activities/RecipeEditor.py
  2. 5 1
      app/db_utils.py
  3. 85 0
      recipe.py

+ 219 - 0
app/activities/RecipeEditor.py

@@ -0,0 +1,219 @@
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+import itertools
+from decimal import Decimal, InvalidOperation
+from itertools import chain
+from typing import Callable, Union
+from urwid import (
+    connect_signal,
+    AttrMap,
+    Button,
+    Columns,
+    Divider,
+    Edit,
+    Filler,
+    LineBox,
+    Padding,
+    Pile,
+    Text,
+)
+
+from .. import COPYRIGHT
+from ..widgets import (
+    AutoCompleteEdit,
+    AutoCompleteFloatEdit,
+    FocusWidget,
+    AutoCompletePopUp,
+    NoTabCheckBox,
+    FlowBarGraphWithVScale,
+)
+from ..db_utils import QueryManager
+from . import ActivityManager, show_or_exit
+from .Rating import Rating
+
+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)
+        else:
+            return super().keypress(size, key)
+
+    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:
+                self.apply_changes(k, 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):
+        ret = dict(itertools.chain(
+            [ (f'product#{idx}', v[0].get_edit_text()) for idx,v in enumerate(self.ingredients) ],
+            [ (f'unit#{idx}', v[1].get_edit_text()) for idx, v in enumerate(self.ingredients) ],
+            [ ('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)
+                self.ingredients[int(idx)][0 if name == 'product' else 1 ].set_edit_text(v)
+            if k == 'organic':
+                self.organic.set_state(v)
+
+    def clear(self):
+        for ef in self.ingredients:
+            ef[0].set_edit_text('')
+            ef[1].set_edit_text('')
+        self.organic.set_state('mixed')
+        return self.update()
+
+    def update(self):
+        return self
+
+    def __init__(self,
+        activity_manager: ActivityManager,
+        query_manager: QueryManager,
+        autocomplete_cb: Callable[[
+            Union[AutoCompleteEdit, AutoCompleteFloatEdit], str, dict
+        ], None],
+    ):
+        self.buttons = {
+          'clear': Button(('streak', 'Clear')),
+          'exit': Button(('streak', 'Exit')),
+          'add': Button(('streak', 'Add')),
+        }
+        self.ingredients = [
+          (AutoCompleteEdit(('bg', 'product#0')), AutoCompleteEdit(('bg', 'unit#0'))),
+        ]
+        self.organic = NoTabCheckBox(('bg', "Organic"), state='mixed')
+        self.instructions = Edit(f'')
+
+        bottom_pane = [
+            self.organic,
+            LineBox(self.instructions, title=f'Instructions'),
+        ]
+
+        self.query_manager = query_manager
+        connect_signal(self.organic, 'postchange', lambda _,v: self.update())
+
+        # todo: call this when adding new ingredient
+        for idx, widget in enumerate(self.ingredients):
+            connect_signal(widget[0], 'postchange', lambda _,v: self.update())
+            connect_signal(widget[0], 'apply', lambda w, name: autocomplete_cb(
+                w, name, self.data)
+            )
+            connect_signal(widget[1], 'postchange', lambda _,v: self.update())
+            connect_signal(widget[1], 'apply', lambda w, name: autocomplete_cb(
+                w, name, self.data)
+            )
+
+        connect_signal(self.buttons['clear'], 'click', lambda x: self.clear().update())
+        connect_signal(self.buttons['exit'], 'click', lambda x: show_or_exit('esc'))
+        self.clear()
+
+        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')
+
+        # todo: call this when adding new ingredient
+        left_pane = [
+            LineBox(AttrMap(
+                AutoCompletePopUp(
+                    ingredient[0],
+                    self.apply_choice,
+                    lambda: activity_manager.show(self.update())
+                ), 'streak'), title=f'Ingredient {idx}', title_align='left'
+            ) for idx, ingredient in enumerate(self.ingredients)
+        ]
+        middle_pane = [
+            LineBox(AttrMap(
+                AutoCompletePopUp(
+                    ingredient[1],
+                    self.apply_choice,
+                    lambda: activity_manager.show(self.update())
+                ), 'streak'), title=f'Unit {idx}', title_align='left'
+            ) for idx, ingredient in enumerate(self.ingredients)
+        ]
+        right_pane = [
+            self.buttons['add'],
+        ]
+
+
+        components = {
+            'top_pane': Columns([
+                (9, Pile([
+                    Divider(),
+                    AttrMap(self.buttons['clear'], 'streak'),
+                    Divider(),
+                ])),
+                Divider(),
+                (9, Pile([
+                Divider(),
+                AttrMap(self.buttons['exit'], 'streak'),
+                Divider(),
+                ]))
+            ], dividechars=1),
+            'bottom_pane': Pile(bottom_pane),
+            'right_pane': (8, Pile(right_pane)),
+            'middle_pane': (16, Pile(middle_pane)),
+            'left_pane': Pile(left_pane),
+        }
+
+        widget = Pile([
+            banner,
+            Divider(),
+            components['top_pane'],
+            Columns((components['left_pane'], components['middle_pane'], (1,Divider()), components['right_pane']),
+                dividechars=0,
+            ),
+            components['bottom_pane'],
+        ])
+        widget = Filler(widget, 'top')
+        widget = AttrMap(widget, 'bg')
+        super().__init__(widget, map(
+            lambda x: next(w for n,w in chain(
+                self.buttons.items(),
+                [
+                    ('ingredients', self.ingredients[-1][0]),
+                    ('units', self.ingredients[-1][1]),
+                    ('organic', self.organic)
+                ],
+            ) if x == n),
+            [
+                'ingredients', 'units', 'add',
+                'organic',
+                'clear', 'exit',
+            ]
+        ))

+ 5 - 1
app/db_utils.py

@@ -71,7 +71,7 @@ def get_session_transactions(cursor, statement, display):
 def record_matches(record, strict=None, **kwargs):
     strict = [ x.lower() for x in (strict or []) ]
     for key, query, candidate in (
-        (k.lower(), f"{v}".lower(), f"{record[k]}".lower()) for k, v in kwargs.items()
+        (k.lower(), f"{v}".lower(), f"{record[k.split('#',1)[0]]}".lower()) for k, v in kwargs.items()
     ):
         if not query:
             continue
@@ -86,6 +86,8 @@ def record_matches(record, strict=None, **kwargs):
     return True
 
 def unique_suggestions(cur, statement, name, display, exclude=NON_IDENTIFIER_COLUMNS, **kwargs):
+    if len(name.split('#', 1)) > 1:
+        name, _ = name.split('#', 1)
     exclude = filter(
         lambda x: x != name or name == 'ts',
         exclude,
@@ -109,6 +111,8 @@ def unique_suggestions(cur, statement, name, display, exclude=NON_IDENTIFIER_COL
     return ret
 
 def suggestions(cur, statement, name, display, exclude=NON_IDENTIFIER_COLUMNS, **kwargs):
+    if len(name.split('#', 1)) > 1:
+        name, _ = name.split('#', 1)
     exclude = filter(
         lambda x: x != name or name == 'ts',
         exclude,

+ 85 - 0
recipe.py

@@ -0,0 +1,85 @@
+#!/usr/bin/python3
+#
+# Copyright (c) Daniel Sheffield 2023
+#
+# All rights reserved
+#
+# THIS SOFTWARE IS PROVIDED AS IS WITHOUT WARRANTY
+from sqlite3 import Cursor
+from typing import Union
+
+import urwid
+from urwid import raw_display
+from app.widgets import (AutoCompleteEdit, AutoCompleteFloatEdit)
+from app.activities import ActivityManager, show_or_exit
+from app.activities.RecipeEditor import RecipeEditor
+from app.db_utils import QueryManager, display_mapper
+from app.palette import high_contrast
+
+try:
+    from db_credentials import HOST, PASSWORD
+    host = f'host={HOST}'
+    password = f'password={PASSWORD}'
+except:
+    host = ''
+    password = ''
+
+try:
+    import os
+
+    import psycopg
+    user = os.getenv('USER')
+    conn = psycopg.connect(f"{host} dbname=grocery user={user} {password}")
+    cur: Cursor = conn.cursor()
+except:
+    print('Failed to set up db connection. Entering Mock mode')
+    exit(1)
+    #from mock import *
+
+def _autocomplete_callback(
+    query_manager: QueryManager,
+    widget: Union[AutoCompleteEdit, AutoCompleteFloatEdit],
+    name: str, data: dict
+):
+    options = query_manager.unique_suggestions(name, **data)
+    if len(options) > 0:
+        widget._emit('open', options)
+
+class Recipe(urwid.WidgetPlaceholder):
+    def __init__(self, activity_manager):
+        super().__init__(urwid.SolidFill(u'/'))
+        self.activity_manager = activity_manager
+        recipe = self.activity_manager.get('recipe_editor')
+
+        self.activity_manager.show(self)
+        self.activity_manager.show(recipe)
+
+cur.execute("BEGIN")
+
+activity_manager = ActivityManager()
+query_manager = QueryManager(cur, display_mapper)
+
+activity_manager.create(RecipeEditor, 'recipe_editor',
+    activity_manager, query_manager,
+    lambda widget, name, data: _autocomplete_callback(query_manager, widget, name, data),
+)
+
+app = Recipe(activity_manager)
+
+def iter_palettes():
+    palettes = [ v for k,v in high_contrast.theme.items() ]
+    while True:
+        p = palettes.pop(0)
+        palettes.append(p)
+        yield p
+
+palettes = iter_palettes()
+
+screen = raw_display.Screen()
+loop = urwid.MainLoop(app, next(palettes), screen=screen,
+        unhandled_input=lambda k: show_or_exit(k, screen=screen, palettes=palettes),
+        pop_ups=True)
+loop.run()
+
+cur.close()
+conn.close()