Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed images/wifi_processing.png
Binary file not shown.
Binary file added images/wifi_processing_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/wifi_processing_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/wifi_processing_3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions pistomp-testui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
43 changes: 29 additions & 14 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions tests/test_image_widget.py
Original file line number Diff line number Diff line change
@@ -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
52 changes: 38 additions & 14 deletions tests/test_lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,38 +159,62 @@ 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
mock_handler.wifi_manager.queue.pending_op_count.return_value = 0
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):
Expand Down
41 changes: 30 additions & 11 deletions uilib/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,49 @@
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see <https://www.gnu.org/licenses/>.

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()