diff --git a/picamera_coverage.zip b/picamera_coverage.zip
index 0c04bf3d..d17ba963 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 5a39f11c..0d61bb23 100644
--- a/src/openflexure_microscope_server/things/camera/__init__.py
+++ b/src/openflexure_microscope_server/things/camera/__init__.py
@@ -332,6 +332,13 @@ class BaseCamera(OFMThing, ABC):
self.logger.info(f"Deleting: {capture}")
os.remove(capture)
+ def get_gallery_bulk_actions(self) -> list[ActionButton]:
+ """Return the bulk gallery actions for cameras.
+
+ By default there are no bulk actions.
+ """
+ return []
+
@lt.endpoint(
"delete",
"capture/{name}",
diff --git a/src/openflexure_microscope_server/things/gallery.py b/src/openflexure_microscope_server/things/gallery.py
index 23f4f1bd..35b0aa32 100644
--- a/src/openflexure_microscope_server/things/gallery.py
+++ b/src/openflexure_microscope_server/things/gallery.py
@@ -12,6 +12,7 @@ from pydantic import BaseModel
import labthings_fastapi as lt
from openflexure_microscope_server.things import OFMThing
+from openflexure_microscope_server.ui import ActionButton
@runtime_checkable
@@ -38,6 +39,8 @@ class GalleryCompatibleThing(Protocol):
def delete_all_gallery_items(self) -> None: ... # noqa: D102
+ def get_gallery_bulk_actions(self) -> list[ActionButton]: ... # noqa: D102
+
class GalleryThing(lt.Thing):
"""A Thing for communicating with the front end gallery."""
@@ -125,6 +128,19 @@ class GalleryThing(lt.Thing):
raise RuntimeError("Cannot access card_types before server has started.")
return self._card_types
+ # Cache result after first call.
+ _bulk_actions: Optional[list[ActionButton]] = None
+
+ @lt.property
+ def bulk_actions(self) -> list[ActionButton]:
+ """All bulk actions."""
+ if self._bulk_actions is None:
+ actions: list[ActionButton] = []
+ for thing in self.gallery_providing_things.values():
+ actions += thing.get_gallery_bulk_actions()
+ self._bulk_actions = actions
+ return self._bulk_actions
+
@lt.property
def list_data(self) -> list[dict[str, Any]]:
"""List the data from all registered things.
diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py
index d22cb55f..337c8b40 100644
--- a/src/openflexure_microscope_server/things/smart_scan.py
+++ b/src/openflexure_microscope_server/things/smart_scan.py
@@ -32,6 +32,7 @@ import labthings_fastapi as lt
from openflexure_microscope_server import scan_directories, stitching
from openflexure_microscope_server.things import OFMThing
+from openflexure_microscope_server.ui import ActionButton, action_button_for
from openflexure_microscope_server.utilities import coerce_thing_selector
# Things
@@ -199,6 +200,26 @@ class SmartScanThing(OFMThing):
self.logger.info(f"Deleting: {scan_name}")
self._delete_scan(scan_name)
+ def get_gallery_bulk_actions(self) -> list[ActionButton]:
+ """Return the bulk gallery actions for smart scan."""
+ # Stitch all scans
+ return [
+ action_button_for(
+ self,
+ "stitch_all_scans",
+ submit_label="Stitch All Unstitched Scans",
+ can_terminate=True,
+ button_primary=False,
+ modal_progress=True,
+ requires_confirmation=True,
+ confirmation_message=(
+ "
Stitch all unstitched scans?
"
+ "
Depending on the number and size of scans, this may be slow, "
+ "and your microscope should not be used during the stitching.'"
+ ),
+ )
+ ]
+
# Note that the default detector name is set at init. This is over written if
# setting is loaded from disk.
@lt.setting
diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py
index 2a099185..3b1f3db9 100644
--- a/tests/unit_tests/test_gallery.py
+++ b/tests/unit_tests/test_gallery.py
@@ -15,6 +15,7 @@ from openflexure_microscope_server.things.gallery import (
GalleryCompatibleThing,
GalleryThing,
)
+from openflexure_microscope_server.ui import ActionButton
from ..shared_utils.lt_test_utils import LabThingsTestEnv
@@ -63,6 +64,10 @@ class MinimalGalleryClass:
def delete_all_gallery_items(self) -> None:
"""Mock deleting all the data."""
+ def get_gallery_bulk_actions(self) -> list[ActionButton]:
+ """No bulk actions."""
+ return []
+
class MinimalGallerySource(OFMThing, MinimalGalleryClass):
"""Minimal example of a Thing that can show data in the gallery.
@@ -94,6 +99,10 @@ class BadGallerySource(OFMThing):
def delete_all_gallery_items(self) -> None:
"""Mock deleting all the data."""
+ def get_gallery_bulk_actions(self) -> list[ActionButton]:
+ """No bulk actions."""
+ return []
+
class BadMockGalleryData(BaseModel):
"""Mock gallery data without a card type."""
@@ -119,6 +128,10 @@ class BadGallerySource2(OFMThing):
def delete_all_gallery_items(self) -> None:
"""Mock deleting all the data."""
+ def get_gallery_bulk_actions(self) -> list[ActionButton]:
+ """No bulk actions."""
+ return []
+
def test_gallery_compatible_protocol():
"""Check the gallery compatible protocol detects compatible classes only."""
@@ -161,6 +174,16 @@ def test_gallery_thing_finds_all_providers(simulation_test_env):
assert gallery.card_types == ["Capture", "Scan"]
assert gallery._card_type_map == {"camera": "Capture", "smart_scan": "Scan"}
+ # Also check all bulk actions
+ bulk_actions = gallery.bulk_actions
+ # Only actions should be stitch all scans
+ assert len(bulk_actions) == 1
+ assert bulk_actions[0].thing == "smart_scan"
+ assert bulk_actions[0].action == "stitch_all_scans"
+ assert bulk_actions[0].can_terminate
+ assert bulk_actions[0].requires_confirmation
+ assert "Stitch all unstitched scans?" in bulk_actions[0].confirmation_message
+
# Also check the runtime checkable protocol GalleryCompatibleThing works directly
# when called with isinstance.
assert not isinstance(snake_workflow, GalleryCompatibleThing)
diff --git a/webapp/src/assets/less/variables.less b/webapp/src/assets/less/variables.less
index ead4c33a..739ec6cb 100644
--- a/webapp/src/assets/less/variables.less
+++ b/webapp/src/assets/less/variables.less
@@ -198,6 +198,74 @@
}
}
+/*
+ * OpenFlexure tabs.
+ *
+ * The ofm-* rules are global rules that are written for the to OFM interface (as opposed to
+ * imported global rules). All tab content is in an `ofm-tab`
+ */
+
+.ofm-tab {
+ --ofm-navbar-height: 50px;
+}
+
+// For adding a nav bar at the top of an `.ofm-tab`
+.ofm-tab-navbar {
+ position: sticky;
+ top: 0;
+ z-index: 1000;
+ display: flex;
+ justify-content: flex-end;
+ height: var(--ofm-navbar-height);
+ border-width: 0 0 1px;
+ border-style: solid;
+ border-color: rgba(180, 180, 180, 0.25);
+ padding-right: 20px;
+ background: #fff;
+}
+.hook-inverse() {
+ .ofm-tab-navbar {
+ background: #252525;
+ }
+}
+
+/*
+ * A container with a sidebar.
+ *
+ * This is designed to be used inside an `.ofm-tab` to create a container with a sidebar that can be
+ * opened on the right hand side.
+ */
+.ofm-container-with-sidebar
+{
+ display: grid;
+ grid-template-columns: 1fr 0;
+ height: 100%;
+}
+
+.ofm-container-with-sidebar.sidebar-open {
+ grid-template-columns: 1fr 250px;
+}
+
+.ofm-sidebar {
+ padding: 10px;
+ overflow: hidden;
+ border-left: none;
+ display: none;
+ background:#f5f5f5;
+}
+
+.ofm-sidebar.open {
+ border-left: 1px solid rgba(180, 180, 180, 0.25);
+ display: block;
+}
+
+.hook-inverse() {
+ .ofm-sidebar{
+ background: #282626;
+ }
+}
+
+
/*
* Links
*/
diff --git a/webapp/src/components/appContent.vue b/webapp/src/components/appContent.vue
index 61051a6c..d5fac1a0 100644
--- a/webapp/src/components/appContent.vue
+++ b/webapp/src/components/appContent.vue
@@ -267,7 +267,6 @@ 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
index 6f89e866..a8294895 100644
--- a/webapp/src/components/genericComponents/multiSelectDropdown.vue
+++ b/webapp/src/components/genericComponents/multiSelectDropdown.vue
@@ -104,7 +104,8 @@ export default {
.multi-select {
position: relative;
- width: 250px;
+ width: 100%;
+ max-width: 250px;
}
.dropdown-top {
diff --git a/webapp/src/components/genericComponents/tabContent.vue b/webapp/src/components/genericComponents/tabContent.vue
index 156e28d1..15e38d57 100644
--- a/webapp/src/components/genericComponents/tabContent.vue
+++ b/webapp/src/components/genericComponents/tabContent.vue
@@ -2,7 +2,7 @@
diff --git a/webapp/src/components/tabContentComponents/galleryContent.vue b/webapp/src/components/tabContentComponents/galleryContent.vue
index 65da50bd..366da1b0 100644
--- a/webapp/src/components/tabContentComponents/galleryContent.vue
+++ b/webapp/src/components/tabContentComponents/galleryContent.vue
@@ -1,47 +1,10 @@
-
+
-