From d6ab418c2c295ed84f575cce72d41a0813d913be Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 17 May 2026 08:33:16 -0400 Subject: [PATCH 1/6] Animated wifi menu --- images/wifi_processing_1.png | Bin 0 -> 382 bytes images/wifi_processing_2.png | Bin 0 -> 381 bytes images/wifi_processing_3.png | Bin 0 -> 355 bytes modalapi/modhandler.py | 1 + pistomp/lcd320x240.py | 30 ++++++---- tests/test_animated_image.py | 110 +++++++++++++++++++++++++++++++++++ tests/test_lcd320x240.py | 40 ++++++++----- uilib/__init__.py | 1 + uilib/animated_image.py | 73 +++++++++++++++++++++++ uilib/image.py | 19 +++--- 10 files changed, 243 insertions(+), 31 deletions(-) create mode 100644 images/wifi_processing_1.png create mode 100644 images/wifi_processing_2.png create mode 100644 images/wifi_processing_3.png create mode 100644 tests/test_animated_image.py create mode 100644 uilib/animated_image.py diff --git a/images/wifi_processing_1.png b/images/wifi_processing_1.png new file mode 100644 index 0000000000000000000000000000000000000000..c7c54955b620f1448ae147766a9946d90e410bda GIT binary patch literal 382 zcmV-^0fGLBP)r{Re7WoM_>1|V_FuOk>MG7rK zKnQArm}H}2!YtgpxUZW71NUR*+%soxqoG zVh3~j!x?t*1~(<)3mjp#NdAov_=xj7o4^8|<57|ODaEvca2 z42IPshH#n>{3E%z@a!tr_dYoAfQmqng#{5YtXlL#_+RLUFxm|B cBVt{PKa;IZz_u#}$ILJ)L8My%m4%@lWD1;D*tt6m{ zVC-lpEaCC!e%&6ZN8L-^Q+3|Cw|3vs4HKBcBt|jtldXETD-~TnFJCUZI|cg5U>!;a`tQhf>Y+ z!qm(77KF1&c8Bk1>yK!3k=!r7gzxaFU{?Icz380lH+YNbTzBvRyWOr$pp|Zga~s%7xkZG->(~qv za8NbjW0RqDy9$jor&B&$MyFKt42zk6%@?9q!+M`70znoRIO27`;z0HX1v*m b$Sd(5>=;TH`cbM^00000NkvXXu0mjf@lmeM literal 0 HcmV?d00001 diff --git a/images/wifi_processing_3.png b/images/wifi_processing_3.png new file mode 100644 index 0000000000000000000000000000000000000000..c197386d2143aa7a5d5ef63b0efd51ed0d0924b2 GIT binary patch literal 355 zcmV-p0i6DcP)lHx9}y|x1$eV6 ztaQow60ivLfi7?gWFp)Ec7TS9L~sS%0iQFJ&Uu>6K=l9^i?Cy|DR2SY@H^CK;^+d~ z=IA8F@O$jOy~u*1HAfmPMT@w1lvpwGScEY|uxGF8@Cuy8n+~@uw1EQ~E`0cj$XD!D z%XMma2#kP-S%jI3wY^I}2YDN!;YIfRY_d9qxxH-rTZO6rrB;^5-C|5UMu}tKT!sTj z!Trd&gs56d3=!#_mgn8N#!e0naf{}Hu#TP 0: - img = "wifi_processing.png" - elif util.DICT_GET(wifi_status, 'hotspot_active'): + if not self.w_wifi.is_playing: + self.w_wifi.play() + else: + self.w_wifi.stop(self._resolved_wifi_png(wifi_status)) + + def _resolved_wifi_png(self, wifi_status): + if util.DICT_GET(wifi_status, 'hotspot_active'): img = "wifi_orange.png" elif util.DICT_GET(wifi_status, 'wifi_connected'): img = "wifi_silver.png" else: img = "wifi_gray.png" - image_path = os.path.join(self.imagedir, img) - if image_path == self._wifi_img_path: - return - self._wifi_img_path = image_path - self.w_wifi.replace_img(image_path) + return os.path.join(self.imagedir, img) + + def tick_wifi(self): + if self.w_wifi is not None: + self.w_wifi.tick() def update_eq(self, eq_status): pass diff --git a/tests/test_animated_image.py b/tests/test_animated_image.py new file mode 100644 index 00000000..6ac380db --- /dev/null +++ b/tests/test_animated_image.py @@ -0,0 +1,110 @@ +import os +from unittest.mock import patch + +import pytest + +from uilib.animated_image import AnimatedImageWidget +from uilib.box import Box + + +IMAGES = os.path.join(os.path.dirname(__file__), "..", "images") +STATIC = os.path.join(IMAGES, "wifi_gray.png") +FRAMES = [os.path.join(IMAGES, f"wifi_processing_{i}.png") for i in range(1, 4)] + + +def make_widget(frame_paths=FRAMES): + return AnimatedImageWidget( + static_path=STATIC, frame_paths=frame_paths, box=Box.xywh(0, 0, 20, 20) + ) + + +def test_tick_on_stopped_widget_is_noop(): + w = make_widget() + with patch.object(w, "refresh") as mock_refresh: + w.tick() + w.tick() + mock_refresh.assert_not_called() + + +def test_play_then_tick_advances_and_refreshes(): + w = make_widget() + w.play() + with patch.object(w, "refresh") as mock_refresh: + w.tick() + assert w.image is w._frames[1] + w.tick() + assert w.image is w._frames[2] + assert mock_refresh.call_count == 2 + + +def test_tick_wraps_around(): + w = make_widget() + w.play() + for _ in range(len(FRAMES)): + w.tick() + # After n ticks of n frames, idx wraps to 0 + assert w._frame_idx == 0 + assert w.image is w._frames[0] + + +def test_stop_same_path_does_not_refresh(): + w = make_widget() + with patch.object(w, "refresh") as mock_refresh: + w.stop(STATIC) + mock_refresh.assert_not_called() + assert not w.is_playing + + +def test_stop_different_path_refreshes_once(): + w = make_widget() + with patch.object(w, "refresh") as mock_refresh: + w.stop(os.path.join(IMAGES, "wifi_silver.png")) + mock_refresh.assert_called_once() + + +def test_play_twice_is_noop(): + w = make_widget() + w.play() + w._frame_idx = 2 + w.play() # second call must not reset + assert w._frame_idx == 2 + + +def test_empty_frames_tick_is_safe_even_when_playing(): + w = make_widget(frame_paths=()) + w.play() + with patch.object(w, "refresh") as mock_refresh: + w.tick() + mock_refresh.assert_not_called() + + +def test_mismatched_frame_size_raises(): + bad = os.path.join(IMAGES, "wifi_silver.png") # same size, so use a deliberately mismatched one + # Find some other image that differs in size + from PIL import Image + base_size = Image.open(STATIC).size + # Search for a differently-sized image + other = None + for fname in os.listdir(IMAGES): + p = os.path.join(IMAGES, fname) + if not fname.lower().endswith(".png"): + continue + try: + if Image.open(p).size != base_size: + other = p + break + except Exception: + continue + if other is None: + pytest.skip("no differently-sized image available to test mismatch") + with pytest.raises(ValueError): + AnimatedImageWidget( + static_path=STATIC, frame_paths=[other], box=Box.xywh(0, 0, 20, 20) + ) + + +def test_replace_img_path_equality_guard(): + w = make_widget() + with patch.object(w, "refresh") as mock_refresh: + w.replace_img(STATIC) # same as ctor path + mock_refresh.assert_not_called() diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py index 7ad736d9..e8179a40 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -159,27 +159,39 @@ def test_update_footswitch_on_snapshot(lcd, snapshot): snapshot() -@pytest.mark.parametrize("pending,status,expected", [ - (1, {"wifi_connected": True, "hotspot_active": False}, "wifi_processing.png"), - (1, {"wifi_connected": False, "hotspot_active": True}, "wifi_processing.png"), - (1, {"wifi_connected": False, "hotspot_active": False}, "wifi_processing.png"), - (0, {"wifi_connected": False, "hotspot_active": True}, "wifi_orange.png"), - (0, {"wifi_connected": True, "hotspot_active": False}, "wifi_silver.png"), - (0, {"wifi_connected": False, "hotspot_active": False}, "wifi_gray.png"), +@pytest.mark.parametrize("status,expected", [ + ({"wifi_connected": False, "hotspot_active": True}, "wifi_orange.png"), + ({"wifi_connected": True, "hotspot_active": False}, "wifi_silver.png"), + ({"wifi_connected": False, "hotspot_active": False}, "wifi_gray.png"), ]) -def test_update_wifi_icon_selection(lcd, mock_handler, pending, status, expected): - """Icon precedence: pending > hotspot > connected > disconnected.""" +def test_update_wifi_idle_icon_selection(lcd, mock_handler, status, expected): + """When no ops pending, icon resolves to hotspot/connected/disconnected.""" instance, _ = lcd - mock_handler.wifi_manager.queue.pending_op_count.return_value = pending + mock_handler.wifi_manager.queue.pending_op_count.return_value = 0 instance.draw_tools() # creates w_wifi + # Start from a known different path so stop() triggers a replace_img. + import os + instance.w_wifi.stop(os.path.join(instance.imagedir, "wifi_processing_1.png")) with patch.object(instance.w_wifi, "replace_img") as mock_replace: - # Force first call to register by clearing the path cache. - instance._wifi_img_path = None instance.update_wifi(status) mock_replace.assert_called_once() assert mock_replace.call_args[0][0].endswith(expected) +@pytest.mark.parametrize("status", [ + {"wifi_connected": True, "hotspot_active": False}, + {"wifi_connected": False, "hotspot_active": True}, + {"wifi_connected": False, "hotspot_active": False}, +]) +def test_update_wifi_pending_plays_animation(lcd, mock_handler, status): + """When ops are pending, the widget animates regardless of status.""" + instance, _ = lcd + mock_handler.wifi_manager.queue.pending_op_count.return_value = 1 + instance.draw_tools() + instance.update_wifi(status) + assert instance.w_wifi.is_playing + + def test_update_wifi_noop_when_path_unchanged(lcd, mock_handler): """Repeated update_wifi calls with same status don't re-blit the icon.""" instance, _ = lcd @@ -187,10 +199,10 @@ def test_update_wifi_noop_when_path_unchanged(lcd, mock_handler): instance.draw_tools() status = {"wifi_connected": True, "hotspot_active": False} instance.update_wifi(status) # first call sets path - with patch.object(instance.w_wifi, "replace_img") as mock_replace: + with patch.object(instance.w_wifi, "refresh") as mock_refresh: instance.update_wifi(status) instance.update_wifi(status) - mock_replace.assert_not_called() + mock_refresh.assert_not_called() def test_tap_tempo_snapshot(lcd, snapshot): diff --git a/uilib/__init__.py b/uilib/__init__.py index ac7f6501..7a0a81d5 100644 --- a/uilib/__init__.py +++ b/uilib/__init__.py @@ -21,6 +21,7 @@ from uilib.panel import * from uilib.text import * from uilib.image import * +from uilib.animated_image import * from uilib.menu import * from uilib.dialog import * from uilib.config import * diff --git a/uilib/animated_image.py b/uilib/animated_image.py new file mode 100644 index 00000000..340fc5e6 --- /dev/null +++ b/uilib/animated_image.py @@ -0,0 +1,73 @@ +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pi-stomp. If not, see . + +from PIL import Image + +from uilib.image import ImageWidget + + +class AnimatedImageWidget(ImageWidget): + """ImageWidget that can also cycle through a pre-loaded set of frames. + + Externally driven via tick() — no internal timer. tick() on a stopped widget + or on a widget with no frames is a no-op, so callers can poll blindly. + + ticks_per_frame controls how many tick() calls a frame stays on screen for. + """ + + def __init__(self, static_path, frame_paths=(), ticks_per_frame=1, **kwargs): + super().__init__(static_path, **kwargs) + self._frames = [Image.open(p) for p in frame_paths] + if self._frames: + base = self.image.size + for p, f in zip(frame_paths, self._frames): + if f.size != base: + raise ValueError( + f"AnimatedImageWidget frame {p} size {f.size} does not match static image size {base}" + ) + if ticks_per_frame < 1: + raise ValueError("ticks_per_frame must be >= 1") + self._ticks_per_frame = ticks_per_frame + self._frame_idx = 0 + self._tick_count = 0 + self._playing = False + + def play(self): + if self._playing: + return + self._playing = True + self._frame_idx = 0 + self._tick_count = 0 + + def stop(self, static_path): + """Stop animating and show static_path. Caller-supplied because the + right resolved frame is a domain decision.""" + self._playing = False + self.replace_img(static_path) + + def tick(self): + if not self._playing or not self._frames: + return + self._tick_count += 1 + if self._tick_count < self._ticks_per_frame: + return + self._tick_count = 0 + self._frame_idx = (self._frame_idx + 1) % len(self._frames) + self.image = self._frames[self._frame_idx] + self.refresh() + + @property + def is_playing(self): + return self._playing diff --git a/uilib/image.py b/uilib/image.py index cbb4262b..43659984 100644 --- a/uilib/image.py +++ b/uilib/image.py @@ -13,30 +13,35 @@ # You should have received a copy of the GNU General Public License # along with pi-stomp. If not, see . -from uilib.widget import * +from uilib.widget import Widget from PIL import Image + class ImageWidget(Widget): """A simple widget with an image""" + def __init__(self, image_path, **kwargs): self._init_attrs(Widget.INH_ATTRS, kwargs) - super(ImageWidget,self).__init__(**kwargs) + super(ImageWidget, self).__init__(**kwargs) + self._image_path = image_path self.image = Image.open(image_path) def _draw(self, image, draw, real_box): # XXX TODO Centre and crop it ? For now just centre. XXX Assume box > image size, # this needs to be cleaned up and made shinnier, possibly with a Box() helper - width,height = self.image.size + width, height = self.image.size offx = int((real_box.width - width) / 2) offy = int((real_box.height - height) / 2) - loc = real_box.offset((offx,offy)).topleft - + loc = real_box.offset((offx, offy)).topleft + # Draw image - mask = self.image if self.image.mode == 'RGBA' else None + mask = self.image if self.image.mode == "RGBA" else None image.paste(self.image, loc, mask) def replace_img(self, image_path): # XXX Note that the new image must be the same size as the original + if image_path == self._image_path: + return + self._image_path = image_path self.image = Image.open(image_path) self.refresh() - From b64eb59b529f2f2fce38df1b198c49d38f4ebdfb Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 17 May 2026 08:34:59 -0400 Subject: [PATCH 2/6] Swap processing 1/3 --- images/wifi_processing.png | Bin 387 -> 0 bytes images/wifi_processing_1.png | Bin 382 -> 355 bytes images/wifi_processing_3.png | Bin 355 -> 382 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 images/wifi_processing.png diff --git a/images/wifi_processing.png b/images/wifi_processing.png deleted file mode 100644 index c86420172bd6a66b2c0f49644a72b2e2e2e84199..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 387 zcmV-}0et?6P)Nkl9BB?>JDYEq<-3GSi-ZhB8|X$au4dYe2vbXjFENA0>~IPn@#lhM z%^1WOI%9T#*U4}VJvhWD-j=;oP>U0eRNgDH$2BuJ}eENVm_{t*u^5g$G!`XaXt1&af{D+2RX@p6YUtsJM0yj zYj}l8Y^C0+5++*E6t7)H`d9yACm*KrOLhvgC-@m@2D14^Y3722CCTV^w(h7BpO^5z hxQyY8?8<3f$r;YHRYL)9dOrXF002ovPDHLkV1ky{tJMGi diff --git a/images/wifi_processing_1.png b/images/wifi_processing_1.png index c7c54955b620f1448ae147766a9946d90e410bda..c197386d2143aa7a5d5ef63b0efd51ed0d0924b2 100644 GIT binary patch delta 328 zcmV-O0k{7C0^vXc(W?3bjkS=un6>lE^rEDB7fWfc7TS9L~sS%0iQFJ z&Uu>6K=l9^i?Cy|DR2SY@H^CK;^+d~=IA8F@O$jOy~u*1HAfmPMT@w1lvpwGScEY| zuxGF8@Cuy8n+~@uw1EQ~E`0cj$XD!D%XMma2#kP-S%jI3wY^I}2YDN!;YIfRY_d9q zxxH-rTZO6rr9oDf$K7H~JVuFQ;9Q0SN5TEbxrC@%N(>R{otEd_y5uT_|H5AgPlAzk auEiHZX-Qlg74>TX0000k4RMlF;CL42IPshH#n>{3E%N#dL2gIRpH)X)1lcE-&wExtU+0tEv}<@@sYKTJE;lsn{@`>+tL< z*Y`d+@PLXykc9;iF|1njL-=3lhcMa<^CMzii$9aCO~8F==^Fq5002ovPDHLkV1h|U Bsv!UX diff --git a/images/wifi_processing_3.png b/images/wifi_processing_3.png index c197386d2143aa7a5d5ef63b0efd51ed0d0924b2..c7c54955b620f1448ae147766a9946d90e410bda 100644 GIT binary patch delta 355 zcmV-p0i6Eh0{#M!B!5LoL_t(|oR!i&OPo;<1>k4RMlF;CL42IPshH#n>{3E%N#dL2gIRpH)X)1lcE-&wExtU+0tEv}<@@sYKTJE;lsn{@`>+tL< z*Y`d+@PLXykc9;iF|1njL-=3lhcMa<^CMzii$9aCO~8F==^Fq5002ovPDHLkV1h|U Bsv!UX delta 328 zcmV-O0k{7C0^vXc(W?3bjkS=un6>lE^rEDB7fWfc7TS9L~sS%0iQFJ z&Uu>6K=l9^i?Cy|DR2SY@H^CK;^+d~=IA8F@O$jOy~u*1HAfmPMT@w1lvpwGScEY| zuxGF8@Cuy8n+~@uw1EQ~E`0cj$XD!D%XMma2#kP-S%jI3wY^I}2YDN!;YIfRY_d9q zxxH-rTZO6rr9oDf$K7H~JVuFQ;9Q0SN5TEbxrC@%N(>R{otEd_y5uT_|H5AgPlAzk auEiHZX-Qlg74>TX0000 Date: Sun, 17 May 2026 08:35:52 -0400 Subject: [PATCH 3/6] Faster animation --- pistomp/lcd320x240.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 013af387..6b7d1445 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -181,7 +181,7 @@ def draw_tools(self, wifi_type=None, eq_type=None, bypass_type=None, system_type box=Box.xywh(210, 0, 20, 20), static_path=os.path.join(self.imagedir, 'wifi_gray.png'), frame_paths=[os.path.join(self.imagedir, f'wifi_processing_{i}.png') for i in range(1, 4)], - ticks_per_frame=4, + ticks_per_frame=2, parent=self.main_panel, action=self.wifi_menu.open, ) From 32baa214844e8af26f6f91d8d1b184d103563c6a Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 17 May 2026 08:57:43 -0400 Subject: [PATCH 4/6] Simpler approach --- modalapi/modhandler.py | 1 - pistomp-testui.py | 6 +- pistomp/lcd320x240.py | 38 +++++++----- tests/test_animated_image.py | 110 ----------------------------------- tests/test_lcd320x240.py | 64 +++++++++++++++++--- uilib/__init__.py | 1 - uilib/animated_image.py | 73 ----------------------- uilib/image.py | 30 +++++++--- 8 files changed, 105 insertions(+), 218 deletions(-) delete mode 100644 tests/test_animated_image.py delete mode 100644 uilib/animated_image.py diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index a3484ce0..a43152aa 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -237,7 +237,6 @@ def poll_system_info(self): def poll_lcd_updates(self): if self._lcd is not None: self._lcd.update_wifi(self.wifi_status) - self._lcd.tick_wifi() self._lcd.poll_updates() def universal_encoder_select(self, direction): diff --git a/pistomp-testui.py b/pistomp-testui.py index 5859de9a..0dd7b0ac 100755 --- a/pistomp-testui.py +++ b/pistomp-testui.py @@ -91,11 +91,11 @@ def do_main_screen(): p = Panel(box = Box.xywh(0,0,display_width,180)) # toolbar - wifi = ImageWidget(box=Box.xywh(240,0,20,20), image_path='./images/wifi_orange.png', parent=p, action=do_wifi_dialog) + wifi = ImageWidget(box=Box.xywh(240,0,20,20), image='./images/wifi_orange.png', parent=p, action=do_wifi_dialog) p.add_sel_widget(wifi) - power = ImageWidget(box=Box.xywh(270,0,20,20), image_path='./images/power_green.png', parent=p) + power = ImageWidget(box=Box.xywh(270,0,20,20), image='./images/power_green.png', parent=p) p.add_sel_widget(power) - wrench = ImageWidget(box=Box.xywh(296,0,20,20), image_path='./images/wrench_silver.png', parent=p, + wrench = ImageWidget(box=Box.xywh(296,0,20,20), image='./images/wrench_silver.png', parent=p, action=do_menu) p.add_sel_widget(wrench) diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 6b7d1445..0040064d 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -24,7 +24,7 @@ import pistomp.category as Category import pistomp.lcd as abstract_lcd import pistomp.switchstate as switchstate -from PIL import ImageColor +from PIL import Image, ImageColor from uilib import * from uilib.lcd_ili9341 import * @@ -93,6 +93,10 @@ def __init__(self, cwd, handler=None, flip=False): # widgets self.w_wifi = None + self._wifi_frames: list[Image.Image] = [] + self._wifi_frame_idx = 0 + self._wifi_tick_count = 0 + self._wifi_ticks_per_frame = 2 self.wifi_menu: Optional[WifiMenu] = None self.w_eq = None self.w_power = None @@ -177,24 +181,27 @@ def poll_updates(self): def draw_tools(self, wifi_type=None, eq_type=None, bypass_type=None, system_type=None): if self.w_wifi is not None: return - self.w_wifi = AnimatedImageWidget( + self._wifi_frames = [] + for i in range(1, 4): + frame = Image.open(os.path.join(self.imagedir, f'wifi_processing_{i}.png')) + frame.load() # force decode now so animation never blocks on disk I/O + self._wifi_frames.append(frame) + self.w_wifi = ImageWidget( box=Box.xywh(210, 0, 20, 20), - static_path=os.path.join(self.imagedir, 'wifi_gray.png'), - frame_paths=[os.path.join(self.imagedir, f'wifi_processing_{i}.png') for i in range(1, 4)], - ticks_per_frame=2, + image=os.path.join(self.imagedir, 'wifi_gray.png'), parent=self.main_panel, action=self.wifi_menu.open, ) self.main_panel.add_sel_widget(self.w_wifi) if self.w_eq is not None: return - self.w_eq = ImageWidget(box=Box.xywh(240, 0, 20, 20), image_path=os.path.join(self.imagedir, + self.w_eq = ImageWidget(box=Box.xywh(240, 0, 20, 20), image=os.path.join(self.imagedir, 'eq_blue.png'), parent=self.main_panel, action=self.draw_audio_menu) self.main_panel.add_sel_widget(self.w_eq) - self.w_power = ImageWidget(box=Box.xywh(270, 0, 20, 20), image_path=os.path.join(self.imagedir, + self.w_power = ImageWidget(box=Box.xywh(270, 0, 20, 20), image=os.path.join(self.imagedir, 'power_gray.png'), parent=self.main_panel, action=self.toggle_bypass) self.main_panel.add_sel_widget(self.w_power) - self.w_wrench = ImageWidget(box=Box.xywh(296, 0, 20, 20), image_path=os.path.join(self.imagedir, + self.w_wrench = ImageWidget(box=Box.xywh(296, 0, 20, 20), image=os.path.join(self.imagedir, 'wrench_silver.png'), parent=self.main_panel, action=self.draw_system_menu) self.main_panel.add_sel_widget(self.w_wrench) @@ -610,10 +617,15 @@ def update_wifi(self, wifi_status): if self.w_wifi is None: return if self.handler.wifi_manager.queue.pending_op_count() > 0: - if not self.w_wifi.is_playing: - self.w_wifi.play() + self._wifi_tick_count += 1 + if self._wifi_tick_count >= self._wifi_ticks_per_frame: + self._wifi_tick_count = 0 + self._wifi_frame_idx = (self._wifi_frame_idx + 1) % len(self._wifi_frames) + self.w_wifi.replace_img(self._wifi_frames[self._wifi_frame_idx]) else: - self.w_wifi.stop(self._resolved_wifi_png(wifi_status)) + self._wifi_frame_idx = 0 + self._wifi_tick_count = 0 + self.w_wifi.replace_img(self._resolved_wifi_png(wifi_status)) def _resolved_wifi_png(self, wifi_status): if util.DICT_GET(wifi_status, 'hotspot_active'): @@ -624,10 +636,6 @@ def _resolved_wifi_png(self, wifi_status): img = "wifi_gray.png" return os.path.join(self.imagedir, img) - def tick_wifi(self): - if self.w_wifi is not None: - self.w_wifi.tick() - def update_eq(self, eq_status): pass diff --git a/tests/test_animated_image.py b/tests/test_animated_image.py deleted file mode 100644 index 6ac380db..00000000 --- a/tests/test_animated_image.py +++ /dev/null @@ -1,110 +0,0 @@ -import os -from unittest.mock import patch - -import pytest - -from uilib.animated_image import AnimatedImageWidget -from uilib.box import Box - - -IMAGES = os.path.join(os.path.dirname(__file__), "..", "images") -STATIC = os.path.join(IMAGES, "wifi_gray.png") -FRAMES = [os.path.join(IMAGES, f"wifi_processing_{i}.png") for i in range(1, 4)] - - -def make_widget(frame_paths=FRAMES): - return AnimatedImageWidget( - static_path=STATIC, frame_paths=frame_paths, box=Box.xywh(0, 0, 20, 20) - ) - - -def test_tick_on_stopped_widget_is_noop(): - w = make_widget() - with patch.object(w, "refresh") as mock_refresh: - w.tick() - w.tick() - mock_refresh.assert_not_called() - - -def test_play_then_tick_advances_and_refreshes(): - w = make_widget() - w.play() - with patch.object(w, "refresh") as mock_refresh: - w.tick() - assert w.image is w._frames[1] - w.tick() - assert w.image is w._frames[2] - assert mock_refresh.call_count == 2 - - -def test_tick_wraps_around(): - w = make_widget() - w.play() - for _ in range(len(FRAMES)): - w.tick() - # After n ticks of n frames, idx wraps to 0 - assert w._frame_idx == 0 - assert w.image is w._frames[0] - - -def test_stop_same_path_does_not_refresh(): - w = make_widget() - with patch.object(w, "refresh") as mock_refresh: - w.stop(STATIC) - mock_refresh.assert_not_called() - assert not w.is_playing - - -def test_stop_different_path_refreshes_once(): - w = make_widget() - with patch.object(w, "refresh") as mock_refresh: - w.stop(os.path.join(IMAGES, "wifi_silver.png")) - mock_refresh.assert_called_once() - - -def test_play_twice_is_noop(): - w = make_widget() - w.play() - w._frame_idx = 2 - w.play() # second call must not reset - assert w._frame_idx == 2 - - -def test_empty_frames_tick_is_safe_even_when_playing(): - w = make_widget(frame_paths=()) - w.play() - with patch.object(w, "refresh") as mock_refresh: - w.tick() - mock_refresh.assert_not_called() - - -def test_mismatched_frame_size_raises(): - bad = os.path.join(IMAGES, "wifi_silver.png") # same size, so use a deliberately mismatched one - # Find some other image that differs in size - from PIL import Image - base_size = Image.open(STATIC).size - # Search for a differently-sized image - other = None - for fname in os.listdir(IMAGES): - p = os.path.join(IMAGES, fname) - if not fname.lower().endswith(".png"): - continue - try: - if Image.open(p).size != base_size: - other = p - break - except Exception: - continue - if other is None: - pytest.skip("no differently-sized image available to test mismatch") - with pytest.raises(ValueError): - AnimatedImageWidget( - static_path=STATIC, frame_paths=[other], box=Box.xywh(0, 0, 20, 20) - ) - - -def test_replace_img_path_equality_guard(): - w = make_widget() - with patch.object(w, "refresh") as mock_refresh: - w.replace_img(STATIC) # same as ctor path - mock_refresh.assert_not_called() diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py index e8179a40..d7266148 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -169,9 +169,6 @@ def test_update_wifi_idle_icon_selection(lcd, mock_handler, status, expected): instance, _ = lcd mock_handler.wifi_manager.queue.pending_op_count.return_value = 0 instance.draw_tools() # creates w_wifi - # Start from a known different path so stop() triggers a replace_img. - import os - instance.w_wifi.stop(os.path.join(instance.imagedir, "wifi_processing_1.png")) with patch.object(instance.w_wifi, "replace_img") as mock_replace: instance.update_wifi(status) mock_replace.assert_called_once() @@ -183,13 +180,66 @@ def test_update_wifi_idle_icon_selection(lcd, mock_handler, status, expected): {"wifi_connected": False, "hotspot_active": True}, {"wifi_connected": False, "hotspot_active": False}, ]) -def test_update_wifi_pending_plays_animation(lcd, mock_handler, status): - """When ops are pending, the widget animates regardless of status.""" +def test_update_wifi_pending_shows_frame(lcd, mock_handler, status): + """When ops are pending, the widget shows a preloaded animation frame.""" instance, _ = lcd mock_handler.wifi_manager.queue.pending_op_count.return_value = 1 instance.draw_tools() - instance.update_wifi(status) - assert instance.w_wifi.is_playing + with patch.object(instance.w_wifi, "replace_img") as mock_replace: + instance.update_wifi(status) + mock_replace.assert_called_once() + # Argument must be one of the preloaded PIL.Image frames, not a path. + assert mock_replace.call_args[0][0] in instance._wifi_frames + + +def test_update_wifi_pending_cycles_frames(lcd, mock_handler): + """Consecutive pending updates advance through frames at ticks_per_frame cadence and wrap.""" + instance, _ = lcd + mock_handler.wifi_manager.queue.pending_op_count.return_value = 1 + instance.draw_tools() + status = {"wifi_connected": True, "hotspot_active": False} + seen = [] + n_frames = len(instance._wifi_frames) + # Drive 2*n_frames frame-advances so we see at least one wrap. + for _ in range(n_frames * 2 * instance._wifi_ticks_per_frame): + with patch.object(instance.w_wifi, "replace_img") as mock_replace: + instance.update_wifi(status) + seen.append(mock_replace.call_args[0][0]) + # The visited frames should cycle through every preloaded frame. + visited = {instance._wifi_frames.index(f) for f in seen} + assert visited == set(range(n_frames)) + + +def test_update_wifi_pending_to_idle_resets_animation(lcd, mock_handler): + """Leaving the pending state resets frame counters and shows the resolved idle image.""" + instance, _ = lcd + instance.draw_tools() + status = {"wifi_connected": True, "hotspot_active": False} + # Drive the animation forward a few frames. + mock_handler.wifi_manager.queue.pending_op_count.return_value = 1 + for _ in range(instance._wifi_ticks_per_frame * 2): + instance.update_wifi(status) + assert instance._wifi_frame_idx != 0 or instance._wifi_tick_count != 0 + # Now clear pending. + mock_handler.wifi_manager.queue.pending_op_count.return_value = 0 + with patch.object(instance.w_wifi, "replace_img") as mock_replace: + instance.update_wifi(status) + mock_replace.assert_called_once() + assert mock_replace.call_args[0][0].endswith("wifi_silver.png") + assert instance._wifi_frame_idx == 0 + assert instance._wifi_tick_count == 0 + + +def test_wifi_frames_are_preloaded(lcd): + """Frames are decoded once at draw_tools time, not opened on every update.""" + instance, _ = lcd + instance.draw_tools() + assert len(instance._wifi_frames) == 3 + from PIL import Image as PILImage + for f in instance._wifi_frames: + assert isinstance(f, PILImage.Image) + # .load() populates the .im attribute; absence means lazy/closed. + assert f.im is not None def test_update_wifi_noop_when_path_unchanged(lcd, mock_handler): diff --git a/uilib/__init__.py b/uilib/__init__.py index 7a0a81d5..ac7f6501 100644 --- a/uilib/__init__.py +++ b/uilib/__init__.py @@ -21,7 +21,6 @@ from uilib.panel import * from uilib.text import * from uilib.image import * -from uilib.animated_image import * from uilib.menu import * from uilib.dialog import * from uilib.config import * diff --git a/uilib/animated_image.py b/uilib/animated_image.py deleted file mode 100644 index 340fc5e6..00000000 --- a/uilib/animated_image.py +++ /dev/null @@ -1,73 +0,0 @@ -# This file is part of pi-stomp. -# -# pi-stomp is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# pi-stomp is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with pi-stomp. If not, see . - -from PIL import Image - -from uilib.image import ImageWidget - - -class AnimatedImageWidget(ImageWidget): - """ImageWidget that can also cycle through a pre-loaded set of frames. - - Externally driven via tick() — no internal timer. tick() on a stopped widget - or on a widget with no frames is a no-op, so callers can poll blindly. - - ticks_per_frame controls how many tick() calls a frame stays on screen for. - """ - - def __init__(self, static_path, frame_paths=(), ticks_per_frame=1, **kwargs): - super().__init__(static_path, **kwargs) - self._frames = [Image.open(p) for p in frame_paths] - if self._frames: - base = self.image.size - for p, f in zip(frame_paths, self._frames): - if f.size != base: - raise ValueError( - f"AnimatedImageWidget frame {p} size {f.size} does not match static image size {base}" - ) - if ticks_per_frame < 1: - raise ValueError("ticks_per_frame must be >= 1") - self._ticks_per_frame = ticks_per_frame - self._frame_idx = 0 - self._tick_count = 0 - self._playing = False - - def play(self): - if self._playing: - return - self._playing = True - self._frame_idx = 0 - self._tick_count = 0 - - def stop(self, static_path): - """Stop animating and show static_path. Caller-supplied because the - right resolved frame is a domain decision.""" - self._playing = False - self.replace_img(static_path) - - def tick(self): - if not self._playing or not self._frames: - return - self._tick_count += 1 - if self._tick_count < self._ticks_per_frame: - return - self._tick_count = 0 - self._frame_idx = (self._frame_idx + 1) % len(self._frames) - self.image = self._frames[self._frame_idx] - self.refresh() - - @property - def is_playing(self): - return self._playing diff --git a/uilib/image.py b/uilib/image.py index 43659984..eb401780 100644 --- a/uilib/image.py +++ b/uilib/image.py @@ -20,11 +20,19 @@ class ImageWidget(Widget): """A simple widget with an image""" - def __init__(self, image_path, **kwargs): + image: Image.Image + _image_path: str | None + + def __init__(self, image: str | Image.Image, **kwargs): self._init_attrs(Widget.INH_ATTRS, kwargs) super(ImageWidget, self).__init__(**kwargs) - self._image_path = image_path - self.image = Image.open(image_path) + + if isinstance(image, str): + self._image_path = image + self.image = Image.open(self._image_path) + else: + self._image_path = None + self.image = image def _draw(self, image, draw, real_box): # XXX TODO Centre and crop it ? For now just centre. XXX Assume box > image size, @@ -38,10 +46,16 @@ def _draw(self, image, draw, real_box): mask = self.image if self.image.mode == "RGBA" else None image.paste(self.image, loc, mask) - def replace_img(self, image_path): + def replace_img(self, image: str | Image.Image): # XXX Note that the new image must be the same size as the original - if image_path == self._image_path: - return - self._image_path = image_path - self.image = Image.open(image_path) + if isinstance(image, str): + if image == self._image_path: + return + self._image_path = image + self.image = Image.open(image) + else: + if self.image is image: + return + self._image_path = None + self.image = image self.refresh() From f5123bb1cfbe075ac4c39cad7032e8492e8e48d2 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 17 May 2026 09:06:01 -0400 Subject: [PATCH 5/6] Simpler --- pistomp/lcd320x240.py | 27 ++++++++++++--------------- tests/test_lcd320x240.py | 5 ++--- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 0040064d..e61da4e8 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -93,9 +93,13 @@ def __init__(self, cwd, handler=None, flip=False): # widgets self.w_wifi = None - self._wifi_frames: list[Image.Image] = [] - self._wifi_frame_idx = 0 - self._wifi_tick_count = 0 + self._wifi_frames: list[Image.Image] = [ + Image.open(os.path.join(self.imagedir, f'wifi_processing_{i}.png')) + for i in range(1, 4) + ] + for frame in self._wifi_frames: + frame.load() + self._wifi_tick = 0 self._wifi_ticks_per_frame = 2 self.wifi_menu: Optional[WifiMenu] = None self.w_eq = None @@ -181,11 +185,6 @@ def poll_updates(self): def draw_tools(self, wifi_type=None, eq_type=None, bypass_type=None, system_type=None): if self.w_wifi is not None: return - self._wifi_frames = [] - for i in range(1, 4): - frame = Image.open(os.path.join(self.imagedir, f'wifi_processing_{i}.png')) - frame.load() # force decode now so animation never blocks on disk I/O - self._wifi_frames.append(frame) self.w_wifi = ImageWidget( box=Box.xywh(210, 0, 20, 20), image=os.path.join(self.imagedir, 'wifi_gray.png'), @@ -617,14 +616,12 @@ def update_wifi(self, wifi_status): if self.w_wifi is None: return if self.handler.wifi_manager.queue.pending_op_count() > 0: - self._wifi_tick_count += 1 - if self._wifi_tick_count >= self._wifi_ticks_per_frame: - self._wifi_tick_count = 0 - self._wifi_frame_idx = (self._wifi_frame_idx + 1) % len(self._wifi_frames) - self.w_wifi.replace_img(self._wifi_frames[self._wifi_frame_idx]) + period = self._wifi_ticks_per_frame * len(self._wifi_frames) + self._wifi_tick = (self._wifi_tick + 1) % period + idx = self._wifi_tick // self._wifi_ticks_per_frame + self.w_wifi.replace_img(self._wifi_frames[idx]) else: - self._wifi_frame_idx = 0 - self._wifi_tick_count = 0 + self._wifi_tick = 0 self.w_wifi.replace_img(self._resolved_wifi_png(wifi_status)) def _resolved_wifi_png(self, wifi_status): diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py index d7266148..12bb3c34 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -219,15 +219,14 @@ def test_update_wifi_pending_to_idle_resets_animation(lcd, mock_handler): mock_handler.wifi_manager.queue.pending_op_count.return_value = 1 for _ in range(instance._wifi_ticks_per_frame * 2): instance.update_wifi(status) - assert instance._wifi_frame_idx != 0 or instance._wifi_tick_count != 0 + assert instance._wifi_tick != 0 # Now clear pending. mock_handler.wifi_manager.queue.pending_op_count.return_value = 0 with patch.object(instance.w_wifi, "replace_img") as mock_replace: instance.update_wifi(status) mock_replace.assert_called_once() assert mock_replace.call_args[0][0].endswith("wifi_silver.png") - assert instance._wifi_frame_idx == 0 - assert instance._wifi_tick_count == 0 + assert instance._wifi_tick == 0 def test_wifi_frames_are_preloaded(lcd): From c5fa08ae4f5221bfe2743503fed2e01af66b2d2d Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 17 May 2026 09:16:26 -0400 Subject: [PATCH 6/6] Better tests --- tests/test_image_widget.py | 75 ++++++++++++++++++++++++++++++++++++++ tests/test_lcd320x240.py | 37 ------------------- 2 files changed, 75 insertions(+), 37 deletions(-) create mode 100644 tests/test_image_widget.py diff --git a/tests/test_image_widget.py b/tests/test_image_widget.py new file mode 100644 index 00000000..abad87e7 --- /dev/null +++ b/tests/test_image_widget.py @@ -0,0 +1,75 @@ +import os +from unittest.mock import patch + +from PIL import Image + +from uilib.box import Box +from uilib.image import ImageWidget + + +IMAGES = os.path.join(os.path.dirname(__file__), "..", "images") +GRAY = os.path.join(IMAGES, "wifi_gray.png") +SILVER = os.path.join(IMAGES, "wifi_silver.png") + + +def make_widget(image: str | Image.Image = GRAY): + return ImageWidget(image=image, box=Box.xywh(0, 0, 20, 20)) + + +def test_construct_from_path_opens_image(): + w = make_widget(GRAY) + assert isinstance(w.image, Image.Image) + assert w._image_path == GRAY + + +def test_construct_from_pil_image_uses_it_directly(): + img = Image.open(GRAY) + w = make_widget(img) + assert w.image is img + assert w._image_path is None + + +def test_replace_img_same_path_is_noop(): + w = make_widget(GRAY) + original = w.image + with patch.object(w, "refresh") as mock_refresh: + w.replace_img(GRAY) + mock_refresh.assert_not_called() + assert w.image is original + + +def test_replace_img_different_path_loads_and_refreshes(): + w = make_widget(GRAY) + with patch.object(w, "refresh") as mock_refresh: + w.replace_img(SILVER) + mock_refresh.assert_called_once() + assert w._image_path == SILVER + + +def test_replace_img_with_pil_image_swaps_and_clears_path(): + w = make_widget(GRAY) + new_img = Image.open(SILVER) + with patch.object(w, "refresh") as mock_refresh: + w.replace_img(new_img) + mock_refresh.assert_called_once() + assert w.image is new_img + assert w._image_path is None + + +def test_replace_img_with_same_pil_image_is_noop(): + img = Image.open(GRAY) + w = make_widget(img) + with patch.object(w, "refresh") as mock_refresh: + w.replace_img(img) + mock_refresh.assert_not_called() + + +def test_replace_path_after_pil_swap_reloads_even_if_path_matches_prior(): + """After a PIL.Image swap, _image_path is cleared, so re-supplying the + original path must actually reload (not be skipped by the same-path guard).""" + w = make_widget(GRAY) + w.replace_img(Image.open(SILVER)) # clears _image_path + with patch.object(w, "refresh") as mock_refresh: + w.replace_img(GRAY) + mock_refresh.assert_called_once() + assert w._image_path == GRAY diff --git a/tests/test_lcd320x240.py b/tests/test_lcd320x240.py index 12bb3c34..4b274e6b 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -192,43 +192,6 @@ def test_update_wifi_pending_shows_frame(lcd, mock_handler, status): assert mock_replace.call_args[0][0] in instance._wifi_frames -def test_update_wifi_pending_cycles_frames(lcd, mock_handler): - """Consecutive pending updates advance through frames at ticks_per_frame cadence and wrap.""" - instance, _ = lcd - mock_handler.wifi_manager.queue.pending_op_count.return_value = 1 - instance.draw_tools() - status = {"wifi_connected": True, "hotspot_active": False} - seen = [] - n_frames = len(instance._wifi_frames) - # Drive 2*n_frames frame-advances so we see at least one wrap. - for _ in range(n_frames * 2 * instance._wifi_ticks_per_frame): - with patch.object(instance.w_wifi, "replace_img") as mock_replace: - instance.update_wifi(status) - seen.append(mock_replace.call_args[0][0]) - # The visited frames should cycle through every preloaded frame. - visited = {instance._wifi_frames.index(f) for f in seen} - assert visited == set(range(n_frames)) - - -def test_update_wifi_pending_to_idle_resets_animation(lcd, mock_handler): - """Leaving the pending state resets frame counters and shows the resolved idle image.""" - instance, _ = lcd - instance.draw_tools() - status = {"wifi_connected": True, "hotspot_active": False} - # Drive the animation forward a few frames. - mock_handler.wifi_manager.queue.pending_op_count.return_value = 1 - for _ in range(instance._wifi_ticks_per_frame * 2): - instance.update_wifi(status) - assert instance._wifi_tick != 0 - # Now clear pending. - mock_handler.wifi_manager.queue.pending_op_count.return_value = 0 - with patch.object(instance.w_wifi, "replace_img") as mock_replace: - instance.update_wifi(status) - mock_replace.assert_called_once() - assert mock_replace.call_args[0][0].endswith("wifi_silver.png") - assert instance._wifi_tick == 0 - - def test_wifi_frames_are_preloaded(lcd): """Frames are decoded once at draw_tools time, not opened on every update.""" instance, _ = lcd