From 2dcd3866619344824be586c30a97eb1b9ea3edfd Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 25 Jun 2026 16:52:44 +0100 Subject: [PATCH 1/5] Add way for gallery providers to specify bulk actions. --- .../things/camera/__init__.py | 7 +++++++ .../things/gallery.py | 16 ++++++++++++++ .../things/smart_scan.py | 21 +++++++++++++++++++ tests/unit_tests/test_gallery.py | 13 ++++++++++++ 4 files changed, 57 insertions(+) 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..5701beeb 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.""" From 8d201dd1a9326a59f8c4339cbcd2f548dbac8c90 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 25 Jun 2026 17:00:03 +0100 Subject: [PATCH 2/5] Add sidebar to gallery for filtering and bulk actions --- webapp/src/assets/less/variables.less | 57 +++++++ webapp/src/components/appContent.vue | 1 - .../genericComponents/multiSelectDropdown.vue | 3 +- .../genericComponents/tabContent.vue | 2 +- .../tabContentComponents/galleryContent.vue | 157 +++++++++++------- 5 files changed, 154 insertions(+), 66 deletions(-) diff --git a/webapp/src/assets/less/variables.less b/webapp/src/assets/less/variables.less index ead4c33a..1a1491fb 100644 --- a/webapp/src/assets/less/variables.less +++ b/webapp/src/assets/less/variables.less @@ -198,6 +198,63 @@ } } +.ofm-tab { + --ofm-navbar-height: 50px; +} + +.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; + } +} + +/* + * Sidebar + */ +.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 @@ From d020185c13d4345e4b92283e7d03c60c2401cd96 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 25 Jun 2026 17:14:43 +0100 Subject: [PATCH 3/5] Update picamera test results --- picamera_coverage.zip | Bin 54038 -> 54038 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/picamera_coverage.zip b/picamera_coverage.zip index 0c04bf3dfe3d06461c4f0ca4ab5124e19b98f671..d17ba9634d384f4fbc817ec42bcd1bad2293477f 100644 GIT binary patch delta 636 zcmbQXjCtBJX2Ae&W)=|!5V+WRGv@vEDH{b%9GLs~d^dYIeBxzuH#s0c zk-495_hv_dNgSKmB28IYoLCy!Cr^yjnmpA{gq72QrIC5^#7IMsSu7md%+Zr4Mk;fH zjbb-q&IIu`3&yJ|s_bOo|H(g---4f+?>S!_pEmC@UP+$2Jga%CxIc0Ca65AIa_!vg zDA3O}`At_8Bl~3c?uz;pK2}D~Mh|ADKMXtONgqGJq;p#4K?6^TYy$&>?FEho1`Y;> zV+S%C45bzv1BCzsgAD_N1Oo#Do5TSKpTPi38!$98GJKfMaO0mELmg8?BLf4E0mFys zAVoO`8d-T+89AG{xSAM5lN{t4&McVh$7%s1!Y;9z56=_)Y#0x)X>7h%p}z)Ep79K3-ye)sV0V&DJID#78VvMDJEuyN#-erCMhY2 p<`zat=H?bj#+JzzhGu4|O0@yrj7%cTC`oE^&m~KQ0syXO##{gZ delta 590 zcmbQXjCtBJX2Ae&W)=|!5IB~2Bj&Ih-$p?b2j)sXv&|k3pLm(HI42+Q(_(Vum~7+c z#K=Cm-p`)NkZrQOzv^Z|e;Y<-bH1v{0Rf84++G(OmD+cS%i zgOLLy&%{3Yey>)pO%2UVO;Qb# z5={~flah?h%n~h94NS}vlanlyl2Q`QO%2TqOij~Fm1+aL8JR?wQ4-1Io=cVp%g Date: Thu, 25 Jun 2026 17:36:24 +0100 Subject: [PATCH 4/5] Add tests to for gallery bulk actions --- tests/unit_tests/test_gallery.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit_tests/test_gallery.py b/tests/unit_tests/test_gallery.py index 5701beeb..3b1f3db9 100644 --- a/tests/unit_tests/test_gallery.py +++ b/tests/unit_tests/test_gallery.py @@ -174,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) From 57e01fa823e388bc5de8dbe2e1e448a245224ce2 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 29 Jun 2026 10:54:24 +0000 Subject: [PATCH 5/5] Add comments to global CSS for ofm-tab --- webapp/src/assets/less/variables.less | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/webapp/src/assets/less/variables.less b/webapp/src/assets/less/variables.less index 1a1491fb..739ec6cb 100644 --- a/webapp/src/assets/less/variables.less +++ b/webapp/src/assets/less/variables.less @@ -198,10 +198,18 @@ } } +/* + * 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; @@ -222,7 +230,10 @@ } /* - * Sidebar + * 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 {