diff --git a/README.md b/README.md index 3129be9..040d407 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,12 @@ uv run -m frontend ## Navigation +- L: left press +- LL: left long press +- R: right press +- RL: right long press +- LB: long left and right press + ```mermaid stateDiagram-v2 [*] --> Main @@ -28,34 +34,34 @@ stateDiagram-v2 state "Edit Step – Value Entry
(morse input)" as ValueEntry state "Edit Step – Goal Time Entry
(morse input)" as GoalTime - Main --> Menu : both long press - Main --> RecipeSelection : right long press + Main --> Menu : LB + Main --> RecipeSelection : RL - Menu --> Main : left long press [back] - Menu --> BTPairing : right long press [BT Pair] + Menu --> Main : LL [back] + Menu --> BTPairing : RL [BT Pair] - BTPairing --> Menu : left long press [cancel] + BTPairing --> Menu : LL [cancel] - RecipeSelection --> Main : left long press - RecipeSelection --> DoRecipe : right long press [on recipe → run] - RecipeSelection --> EditRecipe : both long press [on recipe → edit]
or right long press [on + → add] + RecipeSelection --> Main : LL + RecipeSelection --> DoRecipe : RL [on recipe → run] + RecipeSelection --> EditRecipe : LB [on recipe → edit]
or RL [on + → add] - DoRecipe --> Main : left long press [back]
or right press [last step → done] + DoRecipe --> Main : LL [back]
or R [last step → done] - EditRecipe --> RecipeSelection : left long press [back]
or right long press [on save] - EditRecipe --> TypeSelect : right long press [on step]
or right long press [on + → add step] - EditRecipe --> ValueEntry : right long press [on name] - EditRecipe --> MoveMode : both long press [on step] + EditRecipe --> RecipeSelection : LL [back]
or RL [on save] + EditRecipe --> TypeSelect : RL [on step]
or RL [on + → add step] + EditRecipe --> ValueEntry : RL [on name] + EditRecipe --> MoveMode : LB [on step] - MoveMode --> EditRecipe : both long press [confirm]
or left long press [cancel] + MoveMode --> EditRecipe : LB [confirm]
or LL [cancel] - TypeSelect --> ValueEntry : right long press [type with value] - TypeSelect --> EditRecipe : right long press [type w/o value → saved]
or right long press [delete → deleted]
or left long press [back] + TypeSelect --> ValueEntry : RL [type with value] + TypeSelect --> EditRecipe : RL [type w/o value → saved]
or RL [delete → deleted]
or LL [back] - ValueEntry --> EditRecipe : left long press [save]
or left press [cancel] - ValueEntry --> GoalTime : left long press [WEIGH_WITH_TIMER] + ValueEntry --> EditRecipe : LL [save]
or L [cancel] + ValueEntry --> GoalTime : LL [WEIGH_WITH_TIMER] - GoalTime --> EditRecipe : left long press [save]
or left press [cancel] + GoalTime --> EditRecipe : LL [save]
or L [cancel] ``` ## Tools diff --git a/frontend/__main__.py b/frontend/__main__.py index 3a9cd2a..e5be5b6 100644 --- a/frontend/__main__.py +++ b/frontend/__main__.py @@ -12,6 +12,7 @@ from python_toolkit.gui.connect import ConnectFrame from .config import DEFAULT_CALIB_WEIGHT, DEFAULT_CALIB, DISPLAY_TYPES, MOV_AVG_DEFAULTS from .views import MainView +from .views.loading import FRAMES class WeightApp(tk.Tk): def __init__(self, weight_reader: SerialReader): diff --git a/frontend/views/loading.py b/frontend/views/loading.py index ef2311c..e2282b1 100644 --- a/frontend/views/loading.py +++ b/frontend/views/loading.py @@ -1,41 +1,54 @@ from PIL import Image, ImageDraw +# SVG cubic bezier segments (absolute coordinates in 500x500 viewport) +# P path: starts at center, goes up and curves around the bump +_P_SEGMENTS = [ + ((248.462, 250.406), (248.125, 213.074), (246.529, 178.548), (246.529, 178.548)), + ((246.529, 178.548), (250.153, 120.959), (302.781, 109.091), (316.648, 109.906)), + ((316.648, 109.906), (384.748, 113.909), (403.0, 185.637), (372.292, 222.732)), +] +# J path: starts at bottom-left hook, ends at center +_J_SEGMENTS = [ + ((113.29, 351.138), (138.503, 404.984), (237.45, 410.427), (246.53, 320.295)), + ((246.53, 320.295), (248.338, 302.355), (248.69, 275.888), (248.466, 249.666)), +] class LoadingView: """Loading screen with animated JP logo. Circle matches CircleView.""" - - SETUP_FRAMES = 50 + SETUP_FRAMES = 100 ANIMATION_FRAMES = 100 # ~1s at 50fps HOLD_FRAMES = 100 # hold completed logo ~9s (total ~10s) - # SVG cubic bezier segments (absolute coordinates in 500x500 viewport) - # P path: starts at center, goes up and curves around the bump - _P_SEGMENTS = [ - ((248.462, 250.406), (248.125, 213.074), (246.529, 178.548), (246.529, 178.548)), - ((246.529, 178.548), (250.153, 120.959), (302.781, 109.091), (316.648, 109.906)), - ((316.648, 109.906), (384.748, 113.909), (403.0, 185.637), (372.292, 222.732)), - ] - # J path: starts at bottom-left hook, ends at center - _J_SEGMENTS = [ - ((113.29, 351.138), (138.503, 404.984), (237.45, 410.427), (246.53, 320.295)), - ((246.53, 320.295), (248.338, 302.355), (248.69, 275.888), (248.466, 249.666)), - ] - def __init__(self, size, center, radius_offset=11): - self.size = size - self.center = center - self.target_radius = min(center) - radius_offset + im_size = (168, 144) + center = (168 // 2, 144 // 2) + + TARGET_RADIUS = min(center) - 11 # 11px padding to match CircleView's radius_offset + SCALE = TARGET_RADIUS / 240.0 + svg_cx, svg_cy = 252.565, 249.335 + dx = center[0] - svg_cx * SCALE + dy = center[1] - svg_cy * SCALE + + def __init__(self, size, center): self.frame = 0 self.total_frames = self.SETUP_FRAMES + self.ANIMATION_FRAMES + self.HOLD_FRAMES - # Transform SVG coords → display coords - scale = self.target_radius / 240.0 - svg_cx, svg_cy = 252.565, 249.335 - dx = self.center[0] - svg_cx * scale - dy = self.center[1] - svg_cy * scale - - self.p_points = self._sample_path(self._P_SEGMENTS, scale, dx, dy) - self.j_points = self._sample_path(self._J_SEGMENTS, scale, dx, dy) + # Pre-render all frames at init time + self._cached_frames = FRAMES + + @staticmethod + def _build_frame_cache(): + frames = [] + blank = LoadingView._render(0.0) + for i in range(LoadingView.SETUP_FRAMES): + frames.append(blank) + for i in range(LoadingView.ANIMATION_FRAMES): + progress = LoadingView._ease_in_out(i / LoadingView.ANIMATION_FRAMES) + frames.append(LoadingView._render(progress)) + done = LoadingView._render(1.0) + for i in range(LoadingView.HOLD_FRAMES): + frames.append(done) + return frames @staticmethod def _sample_path(segments, scale, dx, dy, samples_per_segment=30): @@ -56,43 +69,43 @@ class LoadingView: if t < 0.5: return 2 * t * t return 1 - (-2 * t + 2) ** 2 / 2 - + def get_frame(self): """Return (PIL Image, is_done) advancing the animation by one frame.""" if self.frame >= self.total_frames: - return self._render(1.0), True + return self._cached_frames[-1], True - if self.frame >= self.SETUP_FRAMES + self.ANIMATION_FRAMES: - progress = 1.0 - elif self.frame < self.SETUP_FRAMES: - progress = 0.0 - else: - progress = self._ease_in_out((self.frame - self.SETUP_FRAMES) / self.ANIMATION_FRAMES) - - im = self._render(progress) + im = self._cached_frames[self.frame] self.frame += 1 return im, False - def _render(self, progress): - im = Image.new('1', self.size, 'white') + @staticmethod + def _render(progress): + im = Image.new('1', LoadingView.im_size, 'white') draw = ImageDraw.Draw(im) # Circle outline — same as CircleView - draw.circle(self.center, self.target_radius, outline='black') + draw.circle(LoadingView.center, LoadingView.TARGET_RADIUS, outline='black') # P path: reveal from start forward (center appears first, extends toward bump) - p_count = int(progress * len(self.p_points)) + p_count = int(progress * len(P_POINTS)) if p_count > 1: - pts = self.p_points[:p_count] + pts = P_POINTS[:p_count] for i in range(len(pts) - 1): draw.line([pts[i], pts[i + 1]], fill='black', width=2) # J path: reveal from end backward (center appears first, extends toward hook) - j_count = int(progress * len(self.j_points)) + j_count = int(progress * len(J_POINTS)) if j_count > 1: - start = len(self.j_points) - j_count - pts = self.j_points[start:] + start = len(J_POINTS) - j_count + pts = J_POINTS[start:] for i in range(len(pts) - 1): draw.line([pts[i], pts[i + 1]], fill='black', width=2) return im + + +P_POINTS = LoadingView._sample_path(_P_SEGMENTS, LoadingView.SCALE, LoadingView.dx, LoadingView.dy) +J_POINTS = LoadingView._sample_path(_J_SEGMENTS, LoadingView.SCALE, LoadingView.dx, LoadingView.dy) + +FRAMES = LoadingView._build_frame_cache() diff --git a/frontend/views/main_view.py b/frontend/views/main_view.py index 52e3b05..4b1df02 100644 --- a/frontend/views/main_view.py +++ b/frontend/views/main_view.py @@ -1,8 +1,7 @@ -import io import tkinter as tk -from tkinter import Frame, Canvas, ttk, PhotoImage +from tkinter import Frame, Canvas, ttk -from PIL import ImageChops +from PIL import ImageChops, ImageTk from ..config import DISPLAY_TYPES, DISPLAY_MODES from .draw_utils import draw_clock, draw_bluetooth_icon @@ -52,6 +51,7 @@ class MainView(tk.Frame, ButtonInterface): self.buttons = ButtonsManager(self, self.im_size, self.center, curr_view=self) self.loading_view = LoadingView(self.im_size, self.center) + self._prev_frame_bytes = None self.recipes_manager = RecipeManager() self.current_steps = [None] * 3 self.tare_buffer = [] @@ -323,18 +323,18 @@ class MainView(tk.Frame, ButtonInterface): 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()) + + # Skip redraw if frame hasn't changed + frame_bytes = combined_im.tobytes() + if frame_bytes == self._prev_frame_bytes: + return + self._prev_frame_bytes = frame_bytes + + self.canvas.delete("all") + self.photo = ImageTk.PhotoImage(combined_im) self.canvas.create_image(0, 0, anchor="nw", image=self.photo)