speed up mock
This commit is contained in:
44
README.md
44
README.md
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
|
||||||
dy = self.center[1] - svg_cy * scale
|
|
||||||
|
|
||||||
self.p_points = self._sample_path(self._P_SEGMENTS, scale, dx, dy)
|
@staticmethod
|
||||||
self.j_points = self._sample_path(self._J_SEGMENTS, scale, dx, dy)
|
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
|
@staticmethod
|
||||||
def _sample_path(segments, scale, dx, dy, samples_per_segment=30):
|
def _sample_path(segments, scale, dx, dy, samples_per_segment=30):
|
||||||
@@ -60,39 +73,39 @@ class LoadingView:
|
|||||||
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()
|
||||||
|
|||||||
@@ -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.canvas.delete("all")
|
||||||
self.photo = PhotoImage(data=buffer.getvalue())
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user