add edit step + do recipe in main view, added carousel for recipe selection

This commit is contained in:
2026-03-12 22:57:15 +01:00
parent 90257a62a0
commit d5dacb8fc4
21 changed files with 1052 additions and 279 deletions

View File

@@ -3,10 +3,49 @@
## Install Requirements ## Install Requirements
```bash ```bash
pip install -r requirements.txt uv sync
``` ```
## Run ## Run
```bash ```bash
python -m frontend.app uv run -m frontend
```
## Navigation
```mermaid
stateDiagram-v2
[*] --> Main
state "Main View<br>(timer + weight)" as Main
state "Recipe Selection<br>(carousel)" as RecipeSelection
state "Do Recipe<br>(step-by-step)" as DoRecipe
state "Edit Recipe<br>(step list)" as EditRecipe
state "Edit Recipe Move Mode<br>(reorder steps)" as MoveMode
state "Edit Step Type Selection<br>(list view)" as TypeSelect
state "Edit Step Value Entry<br>(morse input)" as ValueEntry
state "Edit Step Goal Time Entry<br>(morse input)" as GoalTime
Main --> RecipeSelection : right long press
RecipeSelection --> Main : left long press
RecipeSelection --> DoRecipe : right long press [on recipe → run]
RecipeSelection --> EditRecipe : both long press [on recipe → edit]<br>or right long press [on + → add]
DoRecipe --> Main : left long press [back]<br>or right press [last step → done]
EditRecipe --> RecipeSelection : left long press [back]<br>or right long press [on save]
EditRecipe --> TypeSelect : right long press [on step]<br>or right long press [on + → add step]
EditRecipe --> ValueEntry : right long press [on name]
EditRecipe --> MoveMode : both long press [on step]
MoveMode --> EditRecipe : both long press [confirm]<br>or left long press [cancel]
TypeSelect --> ValueEntry : right long press [type with value]
TypeSelect --> EditRecipe : right long press [type w/o value → saved]<br>or right long press [delete → deleted]<br>or left long press [back]
ValueEntry --> EditRecipe : left long press [save]<br>or left press [cancel]
ValueEntry --> GoalTime : left long press [WEIGH_WITH_TIMER]
GoalTime --> EditRecipe : left long press [save]<br>or left press [cancel]
``` ```

View File

@@ -71,6 +71,18 @@ class WeightApp(tk.Tk):
self.record_button = ttk.Button(self.recording_frame, text="Record", command=self.trigger_record) self.record_button = ttk.Button(self.recording_frame, text="Record", command=self.trigger_record)
self.record_button.pack() self.record_button.pack()
#### Morse Reference ####
morse_text = (
"A .- B -... C -.-. D -.. E . F .-..\n"
"G --. H .... I .. J .--- K -.- L .-..\n"
"M -- N -. O --- P .--. Q --.- R .-. \n"
"S ... T - U ..- V ...- W .-- X -..-\n"
"Y -.-- Z --.. 0 ----- 1 .---- 2 ..--- 3 ...--\n"
"4 ....- 5 ..... 6 -.... 7 --... 8 ---.. 9 ----."
)
self.morse_label = tk.Label(self.toolbar, text=morse_text, font=("Courier", 9), justify="left",
anchor="w")
#### Display #### #### Display ####
self.update_view() self.update_view()
@@ -86,11 +98,13 @@ class WeightApp(tk.Tk):
self.reader_settings.pack_forget() self.reader_settings.pack_forget()
self.view_type.pack_forget() self.view_type.pack_forget()
self.recording_frame.pack_forget() self.recording_frame.pack_forget()
self.morse_label.pack_forget()
self.view.pack_forget() self.view.pack_forget()
self.reader_settings.pack() self.reader_settings.pack()
self.view_type.pack() self.view_type.pack()
self.recording_frame.pack() self.recording_frame.pack()
self.morse_label.pack(pady=(10, 0))
self.view.pack() self.view.pack()
def hide_device_components(self): def hide_device_components(self):
@@ -98,6 +112,7 @@ class WeightApp(tk.Tk):
self.reader_settings.pack_forget() self.reader_settings.pack_forget()
self.view_type.pack_forget() self.view_type.pack_forget()
self.recording_frame.pack_forget() self.recording_frame.pack_forget()
self.morse_label.pack_forget()
self.view.pack_forget() self.view.pack_forget()
self.connection_settings.pack() self.connection_settings.pack()

View File

@@ -1,4 +1,5 @@
from .number import NumberView from .number import NumberView
from .circle import CircleView from .circle import CircleView
from .timer import TimerView from .timer import TimerView
from .text import TextView
from .main_view import MainView from .main_view import MainView

View File

@@ -32,3 +32,6 @@ class ButtonInterface:
def render_right_long_press(self, draw, x, y): def render_right_long_press(self, draw, x, y):
pass pass
def render_both_long_press(self, draw, x, y):
pass

View File

@@ -48,23 +48,25 @@ class ButtonsManager(View):
has_buttons = self.current_view.has_button() has_buttons = self.current_view.has_button()
# Draw left button # Draw left button
if has_buttons[0]: if has_buttons[0]:
draw.circle((10, 10), 2, fill='black') # draw.circle((10, 10), 2, fill='black')
self.current_view.render_left_press(draw, 20, 10) self.current_view.render_left_press(draw, 10, 10)
if has_buttons[1]: if has_buttons[1]:
y = self.size[1] - 10 y = self.size[1] - 10
draw_long_press(draw, (10, y)) # draw_long_press(draw, (10, y))
self.current_view.render_left_long_press(draw, 24, y) self.current_view.render_left_long_press(draw, 10, y)
# Draw right button # Draw right button
if has_buttons[2]: if has_buttons[2]:
draw.circle((self.size[0] - 10, 10), 2, fill='black') # draw.circle((self.size[0] - 10, 10), 2, fill='black')
self.current_view.render_right_press(draw, self.size[0] - 20, 4) self.current_view.render_right_press(draw, self.size[0] - 10, 4)
if has_buttons[3]: if has_buttons[3]:
y = self.size[1] - 10 y = self.size[1] - 10
draw_long_press(draw, (self.size[0] - 10, y)) # draw_long_press(draw, (self.size[0] - 10, y))
self.current_view.render_right_long_press(draw, self.size[0] - 24, y) self.current_view.render_right_long_press(draw, self.size[0] - 10, y)
self.current_view.render_both_long_press(draw, self.size[0] // 2, self.size[1] - 10)
return im return im

View File

@@ -6,6 +6,18 @@ from .base import View
class CircleView(View): class CircleView(View):
@property
def target(self):
try:
return float(self._target.get())
except ValueError:
return 0.0
@target.setter
def target(self, value):
self._target.delete(0, tk.END)
self._target.insert(0, str(value))
def __init__(self, parent, size, center, radius_offset=11, **kwargs): def __init__(self, parent, size, center, radius_offset=11, **kwargs):
self.target_radius = min(center) - radius_offset self.target_radius = min(center) - radius_offset
super().__init__(parent, size, center, **kwargs) super().__init__(parent, size, center, **kwargs)
@@ -13,11 +25,11 @@ class CircleView(View):
def init_ui(self, parent): def init_ui(self, parent):
self.ui = tk.Frame(parent) self.ui = tk.Frame(parent)
self.ui.pack() self.ui.pack()
self.target_label = ttk.Label(self.ui, text="Target (g)") self._target_label = ttk.Label(self.ui, text="Target (g)")
self.target_label.pack(side=tk.LEFT) self._target_label.pack(side=tk.LEFT)
self.target = ttk.Entry(self.ui) self._target = ttk.Entry(self.ui)
self.target.insert(0, 100.0) self._target.insert(0, "0.0")
self.target.pack(side=tk.LEFT) self._target.pack(side=tk.LEFT)
def _init_im(self): def _init_im(self):
im = Image.new('1', self.size, 'white') im = Image.new('1', self.size, 'white')
@@ -30,7 +42,7 @@ class CircleView(View):
weight_radius = 0.0 weight_radius = 0.0
bkg_im = self.bkg_im.copy() bkg_im = self.bkg_im.copy()
try: try:
target = float(self.target.get()) target = float(self._target.get())
if target > 0: if target > 0:
draw = ImageDraw.Draw(bkg_im) draw = ImageDraw.Draw(bkg_im)
draw.text((60, 98), f"{target:.1f} g", fill='black', font_size=16) draw.text((60, 98), f"{target:.1f} g", fill='black', font_size=16)

View File

@@ -0,0 +1,4 @@
from .item import ListItem
from .base import SelectableList
from .list_view import ListView
from .carousel import CarouselView

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List
from PIL import ImageDraw
from .item import ListItem
class SelectableList(ABC):
"""Abstract base for list and carousel selection widgets.
Manages a collection of :class:`ListItem` objects with a single
*selected_index*, wrapping navigation, and a shared highlight-bar
drawing primitive. Concrete subclasses (:class:`ListView`,
:class:`CarouselView`) only differ in how they render the items.
The two subclasses expose an identical public interface so they can
be swapped interchangeably.
"""
def __init__(self,
items: List[ListItem] | None = None,
x_offset: int = 40,
max_visible: int = 5,
item_height: int = 20):
self._items: List[ListItem] = list(items or [])
self.selected_index: int = 0
self.x_offset = x_offset
self.max_visible = max_visible
self.item_height = item_height
# ---- item access ------------------------------------------------
@property
def items(self) -> List[ListItem]:
return self._items
@items.setter
def items(self, value: List[ListItem]):
self._items = list(value)
if self._items and self.selected_index >= len(self._items):
self.selected_index = len(self._items) - 1
elif not self._items:
self.selected_index = 0
@property
def selected_item(self) -> ListItem | None:
if self._items and 0 <= self.selected_index < len(self._items):
return self._items[self.selected_index]
return None
# ---- navigation -------------------------------------------------
def select_previous(self):
"""Move selection to the previous item (wraps around)."""
if self._items:
self.selected_index = (self.selected_index - 1) % len(self._items)
def select_next(self):
"""Move selection to the next item (wraps around)."""
if self._items:
self.selected_index = (self.selected_index + 1) % len(self._items)
# ---- drawing helpers --------------------------------------------
def _draw_highlight(self,
draw: ImageDraw.ImageDraw,
x: int,
y_center: int,
font_size: int = 10,
width: int = 90):
"""Draw a rounded highlight bar via overlapping circles.
The bar radius is derived from *font_size* so the bar always
fits snugly around the rendered text.
"""
r = font_size // 2 + 4
for i in range(0, width, max(1, r // 2)):
draw.circle((x + i, y_center), r, fill='black')
# ---- rendering (subclass) ---------------------------------------
@abstractmethod
def render(self, draw: ImageDraw.ImageDraw, y_start: int) -> None:
"""Render the widget onto *draw* starting at *y_start*."""
...

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from PIL import ImageDraw
from .base import SelectableList
class CarouselView(SelectableList):
"""Single-item display shows the selected item centred and larger.
Position indicator dots at the bottom communicate how many items
exist and which one is currently selected. Uses
:pymethod:`ListItem.render_large` so callers can optionally provide
a more detailed rendering for carousel mode.
``large_font_size`` controls the font size passed to
:pymethod:`ListItem.render_large` (default 30, roughly 3× PIL default).
``center_x`` sets the horizontal centre used when drawing the item text
(default 84, the midpoint of a 168 px wide display). PIL's ``anchor='mm'``
is forwarded so the text is centred on that x coordinate.
``render_height`` is the total vertical space (in pixels) allocated to the
carousel; dots are placed at the very bottom of this region. Defaults to
``max_visible * item_height``.
"""
def __init__(self, *args,
large_font_size: int = 30,
center_x: int = 84,
render_height: int | None = None,
**kwargs):
super().__init__(*args, **kwargs)
self.large_font_size = large_font_size
self.center_x = center_x
self.render_height = render_height
def render(self, draw: ImageDraw.ImageDraw, y_start: int) -> None:
if not self._items:
return
item = self._items[self.selected_index]
height = self.render_height or (self.max_visible * self.item_height)
y_center = y_start + height // 2
# Render selected item centred (large variant, no highlight)
item.render_large(
draw,
(self.center_x, y_center),
fill='black',
font_size=self.large_font_size,
anchor='mm',
)
total = len(self._items)
# Position indicator dots at the top of the allocated area
if total > 1:
indicator_y = y_start
dot_spacing = min(8, max(4, 80 // max(total - 1, 1)))
total_width = (total - 1) * dot_spacing
start_x = self.center_x - total_width // 2
for i in range(total):
x = start_x + i * dot_spacing
r = 2 if i == self.selected_index else 1
draw.circle((x, indicator_y), r, fill='black')

View File

@@ -0,0 +1,47 @@
from typing import Callable, Tuple
from PIL import ImageDraw
RenderFn = Callable[[ImageDraw.ImageDraw, Tuple[int, int], str], None]
class ListItem:
"""A renderable item for use in ListView or CarouselView.
Each item carries a render function called as render_fn(draw, (x, y), fill).
An optional render_large_fn is used by CarouselView for a more prominent
display; it falls back to render_fn when not provided.
"""
def __init__(self,
render_fn: RenderFn,
render_large_fn: RenderFn | None = None):
self._render_fn = render_fn
self._render_large_fn = render_large_fn
def render(self,
draw: ImageDraw.ImageDraw,
position: Tuple[int, int],
fill: str = 'black',
**kwargs):
"""Render the item at normal (list) size.
Extra keyword arguments (e.g. ``font_size``) are forwarded to the
underlying render function.
"""
self._render_fn(draw, position, fill, **kwargs)
def render_large(self,
draw: ImageDraw.ImageDraw,
position: Tuple[int, int],
fill: str = 'black',
**kwargs):
"""Render the item at large (carousel) size.
Uses render_large_fn if provided, otherwise falls back to render_fn.
Extra keyword arguments (e.g. ``font_size``) are forwarded to the
underlying render function.
"""
fn = self._render_large_fn or self._render_fn
fn(draw, position, fill, **kwargs)

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import List, Tuple
from PIL import ImageDraw
from .base import SelectableList
from .item import ListItem
class ListView(SelectableList):
"""Compact scrolling list shows up to *max_visible* items at once.
The visible window is kept centred on the selected item and scrolls
as the selection moves.
``font_size`` is forwarded to :pymeth:`ListItem.render` so the
underlying render function can use it (e.g. for PIL ``draw.text``).
Pass ``None`` to let render functions use their own default.
"""
def __init__(self, *args, font_size: int | None = None, **kwargs):
super().__init__(*args, **kwargs)
self.font_size = font_size
def _get_visible_window(self) -> Tuple[List[ListItem], int]:
"""Return (visible_items, start_offset)."""
total = len(self._items)
if total <= self.max_visible:
return self._items, 0
start = max(0, self.selected_index - self.max_visible // 2)
start = min(start, total - self.max_visible)
end = start + self.max_visible
return self._items[start:end], start
def render(self, draw: ImageDraw.ImageDraw, y_start: int) -> None:
if not self._items:
return
visible_items, start = self._get_visible_window()
fs = self.font_size or 10
kwargs = {} if self.font_size is None else {'font_size': self.font_size}
for idx, item in enumerate(visible_items):
actual_idx = start + idx
y_pos = y_start + idx * self.item_height
if actual_idx == self.selected_index:
self._draw_highlight(draw, self.x_offset, y_pos + fs // 2, font_size=fs)
item.render(draw, (self.x_offset, y_pos), fill='white', **kwargs)
else:
item.render(draw, (self.x_offset, y_pos), fill='black', **kwargs)

View File

@@ -6,12 +6,12 @@ from PIL import ImageChops
from ..config import DISPLAY_TYPES, DISPLAY_MODES from ..config import DISPLAY_TYPES, DISPLAY_MODES
from .draw_utils import draw_clock from .draw_utils import draw_clock
from . import NumberView, CircleView, TimerView from . import NumberView, CircleView, TimerView, TextView
from .button_interface import ButtonInterface from .button_interface import ButtonInterface
from .buttons_manager import ButtonsManager from .buttons_manager import ButtonsManager
from .recipes import RecipeSelection, RecipeManager, EditRecipe, Recipe, EditStep from .recipes import RecipeSelection, RecipeManager, EditRecipe, EditStep, StepType, Step
class MainView(tk.Frame, ButtonInterface): class MainView(tk.Frame, ButtonInterface):
def __init__(self, parent, def __init__(self, parent,
@@ -19,6 +19,7 @@ class MainView(tk.Frame, ButtonInterface):
calibrate_command=None, calibrate_command=None,
**kwargs): **kwargs):
super().__init__(parent, **kwargs) super().__init__(parent, **kwargs)
self.curr_mode = DISPLAY_MODES.MAIN
self.views = [] self.views = []
self.timer_view = None # Timer view is always active self.timer_view = None # Timer view is always active
self.tare_command = tare_command self.tare_command = tare_command
@@ -37,42 +38,73 @@ class MainView(tk.Frame, ButtonInterface):
highlightthickness=1, highlightbackground="black") highlightthickness=1, highlightbackground="black")
self.canvas.pack() self.canvas.pack()
self.timer_view = TimerView(self.actions, self.im_size, self.center) self.timer_view = TimerView(self.actions, self.im_size, self.center)
self.weigh_view = None # Initialize weigh_view to None; will be created if CIRCLE display type is selected
self.text_view = TextView(self.actions, self.im_size, self.center)
self.buttons = ButtonsManager(self, self.im_size, self.center, self.buttons = ButtonsManager(self, self.im_size, self.center,
curr_view=self) curr_view=self)
self.recipes_manager = RecipeManager() self.recipes_manager = RecipeManager()
self.current_steps = [None] * 3
self.curr_mode = DISPLAY_MODES.MAIN self.tare_buffer = []
self.timer_buffer = []
self.weigh_target_buffer = []
def has_button(self): def has_button(self):
return True, True, True, True return True, True, True, True
def left_press(self): def left_press(self):
self.timer_view.toggle_timer() if self.curr_mode == DISPLAY_MODES.MAIN:
self.timer_view.toggle_timer()
elif self.curr_mode == DISPLAY_MODES.DO_RECIPE:
self.previous_recipe_step()
def left_long_press(self): def left_long_press(self):
self.timer_view.reset_timer() if self.curr_mode == DISPLAY_MODES.MAIN:
self.timer_view.reset_timer()
elif self.curr_mode == DISPLAY_MODES.DO_RECIPE:
self.exit_recipe()
def right_press(self): def right_press(self):
self.tare_command() if self.curr_mode == DISPLAY_MODES.MAIN:
self.tare_command()
elif self.curr_mode == DISPLAY_MODES.DO_RECIPE:
self.next_recipe_step()
def right_long_press(self): def right_long_press(self):
if self.curr_mode == DISPLAY_MODES.DO_RECIPE:
return
self.enter_recipe_selection() self.enter_recipe_selection()
def render_left_press(self, draw, x, y): def render_left_press(self, draw, x, y):
draw_clock(draw, (x, y), radius=3) if self.curr_mode == DISPLAY_MODES.MAIN:
draw_clock(draw, (x, y), radius=3)
elif self.curr_mode == DISPLAY_MODES.DO_RECIPE:
if self.current_steps[0] is not None:
draw.regular_polygon((x, y, 5), 3, fill='black', rotation=210)
draw.text((x + 6, y - 5), self.current_steps[0].value_str, fill='black')
def render_left_long_press(self, draw, x, y): def render_left_long_press(self, draw, x, y):
draw_clock(draw, (x, y), radius=3) if self.curr_mode == DISPLAY_MODES.MAIN:
draw.text((x + 6, y - 5), "0.0", fill='black') draw_clock(draw, (x, y), radius=3)
draw.text((x + 6, y - 5), "0.0", fill='black')
elif self.curr_mode == DISPLAY_MODES.DO_RECIPE:
draw.text((x, y - 5), "Exit", fill='black')
def render_right_press(self, draw, x, y): def render_right_press(self, draw, x, y):
draw.text((x, y), "T", fill='black') if self.curr_mode == DISPLAY_MODES.MAIN:
draw.text((x, y), "T", fill='black')
elif self.curr_mode == DISPLAY_MODES.DO_RECIPE:
if self.current_steps[2] is not None:
draw.regular_polygon((x, y+5, 5), 3, fill='black', rotation=30)
value = self.current_steps[2].value_str
draw.text((x - 6 * len(value) - 5, y), value, fill='black')
else:
draw.text((x - 24, y), "Done", fill='black')
def render_right_long_press(self, draw, x, y): def render_right_long_press(self, draw, x, y):
draw.text((x, y - 5), "R", fill='black') if self.curr_mode == DISPLAY_MODES.MAIN:
draw.text((x, y - 5), "R", fill='black')
def enter_main_mode(self): def enter_main_mode(self):
@@ -86,9 +118,18 @@ class MainView(tk.Frame, ButtonInterface):
self.im_size, self.center, self.im_size, self.center,
recipe_manager=self.recipes_manager, recipe_manager=self.recipes_manager,
edit_recipe_command=self.enter_edit_recipe, edit_recipe_command=self.enter_edit_recipe,
run_recipe_command=self.enter_do_recipe,
deactivate_command=self.enter_main_mode) deactivate_command=self.enter_main_mode)
self.refresh(0.0) self.refresh(0.0)
def enter_do_recipe(self, recipe_id: int):
self.curr_mode = DISPLAY_MODES.DO_RECIPE
self.timer_view.reset_timer() # Reset timer at start of recipe
self.recipes_manager.activate_recipe(recipe_id)
self.current_steps = self.recipes_manager.get_current_step()
self.buttons.current_view = self
self.refresh(0.0)
def enter_edit_recipe(self, recipe_id: int = None): def enter_edit_recipe(self, recipe_id: int = None):
self.curr_mode = DISPLAY_MODES.EDIT_RECIPE self.curr_mode = DISPLAY_MODES.EDIT_RECIPE
self.buttons.current_view = EditRecipe(self, self.buttons.current_view = EditRecipe(self,
@@ -109,6 +150,77 @@ class MainView(tk.Frame, ButtonInterface):
deactivate_command=lambda: self.enter_edit_recipe(recipe_id)) deactivate_command=lambda: self.enter_edit_recipe(recipe_id))
self.refresh(0.0) self.refresh(0.0)
##### Recipe navigation in DO_RECIPE mode #####
def next_recipe_step(self):
self.current_steps = self.recipes_manager.next_step()
if self.current_steps[1] is None:
self.exit_recipe()
return
self.do_recipe_step(self.current_steps[1])
self.refresh(0.0)
def previous_recipe_step(self):
self.current_steps = self.recipes_manager.previous_step()
self.do_recipe_step(self.current_steps[1], reverse=True)
self.refresh(0.0)
def exit_recipe(self):
self.recipes_manager.deactivate_recipe()
self.timer_view.reset_timer()
self.weigh_view.target = 0.0
self.weigh_target_buffer.clear()
self.timer_buffer.clear()
self.tare_buffer.clear()
self.enter_main_mode()
def do_recipe_step(self, step: Step, reverse=False):
if step is None:
return
last_step = self.current_steps[0]
if last_step is not None and last_step.step_type == StepType.WEIGH_WITH_TIMER and not reverse:
self.timer_view.goal_secs = 0.0
elif last_step is not None and last_step.step_type == StepType.SECTION and not reverse:
self.weigh_view.target = 0.0
self.timer_view.goal_secs = 0.0
if step.step_type == StepType.TARE:
if not reverse:
tare_weight = self.tare_command()
self.tare_buffer.append(tare_weight) # buffer last tare weight in case we need to revert
elif self.tare_buffer:
tare_weight = self.tare_buffer.pop()
self.tare_command(tare_weight)
else:
raise ValueError("Tare buffer is empty, cannot revert tare step")
elif step.step_type == StepType.WEIGH:
if not reverse:
self.weigh_view.target = step.value
self.weigh_target_buffer.append(step.value)
else:
if self.weigh_target_buffer:
self.weigh_view.target = self.weigh_target_buffer.pop()
else:
self.weigh_view.target = 0.0
elif step.step_type == StepType.WEIGH_WITH_TIMER:
if not reverse:
self.timer_view.reset_timer()
self.timer_view.goal_secs = step.goal_time if step.goal_time > 0 else 0
self.timer_view.toggle_timer()
self.weigh_view.target = step.value
self.weigh_target_buffer.append(step.value)
else:
self.timer_view.reset_timer()
if self.weigh_target_buffer:
self.weigh_view.target = self.weigh_target_buffer.pop()
else:
self.weigh_view.target = 0.0
################ VIEW MANAGEMENT ################ ################ VIEW MANAGEMENT ################
def update_views(self, selected_types: DISPLAY_TYPES): def update_views(self, selected_types: DISPLAY_TYPES):
@@ -123,25 +235,41 @@ class MainView(tk.Frame, ButtonInterface):
self.views.append(number_view) self.views.append(number_view)
if selected_types & DISPLAY_TYPES.CIRCLE: if selected_types & DISPLAY_TYPES.CIRCLE:
circle_view = CircleView(self.actions, self.im_size, self.center) self.weigh_view = CircleView(self.actions, self.im_size, self.center)
self.views.append(circle_view) self.views.append(self.weigh_view)
def refresh(self, weight: float): def refresh(self, weight: float):
ims = [] ims = []
if self.curr_mode == DISPLAY_MODES.MAIN: if self.curr_mode == DISPLAY_MODES.MAIN or self.curr_mode == DISPLAY_MODES.DO_RECIPE:
# Always include timer and button view # Always include timer and button view
if self.timer_view: if self.curr_mode == DISPLAY_MODES.MAIN or \
(self.current_steps[1] is not None and \
self.current_steps[1].step_type != StepType.SECTION):
timer_im = self.timer_view.update_weight(weight) timer_im = self.timer_view.update_weight(weight)
button_im = self.buttons.update_weight(weight)
ims.append(timer_im) ims.append(timer_im)
ims.append(button_im) elif self.curr_mode == DISPLAY_MODES.DO_RECIPE and \
self.current_steps[1].step_type == StepType.SECTION:
if self.current_steps[1] is None:
self.text_view.set_text("Enjoy")
else:
# In recipe mode, if current step is a section, show a blank screen instead of timer
self.text_view.set_text(self.current_steps[1].value_str)
# Add other selected views text_im = self.text_view.update_weight(weight)
for view in self.views: ims.append(text_im)
im = view.update_weight(weight)
ims.append(im) button_im = self.buttons.update_weight(weight)
ims.append(button_im)
if self.curr_mode == DISPLAY_MODES.MAIN or \
self.current_steps[1] is None or \
self.current_steps[1].step_type != StepType.SECTION:
# Add other selected views
for view in self.views:
im = view.update_weight(weight)
ims.append(im)
else: else:
button_im = self.buttons.update_weight(weight) button_im = self.buttons.update_weight(weight)

View File

@@ -2,4 +2,4 @@ from .recipe_selection import RecipeSelection
from .recipe_manager import RecipeManager from .recipe_manager import RecipeManager
from .edit_recipe import EditRecipe from .edit_recipe import EditRecipe
from .edit_step import EditStep from .edit_step import EditStep
from .recipe import Recipe from .recipe import Recipe, StepType, Step

View File

@@ -1,11 +1,38 @@
from typing import Tuple from typing import Tuple
from PIL import ImageDraw, Image from PIL import ImageDraw, Image
from copy import deepcopy
from ..base import View from ..base import View
from ..button_interface import ButtonInterface from ..button_interface import ButtonInterface
from ..list_select import ListItem, ListView
from .recipe_manager import RecipeManager from .recipe_manager import RecipeManager
from .recipe import Recipe from .recipe import Recipe, Step, StepType
def _make_step_item(step):
"""ListItem that renders a recipe step (icon + value)."""
def render(draw, pos, fill, **kw):
step.step_type.render(draw, pos, fill=fill, **kw)
font_size = kw.get('font_size', 10)
draw.text((pos[0] + font_size + 12, pos[1]), step.value_str, fill=fill, **kw)
return ListItem(render)
def _make_add_item():
"""ListItem that renders as a '+' add button."""
return ListItem(lambda draw, pos, fill, **kw: draw.text(pos, '+', fill=fill, **kw))
def _make_cancel_item():
"""ListItem that renders as 'Cancel'."""
return ListItem(lambda draw, pos, fill, **kw: draw.text(pos, 'Cancel', fill=fill, **kw))
def _make_save_item():
"""ListItem that renders as 'Save'."""
return ListItem(lambda draw, pos, fill, **kw: draw.text(pos, 'Save', fill=fill, **kw))
class EditRecipe(View, ButtonInterface): class EditRecipe(View, ButtonInterface):
def __init__(self, parent, im_size, center, def __init__(self, parent, im_size, center,
@@ -22,108 +49,144 @@ class EditRecipe(View, ButtonInterface):
self.recipe = Recipe("New", []) self.recipe = Recipe("New", [])
self.recipe_manager.tmp_recipe = self.recipe self.recipe_manager.tmp_recipe = self.recipe
else: else:
self.recipe = recipe_manager.get_recipe(recipe_id) self.recipe = deepcopy(recipe_manager.get_recipe(recipe_id))
self.confirm_view = False self.confirm_view = False
self.selected_field = 0 # 0: name, 1+: steps self.move_mode = False
self.item_list = ListView(x_offset=40, max_visible=5, font_size=15)
self._rebuild_items()
super().__init__(parent, im_size, center) super().__init__(parent, im_size, center)
def _get_visual_steps(self): def _make_name_item(self):
steps = self.recipe.steps + ['+', 'BACK'] """ListItem that renders the recipe name field."""
if len(steps) < 4: recipe = self.recipe
return steps, 0 def render(draw, pos, fill, **kw):
draw.text(pos, f"Name: {recipe.name}", fill=fill, **kw)
return ListItem(render)
start = max(0, self.selected_field - 2) def _rebuild_items(self):
end = min(len(steps), start + 4) """Rebuild list items from current recipe state."""
old_index = self.item_list.selected_index
steps = steps[start:end] items = [self._make_name_item()]
for step in self.recipe.steps:
return steps, start items.append(_make_step_item(step))
if self.move_mode:
items.append(_make_cancel_item())
else:
items.append(_make_add_item())
items.append(_make_save_item())
self.item_list.items = items
self.item_list.selected_index = min(old_index, len(items) - 1)
def update_weight(self, weight: float) -> Image.Image: def update_weight(self, weight: float) -> Image.Image:
im = self.bkg_im.copy() im = self.bkg_im.copy()
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
self._rebuild_items()
x = 40 self.item_list.render(draw, y_start=10)
if self.move_mode:
draw.text((x, 10), "Name:", fill='black') draw.text((2, 10), '\u2195', fill='black')
if self.selected_field == 0:
r = 10
offset = 35
for i in range(0, 90, r // 2):
draw.circle((x + i, offset), r, fill='black')
draw.text((x, 30), self.recipe.name, fill='white')
else:
draw.text((x, 30), self.recipe.name, fill='black')
visual_steps, start = self._get_visual_steps()
for idx, step in enumerate(visual_steps):
y_pos = 60 + idx * 20
if start + idx + 1 == self.selected_field:
r = 10
offset = 15
for i in range(0, 90, r // 2):
draw.circle((x + i, y_pos), r, fill='black')
if str(step) == '+':
draw.text((x, y_pos - 5), '+', fill='white')
elif str(step) == 'BACK':
draw.regular_polygon((x + 5, y_pos, 5), 3, fill='white', rotation=90)
else:
step.step_type.render(draw, (x, y_pos - 5), fill='white')
draw.text((x + 30, y_pos - 5), step.value_str, fill='white')
elif str(step) == '+':
draw.text((x, y_pos - 5), '+', fill='black')
elif str(step) == 'BACK':
draw.regular_polygon((x + 5, y_pos, 5), 3, fill='black', rotation=90)
else:
step.step_type.render(draw, (x, y_pos - 5), fill='black')
draw.text((x + 30, y_pos - 5), step.value_str, fill='black')
return im return im
def left_press(self): def left_press(self):
self.selected_field = (self.selected_field - 1) % (len(self.recipe.steps) + 3) if self.move_mode:
idx = self.item_list.selected_index
num_steps = len(self.recipe.steps)
if 1 < idx <= num_steps: # swap step upward
steps = self.recipe.steps
step_idx = idx - 1
steps[step_idx - 1], steps[step_idx] = steps[step_idx], steps[step_idx - 1]
self.item_list.selected_index -= 1
elif idx == num_steps + 1: # on cancel -> move to last step (clamped, no wrap)
self.item_list.selected_index = num_steps
else:
self.item_list.select_previous()
def left_long_press(self): def left_long_press(self):
if self.selected_field == len(self.recipe.steps) + 2: if self.move_mode:
# back idx = self.item_list.selected_index
self.deactivate_command() num_steps = len(self.recipe.steps)
elif self.selected_field == len(self.recipe.steps) + 1: if idx == num_steps + 1: # cancel item
# add step self.move_mode = False
pass return
else: # always go back (discard unsaved changes)
# edit name self.deactivate_command()
self.edit_step_command(self.recipe_id, self.selected_field)
def right_press(self): def right_press(self):
self.selected_field = (self.selected_field + 1) % (len(self.recipe.steps) + 3) if self.move_mode:
idx = self.item_list.selected_index
num_steps = len(self.recipe.steps)
if 1 <= idx < num_steps: # swap step downward
steps = self.recipe.steps
step_idx = idx - 1
steps[step_idx], steps[step_idx + 1] = steps[step_idx + 1], steps[step_idx]
self.item_list.selected_index += 1
elif idx == num_steps: # on last step -> move to cancel (clamped, no wrap)
self.item_list.selected_index = num_steps + 1
# on cancel or name: do nothing
else:
self.item_list.select_next()
def both_long_press(self):
idx = self.item_list.selected_index
num_steps = len(self.recipe.steps)
if self.move_mode:
self.move_mode = False
elif 1 <= idx <= num_steps:
self.move_mode = True
def right_long_press(self): def right_long_press(self):
# save if self.move_mode:
if self.is_add_form: return
self.recipe_manager.add_recipe(self.recipe) idx = self.item_list.selected_index
num_steps = len(self.recipe.steps)
if idx == num_steps + 2:
# save item
if self.is_add_form:
self.recipe_manager.add_recipe(self.recipe)
else:
self.recipe_manager.update_recipe(self.recipe_id, self.recipe)
self.deactivate_command()
elif idx == num_steps + 1:
# add step
new_step = Step(StepType.SECTION, "")
self.recipe.steps.append(new_step)
self.edit_step_command(self.recipe_id, num_steps + 1)
else: else:
self.recipe_manager.update_recipe(self.recipe_id, self.recipe) # edit name (idx=0) or step (idx=1+)
# if view is in edit mode, the recipe is already updated (by reference) self.edit_step_command(self.recipe_id, idx)
self.deactivate_command()
def has_button(self) -> Tuple[bool, bool, bool, bool]: def has_button(self) -> Tuple[bool, bool, bool, bool]:
return True, True, True, True return True, True, True, True
def render_left_press(self, draw, x, y): def render_left_press(self, draw, x, y):
draw.regular_polygon((x, y+2, 5), 3, fill='black') draw.regular_polygon((x, y+2, 5), 3, fill='black')
def render_left_long_press(self, draw, x, y): def render_left_long_press(self, draw, x, y):
draw.text((x, y-5), 'Enter', fill='black') if self.move_mode:
draw.text((x, y-5), 'Cancel', fill='black')
else:
draw.text((x, y-5), 'Back', fill='black')
def render_right_press(self, draw, x, y): def render_right_press(self, draw, x, y):
draw.regular_polygon((x, y+4, 5), 3, fill='black', rotation=180) draw.regular_polygon((x, y+4, 5), 3, fill='black', rotation=180)
def render_right_long_press(self, draw, x, y): def render_right_long_press(self, draw, x, y):
draw.text((x - 20, y-5), 'Save', fill='black') if self.move_mode:
return
idx = self.item_list.selected_index
num_steps = len(self.recipe.steps)
if idx == num_steps + 2:
draw.text((x - 20, y-5), 'Save', fill='black')
elif idx == num_steps + 1:
draw.text((x - 15, y-5), 'Add', fill='black')
else:
draw.text((x - 15, y-5), 'Edit', fill='black')
def render_both_long_press(self, draw, x, y):
idx = self.item_list.selected_index
num_steps = len(self.recipe.steps)
if self.move_mode:
draw.text((x - 15, y - 5), 'Confirm', fill='black')
elif 1 <= idx <= num_steps:
draw.text((x - 10, y - 5), 'Move', fill='black')

View File

@@ -6,11 +6,51 @@ from MorseCodePy import decode
from ..base import View from ..base import View
from ..button_interface import ButtonInterface from ..button_interface import ButtonInterface
from ..list_select import ListItem, ListView
from .recipe_manager import RecipeManager from .recipe_manager import RecipeManager
from .recipe import Recipe, Step, StepType from .recipe import Recipe, Step, StepType
# Step types that accept a typed value
_TYPES_WITH_VALUE = {StepType.SECTION, StepType.WEIGH, StepType.WEIGH_WITH_TIMER}
# Step types whose value is numeric (use numbers language for morse)
_NUMERIC_TYPES = {StepType.WEIGH, StepType.WEIGH_WITH_TIMER}
def _make_type_item(step_type: StepType) -> ListItem:
"""ListItem showing a StepType icon + name."""
def render(draw, pos, fill, **kw):
step_type.render(draw, pos, fill=fill)
draw.text((pos[0] + 30, pos[1]), step_type.name, fill=fill, **kw)
return ListItem(render)
def _make_delete_item() -> ListItem:
"""ListItem that renders as a delete/trash label."""
return ListItem(
lambda draw, pos, fill, **kw: draw.text(pos, 'Delete', fill=fill, **kw)
)
class EditStep(View, ButtonInterface): class EditStep(View, ButtonInterface):
"""Edit a single recipe step (or the recipe name).
Flow for name editing (step_index == 0):
- Go straight to morse value entry.
Flow for step editing (step_index >= 1):
Phase 0 - select step type via ListView.
left_press : navigate up
right_press : navigate down
left_long_press : confirm type -> advance to phase 1, or save
immediately if the type carries no value
Phase 1 - enter value via morse.
left_press : cancel
left_long_press : save and exit
right_press : morse dot '.'
right_long_press : morse dash '-'
"""
def __init__(self, parent, im_size, center, def __init__(self, parent, im_size, center,
recipe_id: int, recipe_id: int,
step_index: int, step_index: int,
@@ -20,133 +60,260 @@ class EditStep(View, ButtonInterface):
self.recipe_manager = recipe_manager self.recipe_manager = recipe_manager
self.recipe_id = recipe_id self.recipe_id = recipe_id
self.step_index = step_index self.step_index = step_index
if step_index == 0:
self.step = recipe_manager.get_recipe(recipe_id).name
self.confirm_view = False recipe = recipe_manager.get_recipe(recipe_id)
self.edit_step = 0 # 0: type, 1: step/name value if step_index == 0:
self.new_type = '' self.step = recipe.name # str -> name editing
else:
self.step = recipe.steps[step_index - 1] # Step
# edit_step: 0 = type selection | 1 = value entry | 2 = goal time entry
# Name editing starts directly in phase 1.
self.edit_step = 1 if step_index == 0 else 0
# Type selection list (used only when editing a Step)
self.type_list = ListView(x_offset=40, max_visible=5)
self.type_list.items = [_make_type_item(t) for t in StepType] + [_make_delete_item()]
if isinstance(self.step, Step):
all_types = list(StepType)
self.type_list.selected_index = all_types.index(self.step.step_type)
self.new_type = self.step.step_type
else:
self.new_type = None
self.new_value = '' self.new_value = ''
self.new_goal_time = ''
self.value_cursor = 0 self.value_cursor = 0
self.value_cursor_pulse = 0.0 self.value_cursor_pulse = 0.0
self.morse_buffer = '' self.morse_buffer = ''
self.morse_code_language = 'english' self.morse_code_language = self._morse_language_for(self.new_type)
if isinstance(self.step, Step) and \
self.step.step_type in [StepType.START_TIME, StepType.WEIGH]:
self.morse_code_language = 'numbers'
self.last_input = None self.last_input = None
self.letter_timeout = 2.0 # seconds self.letter_timeout = 2.0 # seconds
super().__init__(parent, im_size, center) super().__init__(parent, im_size, center)
def update_weight(self, weight: float) -> Image.Image: # ------------------------------------------------------------------
im = self.bkg_im.copy() # helpers
draw = ImageDraw.Draw(im) # ------------------------------------------------------------------
x = 40 @staticmethod
def _morse_language_for(step_type) -> str:
return 'numbers' if step_type in _NUMERIC_TYPES else 'english'
def _process_morse_timeout(self):
"""Decode pending morse buffer after the letter timeout."""
if self.last_input is not None: if self.last_input is not None:
if time() - self.last_input > self.letter_timeout: if time() - self.last_input > self.letter_timeout:
if len(self.morse_buffer) < 10: if self.edit_step == 2:
self.value_cursor += 1 if len(self.morse_buffer) < 10:
self.new_value += decode(self.morse_buffer, language=self.morse_code_language).upper() decoded = decode(self.morse_buffer, language=self.morse_code_language)
self.new_goal_time += decoded.upper()
self.value_cursor += 1
else:
self.new_goal_time = self.new_goal_time[:-1]
self.value_cursor = max(0, self.value_cursor - 1)
else: else:
self.value_cursor -= 1 if len(self.morse_buffer) < 10:
self.value_cursor = max(0, self.value_cursor) decoded = decode(self.morse_buffer, language=self.morse_code_language)
self.new_value = self.new_value[:-1] self.new_value += decoded.upper()
# process morse buffer self.value_cursor += 1
else:
self.new_value = self.new_value[:-1]
self.value_cursor = max(0, self.value_cursor - 1)
self.last_input = None self.last_input = None
self.morse_buffer = '' self.morse_buffer = ''
def _render_value_entry(self, draw: ImageDraw.ImageDraw, x: int):
"""Render the morse value-entry screen."""
font_size = 16
y_start = 30
if isinstance(self.step, str): if isinstance(self.step, str):
font_size = 16 label = "Name:"
y_start = 30 elif self.edit_step == 2:
draw.text((x, y_start), "Name:", fill='black') label = "Goal time(s):"
draw.text((x, y_start + 20), self.new_value, fill='black', font_size=font_size)
if self.value_cursor_pulse > 1.0:
draw.rectangle((x + self.value_cursor * 10,
y_start + 20 - 2,
x + self.value_cursor * 10 + 8,
y_start + 34),
fill='black')
if self.value_cursor_pulse > 2.0:
self.value_cursor_pulse = 0.0
self.value_cursor_pulse += 0.1
draw.line((x, y_start + 37, x + 80, y_start + 37), fill='black')
if self.morse_buffer != '':
for i, suffix in enumerate(['']):#, '.', '-', '.' * 10]):
letter = 'del'
if len(suffix) != 10 and len(self.morse_buffer) + len(suffix) < 10:
letter = decode(self.morse_buffer + suffix, language=self.morse_code_language)
draw.text((x, y_start + 50 + i * 15),
f'{self.morse_buffer + suffix} {letter}',
fill='black', font_size=14)
elif self.edit_step == 0:
pass
else: else:
pass label = f"{self.new_type.name}:"
draw.text((x, y_start), label, fill='black')
# visual_steps, start = self._get_visual_steps() current_value = self.new_goal_time if self.edit_step == 2 else self.new_value
draw.text((x, y_start + 20), current_value, fill='black', font_size=font_size)
# for idx, step in enumerate(visual_steps): if self.value_cursor_pulse > 1.0:
# y_pos = 60 + idx * 20 draw.rectangle(
# if start + idx + 1 == self.selected_field: (x + self.value_cursor * 10, y_start + 18,
# r = 10 x + self.value_cursor * 10 + 8, y_start + 34),
# offset = 15 fill='black'
# for i in range(0, 90, r // 2): )
# draw.circle((x + i, y_pos), r, fill='black') self.value_cursor_pulse += 0.1
if self.value_cursor_pulse > 2.0:
self.value_cursor_pulse = 0.0
draw.line((x, y_start + 37, x + 80, y_start + 37), fill='black')
# if str(step) == '+': if self.morse_buffer:
# draw.text((x, y_pos - 5), '+', fill='white') letter = decode(self.morse_buffer, language=self.morse_code_language) \
# elif str(step) == 'BACK': if len(self.morse_buffer) < 10 else 'del'
# draw.regular_polygon((x + 5, y_pos, 5), 3, fill='white', rotation=90) draw.text((x, y_start + 50), f'{self.morse_buffer} -> {letter}',
# else: fill='black', font_size=14)
# step.step_type.render(draw, (x, y_pos - 5), fill='white')
# draw.text((x + 30, y_pos - 5), step.value_str, fill='white')
# elif str(step) == '+': def _render_type_selection(self, draw: ImageDraw.ImageDraw, x: int):
# draw.text((x, y_pos - 5), '+', fill='black') """Render the step-type selection screen."""
# elif str(step) == 'BACK': draw.text((x, 10), "Step type:", fill='black')
# draw.regular_polygon((x + 5, y_pos, 5), 3, fill='black', rotation=90) self.type_list.render(draw, y_start=30)
# else:
# step.step_type.render(draw, (x, y_pos - 5), fill='black') # ------------------------------------------------------------------
# draw.text((x + 30, y_pos - 5), step.value_str, fill='black') # View
# ------------------------------------------------------------------
def update_weight(self, weight: float) -> Image.Image:
im = self.bkg_im.copy()
draw = ImageDraw.Draw(im)
x = 40
if isinstance(self.step, str) or self.edit_step >= 1:
self._process_morse_timeout()
self._render_value_entry(draw, x)
else:
self._render_type_selection(draw, x)
return im return im
# ------------------------------------------------------------------
# ButtonInterface
# ------------------------------------------------------------------
def left_press(self): def left_press(self):
self.deactivate_command() if isinstance(self.step, str) or self.edit_step >= 1:
self.deactivate_command()
else:
self.type_list.select_previous()
def left_long_press(self): def left_long_press(self):
if self.step_index == 0: if isinstance(self.step, str):
# editing name
if self.new_value: if self.new_value:
recipe = self.recipe_manager.get_recipe(self.recipe_id) recipe = self.recipe_manager.get_recipe(self.recipe_id)
recipe.name = self.new_value recipe.name = self.new_value
self.deactivate_command() self.deactivate_command()
elif self.edit_step == 0:
# always go back from type selection
self.deactivate_command()
elif self.edit_step == 1 and self.new_type == StepType.WEIGH_WITH_TIMER:
# Save weight value and advance to goal time entry
raw = self.new_value
if raw:
try:
self._saved_weight = int(raw)
except ValueError:
self._saved_weight = None
else:
self._saved_weight = None
self.edit_step = 2
self.morse_code_language = 'numbers'
self.new_goal_time = ''
self.value_cursor = 0
self.morse_buffer = ''
self.last_input = None
else: else:
pass if self.edit_step == 2:
raw_goal = self.new_goal_time
try:
goal_time = float(raw_goal) if raw_goal else 0.0
except ValueError:
goal_time = 0.0
value = self._saved_weight
else:
raw = self.new_value
if raw and self.new_type in _NUMERIC_TYPES:
try:
value = int(raw)
except ValueError:
value = None
elif raw:
value = raw
else:
value = None
goal_time = 0.0
recipe = self.recipe_manager.get_recipe(self.recipe_id)
step = recipe.steps[self.step_index - 1]
step.step_type = self.new_type
step.value = value
step.goal_time = goal_time
self.deactivate_command()
def right_press(self): def right_press(self):
self.last_input = time() if isinstance(self.step, str) or self.edit_step >= 1:
self.morse_buffer += '.' self.last_input = time()
self.morse_buffer += '.'
else:
self.type_list.select_next()
def right_long_press(self): def right_long_press(self):
self.last_input = time() if isinstance(self.step, str) or self.edit_step >= 1:
self.morse_buffer += '-' self.last_input = time()
self.morse_buffer += '-'
elif self.edit_step == 0:
all_types = list(StepType)
if self.type_list.selected_index == len(all_types):
# delete item selected
recipe = self.recipe_manager.get_recipe(self.recipe_id)
del recipe.steps[self.step_index - 1]
self.deactivate_command()
return
self.new_type = all_types[self.type_list.selected_index]
if self.new_type not in _TYPES_WITH_VALUE:
# No value needed -> save immediately
recipe = self.recipe_manager.get_recipe(self.recipe_id)
recipe.steps[self.step_index - 1].step_type = self.new_type
recipe.steps[self.step_index - 1].value = None
self.deactivate_command()
else:
# Advance to value entry
self.morse_code_language = self._morse_language_for(self.new_type)
self.new_value = ''
self.value_cursor = 0
self.morse_buffer = ''
self.last_input = None
self.edit_step = 1
def has_button(self) -> Tuple[bool, bool, bool, bool]: def has_button(self) -> Tuple[bool, bool, bool, bool]:
return True, True, True, False return True, True, True, True
# ------------------------------------------------------------------
# Button hint rendering
# ------------------------------------------------------------------
def render_left_press(self, draw, x, y): def render_left_press(self, draw, x, y):
draw.text((x, y-5), 'Cancel', fill='black') if isinstance(self.step, str) or self.edit_step >= 1:
draw.text((x, y - 5), 'Cancel', fill='black')
else:
draw.regular_polygon((x, y + 2, 5), 3, fill='black')
def render_left_long_press(self, draw, x, y): def render_left_long_press(self, draw, x, y):
draw.text((x, y-5), 'Next', fill='black') if self.edit_step == 0 and not isinstance(self.step, str):
draw.text((x, y - 5), 'Back', fill='black')
elif self.edit_step == 1 and self.new_type == StepType.WEIGH_WITH_TIMER:
draw.text((x, y - 5), 'Next', fill='black')
else:
draw.text((x, y - 5), 'Save', fill='black')
def render_right_press(self, draw, x, y): def render_right_press(self, draw, x, y):
draw.text((x - 30, y), 'Morse', fill='black') if isinstance(self.step, str) or self.edit_step >= 1:
draw.text((x - 5, y - 5), '.', fill='black')
else:
draw.regular_polygon((x, y + 4, 5), 3, fill='black', rotation=180)
def render_right_long_press(self, draw, x, y):
if isinstance(self.step, str) or self.edit_step >= 1:
draw.text((x - 5, y - 5), '-', fill='black')
elif self.edit_step == 0:
all_types = list(StepType)
if self.type_list.selected_index == len(all_types):
draw.text((x - 20, y - 5), 'Delete', fill='black')
else:
draw.text((x - 20, y - 5), 'Select', fill='black')

View File

@@ -8,7 +8,7 @@ from ..draw_utils import draw_clock
class Recipe: class Recipe:
def __init__(self, name: str, steps: list[Step]): def __init__(self, name: str, steps: list[Step]):
self.name = name self.name = name
self.steps = steps + [Step(StepType.SECTION, "Enjoy")] self.steps = steps
def __str__(self): def __str__(self):
return self.name return self.name
@@ -19,48 +19,50 @@ class Recipe:
class StepType(Enum): class StepType(Enum):
SECTION = 0 SECTION = 0
WEIGH = 1 WEIGH = 1
START_TIME = 2 WEIGH_WITH_TIMER = 2
WAIT_TIME_FINISHED = 3 TARE = 3
TARE = 4
def render(self, draw, position, fill='black') -> str: def render(self, draw, position, fill='black', **kwargs) -> str:
font_size = kwargs.get('font_size', 10)
if self == StepType.SECTION: if self == StepType.SECTION:
draw.text(position, "T", fill=fill) draw.text(position, "T", fill=fill, **kwargs)
elif self == StepType.WEIGH: elif self == StepType.WEIGH:
draw.text(position, "W", fill=fill) draw.text(position, "W", fill=fill, **kwargs)
elif self == StepType.START_TIME or self == StepType.WAIT_TIME_FINISHED: elif self == StepType.WEIGH_WITH_TIMER:
draw_clock(draw, (position[0] + 3, position[1] + 5), radius=3, color=fill) draw.text(position, "W", fill=fill, **kwargs)
r = max(3, font_size // 4)
draw_clock(draw, (position[0] + font_size + 2, position[1] + font_size // 2), radius=r, color=fill)
elif self == StepType.TARE: elif self == StepType.TARE:
draw.text(position, "0.0g", fill=fill) draw.text(position, "0.0g", fill=fill, **kwargs)
class Step: class Step:
def __init__(self, def __init__(self,
step_type: StepType, step_type: StepType,
value: float | str = None): value: float | str = None,
goal_time: float = 0.0):
self.step_type = step_type self.step_type = step_type
self.value = value self.value = value
self.goal_time = goal_time
@property @property
def value_str(self) -> str: def value_str(self) -> str:
if self.step_type in [StepType.WEIGH]: if self.step_type == StepType.WEIGH:
return f"{self.value}g" return f"{self.value}g"
elif self.step_type == StepType.START_TIME: elif self.step_type == StepType.WEIGH_WITH_TIMER:
if self.value == -1: s = f"{self.value}g"
return "Start" if self.goal_time > 0:
else: minutes = int(self.goal_time) // 60
minutes = self.value // 60 seconds = int(self.goal_time) % 60
seconds = self.value % 60
if minutes == 0: if minutes == 0:
return f"{seconds}s" s += f" {seconds}s"
else: else:
return f"{minutes}:{seconds:02d}" s += f" {minutes}:{seconds:02d}"
return s
elif self.step_type == StepType.SECTION: elif self.step_type == StepType.SECTION:
return str(self.value) return str(self.value)
elif self.step_type == StepType.TARE: elif self.step_type == StepType.TARE:
return "Tare" return "Tare"
elif self.step_type == StepType.WAIT_TIME_FINISHED:
return "Wait"
else: else:
return "" return ""
@@ -75,10 +77,7 @@ V60 = Recipe(
Step(StepType.SECTION, "Brew"), Step(StepType.SECTION, "Brew"),
Step(StepType.TARE), Step(StepType.TARE),
Step(StepType.START_TIME, 45), Step(StepType.WEIGH_WITH_TIMER, 50, goal_time=45),
Step(StepType.WEIGH, 50),
Step(StepType.WAIT_TIME_FINISHED),
Step(StepType.START_TIME, -1),
Step(StepType.WEIGH, 250), Step(StepType.WEIGH, 250),
] ]
) )
@@ -92,7 +91,6 @@ ESPRESSO = Recipe(
Step(StepType.SECTION, "Brew"), Step(StepType.SECTION, "Brew"),
Step(StepType.TARE), Step(StepType.TARE),
Step(StepType.START_TIME, -1), Step(StepType.WEIGH_WITH_TIMER, 40, goal_time=0.0),
Step(StepType.WEIGH, 40),
] ]
) )

View File

@@ -1,4 +1,5 @@
from .recipe import V60, ESPRESSO, Recipe from typing import List
from .recipe import V60, ESPRESSO, Recipe, Step, StepType
class RecipeManager: class RecipeManager:
def __init__(self): def __init__(self):
@@ -7,6 +8,8 @@ class RecipeManager:
ESPRESSO ESPRESSO
] ]
self.tmp_recipe = None self.tmp_recipe = None
self.active_recipe: Recipe = None
self.current_recipe_step_id = 0
def add_recipe(self, recipe): def add_recipe(self, recipe):
self.recipes.append(recipe) self.recipes.append(recipe)
@@ -21,6 +24,46 @@ class RecipeManager:
return self.recipes.index(recipe) return self.recipes.index(recipe)
def get_recipe(self, recipe_id) -> Recipe: def get_recipe(self, recipe_id) -> Recipe:
if recipe_id == None: if recipe_id is None:
return self.tmp_recipe return self.tmp_recipe
return self.recipes[recipe_id] return self.recipes[recipe_id]
def activate_recipe(self, recipe_id):
self.active_recipe = self.get_recipe(recipe_id)
def deactivate_recipe(self):
self.active_recipe = None
self.current_recipe_step_id = 0
def get_current_step(self) -> List[Step]:
steps = [None] * 3
if self.active_recipe is None:
return steps
if self.current_recipe_step_id > len(self.active_recipe.steps):
return steps
if self.current_recipe_step_id > 0:
steps[0] = self.active_recipe.steps[self.current_recipe_step_id - 1]
if self.current_recipe_step_id < len(self.active_recipe.steps):
steps[1] = self.active_recipe.steps[self.current_recipe_step_id]
elif self.current_recipe_step_id == len(self.active_recipe.steps):
steps[1] = Step(StepType.SECTION, "Enjoy")
if self.current_recipe_step_id < len(self.active_recipe.steps) - 1:
steps[2] = self.active_recipe.steps[self.current_recipe_step_id + 1]
return steps
def next_step(self) -> List[Step]:
if self.active_recipe is None:
return [None] * 3
self.current_recipe_step_id = min(self.current_recipe_step_id + 1, len(self.active_recipe.steps) + 1)
return self.get_current_step()
def previous_step(self) -> List[Step]:
if self.active_recipe is None:
return [None] * 3
self.current_recipe_step_id = max(0, self.current_recipe_step_id - 1)
return self.get_current_step()

View File

@@ -2,12 +2,18 @@ from typing import Tuple
from ..base import View from ..base import View
from ..button_interface import ButtonInterface from ..button_interface import ButtonInterface
from ..list_select import ListItem, ListView, CarouselView
from .recipe_manager import RecipeManager from .recipe_manager import RecipeManager
from .recipe import V60, ESPRESSO
from PIL import ImageDraw, Image from PIL import ImageDraw, Image
def _make_text_item(text):
"""ListItem that renders as plain text (forwards kwargs such as font_size)."""
return ListItem(lambda draw, pos, fill, t=text, **kw: draw.text(pos, str(t), fill=fill, **kw))
class RecipeSelection(View, ButtonInterface): class RecipeSelection(View, ButtonInterface):
@property @property
@@ -17,70 +23,56 @@ class RecipeSelection(View, ButtonInterface):
def __init__(self, parent, im_size, center, def __init__(self, parent, im_size, center,
recipe_manager: RecipeManager = None, recipe_manager: RecipeManager = None,
edit_recipe_command=None, edit_recipe_command=None,
run_recipe_command=None,
deactivate_command=None): deactivate_command=None):
self.selected_index = 0
self.deactivate_command = deactivate_command self.deactivate_command = deactivate_command
self.recipe_manager = recipe_manager self.recipe_manager = recipe_manager
self.edit_recipe_command = edit_recipe_command self.edit_recipe_command = edit_recipe_command
self.run_recipe_command = run_recipe_command
self.item_list = CarouselView(render_height=124)
# self.item_list = ListView(x_offset=40, max_visible=5)
self._rebuild_items()
super().__init__(parent, im_size, center) super().__init__(parent, im_size, center)
def _get_visual_recipes(self): def _rebuild_items(self):
recipes = self.recipes + ['+', 'BACK'] """Rebuild list items from current recipes."""
if len(recipes) < 5: old_index = self.item_list.selected_index
return recipes, 0 items = [_make_text_item(r) for r in self.recipes]
items.append(_make_text_item('+'))
start = max(0, self.selected_index - 2) self.item_list.items = items
end = min(len(recipes), start + 5) self.item_list.selected_index = min(old_index, len(items) - 1)
recipes = recipes[start:end]
return recipes, start
def update_weight(self, weight: float) -> Image.Image: def update_weight(self, weight: float) -> Image.Image:
im = self.bkg_im.copy() im = self.bkg_im.copy()
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
self._rebuild_items()
recipes, start = self._get_visual_recipes() self.item_list.render(draw, y_start=10)
for idx, recipe in enumerate(recipes):
if idx + start == self.selected_index:
r = 10
offset = 15
for i in range(0, 90, r // 2):
draw.circle((40 + i, offset + idx * 20), r, fill='black')
if str(recipe) != 'BACK':
draw.text((40, 10 + idx * 20), str(recipe), fill='white')
else:
draw.regular_polygon((40 + 5, 15 + idx * 20, 5), 3, fill='white', rotation=90)
elif str(recipe) == 'BACK':
draw.regular_polygon((40 + 5, 15 + idx * 20, 5), 3, fill='black', rotation=90)
else:
draw.text((40, 10 + idx * 20), str(recipe), fill='black')
return im return im
def left_press(self): def left_press(self):
self.selected_index = (self.selected_index - 1) % (len(self.recipes) + 2) self.item_list.select_previous()
def left_long_press(self): def left_long_press(self):
if self.selected_index < len(self.recipes): self.item_list.selected_index = 0
self.edit_recipe_command(self.selected_index) self.deactivate_command()
def right_press(self): def right_press(self):
self.selected_index = (self.selected_index + 1) % (len(self.recipes) + 2) self.item_list.select_next()
def right_long_press(self): def right_long_press(self):
if self.selected_index < len(self.recipes): idx = self.item_list.selected_index
# activate selected recipe if idx < len(self.recipes):
print(f"Activating recipe: {self.recipes[self.selected_index]}") # run selected recipe
elif self.selected_index == len(self.recipes): if self.run_recipe_command:
self.run_recipe_command(idx)
elif idx == len(self.recipes):
# add new recipe # add new recipe
self.edit_recipe_command() self.edit_recipe_command()
else:
# back
self.selected_index = 0
self.deactivate_command()
def both_long_press(self):
idx = self.item_list.selected_index
if idx < len(self.recipes):
self.edit_recipe_command(idx)
def has_button(self) -> Tuple[bool, bool, bool, bool]: def has_button(self) -> Tuple[bool, bool, bool, bool]:
return True, True, True, True return True, True, True, True
@@ -89,10 +81,19 @@ class RecipeSelection(View, ButtonInterface):
draw.regular_polygon((x, y+2, 5), 3, fill='black') draw.regular_polygon((x, y+2, 5), 3, fill='black')
def render_left_long_press(self, draw, x, y): def render_left_long_press(self, draw, x, y):
draw.text((x, y-5), 'Edit', fill='black') draw.text((x, y-5), 'Back', fill='black')
def render_right_press(self, draw, x, y): def render_right_press(self, draw, x, y):
draw.regular_polygon((x, y+4, 5), 3, fill='black', rotation=180) draw.regular_polygon((x, y+4, 5), 3, fill='black', rotation=180)
def render_right_long_press(self, draw, x, y): def render_right_long_press(self, draw, x, y):
draw.regular_polygon((x+2, y, 5), 3, fill='black', rotation=270) idx = self.item_list.selected_index
if idx < len(self.recipes):
draw.text((x - 15, y-5), 'Run', fill='black')
else:
draw.text((x - 15, y-5), 'Add', fill='black')
def render_both_long_press(self, draw, x, y):
idx = self.item_list.selected_index
if idx < len(self.recipes):
draw.text((x - 10, y - 5), 'Edit', fill='black')

31
frontend/views/text.py Normal file
View File

@@ -0,0 +1,31 @@
from PIL import ImageDraw
from .base import View
class TextView(View):
def __init__(self, parent, size, center, text="", font_size=32, **kwargs):
self.text = text
self.font_size = font_size
super().__init__(parent, size, center, **kwargs)
def set_text(self, text):
self.text = text
def update_weight(self, weight):
im = self.bkg_im.copy()
draw = ImageDraw.Draw(im)
if self.text:
# Estimate text dimensions for centering
char_w = self.font_size * 0.6
char_h = self.font_size
text_w = len(self.text) * char_w
text_h = char_h
x = self.center[0] - text_w / 2
y = self.center[1] - text_h / 2
draw.text((x, y), self.text, fill="black", font_size=self.font_size)
return im

View File

@@ -6,11 +6,22 @@ import time
from .base import View from .base import View
class TimerView(View): class TimerView(View):
@property
def goal_secs(self):
try:
return float(self.goal_entry.get())
except ValueError:
return 0.0
@goal_secs.setter
def goal_secs(self, value):
self.goal_entry.delete(0, tk.END)
self.goal_entry.insert(0, str(value))
def __init__(self, parent, size, center, width=3, **kwargs): def __init__(self, parent, size, center, width=3, **kwargs):
self.start_time = None self.start_time = None
self.elapsed_time = 0 self.elapsed_time = 0
self.is_running = False self.is_running = False
self.goal_minutes = 0 # 0 means no goal set
self.radius = min(center) - 7 self.radius = min(center) - 7
self.width = width self.width = width
self.goal_achived = 0 self.goal_achived = 0
@@ -79,9 +90,8 @@ class TimerView(View):
# Draw progress circle if goal is set # Draw progress circle if goal is set
try: try:
goal_sec = float(self.goal_entry.get()) if self.goal_secs > 0:
if goal_sec > 0: progress = current_time / self.goal_secs
progress = current_time / goal_sec
else: else:
progress = current_time / 60 progress = current_time / 60
@@ -89,7 +99,7 @@ class TimerView(View):
inverted = int(progress) % 2 == 1 inverted = int(progress) % 2 == 1
# pulse width if goal achieved # pulse width if goal achieved
if int(progress) > 0 and self.is_running: if self.goal_secs > 0 and int(progress) > 0 and self.is_running:
self.goal_achived = (self.goal_achived + self.goal_pulse_freq) % 3 self.goal_achived = (self.goal_achived + self.goal_pulse_freq) % 3
else: else:
self.goal_achived = 0 self.goal_achived = 0