speed up mock

This commit is contained in:
2026-03-13 00:39:06 +01:00
parent bec76d6796
commit 80dccdb38a
4 changed files with 95 additions and 75 deletions

View File

@@ -13,6 +13,12 @@ uv run -m frontend
## Navigation ## Navigation
- L: left press
- LL: left long press
- R: right press
- RL: right long press
- LB: long left and right press
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> Main [*] --> Main
@@ -28,34 +34,34 @@ stateDiagram-v2
state "Edit Step Value Entry<br>(morse input)" as ValueEntry state "Edit Step Value Entry<br>(morse input)" as ValueEntry
state "Edit Step Goal Time Entry<br>(morse input)" as GoalTime state "Edit Step Goal Time Entry<br>(morse input)" as GoalTime
Main --> Menu : both long press Main --> Menu : LB
Main --> RecipeSelection : right long press Main --> RecipeSelection : RL
Menu --> Main : left long press [back] Menu --> Main : LL [back]
Menu --> BTPairing : right long press [BT Pair] Menu --> BTPairing : RL [BT Pair]
BTPairing --> Menu : left long press [cancel] BTPairing --> Menu : LL [cancel]
RecipeSelection --> Main : left long press RecipeSelection --> Main : LL
RecipeSelection --> DoRecipe : right long press [on recipe → run] RecipeSelection --> DoRecipe : RL [on recipe → run]
RecipeSelection --> EditRecipe : both long press [on recipe → edit]<br>or right long press [on + → add] RecipeSelection --> EditRecipe : LB [on recipe → edit]<br>or RL [on + → add]
DoRecipe --> Main : left long press [back]<br>or right press [last step → done] DoRecipe --> Main : LL [back]<br>or R [last step → done]
EditRecipe --> RecipeSelection : left long press [back]<br>or right long press [on save] EditRecipe --> RecipeSelection : LL [back]<br>or RL [on save]
EditRecipe --> TypeSelect : right long press [on step]<br>or right long press [on + → add step] EditRecipe --> TypeSelect : RL [on step]<br>or RL [on + → add step]
EditRecipe --> ValueEntry : right long press [on name] EditRecipe --> ValueEntry : RL [on name]
EditRecipe --> MoveMode : both long press [on step] EditRecipe --> MoveMode : LB [on step]
MoveMode --> EditRecipe : both long press [confirm]<br>or left long press [cancel] MoveMode --> EditRecipe : LB [confirm]<br>or LL [cancel]
TypeSelect --> ValueEntry : right long press [type with value] TypeSelect --> ValueEntry : RL [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] TypeSelect --> EditRecipe : RL [type w/o value → saved]<br>or RL [delete → deleted]<br>or LL [back]
ValueEntry --> EditRecipe : left long press [save]<br>or left press [cancel] ValueEntry --> EditRecipe : LL [save]<br>or L [cancel]
ValueEntry --> GoalTime : left long press [WEIGH_WITH_TIMER] ValueEntry --> GoalTime : LL [WEIGH_WITH_TIMER]
GoalTime --> EditRecipe : left long press [save]<br>or left press [cancel] GoalTime --> EditRecipe : LL [save]<br>or L [cancel]
``` ```
## Tools ## Tools

View File

@@ -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 .config import DEFAULT_CALIB_WEIGHT, DEFAULT_CALIB, DISPLAY_TYPES, MOV_AVG_DEFAULTS
from .views import MainView from .views import MainView
from .views.loading import FRAMES
class WeightApp(tk.Tk): class WeightApp(tk.Tk):
def __init__(self, weight_reader: SerialReader): def __init__(self, weight_reader: SerialReader):

View File

@@ -1,41 +1,54 @@
from PIL import Image, ImageDraw 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: class LoadingView:
"""Loading screen with animated JP logo. Circle matches CircleView.""" """Loading screen with animated JP logo. Circle matches CircleView."""
SETUP_FRAMES = 100
SETUP_FRAMES = 50
ANIMATION_FRAMES = 100 # ~1s at 50fps ANIMATION_FRAMES = 100 # ~1s at 50fps
HOLD_FRAMES = 100 # hold completed logo ~9s (total ~10s) 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): im_size = (168, 144)
self.size = size center = (168 // 2, 144 // 2)
self.center = center
self.target_radius = min(center) - radius_offset 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.frame = 0
self.total_frames = self.SETUP_FRAMES + self.ANIMATION_FRAMES + self.HOLD_FRAMES self.total_frames = self.SETUP_FRAMES + self.ANIMATION_FRAMES + self.HOLD_FRAMES
# Transform SVG coords → display coords # Pre-render all frames at init time
scale = self.target_radius / 240.0 self._cached_frames = FRAMES
svg_cx, svg_cy = 252.565, 249.335
dx = self.center[0] - svg_cx * scale @staticmethod
dy = self.center[1] - svg_cy * scale def _build_frame_cache():
frames = []
self.p_points = self._sample_path(self._P_SEGMENTS, scale, dx, dy) blank = LoadingView._render(0.0)
self.j_points = self._sample_path(self._J_SEGMENTS, scale, dx, dy) 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 @staticmethod
def _sample_path(segments, scale, dx, dy, samples_per_segment=30): def _sample_path(segments, scale, dx, dy, samples_per_segment=30):
@@ -56,43 +69,43 @@ class LoadingView:
if t < 0.5: if t < 0.5:
return 2 * t * t return 2 * t * t
return 1 - (-2 * t + 2) ** 2 / 2 return 1 - (-2 * t + 2) ** 2 / 2
def get_frame(self): def get_frame(self):
"""Return (PIL Image, is_done) advancing the animation by one frame.""" """Return (PIL Image, is_done) advancing the animation by one frame."""
if self.frame >= self.total_frames: 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: im = self._cached_frames[self.frame]
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)
self.frame += 1 self.frame += 1
return im, False return im, False
def _render(self, progress): @staticmethod
im = Image.new('1', self.size, 'white') def _render(progress):
im = Image.new('1', LoadingView.im_size, 'white')
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
# Circle outline — same as CircleView # 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 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: if p_count > 1:
pts = self.p_points[:p_count] pts = P_POINTS[:p_count]
for i in range(len(pts) - 1): for i in range(len(pts) - 1):
draw.line([pts[i], pts[i + 1]], fill='black', width=2) draw.line([pts[i], pts[i + 1]], fill='black', width=2)
# J path: reveal from end backward (center appears first, extends toward hook) # 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: if j_count > 1:
start = len(self.j_points) - j_count start = len(J_POINTS) - j_count
pts = self.j_points[start:] pts = J_POINTS[start:]
for i in range(len(pts) - 1): for i in range(len(pts) - 1):
draw.line([pts[i], pts[i + 1]], fill='black', width=2) draw.line([pts[i], pts[i + 1]], fill='black', width=2)
return im 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()

View File

@@ -1,8 +1,7 @@
import io
import tkinter as tk 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 ..config import DISPLAY_TYPES, DISPLAY_MODES
from .draw_utils import draw_clock, draw_bluetooth_icon 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, self.buttons = ButtonsManager(self, self.im_size, self.center,
curr_view=self) curr_view=self)
self.loading_view = LoadingView(self.im_size, self.center) self.loading_view = LoadingView(self.im_size, self.center)
self._prev_frame_bytes = None
self.recipes_manager = RecipeManager() self.recipes_manager = RecipeManager()
self.current_steps = [None] * 3 self.current_steps = [None] * 3
self.tare_buffer = [] self.tare_buffer = []
@@ -323,18 +323,18 @@ class MainView(tk.Frame, ButtonInterface):
ims.append(recipe_im) ims.append(recipe_im)
self.canvas.delete("all")
# Combine images by logical_and # Combine images by logical_and
if ims: if ims:
combined_im = ims[0] combined_im = ims[0]
for im in ims[1:]: for im in ims[1:]:
combined_im = ImageChops.invert(ImageChops.logical_xor(combined_im, im)) combined_im = ImageChops.invert(ImageChops.logical_xor(combined_im, im))
# Convert PIL image to bytes # Skip redraw if frame hasn't changed
buffer = io.BytesIO() frame_bytes = combined_im.tobytes()
combined_im.save(buffer, format='PNG') if frame_bytes == self._prev_frame_bytes:
buffer.seek(0) return
self._prev_frame_bytes = frame_bytes
# Load into PhotoImage and display on canvas
self.photo = PhotoImage(data=buffer.getvalue()) self.canvas.delete("all")
self.photo = ImageTk.PhotoImage(combined_im)
self.canvas.create_image(0, 0, anchor="nw", image=self.photo) self.canvas.create_image(0, 0, anchor="nw", image=self.photo)