import io import tkinter as tk from tkinter import Frame, Canvas, ttk, PhotoImage from PIL import ImageChops from ..config import DISPLAY_TYPES, DISPLAY_MODES from .draw_utils import draw_clock, draw_bluetooth_icon from . import NumberView, CircleView, TimerView, TextView, LoadingView from .button_interface import ButtonInterface from .buttons_manager import ButtonsManager from .recipes import RecipeSelection, RecipeManager, EditRecipe, EditStep, StepType, Step from .menu import MenuView from .bluetooth_pairing import BluetoothPairingView class MainView(tk.Frame, ButtonInterface): def __init__(self, parent, tare_command=None, calibrate_command=None, bt_connected_getter=None, bt_start_pairing_command=None, bt_stop_pairing_command=None, **kwargs): super().__init__(parent, **kwargs) self.curr_mode = DISPLAY_MODES.LOADING self.views = [] self.timer_view = None # Timer view is always active self.tare_command = tare_command self.calibrate_command = calibrate_command self.bt_connected_getter = bt_connected_getter self.bt_start_pairing_command = bt_start_pairing_command self.bt_stop_pairing_command = bt_stop_pairing_command self.actions = Frame(self) self.actions.pack() self.calibrate_button = ttk.Button(self.actions, text="Calibrate", command=calibrate_command) self.calibrate_button.pack() self.im_size = (168, 144) self.center = (168 // 2, 144 // 2) # Create timer view that's always active self.canvas = Canvas(self, width=168, height=144, background='white', highlightthickness=1, highlightbackground="black") self.canvas.pack() 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, curr_view=self) self.loading_view = LoadingView(self.im_size, self.center) self.recipes_manager = RecipeManager() self.current_steps = [None] * 3 self.tare_buffer = [] self.timer_buffer = [] self.weigh_target_buffer = [] def has_button(self): return True, True, True, True def left_press(self): 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): 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): 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): if self.curr_mode == DISPLAY_MODES.DO_RECIPE: return self.enter_recipe_selection() def both_long_press(self): if self.curr_mode == DISPLAY_MODES.MAIN: self.enter_menu() def render_left_press(self, draw, x, y): 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): if self.curr_mode == DISPLAY_MODES.MAIN: 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): 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): if self.curr_mode == DISPLAY_MODES.MAIN: draw.text((x, y - 5), "R", fill='black') def render_both_long_press(self, draw, x, y): if self.curr_mode == DISPLAY_MODES.MAIN: draw.text((x - 12, y - 5), "Menu", fill='black') def render_status_icons(self, draw): if self.bt_connected_getter and self.bt_connected_getter(): draw_bluetooth_icon(draw, (self.im_size[0] - 8, 8), size=5) def enter_menu(self): self.curr_mode = DISPLAY_MODES.MENU self.buttons.current_view = MenuView(self, self.im_size, self.center, bluetooth_pair_command=self.start_bluetooth_pairing, deactivate_command=self.enter_main_mode) self.refresh(0.0) def start_bluetooth_pairing(self): self.curr_mode = DISPLAY_MODES.BLUETOOTH_PAIRING self.buttons.current_view = BluetoothPairingView( self, self.im_size, self.center, start_command=self.bt_start_pairing_command, stop_command=self.bt_stop_pairing_command, deactivate_command=self.enter_menu, ) self.refresh(0.0) def enter_main_mode(self): self.curr_mode = DISPLAY_MODES.MAIN self.buttons.current_view = self self.refresh(0.0) def enter_recipe_selection(self): self.curr_mode = DISPLAY_MODES.RECIPE_SELECTION self.buttons.current_view = RecipeSelection(self, self.im_size, self.center, recipe_manager=self.recipes_manager, edit_recipe_command=self.enter_edit_recipe, run_recipe_command=self.enter_do_recipe, deactivate_command=self.enter_main_mode) 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): self.curr_mode = DISPLAY_MODES.EDIT_RECIPE self.buttons.current_view = EditRecipe(self, self.im_size, self.center, recipe_id=recipe_id, recipe_manager=self.recipes_manager, edit_step_command=self.enter_edit_step, deactivate_command=self.enter_recipe_selection) self.refresh(0.0) def enter_edit_step(self, recipe_id: int, step_idx: int): self.curr_mode = DISPLAY_MODES.EDIT_STEP self.buttons.current_view = EditStep(self, self.im_size, self.center, recipe_id=recipe_id, step_index=step_idx, recipe_manager=self.recipes_manager, deactivate_command=lambda: self.enter_edit_recipe(recipe_id)) 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 ################ def update_views(self, selected_types: DISPLAY_TYPES): # Clear only the selectable views, not the timer for v in self.views: if v.ui is not None: v.ui.destroy() self.views.clear() if selected_types & DISPLAY_TYPES.NUMBER: number_view = NumberView(self.actions, self.im_size, self.center) self.views.append(number_view) if selected_types & DISPLAY_TYPES.CIRCLE: self.weigh_view = CircleView(self.actions, self.im_size, self.center) self.views.append(self.weigh_view) def refresh(self, weight: float): ims = [] if self.curr_mode == DISPLAY_MODES.LOADING: frame, done = self.loading_view.get_frame() ims.append(frame) if done: self.curr_mode = DISPLAY_MODES.MAIN elif self.curr_mode == DISPLAY_MODES.MAIN or self.curr_mode == DISPLAY_MODES.DO_RECIPE: # Always include timer and button 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) ims.append(timer_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) text_im = self.text_view.update_weight(weight) ims.append(text_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: button_im = self.buttons.update_weight(weight) ims.append(button_im) recipe_im = self.buttons.current_view.update_weight(weight) ims.append(recipe_im) self.canvas.delete("all") # Combine images by logical_and if ims: combined_im = ims[0] for im in ims[1:]: combined_im = ImageChops.invert(ImageChops.logical_xor(combined_im, im)) # Convert PIL image to bytes buffer = io.BytesIO() combined_im.save(buffer, format='PNG') buffer.seek(0) # Load into PhotoImage and display on canvas self.photo = PhotoImage(data=buffer.getvalue()) self.canvas.create_image(0, 0, anchor="nw", image=self.photo)