diff --git a/nion/swift/DataItemThumbnailWidget.py b/nion/swift/DataItemThumbnailWidget.py index d783a88e..e45496f9 100644 --- a/nion/swift/DataItemThumbnailWidget.py +++ b/nion/swift/DataItemThumbnailWidget.py @@ -439,12 +439,8 @@ def close(self) -> None: super().close() def __detach_listeners(self) -> None: - if self.__thumbnail_updated_event_listener: - self.__thumbnail_updated_event_listener.close() - self.__thumbnail_updated_event_listener = None - if self.__thumbnail_source: - self.__thumbnail_source.remove_ref() - self.__thumbnail_source = None + self.__thumbnail_updated_event_listener = None + self.__thumbnail_source = None def __update_thumbnail(self) -> None: if self.__display_item: @@ -461,7 +457,7 @@ def set_display_item(self, display_item: typing.Optional[DisplayItem.DisplayItem self.__detach_listeners() self.__display_item = display_item if display_item: - self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.ui, display_item).add_ref() + self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.ui, display_item) self.__thumbnail_updated_event_listener = self.__thumbnail_source.thumbnail_updated_event.listen(self.__update_thumbnail) self.__update_thumbnail() if self.__display_item_binding: diff --git a/nion/swift/DataPanel.py b/nion/swift/DataPanel.py index b72a1588..1d87d6a3 100644 --- a/nion/swift/DataPanel.py +++ b/nion/swift/DataPanel.py @@ -109,12 +109,8 @@ def display_item_removed() -> None: def close(self) -> None: # remove the listener. - if self.__thumbnail_updated_event_listener: - self.__thumbnail_updated_event_listener.close() - self.__thumbnail_updated_event_listener = None - if self.__thumbnail_source: - self.__thumbnail_source.remove_ref() - self.__thumbnail_source = None + self.__thumbnail_updated_event_listener = None + self.__thumbnail_source = None if self.__display_changed_event_listener: self.__display_changed_event_listener.close() self.__display_changed_event_listener = None @@ -184,7 +180,7 @@ def drag_started(self, ui: UserInterface.UserInterface, x: int, y: int, modifier def calculate_thumbnail_data(self) -> typing.Optional[_NDArray]: # grab the display specifier and if there is a display, handle thumbnail updating. if self.__display_item and not self.__thumbnail_source: - self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.ui, self.__display_item).add_ref() + self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.ui, self.__display_item) def thumbnail_updated() -> None: self.__list_item_drawing_context = None @@ -611,7 +607,7 @@ def thumbnail_updated() -> None: self.__thumbnail_canvas_item.bitmap = Bitmap.Bitmap(rgba_bitmap_data=bitmap_data) self.__thumbnail_canvas_item = thumbnail_canvas_item - self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.__ui, self.__display_item).add_ref() + self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.__ui, self.__display_item) self.__thumbnail_updated_event_listener = self.__thumbnail_source.thumbnail_updated_event.listen(thumbnail_updated) thumbnail_updated() @@ -623,7 +619,8 @@ def thumbnail_updated() -> None: self.__item_changed_listener = display_item.item_changed_event.listen(ReferenceCounting.weak_partial(DataPanelListItem.__item_changed, self)) def close(self) -> None: - self.__thumbnail_source.remove_ref() + self.__thumbnail_updated_event_listener = typing.cast(typing.Any, None) + self.__thumbnail_source = typing.cast(typing.Any, None) super().close() @property @@ -686,7 +683,9 @@ def map_display_item_to_display_item_adapter(display_item: DisplayItem.DisplayIt def unmap_display_item_to_display_item_adapter(display_item_adapter: DisplayItemAdapter) -> None: display_item_adapter.close() - self.__filtered_display_item_adapters_model = ListModel.MappedListModel(container=document_controller.filtered_display_items_model, master_items_key="display_items", items_key="display_item_adapters", map_fn=map_display_item_to_display_item_adapter, unmap_fn=unmap_display_item_to_display_item_adapter) + display_items_model = document_controller.filtered_display_items_model + + self.__filtered_display_item_adapters_model = ListModel.MappedListModel(container=display_items_model, master_items_key="display_items", items_key="display_item_adapters", map_fn=map_display_item_to_display_item_adapter, unmap_fn=unmap_display_item_to_display_item_adapter) self.__selection = self.document_controller.selection @@ -753,8 +752,7 @@ def _get_mime_data_and_thumbnail_data(self, drag_started_event: ListCanvasItem.L mime_data = document_controller.ui.create_mime_data() MimeTypes.mime_data_put_display_item(mime_data, display_item) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.__data_panel.ui, display_item) - with thumbnail_source.ref(): - thumbnail_data = thumbnail_source.thumbnail_data + thumbnail_data = thumbnail_source.thumbnail_data if thumbnail_data is not None: # scaling is very slow thumbnail_data = Image.get_rgba_data_from_rgba( @@ -766,12 +764,11 @@ def _get_mime_data_and_thumbnail_data(self, drag_started_event: ListCanvasItem.L anchor_index = self.__selection.anchor_index or 0 thumbnail_display_item = display_items[anchor_index] thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.__data_panel.ui, thumbnail_display_item) - with thumbnail_source.ref(): - thumbnail_data = thumbnail_source.thumbnail_data + thumbnail_data = thumbnail_source.thumbnail_data return mime_data, thumbnail_data list_item_delegate = ListItemDelegate(self, self.__selection) - list_canvas_item = ListCanvasItem.ListCanvasItem2(document_controller.filtered_display_items_model, self.__selection, list_item_factory, list_item_delegate, item_height=80, key="display_items") + list_canvas_item = ListCanvasItem.ListCanvasItem2(display_items_model, self.__selection, list_item_factory, list_item_delegate, item_height=80, key="display_items") scroll_area_canvas_item = CanvasItem.ScrollAreaCanvasItem(list_canvas_item) scroll_bar_canvas_item = CanvasItem.ScrollBarCanvasItem(scroll_area_canvas_item, CanvasItem.Orientation.Vertical) scroll_group_canvas_item = CanvasItem.CanvasItemComposition() @@ -779,6 +776,17 @@ def _get_mime_data_and_thumbnail_data(self, drag_started_event: ListCanvasItem.L scroll_group_canvas_item.add_canvas_item(scroll_area_canvas_item) scroll_group_canvas_item.add_canvas_item(scroll_bar_canvas_item) + def begin_changes(key: str) -> None: + list_canvas_item._begin_batch_update() + + def end_changes(key: str) -> None: + list_canvas_item._end_batch_update() + + # the display items model can notify us when it is about to change. in order to gang up changes, watch + # for these notification events and tell the list canvas item to only update at the end of the changes. + self.__display_items_begin_changes_listener = display_items_model.begin_changes_event.listen(begin_changes) + self.__display_items_end_changes_listener = display_items_model.end_changes_event.listen(end_changes) + data_list_canvas_item = scroll_group_canvas_item data_list_widget = ui.create_canvas_widget() diff --git a/nion/swift/Thumbnails.py b/nion/swift/Thumbnails.py index bdd2229f..94e1c835 100644 --- a/nion/swift/Thumbnails.py +++ b/nion/swift/Thumbnails.py @@ -5,6 +5,7 @@ from __future__ import annotations # standard libraries +import concurrent.futures import functools import threading import typing @@ -13,6 +14,7 @@ # third-party libraries import numpy +import numpy.typing # local libraries from nion.swift import DisplayPanel @@ -23,83 +25,113 @@ from nion.utils import Event from nion.utils import Geometry from nion.utils import ReferenceCounting -from nion.utils import ThreadPool _NDArray = numpy.typing.NDArray[typing.Any] -_ThumbnailSourceWeakRef = typing.Callable[[], typing.Optional["ThumbnailSource"]] # Python 3.9+ -class ThumbnailProcessor: - """Processes thumbnails for a display in a thread.""" +class ThumbnailSource: + """Produce a thumbnail for a display.""" + _executor = concurrent.futures.ThreadPoolExecutor() + + def __init__(self, ui: UserInterface.UserInterface, display_item: DisplayItem.DisplayItem) -> None: + super().__init__() + self._ui = ui + self._display_item = display_item + + self.width = 256 + self.height = 256 + + self.thumbnail_updated_event = Event.Event() - def __init__(self, display_item: DisplayItem.DisplayItem): self.__display_item = display_item self.__recompute_lock = threading.RLock() - self.__dispatcher = ThreadPool.SingleItemDispatcher(minimum_period=0.5) - self.__display_item_about_to_close_listener = self.__display_item.about_to_close_event.listen(self.__about_to_close_display_item) + self.__recompute_future: typing.Optional[concurrent.futures.Future[typing.Any]] = None + # the cache is used to store the thumbnail data persistently. for performance, it is ideal + # to minimize calling it and instead use the cached value in this class. self.__cache = self.__display_item._display_cache self.__cache_property_name = "thumbnail_data" - self.width = 256 - self.height = 256 - self.on_thumbnail_updated: typing.Optional[typing.Callable[[], None]] = None + self.__cache_properties_known = False + self.__cache_thumbnail_data: typing.Optional[_NDArray] = None + self.__cache_is_dirty = False - def close(self) -> None: - self.on_thumbnail_updated = None - self.__dispatcher.close() - self.__dispatcher = typing.cast(typing.Any, None) - self.__display_item = typing.cast(typing.Any, None) - self.__display_item_about_to_close_listener.close() - self.__display_item_about_to_close_listener = typing.cast(typing.Any, None) + self.thumbnail_dirty_event = Event.Event() # for testing - def __about_to_close_display_item(self) -> None: - self.close() + self.__display_changed_event_listener = display_item.display_changed_event.listen(ReferenceCounting.weak_partial(ThumbnailSource.__thumbnail_changed, self)) + self.__graphics_changed_event_listener = display_item.graphics_changed_event.listen(ReferenceCounting.weak_partial(ThumbnailSource.__graphics_changed, self)) - # used internally and for testing - @property - def _is_cached_value_dirty(self) -> bool: - return self.__cache.is_cached_value_dirty(self.__display_item, self.__cache_property_name) + # initial recompute, if required + self.__recompute_on_thread() - # thread safe - def mark_data_dirty(self) -> None: - """ Called from item to indicate its data or metadata has changed.""" + self.__display_will_close_listener = display_item.display_item_will_close_event.listen(ReferenceCounting.weak_partial(ThumbnailSource.__display_item_will_close, self)) + + def __read_cache_properties(self) -> None: + if not self.__cache_properties_known: + self.__cache_thumbnail_data = typing.cast(typing.Optional[_NDArray], self.__cache.get_cached_value(self.__display_item, self.__cache_property_name)) if self.__display_item else None + self.__cache_is_dirty = self.__cache.is_cached_value_dirty(self.__display_item, self.__cache_property_name) if self.__display_item else False + self.__cache_properties_known = True + self.thumbnail_updated_event.fire() + + def __thumbnail_changed(self) -> None: self.__cache.set_cached_value_dirty(self.__display_item, self.__cache_property_name) + self.thumbnail_dirty_event.fire() + self.__cache_is_dirty = True + self.__cache_properties_known = True + self.__recompute_on_thread() - def __get_cached_value(self) -> typing.Optional[_NDArray]: - return typing.cast(typing.Optional[_NDArray], self.__cache.get_cached_value(self.__display_item, self.__cache_property_name)) + def __recompute_on_thread(self) -> None: + with self.__recompute_lock: + if not self.__recompute_future or self.__recompute_future.done(): + self.__recompute_future = self._executor.submit(self.__recompute_data_if_needed) - def get_cached_data(self) -> typing.Optional[_NDArray]: - """Return the cached data for this processor. + def __graphics_changed(self, graphic_selection: DisplayItem.GraphicSelection) -> None: + self.__thumbnail_changed() - This method is thread safe and always returns quickly, using the cached data. - """ - return self.__get_cached_value() - - def __get_calculated_data(self, ui: UserInterface.UserInterface) -> typing.Optional[DrawingContext.RGBA32Type]: - display_item = self.__display_item - if display_item.display_data_shape and len(display_item.display_data_shape) == 2: - pixel_shape = Geometry.IntSize(height=512, width=512) - else: - pixel_shape = Geometry.IntSize(height=308, width=512) - drawing_context = DisplayPanel.preview(DisplayPanel.DisplayPanelUISettings(ui), display_item, pixel_shape) - thumbnail_drawing_context = DrawingContext.DrawingContext() - thumbnail_drawing_context.scale(self.width / 512, self.height / 512) - thumbnail_drawing_context.translate(0, (pixel_shape.width - pixel_shape.height) * 0.5) - thumbnail_drawing_context.add(drawing_context) - return ui.create_rgba_image(thumbnail_drawing_context, self.width, self.height) - - def recompute(self, ui: UserInterface.UserInterface) -> None: - self.__dispatcher.dispatch(functools.partial(self.recompute_data, ui)) - - def recompute_data(self, ui: UserInterface.UserInterface) -> None: + def __display_item_will_close(self) -> None: + recompute_future: typing.Optional[concurrent.futures.Future[typing.Any]] = None + with self.__recompute_lock: + if self.__recompute_future and not self.__recompute_future.done(): + self.__recompute_future.cancel() + recompute_future = self.__recompute_future + if recompute_future: + try: + concurrent.futures.wait([recompute_future], timeout=10.0) + except concurrent.futures.CancelledError: + pass + self.__display_item_about_to_close_listener = typing.cast(typing.Any, None) + self.__display_item = typing.cast(typing.Any, None) + self.__display_changed_event_listener = typing.cast(typing.Any, None) + self.__graphics_changed_event_listener = typing.cast(typing.Any, None) + + @property + def thumbnail_data(self) -> typing.Optional[_NDArray]: + return self.__cache_thumbnail_data + + def __recompute_data_if_needed(self) -> None: + self.__read_cache_properties() + if self._is_thumbnail_dirty: + self.recompute_data() + + def recompute_data(self) -> None: """Compute the data associated with this processor. This method is thread safe and may take a long time to return. It should not be called from the UI thread. Upon return, the results will be calculated with the latest data available and the cache will not be marked dirty. """ + ui = self._ui with self.__recompute_lock: try: - calculated_data = self.__get_calculated_data(ui) + display_item = self.__display_item + if display_item.display_data_shape and len(display_item.display_data_shape) == 2: + pixel_shape = Geometry.IntSize(height=512, width=512) + else: + pixel_shape = Geometry.IntSize(height=308, width=512) + drawing_context = DisplayPanel.preview(DisplayPanel.DisplayPanelUISettings(ui), display_item, pixel_shape) + thumbnail_drawing_context = DrawingContext.DrawingContext() + thumbnail_drawing_context.scale(self.width / 512, self.height / 512) + thumbnail_drawing_context.translate(0, (pixel_shape.width - pixel_shape.height) * 0.5) + thumbnail_drawing_context.add(drawing_context) + calculated_data = ui.create_rgba_image(thumbnail_drawing_context, self.width, self.height) except Exception as e: import traceback traceback.print_exc() @@ -107,103 +139,54 @@ def recompute_data(self, ui: UserInterface.UserInterface) -> None: raise if calculated_data is None: calculated_data = numpy.zeros((self.height, self.width), dtype=numpy.uint32) + self.__cache_thumbnail_data = calculated_data + self.__cache_is_dirty = False + self.__cache_properties_known = True self.__cache.set_cached_value(self.__display_item, self.__cache_property_name, calculated_data) - if callable(self.on_thumbnail_updated): - self.on_thumbnail_updated() - - -class ThumbnailSource(ReferenceCounting.ReferenceCounted): - """Produce a thumbnail for a display.""" - - def __init__(self, ui: UserInterface.UserInterface, display_item: DisplayItem.DisplayItem) -> None: - super().__init__() - self._ui = ui - self._display_item = display_item - - self.thumbnail_updated_event = Event.Event() - self.__thumbnail_processor = ThumbnailProcessor(display_item) - - def thumbnail_changed() -> None: - thumbnail_processor = self.__thumbnail_processor - if thumbnail_processor: - thumbnail_processor.mark_data_dirty() - thumbnail_processor.recompute(ui) - - def graphics_changed(graphic_selection: DisplayItem.GraphicSelection) -> None: - thumbnail_changed() - - self.__display_changed_event_listener = display_item.display_changed_event.listen(thumbnail_changed) - self.__graphics_changed_event_listener = display_item.graphics_changed_event.listen(graphics_changed) - - def thumbnail_updated() -> None: - self.thumbnail_updated_event.fire() - - self.__thumbnail_processor.on_thumbnail_updated = thumbnail_updated - - # initial recompute, if required - if self.__thumbnail_processor._is_cached_value_dirty: - self.__thumbnail_processor.recompute(ui) - - def display_item_will_close() -> None: - if self.__thumbnail_processor: - self.__thumbnail_processor.close() - self.__thumbnail_processor = typing.cast(typing.Any, None) - - self.__display_will_close_listener = display_item.about_to_be_removed_event.listen(display_item_will_close) - - def about_to_delete(self) -> None: - self.__display_will_close_listener = typing.cast(typing.Any, None) - if self.__thumbnail_processor: - self.__thumbnail_processor.close() - self.__thumbnail_processor = typing.cast(typing.Any, None) - self.__display_changed_event_listener = typing.cast(typing.Any, None) - self.__graphics_changed_event_listener = typing.cast(typing.Any, None) - super().about_to_delete() - - def add_ref(self) -> ThumbnailSource: - super().add_ref() - return self + self.thumbnail_updated_event.fire() @property - def thumbnail_data(self) -> typing.Optional[_NDArray]: - return self.__thumbnail_processor.get_cached_data() if self.__thumbnail_processor else None - - def recompute_data(self) -> None: - self.__thumbnail_processor.recompute_data(self._ui) + def _is_thumbnail_dirty(self) -> bool: + return self.__cache_is_dirty - # used for testing @property - def _is_thumbnail_dirty(self) -> bool: - return self.__thumbnail_processor._is_cached_value_dirty + def _is_valid(self) -> bool: + return self.__display_item is not None class ThumbnailManager(metaclass=Utility.Singleton): """Manages thumbnail sources for displays.""" def __init__(self) -> None: - self.__thumbnail_sources: typing.Dict[uuid.UUID, _ThumbnailSourceWeakRef] = dict() + self.__thumbnail_sources: typing.Dict[uuid.UUID, ThumbnailSource] = dict() self.__lock = threading.RLock() - def thumbnail_sources(self) -> typing.Dict[uuid.UUID, _ThumbnailSourceWeakRef]: - return self.__thumbnail_sources + def reset(self) -> None: + with self.__lock: + self.__thumbnail_sources.clear() + + def clean(self) -> None: + with self.__lock: + for uuid, thumbnail_source in list(self.__thumbnail_sources.items()): + if not thumbnail_source._is_valid: + del self.__thumbnail_sources[uuid] def thumbnail_source_for_display_item(self, ui: UserInterface.UserInterface, display_item: DisplayItem.DisplayItem) -> ThumbnailSource: """Returned ThumbnailSource must be closed.""" with self.__lock: - thumbnail_source_ref = self.__thumbnail_sources.get(display_item.uuid) - thumbnail_source = thumbnail_source_ref() if thumbnail_source_ref else None + self.clean() + thumbnail_source = self.__thumbnail_sources.get(display_item.uuid) if not thumbnail_source: thumbnail_source = ThumbnailSource(ui, display_item) - self.__thumbnail_sources[display_item.uuid] = weakref.ref(thumbnail_source) - weakref.finalize(thumbnail_source, self.__thumbnail_sources.pop, display_item.uuid) + self.__thumbnail_sources[display_item.uuid] = thumbnail_source else: assert thumbnail_source._ui == ui return thumbnail_source def thumbnail_data_for_display_item(self, display_item: typing.Optional[DisplayItem.DisplayItem]) -> typing.Optional[_NDArray]: with self.__lock: - thumbnail_source_ref = self.__thumbnail_sources.get(display_item.uuid) if display_item else None - thumbnail_source = thumbnail_source_ref() if thumbnail_source_ref else None + self.clean() + thumbnail_source = self.__thumbnail_sources.get(display_item.uuid) if display_item else None if thumbnail_source: return thumbnail_source.thumbnail_data return None diff --git a/nion/swift/model/DisplayItem.py b/nion/swift/model/DisplayItem.py index 0d3b8f49..a769f63e 100755 --- a/nion/swift/model/DisplayItem.py +++ b/nion/swift/model/DisplayItem.py @@ -2313,6 +2313,7 @@ def __init__(self, item_uuid: typing.Optional[uuid.UUID] = None, *, data_item: t self.display_property_changed_event = Event.Event() self.display_changed_event = Event.Event() + self.display_item_will_close_event = Event.Event() # used to shut down thumbnail self.__cache = Cache.ShadowCache() self.__suspendable_storage_cache: typing.Optional[Cache.CacheLike] = None @@ -2380,6 +2381,7 @@ def graphic_selection_changed() -> None: self.append_display_data_channel_for_data_item(data_item) def close(self) -> None: + self.display_item_will_close_event.fire() # let the thumbnail shut itself down # wait for outstanding threads to finish with self.__outstanding_condition: while self.__outstanding_thread_count: diff --git a/nion/swift/test/DataItem_test.py b/nion/swift/test/DataItem_test.py index c400bbbe..b723d0bc 100644 --- a/nion/swift/test/DataItem_test.py +++ b/nion/swift/test/DataItem_test.py @@ -290,13 +290,13 @@ def test_clear_thumbnail_when_data_item_changed(self): display_item = document_model.get_display_item_for_data_item(data_item) self.assertTrue(display_item._display_cache.is_cached_value_dirty(display_item, "thumbnail_data")) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - self.assertIsNotNone(thumbnail_source.thumbnail_data) - self.assertFalse(display_item._display_cache.is_cached_value_dirty(display_item, "thumbnail_data")) - with display_item.data_item.data_ref() as data_ref: - data_ref.data = numpy.zeros((8, 8), numpy.uint32) - self.assertTrue(display_item._display_cache.is_cached_value_dirty(display_item, "thumbnail_data")) + thumbnail_source.recompute_data() + self.assertIsNotNone(thumbnail_source.thumbnail_data) + self.assertFalse(display_item._display_cache.is_cached_value_dirty(display_item, "thumbnail_data")) + with display_item.data_item.data_ref() as data_ref: + data_ref.data = numpy.zeros((8, 8), numpy.uint32) + self.assertTrue(thumbnail_source._is_thumbnail_dirty) + thumbnail_source = None def test_thumbnail_2d_handles_small_dimension_without_producing_invalid_thumbnail(self): with TestContext.create_memory_context() as test_context: @@ -305,10 +305,10 @@ def test_thumbnail_2d_handles_small_dimension_without_producing_invalid_thumbnai document_model.append_data_item(data_item) display_item = document_model.get_display_item_for_data_item(data_item) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - thumbnail_data = thumbnail_source.thumbnail_data - self.assertTrue(functools.reduce(lambda x, y: x * y, thumbnail_data.shape) > 0) + thumbnail_source.recompute_data() + thumbnail_data = thumbnail_source.thumbnail_data + thumbnail_source = None + self.assertTrue(functools.reduce(lambda x, y: x * y, thumbnail_data.shape) > 0) def test_thumbnail_2d_handles_nan_data(self): with TestContext.create_memory_context() as test_context: @@ -319,9 +319,9 @@ def test_thumbnail_2d_handles_nan_data(self): document_model.append_data_item(data_item) display_item = document_model.get_display_item_for_data_item(data_item) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source.recompute_data() + self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source = None def test_thumbnail_2d_handles_inf_data(self): with TestContext.create_memory_context() as test_context: @@ -332,9 +332,9 @@ def test_thumbnail_2d_handles_inf_data(self): document_model.append_data_item(data_item) display_item = document_model.get_display_item_for_data_item(data_item) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source.recompute_data() + self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source = None def test_thumbnail_1d(self): with TestContext.create_memory_context() as test_context: @@ -343,9 +343,9 @@ def test_thumbnail_1d(self): document_model.append_data_item(data_item) display_item = document_model.get_display_item_for_data_item(data_item) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source.recompute_data() + self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source = None def test_thumbnail_1d_handles_nan_data(self): with TestContext.create_memory_context() as test_context: @@ -356,9 +356,9 @@ def test_thumbnail_1d_handles_nan_data(self): document_model.append_data_item(data_item) display_item = document_model.get_display_item_for_data_item(data_item) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source.recompute_data() + self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source = None def test_thumbnail_1d_handles_inf_data(self): with TestContext.create_memory_context() as test_context: @@ -369,9 +369,9 @@ def test_thumbnail_1d_handles_inf_data(self): document_model.append_data_item(data_item) display_item = document_model.get_display_item_for_data_item(data_item) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source.recompute_data() + self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source = None def test_thumbnail_marked_dirty_when_source_data_changed(self): with TestContext.create_memory_context() as test_context: @@ -383,17 +383,17 @@ def test_thumbnail_marked_dirty_when_source_data_changed(self): inverted_display_item = document_model.get_display_item_for_data_item(data_item_inverted) document_model.recompute_all() thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, inverted_display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - thumbnail_source.thumbnail_data - # here the data should be computed and the thumbnail should not be dirty - self.assertFalse(inverted_display_item._display_cache.is_cached_value_dirty(inverted_display_item, "thumbnail_data")) - # now the source data changes and the inverted data needs computing. - # the thumbnail should also be dirty. - with data_item.data_ref() as data_ref: - data_ref.data = data_ref.data + 1.0 - document_model.recompute_all() - self.assertTrue(inverted_display_item._display_cache.is_cached_value_dirty(inverted_display_item, "thumbnail_data")) + thumbnail_source.recompute_data() + thumbnail_source.thumbnail_data + # here the data should be computed and the thumbnail should not be dirty + self.assertFalse(inverted_display_item._display_cache.is_cached_value_dirty(inverted_display_item, "thumbnail_data")) + # now the source data changes and the inverted data needs computing. + # the thumbnail should also be dirty. + with data_item.data_ref() as data_ref: + data_ref.data = data_ref.data + 1.0 + document_model.recompute_all() + self.assertTrue(thumbnail_source._is_thumbnail_dirty) + thumbnail_source = None def test_thumbnail_widget_when_data_item_has_no_associated_display_item(self): with TestContext.create_memory_context() as test_context: diff --git a/nion/swift/test/DisplayPanel_test.py b/nion/swift/test/DisplayPanel_test.py index f23d2c46..e471cb09 100644 --- a/nion/swift/test/DisplayPanel_test.py +++ b/nion/swift/test/DisplayPanel_test.py @@ -1171,9 +1171,8 @@ def test_1d_data_with_zero_dimensions_display_fails_without_exception(self): self.assertIsInstance(self.display_panel.display_canvas_item, DisplayPanel.MissingDisplayCanvasItem) # thumbnails and processors thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.document_controller.ui, self.display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - self.assertIsNotNone(thumbnail_source.thumbnail_data) + thumbnail_source.recompute_data() + self.assertIsNotNone(thumbnail_source.thumbnail_data) self.document_controller.periodic() self.document_controller.document_model.recompute_all() @@ -1183,8 +1182,7 @@ def test_2d_data_with_zero_dimensions_display_fails_without_exception(self): self.assertIsInstance(self.display_panel.display_canvas_item, DisplayPanel.MissingDisplayCanvasItem) # thumbnails and processors thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.document_controller.ui, self.display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() + thumbnail_source.recompute_data() self.document_controller.periodic() self.document_controller.document_model.recompute_all() diff --git a/nion/swift/test/Storage_test.py b/nion/swift/test/Storage_test.py index aeea08e7..62bfb354 100644 --- a/nion/swift/test/Storage_test.py +++ b/nion/swift/test/Storage_test.py @@ -320,10 +320,10 @@ def test_reloading_thumbnail_from_cache_does_not_mark_it_as_dirty(self): storage_cache.set_cached_value(display_item, "thumbnail_data", numpy.zeros((128, 128, 4), dtype=numpy.uint8)) self.assertFalse(storage_cache.is_cached_value_dirty(display_item, "thumbnail_data")) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - del thumbnail_source + thumbnail_source.recompute_data() + thumbnail_source = None # read it back + Thumbnails.ThumbnailManager().reset() document_model = profile_context.create_document_model(auto_close=False) with document_model.ref(): read_data_item = document_model.data_items[0] @@ -331,8 +331,8 @@ def test_reloading_thumbnail_from_cache_does_not_mark_it_as_dirty(self): # thumbnail data should still be valid self.assertFalse(storage_cache.is_cached_value_dirty(read_display_item, "thumbnail_data")) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, read_display_item) - with thumbnail_source.ref(): - self.assertFalse(thumbnail_source._is_thumbnail_dirty) + self.assertFalse(thumbnail_source._is_thumbnail_dirty) + thumbnail_source = None def test_reload_data_item_initializes_display_data_range(self): with create_memory_profile_context() as profile_context: diff --git a/nion/swift/test/Thumbnails_test.py b/nion/swift/test/Thumbnails_test.py index 2d4292fd..13bd0e28 100644 --- a/nion/swift/test/Thumbnails_test.py +++ b/nion/swift/test/Thumbnails_test.py @@ -55,16 +55,24 @@ def test_thumbnail_marked_dirty_when_display_layers_change(self): document_model.append_data_item(data_item) display_item = document_model.get_display_item_for_data_item(data_item) thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.app.ui, display_item) - with thumbnail_source.ref(): - thumbnail_source.recompute_data() - thumbnail_source.thumbnail_data - # here the data should be computed and the thumbnail should not be dirty - self.assertFalse(display_item._display_cache.is_cached_value_dirty(display_item, "thumbnail_data")) - # now the source data changes and the inverted data needs computing. - # the thumbnail should also be dirty. - display_item._set_display_layer_property(0, "fill_color", "teal") - document_model.recompute_all() - self.assertTrue(display_item._display_cache.is_cached_value_dirty(display_item, "thumbnail_data")) + thumbnail_source.recompute_data() + thumbnail_source.thumbnail_data + # here the data should be computed and the thumbnail should not be dirty + self.assertFalse(display_item._display_cache.is_cached_value_dirty(display_item, "thumbnail_data")) + # now the source data changes and the inverted data needs computing. + # the thumbnail should also be dirty. + thumbnail_dirty = False + + def handle_thumbnail_dirty() -> None: + nonlocal thumbnail_dirty + thumbnail_dirty = True + + listener = thumbnail_source.thumbnail_dirty_event.listen(handle_thumbnail_dirty) + display_item._set_display_layer_property(0, "fill_color", "teal") + document_model.recompute_all() + # this is a race condition. the thumbnail thread may clear the dirty flag before we check it. + # so use the event instead. + self.assertTrue(thumbnail_dirty) if __name__ == '__main__':