From 2f12054eaed29ad8f2e8e77a9cf2aedf2112a43f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:32:07 +0000 Subject: [PATCH 1/2] Notebook: each step fits a laptop screen; Compare groups in the run step; the app in short in the guide Owner test of v0.5.0 (10 November 2026), items (a) and (b). Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- AGENTS.md | 23 +- CHANGELOG.md | 32 +++ docs/colab-release-check.md | 2 +- docs/user_guide.md | 57 +++++ notebooks/MorphoBricks_Preprocessing.ipynb | 10 +- pyproject.toml | 1 + .../notebook_axes_quick.py | 2 + .../notebook_axis_preview.py | 6 +- .../notebook_axis_step.py | 26 ++- .../notebook_compact.py | 150 ++++++++++++ .../notebook_plots.py | 2 +- .../notebook_quick_scan.py | 13 +- .../notebook_render.py | 30 +++ .../notebook_results.py | 6 +- .../notebook_stepper.py | 105 ++++++++- .../notebook_steps.py | 76 ++----- src/morphobricks_preprocessing/notebook_ui.py | 30 +-- tests/test_notebook_compact.py | 214 ++++++++++++++++++ tests/test_notebook_quick_scan.py | 3 +- tests/test_notebook_ui.py | 14 +- 20 files changed, 679 insertions(+), 123 deletions(-) create mode 100644 src/morphobricks_preprocessing/notebook_compact.py create mode 100644 tests/test_notebook_compact.py diff --git a/AGENTS.md b/AGENTS.md index 3e0a4d17..3ad5f8c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -314,9 +314,21 @@ src/morphobricks_preprocessing/ (plan §12 Q26, Q31). Since PP-9 it holds the page methods, __init__ and the shared helpers; the step methods live on mixin classes in the modules below, and it re-exports every public name it had (`__all__`) - notebook_steps.py steps(): the shipped notebook's 13 steps, one at a time (PR 17a; pure - composition of the cards below); notebook_stepper.py the strip, Back/Next - and the overview; notebook_step_states.py each step's done/one-line state + notebook_steps.py steps(): the shipped notebook's 12 steps, one at a time (PR 17a; pure + composition of the cards below); notebook_stepper.py the strip, Back/Next, + the overview, the compact page (one screen per step: the cards scroll in + their own box) and about()/field_rows(); notebook_step_states.py each + step's done/one-line state + notebook_compact.py the Images and Run steps arranged for one screen: one Folders card, the + collapsed panels, and Compare groups (the former statistics page) in the + run step (redesign of 10 November 2026) + notebook_progress.py Wait: one line per wait (done of total, item, elapsed, time left once + measurable); progress_html, time_left (pure parts) + notebook_quick_scan.py the first step's quick scan: the folder walk only, one random image for + looking only, then the explicit click that reads every header + axis_example.py which planes the axes step's example shows (pure): one tile per channel, + a few time points or slices; notebook_plots.example_png draws them + pixel_size_summary.py the pixel size in one line, which images differ and where (pure) notebook_names.py the names card's rows (conditions on step 4, replicates on step 5) notebook_axes_quick.py the stepper's one-click Confirm these axes (Q3, PR 17b) notebook_row_fold.py reopen: unchanged, answered rows fold; new or changed rows orange @@ -746,9 +758,12 @@ Technical details go to `logging` (shown in the notebook's "Technical details" p | Masks (Q32) | Optional masks folder outside the experiment, one subfolder per mask type mirroring the experiment's folders, or the experiment's folders with `_.tif` (both may be mixed; `ambiguous_mask` when a name could fit two images or both layouts), paired by leaf folder + `position_id`; OME-Zarr label images are masks too. Header-only errors (`masks_folder_not_found`, `masks_inside_experiment`, `mask_names_differ_in_case`, `duplicate_mask`, `ambiguous_mask`, `unreadable_mask`, `mask_not_2d`, `mask_size_mismatch`, `mask_pixel_type`) and warnings (`mask_missing`, `mask_without_image`, `mask_outside_type_folder`) in `check`/`run`; manifest key `masks` (optional, version 1). Optional mask step (`masks` command, `scan_masks`): kinds, relations with evidence, direct-parent hierarchy, `mask_relationships.json` (report version 1); default thresholds the user may change (`thresholds=` keyword, one CLI option each, checked by `mask_thresholds`, written in the report); suggestions only, the user names and confirms. SciPy core dependency. Confirmed by the owner: 2D masks at the image's YX, integer/bool pixels, masks folder outside the experiment. Next: notebook step and a file of the user's answers; tag v0.2.0 proposed after merge. Plane identity (issue #36): masks may have Z and/or T named in their own metadata (never guessed: unnamed axes are `mask_not_2d`); the masks of an image share their planes (`mask_planes_differ`) and `run` checks them against the image's confirmed roles (`mask_planes_mismatch`, `mask_plane_issues`), so a 2D mask is refused for a Z-stack or time-lapse; manifest mask entries gain `axes` and `shape` (optional keys, version 1); `read_mask(z=, t=)` reads one plane; `scan_masks` scans plane by plane | | Notebook style | Follow the lab's NucleiSky and LabChronicle notebooks (see §7) | | Widget widths | **99%**, never 100% | +| Waits | Every wait over about a second shows `notebook_progress.Wait`: done of total, the item, elapsed time, time left once measurable. Readers take a `progress` callback; no bare spinner (owner, 10 November 2026) | +| Disabled actions | Every disabled button has a visible `notebook_render.blocked_line` beside it saying what blocks it; a tooltip alone is a hidden refusal (owner, 10 November 2026) | +| One screen per step | The shipped notebook's steps fit 1366 x 768: one row of chips, explanations under *About this step*, details in collapsed panels, long lists scrolling in their own box (owner, 10 November 2026) | | Paths and filenames in widget HTML and notebook text | **`…` / `**…**`, never `` or inline code** | | Things the user must confirm | **Large buttons** in a highlighted question box (`ConfirmToggle`), never small checkboxes | -| Statistics step | "Step 4 · Calculate the statistics (optional)": clearly optional, writes nothing. A CSV chooser (Browse…) is prefilled with the table just computed, otherwise empty. Not calculated automatically | +| Statistics step | **Shipped notebook since v0.5.1: the collapsed *Compare groups* panel of the run step, for that run's table; no table chooser, no second intensity check (owner, 10 November 2026). The page below remains for `results_step()` without `steps()`.** "Step 4 · Calculate the statistics (optional)": clearly optional, writes nothing. A CSV chooser (Browse…) is prefilled with the table just computed, otherwise empty. Not calculated automatically | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 8731e1b7..ecc4248f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,38 @@ v0.5.1: no run before v0.5.1 is read.) ## Unreleased (v0.5.1) +### Notebook redesign 4: each step fits a laptop screen; Compare groups in the run step + +Owner test of v0.5.0 (10 November 2026), items (a) and (b): "simple and minimalistic". +Layout only: no answer, check, refusal, draft, description or run output changes. + +- **One screen per step (1366 x 768).** The strip is one row (shorter chip names: *Images*, + *Compared to*, *Tags*, *Save*, *Describe masks*; the page keeps the full title). A step's + title and purpose are one line; its cards are in a box that scrolls by itself when they are + higher than the screen, so the title, the strip and Back/Next stay in view. Cards, tiles, + tables, buttons and pictures are denser. Each step's explanation is folded under **About + this step**; a card's help paragraph is one line that opens when pointed at. +- **Images:** one *Folders* card with three labelled rows (images, masks, output); **Scan the + folders** and **Looks right: read all image headers** side by side; after the check the scan + makes way for the result, and the pictures per folder, the check report and *Exclude images* + are collapsed panels. +- **Axes:** a refusal of the roles is one sentence with *Files and what to do* on demand + (`notebook_render.issues_brief_html`), e.g. "Error · different channel counts: The images + have different numbers of channels: 3 channels (10 images), 4 channels (4 images)." +- **Run:** *Before you run* is shown only while something is missing; *Settings and mode* and + *What will happen* are collapsed; after a run the intensity checks and plots, **Compare + groups** and the report are collapsed panels. +- **Compare groups replaces the optional statistics page** in the shipped notebook: the + grouping, the percentiles, the pooled statistics and the distribution plot of the table this + run wrote. There is no table to choose and the intensity checks are not repeated. + `results_step()` after `steps()` returns an empty hidden box, so the shipped notebook's cell + needs no change; called without `steps()` it is the page it was. +- **`welcome()` is one line** (name, version, "the notebook is ready"); the *What next?* boxes + are not shown in the stepper. +- The notebook's Markdown cells and the user guide (§4, "The app in short") describe this + flow. `RELEASE_REF` and the code cells are unchanged: the shipped file opens the redesigned + app once the v0.5.1 release moves the pin. + ### Notebook redesign 3: the axes step shows the data as the roles read it; pixel size in one line Owner test of v0.5.0 (10 November 2026), items (e) and (f). The shipped notebook has 12 steps: diff --git a/docs/colab-release-check.md b/docs/colab-release-check.md index 63666788..a588131c 100644 --- a/docs/colab-release-check.md +++ b/docs/colab-release-check.md @@ -101,7 +101,7 @@ The notebook has three code cells, run in order: **1. Install MorphoBricks prepr press **Check folder**. 17. - [ ] In Step 2, swap the Y and X roles. The red axis check appears, with the **Make named-axis copies (optional)** card under it. Its button is disabled. -18. - [ ] Press **Show preview** and **Yes, the preview looks right**, then **Yes, write the +18. - [ ] Look at the example (drawn by itself) and press **Yes, the example looks right**, then **Yes, write the copies with these roles**. 19. - [ ] Press **Browse…**: the amber card names **Copy path**. Paste a Drive folder inside the experiment and check that it is refused. Then paste one next to the experiment. diff --git a/docs/user_guide.md b/docs/user_guide.md index 8af9768f..02382aff 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -265,6 +265,63 @@ notebook's step 3 (§4), also tells how they relate. ## 4. The notebook, step by step +### The app in short (v0.5.1) + +Cell 3 opens the app: a strip of twelve steps, one on screen at a time, with **Back** and +**Next** under it (**Next** is never blocked; a chip opens its step). Each step fits a laptop +screen: what grows with the experiment (folder lists, pictures, tables) scrolls inside its own +box, explanations are folded under **About this step**, and details are in collapsed panels. +Every wait shows a bar with what is done of the total, the file being read, the time elapsed +and, once it can be measured, the time left. Every greyed-out button has a line beside it +saying what it waits for. + +1. **Images.** Give the folders (images; masks and output are optional). **Scan the folders** + lists every folder with its labels and image count without reading any image header, and + shows one image chosen at random, with its path, for looking only: it is not used for + anything. If this is your experiment, click **Looks right: read all image headers**; the + headers are then read and checked. Changing a folder asks for a new scan. The pictures of + each folder, the check's report and **Exclude images** are collapsed below. +2. **Axes.** One box per image dimension with its role, and beside them an **example of one + image as these roles read it**: one tile per channel (`c=0`, `c=1`, …), a few time points + or slices, or the single plane, each with its height and width. It is drawn by itself and + redrawn whenever you change a role, so a wrong role looks wrong (a strip, stripes). Another + image or other planes are under **Another image, or other planes**. Nothing is confirmed + for you: click **Confirm these axes** (offered when the headers name every axis and + nothing is open), or **Yes, the example looks right** and then **Confirm axis roles**. + - **Pixel size** is one line here: for example *Pixel size: 0.108 × 0.108 µm (from the + headers, same in all 270 images)*. When the headers give none, type it once and click + **Use for all images** (or choose *pixels: deliberately uncalibrated*); leaving it empty + records the images as uncalibrated, nothing is guessed. When the headers do not agree, + the line says how many images have which size and in which folders, and **Set per + imaging unit…** opens: there you state a size for one folder (imaging unit) or image. + **Use for all images** is your statement for every image: it replaces sizes stated per + folder, and a size that differs from an image's header is still listed as a warning. +3. **Conditions**, 4. **Replicates**, 5. **Compared against**, 6. **Image type**, 7. **Stains**, + 8. **Tag channels**, 9. **Masks**: one kind of answer per step, saved as you give it. +10. **Check and save.** Your experiment in sentences, one question per replicate, then **Yes, + save the experiment description**. The line beside the button says what is still missing. +11. **Run.** **Compute histograms**; what is still to do is listed only while something is. + The settings and *What will happen* are collapsed. After the run: its result, and collapsed + panels for the **intensity checks and plots**, **Compare groups** and the report. + **Compare groups** replaces the former optional statistics page: choose how to group + (condition, biological repeat, technical repeat or image) and click **Calculate + statistics** for the pooled statistics and the distribution plot of the table this run + wrote. It writes nothing. +12. **Describe the masks** (optional), after a run that recorded masks. + +Images with different numbers of channels (for example three in most folders and four in +one) are refused in this version (`different_channel_counts`): the axes step names the error in +one sentence, with the files and what to do one click away, and **Confirm axis roles** says +that this is what it waits for. + +The rest of this section describes each card in detail. It is written for the same cards +shown as stacked pages (as other bricks embed them); where the app differs, the list above +is what you see. In particular: **Show preview** and **Yes, the preview looks right** are the +example and **Yes, the example looks right**; **Check folder** is **Looks right: read all +image headers**; and step 4 below is the **Compare groups** panel. + +### The cards in detail + The notebook's code cells are hidden: run them from top to bottom (click a cell and press **Shift + Enter**). Each step is a page of boxes. A blue box at the top says what the step is for, every control has a grey help line, and one coloured message says what happened diff --git a/notebooks/MorphoBricks_Preprocessing.ipynb b/notebooks/MorphoBricks_Preprocessing.ipynb index f27261b8..0e8d1301 100644 --- a/notebooks/MorphoBricks_Preprocessing.ipynb +++ b/notebooks/MorphoBricks_Preprocessing.ipynb @@ -14,7 +14,7 @@ "- **Confirms the experimental design**: folder labels, biological-repeat pairing and controls.\n", "- **Confirms image interpretation**: axis roles and fluorescence/RGB image type. Scientific metadata are never silently guessed.\n", "- **Computes per-plane intensity histograms** used by downstream MorphoBricks.\n", - "- **Runs intensity QC** and provides optional pooled statistics after preprocessing.\n", + "- **Runs intensity QC** and, on request, compares groups after preprocessing.\n", "\n", "### Before you start\n", "\n", @@ -372,12 +372,12 @@ "\n", "In the app:\n", "\n", - "1. Give the **experiment folder** and optional **output/masks folders**, then click **Check folder**.\n", - "2. Go through the steps one at a time with **Next** (or click a step in the strip at the top): axes, pixel sizes, conditions, replicates, comparisons, image type, stains, channel tags and masks.\n", + "1. Give the **experiment folder** (masks and output folders are optional) and click **Scan the folders**. Look at the folders and the example image, then click **Looks right: read all image headers**.\n", + "2. Go through the steps one at a time with **Next** (or click a step in the strip at the top): axes and pixel size, conditions, replicates, comparisons, image type, stains, channel tags and masks.\n", "3. On **Check and save**, read your experiment in words and save it; then **Run** computes the histograms.\n", - "4. Review the QC results; pooled statistics are optional.\n", + "4. After the run, the intensity checks and **Compare groups** are collapsed panels of the run step.\n", "\n", - "Detailed explanations appear inside the app only where a decision is required." + "Every wait shows its progress, and a greyed-out button says beside it what it waits for." ] }, { diff --git a/pyproject.toml b/pyproject.toml index 1deac549..081960a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,7 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.run_labels" = "io" "morphobricks_preprocessing.notebook_axes" = "ui" "morphobricks_preprocessing.notebook_progress" = "ui" +"morphobricks_preprocessing.notebook_compact" = "ui" "morphobricks_preprocessing.notebook_quick_scan" = "ui" "morphobricks_preprocessing.notebook_axes_quick" = "ui" "morphobricks_preprocessing.notebook_channel_thumbs" = "ui" diff --git a/src/morphobricks_preprocessing/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py index 90aa5fd9..76a3c46f 100644 --- a/src/morphobricks_preprocessing/notebook_axes_quick.py +++ b/src/morphobricks_preprocessing/notebook_axes_quick.py @@ -111,6 +111,8 @@ def _refresh_quick_axes(self, drawn: bool = False) -> None: for widget in (self.preview_checked.widget, self.preview_blocker, self.confirm_button, self.confirm_blocker): # fmt: skip ui.set_visible(widget, not offered) + # The offer says the roles are the headers' and nothing is open: the line is not repeated. + ui.set_visible(self.axis_status, not offered) if offer is None: return if offer.roles != self.mapping.roles: diff --git a/src/morphobricks_preprocessing/notebook_axis_preview.py b/src/morphobricks_preprocessing/notebook_axis_preview.py index 0dfa0884..47c441d9 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -200,10 +200,8 @@ def _preview_label(self) -> str: return ui.status("ok", "Preview with the confirmed axis roles.") return ui.status( "info", - "Preview of the roles above, not confirmed yet.", - [ - "Nothing is written: the run uses only the roles you confirm below.", - ], + "Preview of the roles above, not confirmed yet. Nothing is written: the run uses " + "only the roles you confirm.", ) def _preview_header(self): # noqa: ANN202 diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py index 5bfe5f98..d92aac21 100644 --- a/src/morphobricks_preprocessing/notebook_axis_step.py +++ b/src/morphobricks_preprocessing/notebook_axis_step.py @@ -14,10 +14,20 @@ from .image_type import FLUORESCENCE from .notebook_axes import axis_check_html, header_marks from .notebook_options import IMAGE_TYPE_OPTIONS, ROLE_OPTIONS -from .notebook_render import blocked_line, issues_html, plane_summary_html, role_effect +from .notebook_render import ( + blocked_line, + issues_brief_html, + plane_summary_html, + role_effect, +) from .run import Preflight from .validation import plural +ROLES_HELP = ( + "One box per dimension, in the order stored in the files. Exactly one Y and one X; a " + "dimension with more than one plane needs Z, C or T." +) + class _AxisStep: """Step 2's axis roles: the role dropdowns, the axis check and the missing-axes card.""" @@ -131,6 +141,7 @@ def _build_preview_card(self) -> None: ], ) self.axis_sizes_slot = widgets.VBox() # the pixel size card, in the shipped notebook + self.roles_help = ui.help_text(ROLES_HELP) # folded into About this step there def _build_image_type_card(self) -> None: """Build the image type card, hidden until the axis roles are confirmed.""" @@ -182,14 +193,7 @@ def _refresh_axis_step(self) -> None: self._set_locked(False) roles = ui.card( "Axis roles", - [ - ui.help_text( - "One box per dimension, in the order stored in the files. Exactly one Y " - "and one X; a dimension with more than one plane needs Z, C or T." - ), - grid, - self.plane_summary, - ], + [self.roles_help, grid, self.plane_summary], ) left = widgets.VBox( [roles, self.missing_card, self.axis_status, self.axis_sizes_slot], @@ -279,7 +283,7 @@ def _role_grid(self) -> widgets.GridBox: return widgets.GridBox( axis_cards, layout=widgets.Layout( - grid_template_columns="repeat(auto-fill, minmax(240px, 1fr))", grid_gap="10px" + grid_template_columns="repeat(auto-fill, minmax(170px, 1fr))", grid_gap="8px" ), ) @@ -368,7 +372,7 @@ def _mapping_changed(self) -> None: if review.issues: others = [i for i in review.issues if i.code not in AXIS_CHECK_CODES] self.axis_status.value = axis_check_html(review) + ( - issues_html(others) if others else "" + issues_brief_html(others) if others else "" ) self.axis_next.value = "" else: diff --git a/src/morphobricks_preprocessing/notebook_compact.py b/src/morphobricks_preprocessing/notebook_compact.py new file mode 100644 index 00000000..071334e3 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_compact.py @@ -0,0 +1,150 @@ +"""The compact pages of the shipped notebook: what a step shows at once, and what on demand. + +Owner's test of v0.5.0 (10 November 2026, items (a) and (b)): each step fits a laptop screen, +with fewer cards, short labels and the details collapsed. This module only arranges widgets the +other modules own; no answer, check or refusal is decided here. + +- **Images** has one *Folders* card (three labelled rows); the pictures per folder and the + check's report are collapsed once the headers are read. +- **Run** shows what is still missing only while something is, with the settings and *What + will happen* collapsed; after a run, the intensity checks, **Compare groups** (the former + optional statistics page: pooled statistics and the distribution plot of this run's table) + and the report are collapsed panels. +""" + +from __future__ import annotations + +import ipywidgets as widgets + +from . import notebook_style as ui +from .notebook_stepper import about, field_rows + +FOLDER_INTRO = ( + "What happens here: first the folders are listed (labels from the folder names: " + "condition / biological repeat / technical repeat) and one image is shown, so you can see " + "that this is the right experiment. Then every image header is read and checked; problems " + "are listed with what to do. Nothing in the folder is changed." +) +FOLDERS_HELP = ( + "
The folders: paste a path or use Browse… (on Google Colab: Files panel, " + "right-click, Copy path). Masks are optional: a folder outside the images, with one " + "subfolder per mask type laid out like the images (masks/cells/control/rep1/" + "image1.tif), or the images' folders with the mask type after the image name " + "(image1_cells.tif); no mask pixel is read by the check. The output folder is where " + "the results go; leave it empty for the default next to the images." +) +COMPARE_HELP = ( + "Optional; nothing is written. Statistics are pooled from the histograms of all " + "planes of a group and channel: a group's median is the median of all its pixels." +) +CHIPS = { + "Images found": "Images", + "Compared against": "Compared to", + "Tag channels": "Tags", + "Check and save": "Save", + "Describe the masks": "Describe masks", +} +"""The shorter names on the chips, so that the strip is one row on a laptop screen.""" + + +class _CompactPages: + """The cards of the *Images* and *Run* steps, arranged for one screen.""" + + def _images_page(self) -> list[widgets.Widget]: + """Return step 1's widgets: the folders, the scan, the check and what it found.""" + rows = field_rows( + [ + ("Images", [self.folder, self.folder_browse]), + ("Masks (optional)", [self.masks_folder, self.masks_browse]), + ("Output (optional)", [self.output, self.output_browse]), + ] + ) + self.folders_card = ui.card( + "Folders", [rows, self.output_hint, self.masks_recorded, self.masks_drop] + ) + self.found_fold = ui.accordion("Folders found, with a picture of each", [self.labels_card]) + self.report_fold = ui.accordion("Check report", [self.check_report.widget]) + return [ + about(FOLDER_INTRO + FOLDERS_HELP), + self.folders_card, + widgets.HBox([self.scan_button, self.check_button], layout=widgets.Layout(width="99%")), + self.check_blocker, + self.scan_status, + self.scan_box, + self.folder_status, + self.folder_lock.card, + self.folder_tiles, + self.masks_card, + self.issues_card, + self.found_fold, + self.report_fold, + self.exclude_box, + ] + + def _run_page(self) -> list[widgets.Widget]: + """Return the run's widgets (the mask step has its own step after it).""" + self.settings_fold = ui.accordion("Settings and mode", [self.settings_card]) + self.plan_fold = ui.accordion("What will happen", [self.plan_card]) + self.checks_fold = ui.accordion("Intensity checks and plots", [self.run_checks.card]) + self.compare_fold = ui.accordion("Compare groups", [self._compare_box()]) + self.run_report_fold = ui.accordion("Run report", [self.run_report.widget]) + return [ + self.readiness_card, + self.settings_fold, + self.plan_fold, + self.run_button, + self.run_blocker, + self.progress, + self.run_status, + self.run_lock.card, + self.run_tiles, + self.run_issues, + self.checks_fold, + self.compare_fold, + self.run_report_fold, + self.details_box, + ] + + def _compare_box(self) -> widgets.Widget: + """Return *Compare groups*: the grouping, the pooled statistics and the plot. + + The table is the one this run wrote: there is no table to choose, and the intensity + checks are not repeated here (they are in their own panel above). + """ + self._compare_only = True + percentiles = widgets.HBox( + [widgets.HTML("Percentiles "), self.percentiles], + layout=widgets.Layout(align_items="center"), + ) + return widgets.VBox( + [ + ui.help_text(COMPARE_HELP), + self.level, + percentiles, + self.statistics_button, + self.stats_status, + self.stats_tiles, + self.statistics_card, + self.distribution_card, + ] + ) + + def _finish_run(self, result: object) -> None: + super()._finish_run(result) + self._refresh_folds() # the checks, Compare groups and the report now have content + + def _refresh_folds(self) -> None: + """Show each collapsed panel only while it has something in it.""" + if getattr(self, "compare_fold", None) is None: + return + written = self.result is not None and self.result.written + ui.set_visible(self.found_fold, ui.is_visible(self.labels_card)) + ui.set_visible(self.report_fold, bool(self.check_report.where.value)) + ui.set_visible(self.readiness_card, self.run_button.disabled) # only what is missing + ui.set_visible(self.plan_fold, ui.is_visible(self.plan_card)) + ui.set_visible(self.checks_fold, written and ui.is_visible(self.run_checks.card)) + ui.set_visible(self.compare_fold, written) + ui.set_visible(self.run_report_fold, bool(self.run_report.where.value)) + + +__all__ = ["CHIPS", "COMPARE_HELP", "FOLDERS_HELP", "FOLDER_INTRO", "_CompactPages"] diff --git a/src/morphobricks_preprocessing/notebook_plots.py b/src/morphobricks_preprocessing/notebook_plots.py index 962b1b72..b5e9af9e 100644 --- a/src/morphobricks_preprocessing/notebook_plots.py +++ b/src/morphobricks_preprocessing/notebook_plots.py @@ -468,7 +468,7 @@ def plane_png(plane: Plane, size: float = 4.2) -> bytes: return buffer.getvalue() -TILE_INCHES = 1.9 +TILE_INCHES = 1.6 DRAWN_SIDE = 384 """The most pixels along a side handed to the drawing (a larger plane is strided).""" DPI = 100 diff --git a/src/morphobricks_preprocessing/notebook_quick_scan.py b/src/morphobricks_preprocessing/notebook_quick_scan.py index 82fd2364..3a5ae880 100644 --- a/src/morphobricks_preprocessing/notebook_quick_scan.py +++ b/src/morphobricks_preprocessing/notebook_quick_scan.py @@ -35,7 +35,7 @@ SCAN_LABEL, DEEP_LABEL = " Scan the folders", " Looks right: read all image headers" SEED_RANGE = 1_000_000 -EXAMPLE_SIDE = 320 +EXAMPLE_SIDE = 190 """The largest side of the quick scan's picture, in pixels.""" @@ -112,7 +112,10 @@ def _build_quick_scan(self) -> None: ) self.scan_box = widgets.HBox( [ - widgets.VBox([self.scan_tree], layout=widgets.Layout(flex="1 1 0", min_width="0")), + widgets.VBox( + [self.scan_tree], + layout=widgets.Layout(flex="1 1 0", min_width="0", max_height="240px"), + ), widgets.VBox( [self.scan_example, self.scan_another], layout=widgets.Layout(flex="0 0 auto", margin="0 0 0 16px"), @@ -202,8 +205,8 @@ def listed(path: str) -> None: errors = [issue for issue in found.issues if issue.is_error] self.scan_status.value = ui.status( "error" if errors else "ok" if found.images else "warn", - f"Found {scan_summary(found, excluded)} (listed in {wait.took()}).", - ["No image header was read. If this is your experiment, read the headers below."], + f"Found {scan_summary(found, excluded)} (listed in {wait.took()}). No image header " + "was read yet: if this is your experiment, read the headers below.", ) + (issues_html(errors) if errors else "") ui.set_visible(self.scan_box, bool(found.leaves)) self._refresh_scan_gate() @@ -238,7 +241,7 @@ def _show_scan_example(self, seed: int | None, *, status: bool = True) -> None: width=f"{EXAMPLE_SIDE}px", ) chosen = ( - "
One image chosen at random (seed " + "
One image chosen at random (seed " f"{self.scan_seed}), for looking only:
{html.escape(self.scan_image)}
" ) self.scan_example.children = [widgets.HTML(chosen), tile.widget] diff --git a/src/morphobricks_preprocessing/notebook_render.py b/src/morphobricks_preprocessing/notebook_render.py index 99beb1ea..9d5657d8 100644 --- a/src/morphobricks_preprocessing/notebook_render.py +++ b/src/morphobricks_preprocessing/notebook_render.py @@ -41,6 +41,36 @@ def issues_html(issues: Iterable[ValidationIssue]) -> str: return "".join(parts) +def issues_brief_html(issues: Iterable[ValidationIssue]) -> str: + """Render issues as short cards: what is wrong in one sentence, the rest on demand. + + Each card says the issue's first sentence; the whole message, the files and what to do + are under *Files and what to do*. Nothing is left out: a refusal is still shown, shorter. + """ + parts = [] + for issue in sorted(issues, key=lambda issue: not issue.is_error): + first, stop, rest = issue.message.partition(". ") + more = [html.escape(rest)] if rest else [] + if issue.paths: + more.append(ui.path_list(issue.paths, limit=5)) + if issue.action: + more.append(f"What to do: {html.escape(issue.action)}") + label = "Error" if issue.is_error else "Warning" + details = ( + "
Files and what to do" + + "".join(f"
{line}
" for line in more) + + "
" + if more + else "" + ) + parts.append( + f"
" + f"{label} · {html.escape(issue.code.replace('_', ' '))}: " + f"{html.escape(first + stop.strip())}{details}
" + ) + return "".join(parts) + + def blocked_line(reasons: Sequence[str]) -> str: """Return the visible line beside a disabled action saying what blocks it (HTML), or "". diff --git a/src/morphobricks_preprocessing/notebook_results.py b/src/morphobricks_preprocessing/notebook_results.py index 9af4c27c..e8233fce 100644 --- a/src/morphobricks_preprocessing/notebook_results.py +++ b/src/morphobricks_preprocessing/notebook_results.py @@ -28,6 +28,7 @@ class _ResultsStep: def _build_results_step(self) -> None: """Build step 4: the table, the grouping, the statistics, plots and checks.""" + self._compare_only = False # the shipped notebook's Compare groups repeats no checks self._build_csv_card() self._build_grouping_card() self._build_statistics_cards() @@ -243,8 +244,9 @@ def _calculate_statistics(self, path: Path, percentiles: Sequence[float]) -> Non png = distribution_png(groups) self.distribution.value = png or b"" ui.set_visible(self.distribution_card, bool(png)) - wait.phase("Checking the intensities of every image", STAGES, 3) - self.table_checks.show(records) + if not self._compare_only: + wait.phase("Checking the intensities of every image", STAGES, 3) + self.table_checks.show(records) self.stats_status.value = ui.status( "ok", f"Statistics of {plural(len(groups), LEVEL_NAMES[level])} calculated.", diff --git a/src/morphobricks_preprocessing/notebook_stepper.py b/src/morphobricks_preprocessing/notebook_stepper.py index ddf96c4e..0e5924c8 100644 --- a/src/morphobricks_preprocessing/notebook_stepper.py +++ b/src/morphobricks_preprocessing/notebook_stepper.py @@ -32,15 +32,54 @@ ) STEPPER_CSS = """ @@ -90,6 +129,8 @@ class Step: Returns the step's :class:`StepState` from the cards' current state. waiting Returns why the step cannot be answered yet (HTML), or an empty string. + chip + The name on the chip when it is shorter than the title (the strip is one row). """ title: str @@ -97,6 +138,7 @@ class Step: children: Sequence[widgets.Widget] state: Callable[[], StepState] waiting: Callable[[], str] = _no_wait + chip: str = "" class Stepper: @@ -111,10 +153,7 @@ def __init__( count = len(self.steps) self.chips = [self._chip(i) for i in range(count)] self.waits = [widgets.HTML() for _ in self.steps] - self.pages = [ - ui.page(f"Step {i + 1} of {count} · {step.title}", step.purpose, [wait, *step.children]) - for i, (step, wait) in enumerate(zip(self.steps, self.waits, strict=True)) - ] + self.pages = [self._page(i, count) for i in range(count)] self.back = ui.button("Back", icon="arrow-left") self.next = ui.button("Next", style="primary", icon="arrow-right") self.back.on_click(lambda _: self.go(self.current - 1)) @@ -128,8 +167,31 @@ def __init__( [widgets.HTML(STEPPER_CSS), self.strip, *self.pages, nav], layout=widgets.Layout(width="99%"), ) + self.widget.add_class("mb-compact") self.go(0) + def _page(self, index: int, count: int) -> widgets.VBox: + """Return a step's page: its title and purpose on one line, then its cards.""" + step = self.steps[index] + head = widgets.HTML( + f"
Step {index + 1} of {count} · {html.escape(step.title)}" + f" {step.purpose}
" + ) + root = widgets.VBox([head, self._body(index)]) + root.add_class("mb-root") + outer = widgets.VBox([widgets.HTML(ui.STYLE_CSS), root], layout=widgets.Layout(width="99%")) + outer.add_class("mb-page") + return outer + + def _body(self, index: int) -> widgets.VBox: + """Return a step's cards in the box that scrolls when they are higher than a screen. + + The title, the strip and Back/Next then stay in view (one screen per step). + """ + body = widgets.VBox([self.waits[index], *self.steps[index].children]) + body.add_class("mb-step-body") + return body + def _chip(self, index: int) -> widgets.Button: chip = widgets.Button(layout=widgets.Layout(width="auto")) chip.add_class("mb-chip") @@ -156,7 +218,8 @@ def refresh(self) -> None: chip.remove_class(name) chip.add_class(f"mb-chip-{chip_kind(state, i == self.current)}") noted = f" · {state.note}" if state.done and state.note else "" - chip.description = f"{'✓ ' if state.done else ''}{i + 1} {step.title}{noted}" + name = step.chip or step.title + chip.description = f"{'✓ ' if state.done else ''}{i + 1} {name}{noted}" chip.tooltip = state.text self.waits[self.current].value = self.steps[self.current].waiting() last = len(self.steps) - 1 @@ -207,12 +270,36 @@ def overview_line(title: str, state: StepState) -> str: ) +def about(text: str) -> widgets.HTML: + """Return a step's explanation folded behind *About this step* (``text`` is HTML).""" + return widgets.HTML( + "
About this step" + f"
{text}
" + ) + + +def field_rows(rows: Sequence[tuple[str, Sequence[widgets.Widget]]]) -> widgets.GridBox: + """Return labelled input rows: a short label, then the row's widgets side by side.""" + cells: list[widgets.Widget] = [] + for label, parts in rows: + name = widgets.HTML(f"{html.escape(label)}") + cells += [name, widgets.HBox(list(parts), layout=widgets.Layout(width="99%"))] + box = widgets.GridBox( + cells, + layout=widgets.Layout(grid_template_columns="max-content 1fr", grid_gap="4px 10px"), + ) + box.add_class("mb-fields") + return box + + __all__ = [ "CHIP_CLASSES", "STEPPER_CSS", "Step", "StepState", "Stepper", + "about", "chip_kind", + "field_rows", "overview_line", ] diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index c37ac849..cb3f047e 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -22,25 +22,24 @@ from . import notebook_style as ui from .dataset import CONSISTENCY_CODES from .notebook_axes_quick import _QuickAxes +from .notebook_axis_step import ROLES_HELP from .notebook_channels_type import ImageTypeRows from .notebook_check_save import INTRO as CHECK_INTRO +from .notebook_compact import CHIPS, _CompactPages from .notebook_masks_screen import INTRO as MASKS_INTRO from .notebook_quick_scan import _QuickScan from .notebook_step_states import _StepStates -from .notebook_stepper import Step, Stepper +from .notebook_stepper import Step, Stepper, about from .run import Preflight -FOLDER_INTRO = ( - "What happens here: first the folders are listed (labels from the folder names: " - "condition / biological repeat / technical repeat) and one image is shown, so you can see " - "that this is the right experiment. Then every image header is read and checked; problems " - "are listed with what to do. Nothing in the folder is changed." -) AXES_INTRO = ( "Why this matters: every image is cut into flat 2D planes, one per depth slice, " "channel and time point. The roles below are suggested from the image metadata, which is " "not always right, so check them with the example before confirming. The pictures of the " - "later steps are read only through the roles you confirm." + "later steps are read only through the roles you confirm.
" + + ROLES_HELP + + " The pixel size is the size of one pixel along X and Y, from the image headers or stated " + "by you; nothing is guessed, and an image without one is uncalibrated." ) CONDITIONS_HELP = ( "Each condition gets a short name (its ID), which later bricks use and which never changes " @@ -58,7 +57,7 @@ """The steps (0-based) that show a card the saved reopening folds.""" -class _StepsFlow(_QuickScan, _QuickAxes, _StepStates): +class _StepsFlow(_CompactPages, _QuickScan, _QuickAxes, _StepStates): """``steps()``: the stepper of the shipped notebook, and the hooks that keep it current.""" def steps(self) -> widgets.Widget: @@ -83,8 +82,10 @@ def steps(self) -> widgets.Widget: self.type_rows = ImageTypeRows(self.channel_types, lambda: self._channel_thumbs) self.type_rows.reopen = self.channel_grid.reopen = self._reopen_units self.overview_box = widgets.VBox() + self.overview_fold = ui.accordion("Your answers, step by step", [self.overview_box]) self._build_quick_axes() self.axis_sizes_slot.children = [self.calibration_card] # with the axes (owner) + ui.set_visible(self.roles_help, False) # said under About this step self._build_quick_scan() self.consistency_slot = widgets.VBox([self.consistency_card]) self._stepper = Stepper(self._step_list(), self._step_shown) @@ -101,7 +102,7 @@ def _step_list(self) -> list[Step]: ("Images found", "Choose the folder with your images and check it.", self._images_page()), ("Axes", "Say which image dimension is width, height, channel and time.", - [ui.intro(AXES_INTRO), self.axis_box, self.quick_card]), + [about(AXES_INTRO), self.axis_box, self.quick_card]), ("Conditions", "A short name for each condition.", [self._conditions_card()]), ("Replicates", "Which folders are one replicate, and their names.", [self.design_card, self._replicates_card(), self.consistency_slot]), @@ -112,10 +113,10 @@ def _step_list(self) -> list[Step]: [self.channel_intro, self.channel_labels.widget]), ("Tag channels", "Say which stain each channel is, looking at its picture.", [self.channel_grid.widget]), - ("Masks", "Say what each mask type is.", [ui.intro(MASKS_INTRO), self.masks_box]), + ("Masks", "Say what each mask type is.", [about(MASKS_INTRO), self.masks_box]), ("Check and save", "Check your experiment, confirm each replicate, then save.", - [self.overview_box, ui.intro(CHECK_INTRO), self.fold_bar, self.check_waiting, - self.check_card]), + [about(CHECK_INTRO), self.fold_bar, self.check_waiting, self.check_card, + self.overview_fold]), ("Run", "Read the images one plane at a time and write the histogram table.", self._run_page()), ("Describe the masks", "Optional: how the mask types relate.", @@ -123,50 +124,10 @@ def _step_list(self) -> list[Step]: ] # fmt: skip waits = self._step_waits() return [ - Step(title, purpose, children, state, waits.get(i, _nothing)) + Step(title, purpose, children, state, waits.get(i, _nothing), CHIPS.get(title, "")) for i, ((title, purpose, children), state) in enumerate(zip(pages, states, strict=True)) ] - def _images_page(self) -> list[widgets.Widget]: - """Return step 1's cards: the folders, the check and what it found.""" - return [ - ui.intro(FOLDER_INTRO), - self.folder_card, - self.output_card, - self.masks_input_card, - self.exclude_box, - self.scan_button, - self.scan_status, - self.scan_box, - self.check_button, - self.check_blocker, - self.folder_status, - self.folder_lock.card, - self.check_report.widget, - self.folder_tiles, - self.labels_card, - self.masks_card, - self.issues_card, - ] - - def _run_page(self) -> list[widgets.Widget]: - """Return the run's cards (the mask step has its own step after it).""" - return [ - self.readiness_card, - self.settings_card, - self.plan_card, - self.run_button, - self.run_blocker, - self.progress, - self.run_status, - self.run_lock.card, - self.run_report.widget, - self.run_tiles, - self.run_issues, - self.run_checks.card, - self.details_box, - ] - def _conditions_card(self) -> widgets.Widget: return ui.card("Conditions", [ui.help_text(CONDITIONS_HELP), self.condition_rows]) @@ -241,6 +202,7 @@ def _refresh_steps(self) -> None: i.code in CONSISTENCY_CODES for i in self._step1_issues() ) ui.set_visible(self.consistency_slot, warns) + self._refresh_folds() stepper.refresh() def _refresh_run_step(self) -> None: @@ -251,7 +213,11 @@ def _refresh_run_step(self) -> None: def check_folder(self) -> Preflight | None: """Check the folder; a reopened experiment opens on its first step still to do.""" pre = super().check_folder() - if pre is not None: + if pre is not None and getattr(self, "_stepper", None) is not None: + # The headers are read: the quick look made way for what the check found. + ui.set_visible(self.scan_box, False) + self.scan_status.value = "" + self._refresh_folds() self._go_first_open() return pre diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py index 8cadcf41..00f8aede 100644 --- a/src/morphobricks_preprocessing/notebook_ui.py +++ b/src/morphobricks_preprocessing/notebook_ui.py @@ -220,24 +220,12 @@ def welcome(self) -> widgets.Widget: """Confirm that the notebook started, with the package version and what to do next.""" from . import __version__ - return ui.page( - "MorphoBricks preprocessing", - "Per-plane intensity histograms for an experiment folder of TIFF and OME-Zarr images.", - [ - widgets.HTML( - ui.status( - "ok", - f"The notebook is ready (morphobricks-preprocessing {__version__}).", - ) - ), - widgets.HTML( - ui.next_steps( - "Go to Step 1 and check your experiment folder.", - "Run its cell, choose your image folder, then click Check folder.", - ) - ), - ], + line = ( + "
" + f"MorphoBricks preprocessing {__version__} · " + "the notebook is ready
" ) + return widgets.VBox([widgets.HTML(ui.STYLE_CSS + line)], layout=widgets.Layout(width="99%")) def folder_step( self, @@ -407,7 +395,13 @@ def run_step(self) -> widgets.Widget: ) def results_step(self) -> widgets.Widget: - """Step 4: pooled statistics and intensity distributions from the table.""" + """Step 4: pooled statistics and intensity distributions from the table. + + In the shipped notebook (after :meth:`steps`) this is an empty, hidden box: the + statistics are the **Compare groups** panel of the run step (owner, 10 November 2026). + """ + if getattr(self, "_stepper", None) is not None: + return widgets.VBox(layout=widgets.Layout(display="none")) return ui.page( "Calculate the statistics (optional)", # the stepper numbers the steps "Compare conditions and repeats using the histograms computed by the run.", diff --git a/tests/test_notebook_compact.py b/tests/test_notebook_compact.py new file mode 100644 index 00000000..bbb5dc0b --- /dev/null +++ b/tests/test_notebook_compact.py @@ -0,0 +1,214 @@ +"""The compact layout of the shipped notebook, and Compare groups in the run step. + +Owner's test of v0.5.0 (10 November 2026): (a) each step fits a laptop screen: one row of +chips, the step's cards in a box that scrolls on its own, explanations folded; (b) the optional +statistics page is gone: after a run, the run step has a collapsed *Compare groups* panel for +that run's table, without a table chooser and without the intensity checks a second time. +The pixel heights themselves are looked at in screenshots, not here. +""" + +import re + +import pytest + +from test_notebook_check_save import answer_all, exp # noqa: F401 + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +import ipywidgets as widgets # noqa: E402 + +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 +from morphobricks_preprocessing.notebook_compact import CHIPS # noqa: E402 +from morphobricks_preprocessing.notebook_render import issues_brief_html # noqa: E402 +from morphobricks_preprocessing.notebook_stepper import STEPPER_CSS, about, field_rows # noqa: E402 +from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 +from morphobricks_preprocessing.validation import error, warning # noqa: E402 +from test_notebook_axes_quick import stepper # noqa: E402 + + +def _text(html): + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html)).strip() + + +def _holds(widget, target): + if widget is target: + return True + return any(_holds(child, target) for child in getattr(widget, "children", ())) + + +def _ran(exp): # noqa: F811 + nb = stepper(*exp) + nb.quick_confirm.click() + answer_all(nb) + assert nb.save_experiment() + assert nb.run().written + return nb + + +# --- (a) one screen ------------------------------------------------------------------------------ + + +def test_the_strip_is_short_names_and_the_page_keeps_the_full_title(exp): # noqa: F811 + nb = stepper(*exp) + st = nb._stepper + assert "mb-compact" in st.widget._dom_classes + names = [re.sub(r"^(✓ )?\d+ ", "", chip.description).split(" · ")[0] for chip in st.chips] + assert names == [CHIPS.get(step.title, step.title) for step in st.steps] + assert names[0] == "Images" and names[9] == "Save" and names[11] == "Describe masks" + assert sum(len(chip.description) for chip in st.chips) < 150 # one row at 1366 px + head = st.pages[9].children[1].children[0].value + assert "Step 10 of 12 · Check and save" in head and "mb-purpose" in head + + +def test_each_page_is_a_title_line_and_one_box_that_scrolls_by_itself(exp): # noqa: F811 + nb = stepper(*exp) + for page in nb._stepper.pages: + head, body = page.children[1].children + assert isinstance(head, widgets.HTML) and "mb-step-body" in body._dom_classes + assert ( + ".mb-compact .mb-step-body { max-height:" in STEPPER_CSS + and "overflow-y: auto" in STEPPER_CSS + ) + assert ".mb-step-body > * { flex: 0 0 auto; }" in STEPPER_CSS # cards keep their height + + +def test_explanations_are_folded_behind_about_this_step(exp): # noqa: F811 + nb = stepper(*exp) + for index in (0, 1, 8, 9): + first = nb._stepper.pages[index].children[1].children[1].children[1] + assert first.value.startswith( + "
About this step" + ) + html = about("Why").value + assert "Why" in html and " open" not in html + assert not ui.is_visible(nb.roles_help) # said once, under About this step + assert ( + "Exactly one Y and one X" in nb._stepper.pages[1].children[1].children[1].children[1].value + ) + + +def test_the_folders_are_one_card_of_three_labelled_rows(exp): # noqa: F811 + nb = stepper(*exp) + page = nb._stepper.pages[0] + assert _holds(page, nb.folders_card) + for box in ( + nb.folder, + nb.masks_folder, + nb.output, + nb.output_hint, + nb.scan_button, + nb.exclude_box, + ): + assert _holds(page, box) + for card in (nb.folder_card, nb.output_card, nb.masks_input_card): + assert not _holds(page, card) # their help paragraphs are under About this step + labels = [w.value for w in field_rows([("Images", []), ("Masks (optional)", [])]).children[::2]] + assert [_text(v) for v in labels] == ["Images", "Masks (optional)"] + + +def test_after_the_check_the_scan_makes_way_and_the_details_are_folded(exp): # noqa: F811 + nb = PreprocessingNotebook(str(exp[0]), output_folder=str(exp[2])) + nb.steps() + nb.masks_folder.value = str(exp[1]) + assert not ui.is_visible(nb.found_fold) and not ui.is_visible(nb.report_fold) + nb.scan_button.click() + assert ui.is_visible(nb.scan_box) + nb.check_button.click() + assert not ui.is_visible(nb.scan_box) and nb.scan_status.value == "" + assert ui.is_visible(nb.found_fold) and nb.found_fold.selected_index is None + assert ui.is_visible(nb.report_fold) and nb.report_fold.selected_index is None + assert _holds(nb.found_fold, nb.labels_card) and _holds(nb.report_fold, nb.check_report.widget) + nb.folder.value = str(exp[0]) + "/" + assert not ui.is_visible(nb.found_fold) # another folder: nothing found is shown + + +def test_the_welcome_and_the_statistics_page_take_no_room(exp): # noqa: F811 + nb = PreprocessingNotebook(str(exp[0]), output_folder=str(exp[2])) + alone = nb.results_step() # without the stepper: the page other notebooks may show + assert "Calculate the statistics (optional)" in alone.children[1].children[0].value + nb.steps() + hidden = nb.results_step() + assert hidden.children == () and hidden.layout.display == "none" + assert len(nb.welcome().children) == 1 + + +def test_a_refusal_is_one_sentence_with_the_files_and_the_fix_on_demand(): + paths = [f"ctrl/rep2/{i}.tif" for i in range(4)] + issue = error( + "different_channel_counts", + "The images have different numbers of channels: 3 channels (10 images), 4 channels (4 " + "images). Each row of the histogram table holds every channel.", + paths, + action="Split the images.", + ) + html = issues_brief_html([warning("w", "Odd.", ["a.tif"]), issue]) + assert html.index("different channel counts") < html.index("Warning · w") # errors first + shown = html.split("
")[0] + assert _text(shown) == ( + "Error · different channel counts: The images have different numbers of channels: 3 " + "channels (10 images), 4 channels (4 images)." + ) + assert "Files and what to do" in html and "Each row of the histogram table" in html + for path in paths: + assert path in html + assert "What to do: Split the images." in html and "mb-error" in html + assert issues_brief_html([]) == "" + + +# --- (b) Compare groups in the run step ---------------------------------------------------------- + + +def test_the_run_step_shows_only_what_is_missing_and_folds_the_rest(exp): # noqa: F811 + nb = stepper(*exp) + page = nb._stepper.pages[10] + assert ui.is_visible(nb.readiness_card) and nb.run_button.disabled + for fold, inner in ( + (nb.settings_fold, nb.settings_card), + (nb.plan_fold, nb.plan_card), + (nb.checks_fold, nb.run_checks.card), + (nb.run_report_fold, nb.run_report.widget), + ): + assert _holds(page, fold) and _holds(fold, inner) and fold.selected_index is None + assert not ui.is_visible(nb.compare_fold) and not ui.is_visible(nb.checks_fold) + nb.quick_confirm.click() + answer_all(nb) + assert nb.save_experiment() + assert not nb.run_button.disabled and not ui.is_visible(nb.readiness_card) # nothing missing + + +def test_compare_groups_appears_collapsed_after_a_run_for_that_run_s_table(exp): # noqa: F811 + nb = _ran(exp) + page = nb._stepper.pages[10] + assert ui.is_visible(nb.compare_fold) and nb.compare_fold.selected_index is None + assert ui.is_visible(nb.checks_fold) and ui.is_visible(nb.run_report_fold) + assert not ui.is_visible( + nb.plan_fold + ) # it plans a next run; shown again when a setting changes + assert _holds(page, nb.compare_fold) and _holds(nb.compare_fold, nb.statistics_button) + assert not _holds(page, nb.csv_card) and not _holds(page, nb.csv_file) # no table to choose + assert not _holds(page, nb.table_checks.card) and not _holds(page, nb.stats_next) + assert nb.statistics.value == "" # nothing is calculated before the click + nb.statistics_button.click() + assert ( + "calculated." in nb.stats_status.value and str(nb.result.csv_path) in nb.stats_status.value + ) + assert ui.is_visible(nb.statistics_card) and ui.is_visible(nb.distribution_card) + assert nb.distribution.value[:4] == b"\x89PNG" + assert nb.table_checks.report is None # the intensity checks are not run a second time + before = nb.statistics.value + nb.level.value = "biological_repeat" # another grouping recalculates at once + assert nb.statistics.value != before + + +def test_compare_groups_gives_the_same_statistics_as_the_old_page(exp): # noqa: F811 + nb = _ran(exp) + nb.statistics_button.click() + root, masks, out = exp + old = PreprocessingNotebook(str(root), output_folder=str(out)) + old.results_step() + old.csv_file.value = str(nb.result.csv_path) + old.statistics_button.click() + assert old.statistics.value == nb.statistics.value + assert old.distribution.value == nb.distribution.value + assert old.table_checks.report is not None # the old page still checks any table diff --git a/tests/test_notebook_quick_scan.py b/tests/test_notebook_quick_scan.py index d35885fa..4c4f1557 100644 --- a/tests/test_notebook_quick_scan.py +++ b/tests/test_notebook_quick_scan.py @@ -72,7 +72,8 @@ def no_check(*args, **kwargs): assert nb.check_blocker.value == "" said = _text(nb.scan_status.value) assert ( - f"Found {scan_summary(found, frozenset())}" in said and "No image header was read" in said + f"Found {scan_summary(found, frozenset())}" in said + and "No image header was read yet" in said ) assert nb.scan_tree.value == tree_html(found, frozenset()) diff --git a/tests/test_notebook_ui.py b/tests/test_notebook_ui.py index 21059744..73261153 100644 --- a/tests/test_notebook_ui.py +++ b/tests/test_notebook_ui.py @@ -67,13 +67,13 @@ def test_pages_build_without_a_folder(): assert "Enter the experiment folder first" in nb.folder_status.value -def test_welcome_shows_the_version(): - page = PreprocessingNotebook().welcome() - root = page.children[1] - assert "The notebook is ready" in root.children[-2].value - assert "What next?" in root.children[-1].value - assert "Go to Step 1" in root.children[-1].value - assert "
  • " not in root.children[-1].value +def test_welcome_is_one_line_with_the_version(): + from morphobricks_preprocessing import __version__ + + (line,) = PreprocessingNotebook().welcome().children + assert f"MorphoBricks preprocessing {__version__}" in line.value + assert "the notebook is ready" in line.value + assert "What next?" not in line.value # the steps say where to go def test_check_shows_status_tiles_labels_and_roles(experiment): From 0a1885b1f0b67f0d0ad49eb57338d122a1d936cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:42:22 +0000 Subject: [PATCH 2/2] Notebook: the check's progress sits under the button clicked; the scan's picture keeps its size Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- src/morphobricks_preprocessing/notebook_compact.py | 6 +++--- src/morphobricks_preprocessing/notebook_stepper.py | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/morphobricks_preprocessing/notebook_compact.py b/src/morphobricks_preprocessing/notebook_compact.py index 071334e3..6dfd8bf9 100644 --- a/src/morphobricks_preprocessing/notebook_compact.py +++ b/src/morphobricks_preprocessing/notebook_compact.py @@ -69,11 +69,11 @@ def _images_page(self) -> list[widgets.Widget]: self.folders_card, widgets.HBox([self.scan_button, self.check_button], layout=widgets.Layout(width="99%")), self.check_blocker, - self.scan_status, - self.scan_box, - self.folder_status, + self.folder_status, # the check's progress, right under the button clicked self.folder_lock.card, self.folder_tiles, + self.scan_status, + self.scan_box, self.masks_card, self.issues_card, self.found_fold, diff --git a/src/morphobricks_preprocessing/notebook_stepper.py b/src/morphobricks_preprocessing/notebook_stepper.py index 0e5924c8..1b14797b 100644 --- a/src/morphobricks_preprocessing/notebook_stepper.py +++ b/src/morphobricks_preprocessing/notebook_stepper.py @@ -75,6 +75,8 @@ .mb-compact .mb-table-wrap + .mb-help { -webkit-line-clamp: unset; display: block; } .mb-compact img.mb-grey, .mb-compact img.mb-colour, .mb-compact img.mb-crop { width: 108px !important; height: auto; } +.mb-compact .mb-scan img.mb-grey, .mb-compact .mb-scan img.mb-colour, +.mb-compact .mb-scan img.mb-crop { width: 180px !important; } .mb-about { margin: 0 0 6px 0; font-size: 12.5px; color: #475569; } .mb-about summary { cursor: pointer; color: #2563EB; } .mb-about .mb-intro { margin: 4px 0 0 0; }