diff --git a/images/wifi_processing.png b/images/wifi_processing.png deleted file mode 100644 index c8642017..00000000 Binary files a/images/wifi_processing.png and /dev/null differ diff --git a/images/wifi_processing_1.png b/images/wifi_processing_1.png new file mode 100644 index 00000000..c197386d Binary files /dev/null and b/images/wifi_processing_1.png differ diff --git a/images/wifi_processing_2.png b/images/wifi_processing_2.png new file mode 100644 index 00000000..fd423e94 Binary files /dev/null and b/images/wifi_processing_2.png differ diff --git a/images/wifi_processing_3.png b/images/wifi_processing_3.png new file mode 100644 index 00000000..c7c54955 Binary files /dev/null and b/images/wifi_processing_3.png differ 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 39bb2f3a..d4200c59 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,7 +93,14 @@ def __init__(self, cwd, handler=None, flip=False): # widgets self.w_wifi = None - self._wifi_img_path: Optional[str] = None + 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 self.w_power = None @@ -178,18 +185,22 @@ 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 = ImageWidget(box=Box.xywh(210, 0, 20, 20), image_path=os.path.join(self.imagedir, - 'wifi_gray.png'), parent=self.main_panel, action=self.wifi_menu.open) + self.w_wifi = ImageWidget( + box=Box.xywh(210, 0, 20, 20), + 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) @@ -605,18 +616,22 @@ def update_wifi(self, wifi_status): if self.w_wifi is None: return if self.handler.wifi_manager.queue.pending_op_count() > 0: - img = "wifi_processing.png" - elif util.DICT_GET(wifi_status, 'hotspot_active'): + 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_tick = 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'): 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 update_eq(self, eq_status): pass 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 bb79f372..c325663c 100644 --- a/tests/test_lcd320x240.py +++ b/tests/test_lcd320x240.py @@ -159,27 +159,51 @@ 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 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_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() + 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_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): """Repeated update_wifi calls with same status don't re-blit the icon.""" instance, _ = lcd @@ -187,10 +211,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/image.py b/uilib/image.py index cbb4262b..eb401780 100644 --- a/uilib/image.py +++ b/uilib/image.py @@ -13,30 +13,49 @@ # 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): + + 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 = Image.open(image_path) + super(ImageWidget, self).__init__(**kwargs) + + 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, # 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): + def replace_img(self, image: str | Image.Image): # XXX Note that the new image must be the same size as the original - 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() -