diff --git a/picamera_coverage.zip b/picamera_coverage.zip
index e47b59ea..0c04bf3d 100644
Binary files a/picamera_coverage.zip and b/picamera_coverage.zip differ
diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py
index 8a2644f1..5a39f11c 100644
--- a/src/openflexure_microscope_server/things/camera/__init__.py
+++ b/src/openflexure_microscope_server/things/camera/__init__.py
@@ -295,11 +295,6 @@ class BaseCamera(OFMThing, ABC):
# Register with gallery.
_show_data_in_gallery = True
- @property
- def gallery_data_name(self) -> str:
- """Name under which data shows up in gallery."""
- return "Captures"
-
@property
def gallery_data_schema(self) -> type[CaptureInfo]:
"""The schema (BaseModel) for passing data to the gallery."""
diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py
index 2f701c73..23f4f1bd 100644
--- a/src/openflexure_microscope_server/things/gallery.py
+++ b/src/openflexure_microscope_server/things/gallery.py
@@ -23,14 +23,14 @@ class GalleryCompatibleThing(Protocol):
be picked up, but may throw an error when used.
"""
+ name: str
+
# Ensure it is a Thing:
_thing_server_interface: lt.ThingServerInterface
# Ensure it is an OFMThing:
show_data_in_gallery: bool
- gallery_data_name: str
-
gallery_data_schema: type[BaseModel]
# Ignore D102: No docstrings for the protocol.
@@ -44,7 +44,9 @@ class GalleryThing(lt.Thing):
all_ofm_things: Mapping[str, OFMThing] = lt.thing_slot()
- _gallery_providing_things: Optional[Mapping[str, GalleryCompatibleThing]] = None
+ _gallery_providing_things: Optional[dict[str, GalleryCompatibleThing]] = None
+ _card_types: Optional[list[str]] = None
+ _card_type_map: dict[str, str] = {}
@property
def gallery_providing_things(self) -> Mapping[str, GalleryCompatibleThing]:
@@ -83,8 +85,45 @@ class GalleryThing(lt.Thing):
# Cast the type of each thing to "GalleryCompatibleThing" as other Things have
# been popped.
self._gallery_providing_things = cast(
- Mapping[str, GalleryCompatibleThing], gallery_providers
+ dict[str, GalleryCompatibleThing], gallery_providers
)
+ self._set_card_types(self._gallery_providing_things)
+
+ def _set_card_types(
+ self, gallery_providers: dict[str, GalleryCompatibleThing]
+ ) -> None:
+ """Find the card types from each provider.
+
+ :param gallery_providers: The dictionary of gallery providing things. If card
+ data cannot be extracted for a Thing it will be popped from the provider
+ dictionary.
+ """
+ card_types: list[str] = []
+ # cache initial list of keys as it may change in the loop
+ keys = list(gallery_providers.keys())
+ for key in keys:
+ card_schema = gallery_providers[key].gallery_data_schema.schema()
+ props = card_schema["properties"]
+ if "card_type" not in props or "const" not in props["card_type"]:
+ self.logger.error(
+ f"Data from {key} cannot be shown in gallery as data card doesn't "
+ "have a static card_type."
+ )
+ gallery_providers.pop(key)
+ continue
+ card_type = props["card_type"]["const"]
+ if card_type not in card_types:
+ card_types.append(card_type)
+ self._card_type_map[key] = card_type
+
+ self._card_types = card_types
+
+ @lt.property
+ def card_types(self) -> list[str]:
+ """Names for the card types in the gallery."""
+ if self._card_types is None:
+ raise RuntimeError("Cannot access card_types before server has started.")
+ return self._card_types
@lt.property
def list_data(self) -> list[dict[str, Any]]:
@@ -108,10 +147,12 @@ class GalleryThing(lt.Thing):
return data_list
@lt.action
- def delete_all_data(self) -> None:
- """Delete all the gallery data on this microscope."""
+ def delete_all_data(self, card_types: list[str]) -> None:
+ """Delete all the gallery data on this microscope with the given card types."""
for thing in self.gallery_providing_things.values():
- try:
- thing.delete_all_gallery_items()
- except Exception as e:
- self.logger.exception(e)
+ thing_card_type = self._card_type_map.get(thing.name)
+ if thing_card_type in card_types:
+ try:
+ thing.delete_all_gallery_items()
+ except Exception as e:
+ self.logger.exception(e)
diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py
index e51c84eb..d22cb55f 100644
--- a/src/openflexure_microscope_server/things/smart_scan.py
+++ b/src/openflexure_microscope_server/things/smart_scan.py
@@ -170,11 +170,6 @@ class SmartScanThing(OFMThing):
# Register with gallery.
_show_data_in_gallery = True
- @property
- def gallery_data_name(self) -> str:
- """Name under which data shows up in gallery."""
- return "Scans"
-
@property
def gallery_data_schema(self) -> type[scan_directories.ScanInfo]:
"""The schema (BaseModel) for passing data to the gallery."""
diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py
index 7baf8f2f..2a099185 100644
--- a/tests/unit_tests/test_gallery.py
+++ b/tests/unit_tests/test_gallery.py
@@ -2,7 +2,7 @@
import logging
from dataclasses import dataclass
-from typing import Callable, Optional
+from typing import Callable, Literal, Optional
import pytest
from pydantic import BaseModel
@@ -19,14 +19,19 @@ from openflexure_microscope_server.things.gallery import (
from ..shared_utils.lt_test_utils import LabThingsTestEnv
-def test_error_if_accessing_gallery_things_before_started():
- """Check that gallery_providing_things property errors before init."""
+def test_error_if_accessing_properties_before_started():
+ """Check that unfefined properties errors before enter."""
thing = create_thing_without_server(GalleryThing)
with pytest.raises(
RuntimeError,
match="Cannot access gallery_providing_things before server has started.",
):
thing.gallery_providing_things
+ with pytest.raises(
+ RuntimeError,
+ match="Cannot access card_types before server has started.",
+ ):
+ thing.card_types
class MockGalleryData(BaseModel):
@@ -34,6 +39,13 @@ class MockGalleryData(BaseModel):
mock: str
foobar: int
+ card_type: Literal["Mock"] = "Mock"
+
+
+class MockGalleryData2(MockGalleryData):
+ """Mock gallery data with a different card name."""
+
+ card_type: Literal["Mock2"] = "Mock2"
class MinimalGalleryClass:
@@ -42,13 +54,11 @@ class MinimalGalleryClass:
This should fail the protocol check as it is not an OFMThing
"""
- gallery_data_name: str = "Mock"
-
gallery_data_schema: type[BaseModel] = MockGalleryData
def get_data_for_gallery(self) -> list[BaseModel]:
"""Return a list of Mock Gallery Data."""
- return [MockGalleryData(mock="mock", foobar="foobar")]
+ return [self.gallery_data_schema(mock="mock", foobar="foobar")]
def delete_all_gallery_items(self) -> None:
"""Mock deleting all the data."""
@@ -63,16 +73,20 @@ class MinimalGallerySource(OFMThing, MinimalGalleryClass):
show_data_in_gallery = True
+class MinimalGallerySource2(MinimalGallerySource):
+ """Another Thing that can show data in the gallery with a different card type."""
+
+ gallery_data_schema: type[BaseModel] = MockGalleryData2
+
+
class BadGallerySource(OFMThing):
"""An OFMThing that almost defines enough to show in the gallery.
- Shouldn't match the protocol as ``gallery_data_name`` is missing.
+ Shouldn't match the protocol as ``gallery_data_schema`` is missing.
"""
show_data_in_gallery = True
- gallery_data_schema: type[BaseModel] = MockGalleryData
-
def get_data_for_gallery(self) -> list[BaseModel]:
"""Return a list of Mock Gallery Data."""
return [MockGalleryData(mock="mock", foobar="foobar")]
@@ -81,6 +95,31 @@ class BadGallerySource(OFMThing):
"""Mock deleting all the data."""
+class BadMockGalleryData(BaseModel):
+ """Mock gallery data without a card type."""
+
+ mock: str
+ foobar: int
+
+
+class BadGallerySource2(OFMThing):
+ """An OFMThing that defines enough to show in the gallery with bad card data.
+
+ Matches the protocol but will be rejected when setting card types.
+ """
+
+ show_data_in_gallery = True
+
+ gallery_data_schema: type[BaseModel] = BadMockGalleryData
+
+ def get_data_for_gallery(self) -> list[BaseModel]:
+ """Return a list of Mock Gallery Data."""
+ return [BadMockGalleryData(mock="mock", foobar="foobar")]
+
+ def delete_all_gallery_items(self) -> None:
+ """Mock deleting all the data."""
+
+
def test_gallery_compatible_protocol():
"""Check the gallery compatible protocol detects compatible classes only."""
# Minimal class has the gallery specific methods but is not an OFMThing.
@@ -118,18 +157,23 @@ def test_gallery_thing_finds_all_providers(simulation_test_env):
assert camera in gallery.gallery_providing_things.values()
assert smart_scan in gallery.gallery_providing_things.values()
+ # Also check the card types:
+ assert gallery.card_types == ["Capture", "Scan"]
+ assert gallery._card_type_map == {"camera": "Capture", "smart_scan": "Scan"}
+
# Also check the runtime checkable protocol GalleryCompatibleThing works directly
# when called with isinstance.
assert not isinstance(snake_workflow, GalleryCompatibleThing)
assert isinstance(smart_scan, GalleryCompatibleThing)
-def test_gallery_logs_for_bad_providers(caplog):
+@pytest.mark.parametrize("bad_source_class", [BadGallerySource, BadGallerySource2])
+def test_gallery_logs_for_bad_providers(bad_source_class, caplog):
"""Test that an error is logged if a gallery source doesn't match the protocol."""
# Create a config with the minimal Thing and the bad Thing
things = {
"minimal_thing": MinimalGallerySource,
- "bad_thing": BadGallerySource,
+ "bad_thing": bad_source_class,
"gallery": GalleryThing,
}
with caplog.at_level(logging.INFO):
@@ -155,7 +199,7 @@ def minimal_gallery_env():
"""Return a minimal working environment for testing the gallery."""
things = {
"minimal_thing1": MinimalGallerySource,
- "minimal_thing2": MinimalGallerySource,
+ "minimal_thing2": MinimalGallerySource2,
"gallery": GalleryThing,
}
with LabThingsTestEnv(
@@ -178,8 +222,8 @@ def test_gallery_lists_data(minimal_gallery_env, mocker, caplog):
]
thing_2_data = [
- MockGalleryData(mock="thing_2", foobar=1),
- MockGalleryData(mock="thing_2", foobar=2),
+ MockGalleryData2(mock="thing_2", foobar=1),
+ MockGalleryData2(mock="thing_2", foobar=2),
]
# Also mock the get_data_for_gallery methods.
@@ -188,12 +232,12 @@ def test_gallery_lists_data(minimal_gallery_env, mocker, caplog):
# Set the expected returns.
thing_1_return = [
- {"mock": "thing_1", "foobar": 1},
- {"mock": "thing_1", "foobar": 2},
+ {"mock": "thing_1", "foobar": 1, "card_type": "Mock"},
+ {"mock": "thing_1", "foobar": 2, "card_type": "Mock"},
]
thing_2_return = [
- {"mock": "thing_2", "foobar": 1},
- {"mock": "thing_2", "foobar": 2},
+ {"mock": "thing_2", "foobar": 1, "card_type": "Mock2"},
+ {"mock": "thing_2", "foobar": 2, "card_type": "Mock2"},
]
# Check that the gallery returns the expected concatenated data
@@ -216,24 +260,53 @@ class GalleryDeleteTestCase:
The default save kwargs assume a jpeg.
"""
+ cards_to_delete: list[str]
thing_1_side_effect: Optional[Callable]
+ thing_1_call_count: int
thing_2_call_count: int
# Expect directly calling the action will raise the thing 1 side_effect error
action_errors: bool
GALLERY_DELETE_TEST_CASES = [
- # No errors from thing 1, thing 2 gets called
+ # Delete both types of card.
GalleryDeleteTestCase(
- thing_1_side_effect=None, thing_2_call_count=1, action_errors=False
+ cards_to_delete=["Mock", "Mock2"],
+ thing_1_side_effect=None,
+ thing_1_call_count=1,
+ thing_2_call_count=1,
+ action_errors=False,
+ ),
+ # Delete first type of card.
+ GalleryDeleteTestCase(
+ cards_to_delete=["Mock"],
+ thing_1_side_effect=None,
+ thing_1_call_count=1,
+ thing_2_call_count=0,
+ action_errors=False,
+ ),
+ # Delete second type of card.
+ GalleryDeleteTestCase(
+ cards_to_delete=["Mock2"],
+ thing_1_side_effect=None,
+ thing_1_call_count=0,
+ thing_2_call_count=1,
+ action_errors=False,
),
# Thing 1 errors, thing 2 is still called
GalleryDeleteTestCase(
- thing_1_side_effect=RuntimeError, thing_2_call_count=1, action_errors=False
+ cards_to_delete=["Mock", "Mock2"],
+ thing_1_side_effect=RuntimeError,
+ thing_1_call_count=1,
+ thing_2_call_count=1,
+ action_errors=False,
),
- # Thing 1 raises invocation cancelled error. Thing 2 is not called. The InvocationError is raised
+ # Thing 1 raises invocation cancelled error. Thing 2 is not called.
+ # The InvocationError is raised
GalleryDeleteTestCase(
+ cards_to_delete=["Mock", "Mock2"],
thing_1_side_effect=InvocationCancelledError,
+ thing_1_call_count=1,
thing_2_call_count=0,
action_errors=True,
),
@@ -257,10 +330,10 @@ def test_gallery_calls_delete(test_case, minimal_gallery_env, mocker):
# Call delete_all_data, checking for errors if the test case expects an error.
if test_case.action_errors:
with pytest.raises(test_case.thing_1_side_effect):
- gallery.delete_all_data()
+ gallery.delete_all_data(test_case.cards_to_delete)
else:
- gallery.delete_all_data()
+ gallery.delete_all_data(test_case.cards_to_delete)
# Check the call counts are as expected from the test case.
- assert thing_1.delete_all_gallery_items.call_count == 1
+ assert thing_1.delete_all_gallery_items.call_count == test_case.thing_1_call_count
assert thing_2.delete_all_gallery_items.call_count == test_case.thing_2_call_count
diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py
index 0be10c6d..cc8f5bba 100644
--- a/tests/unit_tests/test_simulated_camera.py
+++ b/tests/unit_tests/test_simulated_camera.py
@@ -297,7 +297,6 @@ def test_gallery_responses(camera, mocker, caplog):
with tempfile.TemporaryDirectory() as tmpdir, caplog.at_level(logging.INFO):
camera._data_dir = tmpdir
- assert camera.gallery_data_name == "Captures"
assert camera.gallery_data_schema == CaptureInfo
# When we start there are no captures
diff --git a/webapp/src/assets/less/variables.less b/webapp/src/assets/less/variables.less
index 8f2df722..ead4c33a 100644
--- a/webapp/src/assets/less/variables.less
+++ b/webapp/src/assets/less/variables.less
@@ -249,6 +249,48 @@
}
}
+// A button styled like text. For use in menus
+.ofm-text-button {
+ display: block;
+ background: none;
+ border: none;
+ padding: 8px 10px;
+ cursor: pointer;
+ color: @global-color;
+ width: 100%;
+ text-align: left;
+ font-weight: 600;
+}
+.hook-inverse() {
+ .ofm-text-button{
+ color: @inverse-global-color;
+ }
+}
+
+/*
+ * Opaque - button-like colours
+ *
+ * The buttons are transparent. If an element overlays others it should
+ * Look the same as the buttons, but have no transparency.
+ */
+.ofm-opaque-element{
+ background: #f5f5f5;
+}
+.hook-inverse() {
+ .ofm-opaque-element{
+ background: #282626;
+ }
+}
+
+.ofm-opaque-highlight:hover {
+ background: #fff;
+}
+.hook-inverse() {
+ .ofm-opaque-highlight:hover {
+ background: #000;
+ }
+}
+
/*
* Accordion
*/
diff --git a/webapp/src/components/appContent.vue b/webapp/src/components/appContent.vue
index d5fac1a0..61051a6c 100644
--- a/webapp/src/components/appContent.vue
+++ b/webapp/src/components/appContent.vue
@@ -267,6 +267,7 @@ export default {
#container-left {
overflow: auto;
+ scrollbar-gutter: stable;
background-color: rgba(180, 180, 180, 0.025);
width: 100%;
height: 100%;
diff --git a/webapp/src/components/genericComponents/multiSelectDropdown.vue b/webapp/src/components/genericComponents/multiSelectDropdown.vue
new file mode 100644
index 00000000..6f89e866
--- /dev/null
+++ b/webapp/src/components/genericComponents/multiSelectDropdown.vue
@@ -0,0 +1,150 @@
+
+