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)