diff --git a/AGENTS.md b/AGENTS.md index f873c7dc..ffb56a6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,6 +305,10 @@ 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_names.py the names card's rows (conditions on step 4, replicates on step 5) notebook_folder.py step 1: folder, check, issues, labels (_FolderStep) notebook_design.py step 1: the experiment design card (_DesignCard) notebook_axis_step.py step 2: role dropdowns, axis check, missing-axes card (_AxisStep) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5916904b..ca2f3c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the ## Unreleased +- **Notebook: one step at a time (UX proposal 2026-11-09, PR 17a).** `PreprocessingNotebook.steps()` + shows the shipped notebook's 13 steps one at a time, with a strip of chips (done green with a tick, + current blue, to do grey, changed by a reopen orange; a chip opens its step) and Back/Next (Next is + never blocked: the save and the run still refuse what is missing). The order is the owner's: images + found, axes, pixel sizes, conditions, replicates (pairing with the replicate and technical-repeat + names, then the consistency card only when it warns), compared against, image type (its own page; + the tag grid has no type column there), stains, tag channels, masks, check and save (starting with a + one-line overview of steps 1-10, each with *change*), run, describe the masks. Pure composition: the + cards, answers, draft, save and run are unchanged; a reopened experiment opens on its first step + still to do (the axes, confirmed once per session). The notebook file shows `notebook.steps()` and + `results_step()`; `folder_step()` and `axis_step()` render as before for embedders. Text: no RGB + sentence on a described run's axes page, no broken picture before the preview, "same way?" *above*, + the reader and CSV lines of *Check and save* behind *Details*, "folder" instead of "unit", and the + steps named by their new numbers. The run page's box plots and curves order the replicates of a + condition by replicate ID (`rep1` before `rep2`), not by folder name. - **Re-pin the vendored Spec to 1bed024 (MorphoBricks-Spec PR 16, owner decisions RF1 to RF6); `read_records` follows section 4.6.** The Spec now fixes the bytes and values of `morphobricks.records.jsonl` and names one refusal, `R_RECORDS_FILE_INVALID`, with the reasons diff --git a/notebooks/MorphoBricks_Preprocessing.ipynb b/notebooks/MorphoBricks_Preprocessing.ipynb index 062498d3..20fcccfa 100644 --- a/notebooks/MorphoBricks_Preprocessing.ipynb +++ b/notebooks/MorphoBricks_Preprocessing.ipynb @@ -30,8 +30,8 @@ "In the app:\n", "\n", "1. Give the **experiment folder** and optional **output/masks folders**, then click **Check folder**.\n", - "2. Confirm the labels/design and review the axis roles and image type.\n", - "3. Click **Compute histograms**.\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", + "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", "\n", "Detailed explanations appear inside the app only where a decision is required.\n" @@ -313,16 +313,7 @@ "from IPython.display import display\n", "\n", "app = widgets.VBox(\n", - " [\n", - " notebook.welcome(),\n", - " notebook.folder_step(),\n", - " notebook.axis_step(),\n", - " notebook.channels_step(),\n", - " notebook.masks_step(),\n", - " notebook.check_save_step(),\n", - " notebook.run_step(),\n", - " notebook.results_step(),\n", - " ],\n", + " [notebook.welcome(), notebook.steps(), notebook.results_step()],\n", " layout=widgets.Layout(width=\"99%\"),\n", ")\n", "display(app)" diff --git a/pyproject.toml b/pyproject.toml index 71312f01..c75ad4c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -150,6 +150,10 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.notebook_description" = "ui" "morphobricks_preprocessing.notebook_description_saved" = "ui" "morphobricks_preprocessing.notebook_description_view" = "ui" +"morphobricks_preprocessing.notebook_names" = "ui" +"morphobricks_preprocessing.notebook_step_states" = "ui" +"morphobricks_preprocessing.notebook_stepper" = "ui" +"morphobricks_preprocessing.notebook_steps" = "ui" "morphobricks_preprocessing.notebook_design" = "ui" "morphobricks_preprocessing.notebook_masks" = "ui" "morphobricks_preprocessing.notebook_masks_screen" = "ui" diff --git a/src/morphobricks_preprocessing/notebook_axis_preview.py b/src/morphobricks_preprocessing/notebook_axis_preview.py index 4e3c2474..50ae8c82 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -90,11 +90,13 @@ def _render_preview(self) -> None: ) except (PlaneReadError, IndexError, ValueError) as exc: self.preview_image.value = b"" + ui.set_visible(self.preview_image, False) self.preview_caption.value = ui.status( "warn", "This plane cannot be shown with these roles.", [str(exc)] ) return self.preview_image.value = plane_png(plane) + ui.set_visible(self.preview_image, True) self.preview_caption.value = self._preview_label() + preview_caption( header.relative_path, plane ) diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py index 0fcc4aed..a5328478 100644 --- a/src/morphobricks_preprocessing/notebook_axis_step.py +++ b/src/morphobricks_preprocessing/notebook_axis_step.py @@ -89,6 +89,7 @@ def _build_preview_card(self) -> None: tooltip="Read one plane with the roles above and show it", ) self.preview_image = widgets.Image(format="png", layout=widgets.Layout(max_width="420px")) + ui.set_visible(self.preview_image, False) # no broken-picture icon before a preview self.preview_caption = widgets.HTML() self.preview_checked = ui.ConfirmToggle( "Does the preview look like a normal image of your sample?", @@ -246,6 +247,7 @@ def _reset_preview(self) -> None: for slider in self.preview_sliders.values(): ui.set_visible(slider, False) self.preview_image.value = b"" + ui.set_visible(self.preview_image, False) self.preview_caption.value = "" self.confirmed_status.value = "" diff --git a/src/morphobricks_preprocessing/notebook_channels.py b/src/morphobricks_preprocessing/notebook_channels.py index 49fdff9c..fdb8b2a6 100644 --- a/src/morphobricks_preprocessing/notebook_channels.py +++ b/src/morphobricks_preprocessing/notebook_channels.py @@ -28,7 +28,7 @@ INTRO = ( "What happens here: first name what you stained (the labels), then say, for every " - "unit, which label each channel is, looking at its picture. Nothing is chosen for you: " + "folder, which label each channel is, looking at its picture. Nothing is chosen for you: " "names found in the image headers are offered only as suggestions, and each one needs your " "click." ) diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py index 8a10795e..472781ce 100644 --- a/src/morphobricks_preprocessing/notebook_channels_grid.py +++ b/src/morphobricks_preprocessing/notebook_channels_grid.py @@ -51,8 +51,8 @@ def partial_text(label: str, used: int, total: int) -> str: """Return the sentence for a label used in only some units, with its consequence.""" return ( - f"used in {used} of {total} units: an analysis of {html.escape(label)} will be " - "refused for the other units" + f"used in {used} of {total} folders: an analysis of {html.escape(label)} will be " + "refused for the other folders" ) @@ -66,6 +66,8 @@ def __init__( on_change: Callable[[], None] = lambda: None, ) -> None: self.store, self.types, self._on_change = store, types, on_change + # False when the image type has a page of its own (the stepper): no type column here. + self.place_types = True self.layouts: Sequence[UnitLayout] = () self.labels: list[str] = [] self.thumbs: Thumbnails | None = None @@ -84,9 +86,9 @@ def __init__( "B. Tag each channel", [ ui.help_text( - "For every unit, say which label each channel is, looking at its picture. " + "For every folder, say which label each channel is, looking at its picture. " "The same label should be in the same column in every row; a label used in " - "only some units is shown in orange. Every channel and every image type " + "only some folders is shown in orange. Every channel and every image type " "must be answered." ), self.contrast, @@ -105,7 +107,8 @@ def __init__( def open(self, layouts: Sequence[UnitLayout], labels: list[str], thumbs: Thumbnails) -> None: """Show the grid for these units and labels (the confirmed label list of step A).""" self.layouts, self.labels, self.thumbs = layouts, labels, thumbs - self.types.open(layouts) + if self.place_types: + self.types.open(layouts) self.detail.children = [] self.refresh() ui.set_visible(self.widget, True) @@ -193,7 +196,8 @@ def refresh(self) -> None: self._building = True try: self.cells, self.confirm_buttons = {}, [] - self.types.open(self.layouts) + if self.place_types: + self.types.open(self.layouts) width = max((u.channels for u in self.layouts if not u.rgb), default=3) width = max(width, 3 if any(u.rgb for u in self.layouts) else 1) groups: dict[tuple, list[UnitLayout]] = {} @@ -201,9 +205,9 @@ def refresh(self) -> None: groups.setdefault(layout.signature if layout.usable else (0,), []).append(layout) blocks: list[widgets.Widget] = [] header = widgets.GridBox( - [widgets.HTML("unit")] + [widgets.HTML("folder")] + [widgets.HTML(f"channel {i + 1} (C{i})") for i in range(width)] - + [widgets.HTML("image type")], + + [widgets.HTML("image type" if self.place_types else "")], layout=columns(width), ) blocks.append(header) @@ -221,7 +225,7 @@ def _group(self, units: Sequence[UnitLayout], width: int) -> widgets.Widget: if first.usable: names = " · ".join(quoted(first.name(i)) for i in range(first.channels)) what = f"Header names: {names}" if not first.rgb else "Header says: colour (RGB)" - text = f"{what}  ·  {len(units)} unit(s) with this layout." + text = f"{what}  ·  {len(units)} folder(s) with this layout." proposal = self._proposal(first) pending = [u for u in units if self._pending(u)] if proposal is not None: @@ -233,10 +237,10 @@ def _group(self, units: Sequence[UnitLayout], width: int) -> widgets.Widget: out.append(widgets.HTML(f"
{text}
")) if pending: button = ui.button( - f" Yes, these are right for these {len(pending)} unit(s)", + f" Yes, these are right for these {len(pending)} folder(s)", style="success", icon="check", - tooltip="Save the pre-ticked answers of these units", + tooltip="Save the pre-ticked answers of these folders", ) button.on_click(lambda _, p=pending: self._confirm_layout(p)) self.confirm_buttons.append(button) @@ -269,7 +273,8 @@ def _row(self, layout: UnitLayout, width: int) -> widgets.Widget: cells.append(widgets.HTML(f"{text}")) else: cells.append(self._cell(layout, index, tags[index], index in proposed, shown)) - cells.append(self.types.cell(layout) if layout.usable else widgets.HTML("")) + typed = layout.usable and self.place_types + cells.append(self.types.cell(layout) if typed else widgets.HTML("")) return widgets.GridBox(cells, layout=columns(width)) def _picture(self, layout: UnitLayout, index: int) -> widgets.Widget: @@ -352,7 +357,7 @@ def _summary(self) -> None: for label, places in positions(self.layouts, answers.units, self.labels).items(): if len(places) > 1: where = ", ".join( - f"channel {i + 1} in {n} unit(s)" for i, n in sorted(places.items()) + f"channel {i + 1} in {n} folder(s)" for i, n in sorted(places.items()) ) lines.append( f"
{html.escape(label)} is not always in the same " @@ -373,9 +378,11 @@ def _usage_line(self, label: str, used: int, total: int) -> str: """Return the usage line of a label; one used in only some units is orange.""" mark = chip(label, self.labels, self._shown_as(label)) if used == total: - return f"
{mark} used in all {total} units
" + return f"
{mark} used in all {total} folders
" if used == 0: - return f"
{mark} not used in any unit yet
" + return ( + f"
{mark} not used in any folder yet
" + ) return ( "
{mark} " @@ -387,14 +394,14 @@ def _progress(self, total: int) -> str: answers = self.store.answers() left = remaining(self.layouts, answers.units, self.labels) if left.done: - return ui.status("ok", f"Every channel of every one of the {total} units is tagged.") + return ui.status("ok", f"Every channel of every one of the {total} folders is tagged.") parts = [] if left.cells: parts.append(f"{left.cells} channel(s) still to tag") if left.types: parts.append(f"{left.types} image type(s) still to choose") if left.blocked: - parts.append(f"{left.blocked} unit(s) cannot be tagged") + parts.append(f"{left.blocked} folder(s) cannot be tagged") return ui.status("warn", "Not finished: " + ", ".join(parts) + ".") diff --git a/src/morphobricks_preprocessing/notebook_channels_labels.py b/src/morphobricks_preprocessing/notebook_channels_labels.py index 9e45d095..abe020ea 100644 --- a/src/morphobricks_preprocessing/notebook_channels_labels.py +++ b/src/morphobricks_preprocessing/notebook_channels_labels.py @@ -41,7 +41,7 @@ EXPLANATION = ( "A label is the short name of what was stained, for example DNA or " "ACTIN, not the dye (AF488) or the microscope's slot (Ch1). The same " - "label must be used for the same stain in every unit, and different stains must never share " + "label must be used for the same stain in every folder, and different stains must never share " "a label. Letters and digits, no space at the start or end; capital letters count as the " "same label." ) @@ -112,7 +112,7 @@ def __init__(self, store: AnswerStore, on_change: Callable[[], None]) -> None: "number of channels found in any image; Add label adds more. Header " "names are offered only when they are valid labels as they are and not " "generic. " - "Every channel position of every unit needs a label, so an unused slot " + "Every channel position of every folder needs a label, so an unused slot " "needs one too: declare it here (for example unused)." ), self.note, diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py index af4bd11f..5fa649f8 100644 --- a/src/morphobricks_preprocessing/notebook_channels_type.py +++ b/src/morphobricks_preprocessing/notebook_channels_type.py @@ -11,6 +11,7 @@ from __future__ import annotations +import html from collections.abc import Callable, Sequence import ipywidgets as widgets @@ -19,7 +20,7 @@ from .channel_answers import synced, with_type from .channel_labels import UnitLayout from .draft_answers import UnitKey -from .notebook_channels_view import AnswerStore, blank_chip +from .notebook_channels_view import AnswerStore, blank_chip, quoted, unit_name OPTIONS = [("separate channels", "channels"), ("colour (RGB)", "rgb")] @@ -79,12 +80,12 @@ def _note(self, layout: UnitLayout) -> str: if saved == "channels" and layout.rgb: return ( "The header says colour (RGB): the " - "run refuses a unit declared otherwise." + "run refuses a folder declared otherwise." ) if saved == "rgb" and not layout.rgb: return ( "The header does not say colour: the " - "run refuses this unit unless its header does." + "run refuses this folder unless its header does." ) return "" @@ -127,4 +128,99 @@ def confirm_header(self, keys: Sequence[UnitKey]) -> bool: return ok -__all__ = ["OPTIONS", "ImageTypeColumn"] +class ImageTypeRows: + """The image type on a page of its own: one row per folder, its header and its choice. + + The cells are the :class:`ImageTypeColumn`'s own, so the answers, notes and refusals are + exactly those of the column in the tag grid; only the place differs. + """ + + def __init__(self, types: ImageTypeColumn) -> None: + self.types = types + self.rows = widgets.VBox() + self.status = widgets.HTML() + self.problem = widgets.HTML() + self.widget = ui.card( + "Image type of each folder", + [ + ui.help_text( + "Separate channels: one stain or marker per channel. Colour " + "(RGB): a colour picture whose red, green and blue are kept together. " + "Nothing is chosen for you; colour is pre-ticked only where the image " + "header says colour, and needs your click." + ), + self.rows, + self.problem, + self.status, + ], + visible=False, + ) + + def open(self, layouts: Sequence[UnitLayout]) -> None: + """Show one row per folder of these layouts.""" + self.types.open(layouts) + self.refresh() + ui.set_visible(self.widget, True) + + def close(self) -> None: + """Hide the rows.""" + ui.set_visible(self.widget, False) + + @property + def answered(self) -> int: + """Return how many usable folders have a saved image type.""" + return sum(1 for u in self.types.layouts if u.usable and self.types.saved(u.key)) + + def refresh(self) -> None: + """Rebuild the rows from the saved answers.""" + self.types.open(self.types.layouts) # a fresh set of cells, built from the answers + rows = [self._row(u) for u in sorted(self.types.layouts, key=lambda u: u.key)] + proposed = [u.key for u in self.types.layouts if self.types.proposed(u)] + if proposed: + button = ui.button( + f" Yes, colour (RGB) is right for these {len(proposed)} folder(s)", + style="success", + icon="check", + ) + button.on_click(lambda _, keys=tuple(proposed): self._confirm(keys)) + rows.append(button) + self.rows.children = rows + self.problem.value = self.types.message + usable = sum(1 for u in self.types.layouts if u.usable) + done = self.answered + self.status.value = ( + ui.status("ok", f"The image type of every one of the {usable} folders is answered.") + if done == usable + else ui.status("warn", f"Not finished: {usable - done} image type(s) to choose.") + ) + + def _confirm(self, keys: Sequence[UnitKey]) -> None: + self.types.confirm_header(keys) + + def _row(self, layout: UnitLayout) -> widgets.Widget: + """Build one folder's row: its name, what its header says, and its choice.""" + info = f"{html.escape(unit_name(layout.key))}" + if not layout.usable: + info += ui.status( + "warn", "Cannot be answered.", [html.escape(layout.problem)], raw=True + ) + return widgets.HTML(info) + said = ( + "header says: colour (RGB)" + if layout.rgb + else "header names: " + + " · ".join(quoted(layout.name(i)) for i in range(layout.channels)) + ) + info += f"
{said}
" + return widgets.GridBox( + [widgets.HTML(info), self.types.cell(layout)], + layout=widgets.Layout( + grid_template_columns="minmax(200px, 2fr) minmax(200px, 3fr)", + grid_gap="4px 12px", + width="99%", + margin="0 0 8px 0", + ), + ) + + +__all__ = ["OPTIONS", "ImageTypeColumn", "ImageTypeRows"] diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 4d943fa8..ff2f3938 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -79,7 +79,9 @@ def _build_check_save(self) -> None: self._check: CandidateCheck | None = None self.same_way: dict[str, ui.ConfirmToggle] = {} self.saved_line = widgets.HTML() - self.run_now = ui.button(" Run", style="success", icon="play", tooltip="Run step 3") + self.run_now = ui.button( + " Run", style="success", icon="play", tooltip="Compute the histograms (Run, step 12)" + ) self.change_saved = ui.button(" Change…", style="warning", icon="pencil") self.fold_bar = widgets.HBox([self.saved_line, self.run_now, self.change_saved]) ui.set_visible(self.fold_bar, False) @@ -206,7 +208,7 @@ def _todo(self, applied: object, answers: object, problem: str) -> tuple[list, l questions = list(missing_confirmations(applied, answers)) extra = [problem] if problem else [] left = masks_unanswered(answers, self._mask_types()) - extra += [f"mask {m}: separate objects or regions? (Your masks)" for m in left] + extra += [f"mask {m}: separate objects or regions? (Masks, step 10)" for m in left] if len(applied.conditions) > 1: extra += [ f"what {folder} is compared against (choose, or no comparison)" @@ -310,7 +312,7 @@ def _show_saved_state(self) -> None: state = self._published() if self._saved_current(): when = self._saved_on or (state.saved_on if state else "") - self.save_status.value = ui.status("ok", f"Saved {when}. Step 3 can run.") + self.save_status.value = ui.status("ok", f"Saved {when}. The run (step 12) can start.") self.saved_line.value = ui.status("ok", f"Saved on {when}, nothing changed.") else: self.saved_line.value = "" @@ -345,7 +347,9 @@ def _unfold(self) -> None: def _run_from_fold(self) -> None: if self.run() is None: self.saved_line.value += ui.status( - "warn", "Not run yet.", ["See step 3: its checklist says what is still to do."] + "warn", + "Not run yet.", + ["See Run (step 12): its checklist says what is still to do."], ) # Saving ------------------------------------------------------------------------------ @@ -363,7 +367,9 @@ def save_experiment(self) -> bool: return False if not self._confirm_ids(): self.save_status.value = ui.status( - "error", "Not saved: a short name was refused.", ["See Names for your experiment."] + "error", + "Not saved: a short name was refused.", + ["See Conditions and Replicates (steps 4 and 5)."], ) return False self._save_description() diff --git a/src/morphobricks_preprocessing/notebook_check_view.py b/src/morphobricks_preprocessing/notebook_check_view.py index 71ae5c2e..37c7d0d2 100644 --- a/src/morphobricks_preprocessing/notebook_check_view.py +++ b/src/morphobricks_preprocessing/notebook_check_view.py @@ -25,12 +25,12 @@ "settings). They are then one biological replicate, compared with each other." ) QUESTION_WORDS = { - "id_pending": "a short name is missing for {subject} (Names for your experiment)", - "repeat_not_confirmed": "{subject}: answer “same way?” below", - "confirmed_on_missing": "{subject}: answer “same way?” again below", - "unit_channels_missing": "the channels of {subject} are not tagged (Your channels)", - "unit_channel_untagged": "{subject} is not tagged (Your channels)", - "channel_undeclared": "the label {subject} is used but not declared (Your channels)", + "id_pending": "a short name is missing for {subject} (Conditions or Replicates)", + "repeat_not_confirmed": "{subject}: answer “same way?” above", + "confirmed_on_missing": "{subject}: answer “same way?” again above", + "unit_channels_missing": "the channels of {subject} are not tagged (Tag channels)", + "unit_channel_untagged": "{subject} is not tagged (Tag channels)", + "channel_undeclared": "the label {subject} is used but not declared (Stains)", "control_unresolved": "say what {subject} is compared against", "legacy_folder_missing": "{subject} is gone: confirm the removal", } @@ -116,7 +116,7 @@ def checks_html(check: CandidateCheck | None) -> str: return ui.status( "error", "The description would be refused:", _issue_lines(check.refusals), raw=True ) - parts = [ui.status("ok", "The strict reader accepts the exact bytes that will be saved.")] + parts: list[str] = [] hetero = check.heterogeneous if hetero is not None: parts.append( @@ -145,12 +145,14 @@ def checks_html(check: CandidateCheck | None) -> str: else: parts.append(ui.status("ok", "The images agree with it (headers checked).")) changes = check.changes or ("no row changes",) - parts.append( - "experiment_design.csv (written only when you save, from " + details = ( + ui.status("ok", "The strict reader accepts the exact bytes that will be saved.") + + "experiment_design.csv (written only when you save, from " "the description)
    " + "".join(f"
  • {html.escape(c)}
  • " for c in changes) + "
" ) + parts.append(f"
Details{details}
") return "".join(parts) diff --git a/src/morphobricks_preprocessing/notebook_description.py b/src/morphobricks_preprocessing/notebook_description.py index e83bbd58..35ff6cd2 100644 --- a/src/morphobricks_preprocessing/notebook_description.py +++ b/src/morphobricks_preprocessing/notebook_description.py @@ -36,21 +36,14 @@ from .draft_format import DraftFileError from .notebook_description_saved import _SavedDraft from .notebook_description_view import ( - ID_COLUMNS, KIND_NAMES, - KIND_TITLES, REFERENCE_COLUMNS, Entry, - duplicate_hint, header_row, id_state, - members_text, refusal_card, - same_name, - single_note, - text_box, - why_no_proposal, ) +from .notebook_names import _NameRows, _updated from .run import OutputLockedError from .run.draft_file import save_draft from .run.output_guard import OutputInsideMasksError @@ -65,7 +58,7 @@ ) -class _DescriptionCard(_SavedDraft): +class _DescriptionCard(_NameRows, _SavedDraft): """Step 1's names and references cards: the user's answers to the draft description.""" def _build_description_cards(self) -> None: @@ -88,6 +81,7 @@ def _build_description_cards(self) -> None: self._id_states: dict[tuple[str, str], widgets.HTML] = {} self.names_status = widgets.HTML() self.names_grid = widgets.VBox() + self.condition_rows, self.replicate_rows = widgets.VBox(), widgets.VBox() self.ids_confirmed = ui.ConfirmToggle( IDS_QUESTION, IDS_EXPLANATION, @@ -247,35 +241,6 @@ def _save_description(self) -> None: # The names card ---------------------------------------------------------------------- - def _entries(self) -> list[Entry]: - """Return the rows of the names card: conditions, repeats, technical-repeat folders.""" - draft, answers = self.draft, self.draft_answers - entries = [ - Entry( - "conditions", - folder, - slot, - f"{html.escape(folder)}", - answers.conditions.get(folder, ConditionAnswer()).display_name, - ) - for folder, slot in sorted(draft.conditions.items()) - ] - entries += [ - Entry( - "repeats", - label, - repeat.id, - f"{html.escape(label)}
{members_text(repeat.members)}", - answers.repeats.get(label, RepeatAnswer()).display_name, - ) - for label, repeat in sorted(draft.repeats.items()) - ] - entries += [ - Entry("technical_repeats", folder, slot, f"{html.escape(folder)}") - for folder, slot in sorted(draft.technical_repeats.items()) - ] - return entries - def _ids_all_confirmed(self) -> bool: return not any(q.code == "id_pending" for q in self.draft.open_questions()) @@ -297,55 +262,6 @@ def _refresh_description(self) -> None: ui.set_visible(self.references_card, len(self.draft.conditions) > 1) self._refresh_channels() - def _build_names(self) -> None: - """Rebuild the ID and shown-as boxes, keeping what was typed and not yet confirmed.""" - typed = {key: box.value for key, box in self.id_inputs.items()} - self.id_inputs, self.name_inputs, self._id_states = {}, {}, {} - self.name_hints = {} - entries = self._entries() - blocks: list[widgets.Widget] = [] - for kind, title in KIND_TITLES.items(): - shown = [e for e in entries if e.kind == kind and not e.fixed] - if not shown: - continue - asks_name = kind != "technical_repeats" - rows: list[widgets.Widget] = [ - header_row(title, "ID", "Shown as (optional)" if asks_name else "", "") - ] - for entry in shown: - rows += self._entry_row(entry, typed, asks_name=asks_name) - blocks.append(widgets.VBox(rows)) - self.single_note.value = single_note(entries) - self.names_grid.children = blocks - - def _entry_row(self, entry: Entry, typed: dict, *, asks_name: bool) -> list[widgets.Widget]: - """Build the grid row of one entry (and why it has no proposal, if so).""" - key = (entry.kind, entry.key) - confirmed = entry.slot.confirmed - text = confirmed or typed.get(key) or entry.slot.proposed or "" - box = text_box(text, "type an ID", disabled=confirmed is not None) - state = widgets.HTML(id_state(entry, text)) - box.observe( - lambda change, e=entry, s=state: self._id_typed(e, s, change["new"]), names="value" - ) - self.id_inputs[key], self._id_states[key] = box, state - cells: list[widgets.Widget] = [widgets.HTML(entry.where), box] - if asks_name: - name = text_box(entry.shown or "", "same as the ID") - name.observe(lambda change, e=entry: self._name_typed(e, change["new"]), names="value") - self.name_inputs[key] = name - cells.append(name) - else: - cells.append(widgets.HTML("")) - cells.append(state) - row: list[widgets.Widget] = [widgets.GridBox(cells, layout=ID_COLUMNS)] - if asks_name: - self.name_hints[key] = widgets.HTML() - row.append(self.name_hints[key]) - if why := why_no_proposal(entry): - row.append(widgets.HTML(why)) - return row - def _show_names_status(self) -> None: """Say where the answers are saved (or why not), after any note or refusal.""" if self._draft_saved: @@ -372,31 +288,6 @@ def _id_typed(self, entry: Entry, state: widgets.HTML, text: str) -> None: if entry.kind == "conditions" and not self._refreshing: self._relabel_references() - def _name_typed(self, entry: Entry, text: str) -> None: - """Save the name shown in plots and tables; an empty box means the ID is shown. - - The hint about a duplicate follows every keystroke; the answer is saved without - rebuilding the boxes, so typing is never interrupted. A duplicate is still saved: the - reader refuses it when the description is reviewed. - """ - if self._refreshing or self.draft is None: - return - self._update_name_hints(entry.kind) - self._draft_note = "" - answers = self.draft_answers - kind = "conditions" if entry.kind == "conditions" else "repeats" - new = _updated(answers, kind, entry.key, display_name=text or None) - if new != answers: - self._names_refusal = "" - self._answer(new, rebuild=False) - - def _update_name_hints(self, kind: str) -> None: - """Show a small orange hint beside every shown-as name that another one of its kind has.""" - boxes = {key: box.value for key, box in self.name_inputs.items() if key[0] == kind} - for key, text in boxes.items(): - same = [k for k, other in boxes.items() if k != key and same_name(text, other)] - self.name_hints[key].value = duplicate_hint(kind, text, [k[1] for k in same]) - def _ids_toggled(self, confirmed: bool) -> None: if self.draft is None: return @@ -544,18 +435,6 @@ def _sync_controls(self) -> None: check.value = condition in referenced -def _updated(answers: DraftAnswers, kind: str, key: str, **fields: str | None) -> DraftAnswers: - """Return the answers with fields of one condition or repeat set; an empty entry is dropped.""" - table = dict(getattr(answers, kind)) - empty = ConditionAnswer() if kind == "conditions" else RepeatAnswer() - entry = replace(table.get(key, empty), **fields) - if entry == empty: - table.pop(key, None) - else: - table[key] = entry - return replace(answers, **{kind: table}) - - def _with_ids(answers: DraftAnswers, ids: dict[tuple[str, str], str]) -> DraftAnswers: """Return the answers with exactly these IDs (any ID not in ``ids`` is taken back).""" conditions = { diff --git a/src/morphobricks_preprocessing/notebook_design.py b/src/morphobricks_preprocessing/notebook_design.py index 543779d0..10ae7e9d 100644 --- a/src/morphobricks_preprocessing/notebook_design.py +++ b/src/morphobricks_preprocessing/notebook_design.py @@ -55,7 +55,7 @@ def _build_design_card(self) -> None: "condition (acquired a day later, for example), choose its group in " "the dropdown. Each group may hold one folder per condition. The dropdown " "offers " - "folder names only; the repeat's own ID is set in the Names card." + "folder names only; the repeat's own ID is set with the names below." ), self.pairing_grid, ] diff --git a/src/morphobricks_preprocessing/notebook_mask_question.py b/src/morphobricks_preprocessing/notebook_mask_question.py index b29c418e..bff4b9fd 100644 --- a/src/morphobricks_preprocessing/notebook_mask_question.py +++ b/src/morphobricks_preprocessing/notebook_mask_question.py @@ -144,7 +144,7 @@ def _build_made_from(self, labels: list[str], chosen: tuple[str, ...]) -> None: ) box.observe(lambda _: self._made_from_changed(), names="value") self.made_from[label] = box - empty = "no channel labels yet: declare them on *Your channels*" + empty = "no channel labels yet: declare them on Stains (step 8)" hint = "answer the question first" if labels and self.saved is None else "" tail = [ widgets.HTML(f"{hint or (empty if not labels else '')}") diff --git a/src/morphobricks_preprocessing/notebook_mask_step.py b/src/morphobricks_preprocessing/notebook_mask_step.py index 7f3df601..8b0032a9 100644 --- a/src/morphobricks_preprocessing/notebook_mask_step.py +++ b/src/morphobricks_preprocessing/notebook_mask_step.py @@ -47,7 +47,7 @@ _ICON = "sitemap" _SUGGESTIONS = ( "These are suggestions from the masks' pixels: check the relationships before using them. " - "Each mask keeps the ID it was given on Your masks." + "Each mask keeps the ID it was given on Masks (step 10)." ) diff --git a/src/morphobricks_preprocessing/notebook_masks_screen.py b/src/morphobricks_preprocessing/notebook_masks_screen.py index 30b8f962..a22c93e7 100644 --- a/src/morphobricks_preprocessing/notebook_masks_screen.py +++ b/src/morphobricks_preprocessing/notebook_masks_screen.py @@ -42,7 +42,7 @@ "has its own number, as nuclei or cells from a segmentation) or regions (inside or " "outside, as a tissue area). Nothing is chosen for you: the pixel type and the number of " "values are shown only to help you answer. The mask ID is the folder name when it can be " - "one; it is used everywhere after, also by Describe the masks in step 3." + "one; it is used everywhere after, also by Describe the masks (step 13)." ) diff --git a/src/morphobricks_preprocessing/notebook_names.py b/src/morphobricks_preprocessing/notebook_names.py new file mode 100644 index 00000000..c4f7cec6 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_names.py @@ -0,0 +1,156 @@ +"""The rows of the names card: an ID and a shown-as name per condition, repeat and folder. + +Moved out of :mod:`.notebook_description` so the stepper can place the rows on two steps: the +condition rows on *Conditions* (``condition_rows``) and the replicate and technical-repeat rows +on *Replicates*, beside the pairing (``replicate_rows``). The same row widgets are also the +children of the names card's grid (``names_grid``), which embedding pages show as before. +""" + +from __future__ import annotations + +import html +from dataclasses import replace + +import ipywidgets as widgets + +from .draft_answers import ConditionAnswer, DraftAnswers, RepeatAnswer +from .notebook_description_view import ( + ID_COLUMNS, + KIND_TITLES, + Entry, + duplicate_hint, + header_row, + id_state, + members_text, + same_name, + single_note, + text_box, + why_no_proposal, +) + + +class _NameRows: + """The ID and shown-as rows of the names card (a mixin of the description card).""" + + def _entries(self) -> list[Entry]: + """Return the rows of the names card: conditions, repeats, technical-repeat folders.""" + draft, answers = self.draft, self.draft_answers + entries = [ + Entry( + "conditions", + folder, + slot, + f"{html.escape(folder)}", + answers.conditions.get(folder, ConditionAnswer()).display_name, + ) + for folder, slot in sorted(draft.conditions.items()) + ] + entries += [ + Entry( + "repeats", + label, + repeat.id, + f"{html.escape(label)}
{members_text(repeat.members)}", + answers.repeats.get(label, RepeatAnswer()).display_name, + ) + for label, repeat in sorted(draft.repeats.items()) + ] + entries += [ + Entry("technical_repeats", folder, slot, f"{html.escape(folder)}") + for folder, slot in sorted(draft.technical_repeats.items()) + ] + return entries + + def _build_names(self) -> None: + """Rebuild the ID and shown-as boxes, keeping what was typed and not yet confirmed.""" + typed = {key: box.value for key, box in self.id_inputs.items()} + self.id_inputs, self.name_inputs, self._id_states = {}, {}, {} + self.name_hints = {} + entries = self._entries() + blocks: list[widgets.Widget] = [] + kinds: list[str] = [] + for kind, title in KIND_TITLES.items(): + shown = [e for e in entries if e.kind == kind and not e.fixed] + if not shown: + continue + asks_name = kind != "technical_repeats" + rows: list[widgets.Widget] = [ + header_row(title, "ID", "Shown as (optional)" if asks_name else "", "") + ] + for entry in shown: + rows += self._entry_row(entry, typed, asks_name=asks_name) + blocks.append(widgets.VBox(rows)) + kinds.append(kind) + self.single_note.value = single_note(entries) + self.names_grid.children = blocks + pairs = list(zip(kinds, blocks, strict=True)) + self.condition_rows.children = [b for k, b in pairs if k == "conditions"] + self.replicate_rows.children = [b for k, b in pairs if k != "conditions"] + + def _entry_row(self, entry: Entry, typed: dict, *, asks_name: bool) -> list[widgets.Widget]: + """Build the grid row of one entry (and why it has no proposal, if so).""" + key = (entry.kind, entry.key) + confirmed = entry.slot.confirmed + text = confirmed or typed.get(key) or entry.slot.proposed or "" + box = text_box(text, "type an ID", disabled=confirmed is not None) + state = widgets.HTML(id_state(entry, text)) + box.observe( + lambda change, e=entry, s=state: self._id_typed(e, s, change["new"]), names="value" + ) + self.id_inputs[key], self._id_states[key] = box, state + cells: list[widgets.Widget] = [widgets.HTML(entry.where), box] + if asks_name: + name = text_box(entry.shown or "", "same as the ID") + name.observe(lambda change, e=entry: self._name_typed(e, change["new"]), names="value") + self.name_inputs[key] = name + cells.append(name) + else: + cells.append(widgets.HTML("")) + cells.append(state) + row: list[widgets.Widget] = [widgets.GridBox(cells, layout=ID_COLUMNS)] + if asks_name: + self.name_hints[key] = widgets.HTML() + row.append(self.name_hints[key]) + if why := why_no_proposal(entry): + row.append(widgets.HTML(why)) + return row + + def _name_typed(self, entry: Entry, text: str) -> None: + """Save the name shown in plots and tables; an empty box means the ID is shown. + + The hint about a duplicate follows every keystroke; the answer is saved without + rebuilding the boxes, so typing is never interrupted. A duplicate is still saved: the + reader refuses it when the description is reviewed. + """ + if self._refreshing or self.draft is None: + return + self._update_name_hints(entry.kind) + self._draft_note = "" + answers = self.draft_answers + kind = "conditions" if entry.kind == "conditions" else "repeats" + new = _updated(answers, kind, entry.key, display_name=text or None) + if new != answers: + self._names_refusal = "" + self._answer(new, rebuild=False) + + def _update_name_hints(self, kind: str) -> None: + """Show a small orange hint beside every shown-as name that another one of its kind has.""" + boxes = {key: box.value for key, box in self.name_inputs.items() if key[0] == kind} + for key, text in boxes.items(): + same = [k for k, other in boxes.items() if k != key and same_name(text, other)] + self.name_hints[key].value = duplicate_hint(kind, text, [k[1] for k in same]) + + +def _updated(answers: DraftAnswers, kind: str, key: str, **fields: str | None) -> DraftAnswers: + """Return the answers with fields of one condition or repeat set; an empty entry is dropped.""" + table = dict(getattr(answers, kind)) + empty = ConditionAnswer() if kind == "conditions" else RepeatAnswer() + entry = replace(table.get(key, empty), **fields) + if entry == empty: + table.pop(key, None) + else: + table[key] = entry + return replace(answers, **{kind: table}) + + +__all__ = ["_NameRows", "_updated"] diff --git a/src/morphobricks_preprocessing/notebook_plots.py b/src/morphobricks_preprocessing/notebook_plots.py index 82c994d9..fe5d00f2 100644 --- a/src/morphobricks_preprocessing/notebook_plots.py +++ b/src/morphobricks_preprocessing/notebook_plots.py @@ -287,12 +287,20 @@ def _draw(self) -> None: self.boxes.value = self.curves.value = b"" return channel = self.channel.value or 0 - boxes = repeat_boxes(report, channel) labels = self.labels + boxes = sorted( + repeat_boxes(report, channel), + key=lambda box: labels.order(box.condition, box.biological_repeat), + ) self.boxes.value = ( intensity_boxes_png(boxes, report.high_percentile, channel, labels) or b"" ) - curves = repeat_distributions(self.records, channel) + curves = { + condition: dict( + sorted(found.items(), key=lambda item: labels.order(condition, item[0])) + ) + for condition, found in repeat_distributions(self.records, channel).items() + } shifted = report.shifted_repeats(channel) self.curves.value = repeat_curves_png(curves, shifted, channel, labels) or b"" diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py new file mode 100644 index 00000000..80db79fe --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_step_states.py @@ -0,0 +1,213 @@ +"""What each step of the stepper says about itself: done or not, and one line. + +Every line is read from the state the cards already keep (the folder check, the confirmed axis +roles, the draft answers, the channel and mask cards, the published file, the run); nothing is +checked or computed again here, and nothing is changed. A mixin of +:class:`.notebook_ui.PreprocessingNotebook`, used by :mod:`.notebook_steps`. +""" + +from __future__ import annotations + +from .channel_labels import remaining +from .mask_answers import mask_kinds, masks_unanswered +from .notebook_stepper import StepState +from .notebook_values import size_rows +from .validation import plural + +ANSWER_STEPS = range(3, 11) +"""The steps (0-based) whose answers the published file holds: conditions to check and save.""" + + +def _ids(wanted: dict[tuple[str, str], str], kind: str) -> list[str]: + return [text for (k, _), text in sorted(wanted.items()) if k == kind] + + +class _StepStates: + """The :class:`.notebook_stepper.StepState` of each step, from the cards' state.""" + + def _step_states(self) -> list: + """Return the state callables of the 13 steps, in order.""" + return [ + self._images_state, + self._axes_state, + self._sizes_state, + self._conditions_state, + self._replicates_state, + self._references_state, + self._image_type_state, + self._stains_state, + self._tags_state, + self._masks_state, + self._saved_state, + self._run_state, + self._describe_state, + ] + + def _reopen_changed(self) -> bool: + """Return True when a published file exists and the folders changed since it was saved.""" + state = self._published() if self.preflight is not None else None + return state is not None and state.changed + + def _images_state(self) -> StepState: + """Return step 1: images and folders found, the masks, and changes since the save.""" + pre = self.preflight + if pre is None: + return StepState(False, "not checked yet") + text = ( + f"{plural(len(pre.discovery.images), 'image')} · " + f"{plural(len(pre.discovery.leaves), 'folder')}" + ) + types = self._mask_types() + if types: + text += " · masks " + ", ".join(types) + state = self._published() + if state is not None and state.changed: + rec = state.reconciliation + text += f" · since saved: {len(rec.new)} new, {len(rec.removed)} gone" + if not pre.ok: + text += " · problems to fix" + return StepState(pre.ok, text) + + def _axes_state(self) -> StepState: + mapping = self.confirmed_mapping + if mapping is None: + return StepState(False, "not confirmed") + return StepState(True, ", ".join(role for role in mapping.roles if role) + " confirmed") + + def _sizes_state(self) -> StepState: + """Return step 3: the pixel sizes as the calibration card shows them.""" + if self.preflight is None or not self.preflight.metadata.files: + return StepState(False, "after the folder check") + key = (id(self.preflight), self.confirmed_mapping, self.missing_as_singleton.value) + if getattr(self, "_sizes_key", None) != key: # the card's own sizes, read once + shown, roles = self._shown_calibration() + self._sizes_key, self._sizes_rows = key, size_rows(shown.calibration.sizes, roles) + rows = self._sizes_rows + if len(rows) == 1: + x, y = rows[0][0][:2] + return StepState(True, f"X {x} · Y {y}") + return StepState(True, f"{len(rows)} different sets of sizes") + + def _changed(self, index: int, done: bool) -> bool: + return not done and index in ANSWER_STEPS and self._reopen_changed() + + def _conditions_state(self) -> StepState: + """Return step 4: the condition IDs as shown, with their shown-as names.""" + if self.draft is None: + return StepState(False, "after the folder check") + wanted = self._wanted_ids() + ids = _ids(wanted, "conditions") + shown = { + wanted.get(("conditions", folder), ""): answer.display_name + for folder, answer in self.draft_answers.conditions.items() + if answer.display_name + } + text = ", ".join(f"{i} ({shown[i]})" if i in shown else i or "?" for i in ids) + done = all(ids) + return StepState(done, text or "none", self._changed(3, done)) + + def _replicates_state(self) -> StepState: + """Return step 5: the replicate IDs, the pairing and the technical-repeat IDs.""" + if self.draft is None: + return StepState(False, "after the folder check") + wanted = self._wanted_ids() + repeats, technical = _ids(wanted, "repeats"), _ids(wanted, "technical_repeats") + text = ", ".join(i or "?" for i in repeats) + paired = [ + f"{condition}/{folder} = {wanted.get(('repeats', group)) or group}" + for (condition, folder), group in sorted(self.draft_answers.pairing.items()) + ] + if paired: + text += f" ({'; '.join(paired)})" + named = [i or "?" for i in technical if i != "single"] + if named: + text += " · technical: " + ", ".join(named) + done = all(repeats) and all(technical) + return StepState(done, text or "none", self._changed(4, done)) + + def _references_state(self) -> StepState: + if self.draft is None: + return StepState(False, "after the folder check") + conditions = sorted(self.draft.conditions) + if len(conditions) < 2: + return StepState(True, "one condition: nothing to compare") + chosen = {c: self._chosen_reference(c) for c in conditions} + text = " · ".join(f"{c} → {'?' if t is None else t or 'none'}" for c, t in chosen.items()) + done = None not in chosen.values() + return StepState(done, text, self._changed(5, done)) + + def _image_type_state(self) -> StepState: + """Return step 7: the image types answered, by kind, and how many are left.""" + if self._channels_key is None: + return StepState(False, "after the axes") + types = self.channel_types + usable = [u for u in self._channel_layouts if u.usable] + saved = [types.saved(u.key) for u in usable] + parts = [ + f"{word} ({plural(saved.count(value), 'folder')})" + for word, value in (("separate channels", "channels"), ("colour", "rgb")) + if value in saved + ] + done = None not in saved + if not done: + parts.append(f"{saved.count(None)} to choose") + return StepState(done, ", ".join(parts), self._changed(6, done)) + + def _stains_state(self) -> StepState: + if self._channels_key is None: + return StepState(False, "after the axes") + done = self.channel_labels.confirmed + text = ", ".join(self.channel_labels.labels) if done else "not confirmed" + return StepState(done, text, self._changed(7, done)) + + def _tags_state(self) -> StepState: + """Return step 9: whether every channel of every folder is tagged.""" + if self._channels_key is None or not self.channel_labels.confirmed: + return StepState(False, "after the stains") + layouts = self._channel_layouts + left = remaining(layouts, self.draft_answers.units, self.channel_labels.labels) + done = not left.cells and not left.blocked + text = ( + f"every channel of {plural(len(layouts), 'folder')} tagged" + if done + else f"{left.cells} channel(s) still to tag" + ) + return StepState(done, text, self._changed(8, done)) + + def _masks_state(self) -> StepState: + types = self._mask_types() + if not types: + return StepState(self.preflight is not None, "no masks") + if self.draft_answers is None: + return StepState(False, "after the folder check") + kinds = mask_kinds(self.draft_answers) + text = " · ".join(f"{t} = {kinds.get(t, '?')}" for t in types) + done = not masks_unanswered(self.draft_answers, types) + return StepState(done, text, self._changed(9, done)) + + def _saved_state(self) -> StepState: + if self.draft is None: + return StepState(False, "after the folder check") + if self._saved_current(): + state = self._published() + return StepState(True, f"saved {self._saved_on or state.saved_on}") + return StepState(False, "not saved yet", self._changed(10, False)) + + def _run_state(self) -> StepState: + result = self.result + if result is None: + return StepState(False, "not run yet") + if not result.written: + return StepState(False, "stopped: see the messages") + warnings = [i for i in result.issues if not i.is_error] + return StepState( + True, f"{plural(result.plane_count, 'plane')} · {plural(len(warnings), 'warning')}" + ) + + def _describe_state(self) -> StepState: + if self.mask_step_result.value: + return StepState(True, "masks described") + return StepState(False, "optional") + + +__all__ = ["ANSWER_STEPS", "_StepStates"] diff --git a/src/morphobricks_preprocessing/notebook_stepper.py b/src/morphobricks_preprocessing/notebook_stepper.py new file mode 100644 index 00000000..b2a077e3 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_stepper.py @@ -0,0 +1,196 @@ +"""The notebook's stepper: one step on screen at a time, a strip of chips, Back and Next. + +A layout only (``ui`` layer, owner decision 2026-11-09, UX proposal Q1). Each step is a page of +cards that already exist; the stepper shows one page and hides the others with +:func:`.notebook_style.set_visible`, so a hidden page keeps every widget, answer and picture as +it is. The strip has one chip per step: green with a check mark when the step is done, blue for +the step on screen, grey for a step still to do, and orange for a step a reopened experiment +changed. A chip is a button that opens its step. **Next** is never blocked: what must be answered +is still refused where it was (the save and the run say what is missing). + +What a step says about itself (done or not, a one-line summary) comes from a callable the owner +gives, which reads the cards' own state; the stepper decides nothing. The same lines make the +overview (:meth:`Stepper.overview`), each with a *change* button that opens its step. +""" + +from __future__ import annotations + +import html +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +import ipywidgets as widgets + +from . import notebook_style as ui + +CHIP_CLASSES = ("mb-chip-done", "mb-chip-current", "mb-chip-todo", "mb-chip-changed") +STEPPER_CSS = """ + +""" + + +@dataclass(frozen=True) +class StepState: + """What a step says about itself. + + Attributes + ---------- + done + True when the step needs nothing more. + text + One line saying what was answered or found (plain text). + changed + True when a reopened experiment changed what the step holds (orange). + """ + + done: bool + text: str + changed: bool = False + + +def _no_wait() -> str: + return "" + + +@dataclass(frozen=True) +class Step: + """One step: its chip title, its page and what it says about itself. + + Attributes + ---------- + title + The short name on the chip and in the page title. + purpose + The sentence under the page title (HTML). + children + The page's cards, in order. + state + 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. + """ + + title: str + purpose: str + children: Sequence[widgets.Widget] + state: Callable[[], StepState] + waiting: Callable[[], str] = _no_wait + + +class Stepper: + """The strip, the pages and Back/Next of a list of steps; one page shown at a time.""" + + def __init__( + self, steps: Sequence[Step], on_show: Callable[[int], None] = lambda _: None + ) -> None: + self.steps = list(steps) + self._on_show = on_show + self.current = 0 + 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.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)) + self.next.on_click(lambda _: self.go(self.current + 1)) + self.strip = widgets.HBox(self.chips) + self.strip.add_class("mb-strip") + nav = widgets.HBox([self.back, self.next]) + nav.add_class("mb-nav") + self._overview_lines: list[tuple[int, widgets.HTML]] = [] + self.widget = widgets.VBox( + [widgets.HTML(STEPPER_CSS), self.strip, *self.pages, nav], + layout=widgets.Layout(width="99%"), + ) + self.go(0) + + def _chip(self, index: int) -> widgets.Button: + chip = widgets.Button(layout=widgets.Layout(width="auto")) + chip.add_class("mb-chip") + chip.on_click(lambda _: self.go(index)) + return chip + + def go(self, index: int) -> None: + """Show step ``index`` (0-based; clamped to the steps there are).""" + self.current = max(0, min(index, len(self.steps) - 1)) + for i, page in enumerate(self.pages): + ui.set_visible(page, i == self.current) + self._on_show(self.current) + self.refresh() + + def states(self) -> list[StepState]: + """Return what every step says about itself now.""" + return [step.state() for step in self.steps] + + def refresh(self) -> None: + """Update the chips, the waiting line of the step shown, Back/Next and the overview.""" + states = self.states() + for i, (chip, step, state) in enumerate(zip(self.chips, self.steps, states, strict=True)): + kind = ( + "current" + if i == self.current + else "changed" + if state.changed + else "done" + if state.done + else "todo" + ) + for name in CHIP_CLASSES: + chip.remove_class(name) + chip.add_class(f"mb-chip-{kind}") + chip.description = f"{'✓ ' if state.done else ''}{i + 1} {step.title}" + chip.tooltip = state.text + self.waits[self.current].value = self.steps[self.current].waiting() + last = len(self.steps) - 1 + ui.set_visible(self.back, self.current > 0) + ui.set_visible(self.next, self.current < last) + if self.current > 0: + self.back.description = f" Back: {self.steps[self.current - 1].title}" + if self.current < last: + self.next.description = f" Next: {self.steps[self.current + 1].title}" + for index, line in self._overview_lines: + line.value = overview_line(self.steps[index].title, states[index]) + + def overview(self, count: int) -> widgets.Widget: + """Return the one-line summaries of the first ``count`` steps, each with *change*.""" + rows = [] + for index in range(count): + line = widgets.HTML() + change = ui.button("change", tooltip=f"Open step {index + 1}") + change.add_class("mb-change") + change.on_click(lambda _, i=index: self.go(i)) + self._overview_lines.append((index, line)) + rows.append(widgets.HBox([line, change], layout=widgets.Layout(align_items="center"))) + box = ui.card("Your experiment, step by step", rows) + box.add_class("mb-overview") + self.refresh() + return box + + +def overview_line(title: str, state: StepState) -> str: + """Return one overview line: a mark, the step's name and its summary (HTML).""" + mark, colour = ( + ("●", "#EA580C") if state.changed else ("✓", "#16A34A") if state.done else ("○", "#64748B") + ) + return ( + f"{mark} " + f"{html.escape(title)} · {html.escape(state.text)}" + ) + + +__all__ = ["CHIP_CLASSES", "STEPPER_CSS", "Step", "StepState", "Stepper", "overview_line"] diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py new file mode 100644 index 00000000..282bed24 --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -0,0 +1,284 @@ +"""The shipped notebook's 13 steps, one on screen at a time (owner decision 2026-11-09). + +Pure composition of the cards the notebook already has (UX proposal 2026-11-09, PR 17a): the +order is images found, axes, pixel sizes, conditions, replicates, compared against, image type, +stains, tag channels, masks, check and save (with the overview of steps 1-10), run, and describe +the masks. Each answer is still saved, refused and checked by the card that asks it; the stepper +(:mod:`.notebook_stepper`) only shows one page and says, from each card's state +(:mod:`.notebook_step_states`), which steps are done. + +Three things follow the layout and nothing else: the tag grid leaves the image type to its own +page (:class:`.notebook_channels_type.ImageTypeRows`, the same answers), the condition names and +the replicate names sit on their own steps (:mod:`.notebook_names`), and the consistency card +follows the pairing, shown only when it warns. A page holding a card the saved reopening folded +unfolds it when it is opened. ``folder_step`` and ``axis_step`` are unchanged for embedders. +""" + +from __future__ import annotations + +import ipywidgets as widgets + +from . import notebook_style as ui +from .dataset import CONSISTENCY_CODES +from .notebook_channels import INTRO as CHANNELS_INTRO +from .notebook_channels_type import ImageTypeRows +from .notebook_check_save import INTRO as CHECK_INTRO +from .notebook_masks_screen import INTRO as MASKS_INTRO +from .notebook_step_states import _StepStates +from .notebook_stepper import Step, Stepper +from .run import Preflight + +FOLDER_INTRO = ( + "What happens here: the folders are scanned, the labels are taken from the folder " + "names (condition / biological repeat / technical repeat) and every image header is " + "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 preview before confirming. The pictures of the " + "later steps are read only through the roles you confirm." +) +CONDITIONS_HELP = ( + "Each condition gets a short name (its ID), which later bricks use and which never changes " + "once the experiment description is saved. The folder name is proposed when it can be used " + "as it is; otherwise type one. The shown as name is only for plots and tables." +) +REPLICATES_HELP = ( + "Each biological replicate, and each technical-repeat folder, gets a short name (its ID), " + "which never changes once the experiment description is saved. A replicate pairs one folder " + "per condition (above); its name is proposed from the folder name when it can be used." +) +OVERVIEW_STEPS = 10 +"""The overview on *Check and save* restates steps 1 to 10.""" +UNFOLDING = {4: "design_card", 5: "references_card", 9: "masks_box"} +"""The steps (0-based) that show a card the saved reopening folds.""" + + +class _StepsFlow(_StepStates): + """``steps()``: the stepper of the shipped notebook, and the hooks that keep it current.""" + + def steps(self) -> widgets.Widget: + """Return the notebook's 13 steps, one shown at a time, with a strip and Back/Next. + + The shipped notebook shows this (with ``describe=True``) instead of the stacked + pages. Every answer is given and saved on the card that asks it, as on the pages; + Next is never blocked, and the save and the run still refuse what is missing. + + Returns + ------- + ipywidgets.Widget + The stepper; calling again returns the same one. + """ + if getattr(self, "_stepper", None) is not None: + return self._stepper.widget + self.channel_grid.place_types = False # the image type has its own step + if ui.is_visible(self.channel_grid.widget): + self.channel_grid.refresh() + for box in (self.folder_next, self.axis_next): + ui.set_visible(box, False) # Next and the strip say where to go + self.type_rows = ImageTypeRows(self.channel_types) + self.overview_box = widgets.VBox() + self.consistency_slot = widgets.VBox([self.consistency_card]) + self._stepper = Stepper(self._step_list(), self._step_shown) + self.overview_box.children = [self._stepper.overview(OVERVIEW_STEPS)] + self._refresh_type_rows() + self._refresh_steps() + return self._stepper.widget + + def _step_list(self) -> list[Step]: + """Return the 13 steps: titles, purposes, the existing cards, and their states.""" + states = self._step_states() + pages = [ + ("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]), + ("Pixel sizes", "The size of one pixel, and where it comes from.", + [self.calibration_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]), + ("Compared against", "One question per condition.", [self.references_card]), + ("Image type", "Separate channels or a colour (RGB) picture, per folder.", + [self.type_rows.widget]), + ("Stains", "Name what you stained.", + [ui.intro(CHANNELS_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]), + ("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]), + ("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.", + [self.mask_step_card]), + ] # fmt: skip + waits = self._step_waits() + return [ + Step(title, purpose, children, state, waits.get(i, _nothing)) + 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.check_button, + 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.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]) + + def _replicates_card(self) -> widgets.Widget: + return ui.card( + "Replicate and technical-repeat names", + [ui.help_text(REPLICATES_HELP), self.names_status, self.replicate_rows, + self.single_note], + ) # fmt: skip + + def _step_waits(self) -> dict: + """Return, by step (0-based), why it cannot be answered yet (HTML, empty when it can).""" + after_check = self._wait_for_check + return { + 0: self._consistency_note, + 2: lambda: "" if self.preflight is not None else after_check(), + 3: after_check, + 4: after_check, + 5: self._wait_for_references, + 6: self._wait_for_channels, + 7: self._wait_for_channels, + 8: self._wait_for_tags, + 12: self._wait_for_mask_step, + } + + def _consistency_note(self) -> str: + """Say where the consistency warnings are, since step 1 counts them but shows them later.""" + if not ui.is_visible(self.consistency_slot): + return "" + return ui.status( + "info", + "The consistency warnings are on Replicates (step 5).", + ["They depend on which folders are one replicate: pair the folders there first."], + ) + + def _wait_for_check(self) -> str: + if self.draft is not None: + return "" + return ui.status("info", "Waiting for step 1.", ["Check the experiment folder first."]) + + def _wait_for_references(self) -> str: + if self.draft is not None and len(self.draft.conditions) < 2: + return ui.status("ok", "There is one condition: nothing to compare.") + return self._wait_for_check() + + def _wait_for_channels(self) -> str: + return "" if self._channels_key is not None else self.channels_waiting.value + + def _wait_for_tags(self) -> str: + waiting = self._wait_for_channels() + if waiting or self.channel_labels.confirmed: + return waiting + return ui.status("info", "Waiting for the stains.", ["Confirm the stains in step 8 first."]) + + def _wait_for_mask_step(self) -> str: + if ui.is_visible(self.mask_step_card): + return "" + return ui.status("info", "Not offered.", ["The output folder is not shown."]) + + # Hooks ------------------------------------------------------------------------------- + + def _step_shown(self, index: int) -> None: + """Unfold the saved reopening when a step shows one of the cards it folds.""" + if index in UNFOLDING and getattr(self, "_folded", False): + self._unfold() + + def _refresh_steps(self) -> None: + """Update the strip and the consistency card's place (shown only when it warns).""" + stepper = getattr(self, "_stepper", None) + if stepper is None: + return + warns = self.preflight is not None and any( + i.code in CONSISTENCY_CODES for i in self._step1_issues() + ) + ui.set_visible(self.consistency_slot, warns) + stepper.refresh() + + def _refresh_run_step(self) -> None: + """Update the run step, then the strip (every answer ends here).""" + super()._refresh_run_step() + self._refresh_steps() + + def check_folder(self) -> Preflight | None: + """Check the folder; a reopened experiment opens on its first step still to do.""" + pre = super().check_folder() + stepper = getattr(self, "_stepper", None) + if stepper is not None and pre is not None and self._published() is not None: + states = stepper.states() + first = next( + (i for i, s in enumerate(states[: OVERVIEW_STEPS + 1]) if not s.done), + OVERVIEW_STEPS + 1, + ) + stepper.go(first) + return pre + + def _refresh_type_rows(self) -> None: + """Open the image-type step for the folders the channel step opened, or close it.""" + rows = getattr(self, "type_rows", None) + if rows is None: + return + if self._channels_key is None: + rows.close() + elif rows.types.layouts is not self._channel_layouts or not ui.is_visible(rows.widget): + rows.open(self._channel_layouts) + else: + rows.refresh() + + def _refresh_channels(self) -> None: + super()._refresh_channels() + self._refresh_type_rows() + + def _hide_channels(self, why: str = "") -> None: + super()._hide_channels(why) + self._refresh_type_rows() + + def _types_changed(self) -> None: + super()._types_changed() + self._refresh_type_rows() + self._refresh_steps() + + +def _nothing() -> str: + return "" + + +__all__ = ["_StepsFlow"] diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py index f842374e..22d03aac 100644 --- a/src/morphobricks_preprocessing/notebook_ui.py +++ b/src/morphobricks_preprocessing/notebook_ui.py @@ -94,6 +94,7 @@ from .notebook_results import _ResultsStep from .notebook_run import _RunStep from .notebook_run_plan import _RunPlan +from .notebook_steps import _StepsFlow from .notebook_values import display_range from .planes import PlaneLayout from .run import Preflight, RunResult @@ -124,6 +125,7 @@ class PreprocessingNotebook( + _StepsFlow, _DescribedRun, _CheckSaveScreen, _FolderStep, @@ -374,9 +376,10 @@ def axis_step( "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 " - "preview before confirming. Then confirm whether the images are fluorescence " - "or RGB colour images." + "preview before confirming." ) + if not self._describe: # a described run asks the image type per folder later + intro += " Then confirm whether the images are fluorescence or RGB colour images." return ui.page( "Step 2 · Review the axis roles" if title is None else title, "Tell the brick which image dimension is width, height, depth, channel and time." diff --git a/src/morphobricks_preprocessing/run_labels.py b/src/morphobricks_preprocessing/run_labels.py index 47a41de6..a03aadcd 100644 --- a/src/morphobricks_preprocessing/run_labels.py +++ b/src/morphobricks_preprocessing/run_labels.py @@ -46,6 +46,14 @@ def repeat(self, condition: str, repeat: str) -> str: """Return the replicate ID of a biological-repeat folder, or the folder label.""" return self.repeats.get((condition, repeat), repeat) + def order(self, condition: str, repeat: str) -> tuple[str, str, str]: + """Return the sort key of a biological-repeat folder: condition, replicate ID, folder. + + Within a condition the plots list the replicates by the ID they are shown with + (``rep1`` before ``rep2``), not by folder name; the folder breaks a tie. + """ + return (condition, self.repeat(condition, repeat), repeat) + NO_LABELS = RunLabels() """The labels of a run without a description: the table's own names.""" diff --git a/tests/test_embedding_api.py b/tests/test_embedding_api.py index 48ae6a37..7971978e 100644 --- a/tests/test_embedding_api.py +++ b/tests/test_embedding_api.py @@ -73,6 +73,7 @@ def _parameters(function): ], "check_save_step": [("title", POSITIONAL, None)], "save_experiment": [], + "steps": [], "run_step": [], "results_step": [], "check_folder": [], diff --git a/tests/test_notebook_channels.py b/tests/test_notebook_channels.py index a762e0ef..8b6f8063 100644 --- a/tests/test_notebook_channels.py +++ b/tests/test_notebook_channels.py @@ -207,7 +207,7 @@ def test_incomplete_tagging_blocks_completion(plain, out): assert "3 channel(s) still to tag" in grid.status.value assert "1 image type(s) still to choose" in grid.status.value tag_all(nb) - assert grid.complete and "Every channel of every one of the 4 units" in grid.status.value + assert grid.complete and "Every channel of every one of the 4 folders" in grid.status.value def test_the_dropdown_has_no_empty_option_and_an_unused_slot_needs_a_label(plain, out): @@ -266,10 +266,12 @@ def test_a_label_used_in_only_some_units_says_what_will_be_refused(plain, out): grid = nb.channel_grid tag_all(nb, skip=[("drug", "rep2", "single")]) text = grid.usage_html.value - assert "used in 3 of 4 units: an analysis of DNA will be refused for the other units" in text + assert ( + "used in 3 of 4 folders: an analysis of DNA will be refused for the other folders" in text + ) assert "A_SCOPE_INCOMPLETE" in text tag_all(nb) - assert "used in all 4 units" in grid.usage_html.value + assert "used in all 4 folders" in grid.usage_html.value assert "will be refused" not in grid.usage_html.value @@ -299,7 +301,7 @@ def test_a_pre_tag_from_the_header_is_only_a_suggestion_until_confirmed(tmp_path assert not saved(out).units # nothing is the user's answer yet assert not grid.complete assert len(grid.confirm_buttons) == 1 # one confirm for the one layout - assert "4 unit(s)" in grid.confirm_buttons[0].description + assert "4 folder(s)" in grid.confirm_buttons[0].description grid.confirm_buttons[0].click() assert all(u.channels == (DNA, ACTIN, PHASE) for u in saved(out).units.values()) assert "proposed, not confirmed" not in repr(grid.blocks.children) @@ -315,7 +317,7 @@ def test_units_with_different_header_names_are_different_layouts(tmp_path, out): set_labels(nb, ["ACTIN", "DNA", "PHASE"]) grid = nb.channel_grid assert len(grid.confirm_buttons) == 1 # only the layout that equals the labels exactly - assert "2 unit(s)" in grid.confirm_buttons[0].description + assert "2 folder(s)" in grid.confirm_buttons[0].description assert grid.cells[(("drug", "rep1", "single"), 0)].value is None @@ -353,7 +355,7 @@ def test_confirming_the_colour_layout_declares_r_g_b_and_completes_every_unit(tm nb = open_screen(rgb_tree(tmp_path), out) set_labels(nb, ["DNA"]) grid = nb.channel_grid - assert len(grid.confirm_buttons) == 1 and "4 unit(s)" in grid.confirm_buttons[0].description + assert len(grid.confirm_buttons) == 1 and "4 folder(s)" in grid.confirm_buttons[0].description grid.confirm_buttons[0].click() answers = saved(out) assert all( @@ -393,7 +395,7 @@ def test_every_answer_is_saved_and_a_reopened_notebook_shows_it(plain, out): assert ui.is_visible(grid.widget) and grid.complete assert all(d.value == (DNA, ACTIN, PHASE)[k[1]] for k, d in grid.cells.items()) assert all(r.value == "channels" for r in again.channel_types.radios.values()) - assert "Every channel of every one of the 4 units" in grid.status.value + assert "Every channel of every one of the 4 folders" in grid.status.value def test_changing_the_labels_takes_back_the_tags_of_a_label_that_is_gone(plain, out): diff --git a/tests/test_notebook_check_save.py b/tests/test_notebook_check_save.py index 79ed4201..3e1d04e5 100644 --- a/tests/test_notebook_check_save.py +++ b/tests/test_notebook_check_save.py @@ -191,7 +191,9 @@ def test_the_experiment_is_restated_in_words_with_one_question_per_replicate(exp def test_save_says_saved_and_enables_run(exp): nb = saved_nb(exp) - assert "Saved " in nb.save_status.value and "Step 3 can run" in nb.save_status.value + assert ( + "Saved " in nb.save_status.value and "The run (step 12) can start" in nb.save_status.value + ) assert not nb.run_button.disabled assert nb.confirmed_image_type == "fluorescence" _, _, out = exp diff --git a/tests/test_notebook_file.py b/tests/test_notebook_file.py index d21ad8c8..48aca5cd 100644 --- a/tests/test_notebook_file.py +++ b/tests/test_notebook_file.py @@ -53,14 +53,12 @@ def test_notebook_is_one_readable_markdown_cell_and_one_runnable_cell(): def test_notebook_uses_stable_page_api_not_a_new_app_method(): source = _source() assert "notebook.app(" not in source - for call in ( - "notebook.welcome()", - "notebook.folder_step()", - "notebook.axis_step()", - "notebook.run_step()", - "notebook.results_step()", - ): + # One stepper holds steps 1-13 (owner, 2026-11-09); the statistics stay a page of their own. + for call in ("notebook.welcome()", "notebook.steps()", "notebook.results_step()"): assert call in source + for page in ("folder_step(", "axis_step(", "channels_step(", "check_save_step(", "run_step("): + assert f"notebook.{page}" not in source # never shown twice beside the stepper + assert "PreprocessingNotebook(describe=True)" in source assert "display(app)" in source diff --git a/tests/test_notebook_mask_step.py b/tests/test_notebook_mask_step.py index 93d6352e..df4a1a2b 100644 --- a/tests/test_notebook_mask_step.py +++ b/tests/test_notebook_mask_step.py @@ -143,7 +143,7 @@ def test_describe_the_masks_reuses_the_ids_of_your_masks_and_asks_no_name(tmp_pa nb.mask_step_button.click() status, shown = nb.mask_step_status.value, nb.mask_step_result.value assert "name each mask" not in status - assert "keeps the ID it was given on Your masks" in status + assert "keeps the ID it was given on Masks (step 10)" in status assert "MaskDeclared kindKind (suggested)" in shown assert "Nuclei_seg (nuclei)objectsone object per label" in shown assert "cellsnot described yet" in shown diff --git a/tests/test_notebook_stepper.py b/tests/test_notebook_stepper.py new file mode 100644 index 00000000..153240e7 --- /dev/null +++ b/tests/test_notebook_stepper.py @@ -0,0 +1,269 @@ +"""The shipped notebook's stepper: 13 steps, one on screen at a time (PR 17a, 2026-11-09). + +Widgets are driven headless, as the user would: the strip's chips, Back and Next, and the +existing cards on each page. The stepper is pure composition, so the answers are those of the +cards; these tests pin the layout (order, one page shown, chip states, reopen landing) and that +moving between pages never changes or resets an answer. +""" + +import pytest + +from test_notebook_check_save import TAGS, add_folder, answer_all, exp # noqa: F401 + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 +from morphobricks_preprocessing.notebook_stepper import ( # noqa: E402 + Step, + Stepper, + StepState, +) +from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 + +ORDER = [ + "Images found", + "Axes", + "Pixel sizes", + "Conditions", + "Replicates", + "Compared against", + "Image type", + "Stains", + "Tag channels", + "Masks", + "Check and save", + "Run", + "Describe the masks", +] + + +def stepped(root, masks, out, *, axes=True): + """Return a described notebook shown as the stepper, checked (and its axes confirmed).""" + nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True) + nb.welcome() + nb.steps() + nb.results_step() + nb.masks_folder.value = str(masks) + nb.check_button.click() + if axes: + nb.preview_checked.set(True) + nb.show_preview() + nb.confirm_button.click() + return nb + + +def kinds(nb): + """Return each chip's colour class.""" + return [ + next(c.removeprefix("mb-chip-") for c in chip._dom_classes if c.startswith("mb-chip-")) + for chip in nb._stepper.chips + ] + + +def shown(nb): + """Return the index of the one page on screen (and check there is exactly one).""" + visible = [i for i, page in enumerate(nb._stepper.pages) if ui.is_visible(page)] + assert len(visible) == 1 + return visible[0] + + +def snapshot(nb): + """Return every answer and the value of every answering widget.""" + values = { + "ids": {key: box.value for key, box in nb.id_inputs.items()}, + "names": {key: box.value for key, box in nb.name_inputs.items()}, + "references": {key: box.value for key, box in nb.reference_boxes.items()}, + "types": {key: radio.value for key, radio in nb.channel_types.radios.items()}, + "tags": {key: drop.value for key, drop in nb.channel_grid.cells.items()}, + "same_way": {key: toggle.value for key, toggle in nb.same_way.items()}, + "mask": nb.mask_card.questions["Nuclei"].choice.value, + "pairing": [row.children[2].value for row in nb.pairing_grid.children[1:]], + } + return nb.draft_answers, nb.confirmed_mapping, values + + +# --- the generic stepper ------------------------------------------------------------------------ + + +def _steps(done): + return [ + Step(f"S{i}", "purpose", [], lambda i=i: StepState(i in done, f"line {i}")) + for i in range(4) + ] + + +def test_next_and_back_move_one_page_and_never_block(): + stepper = Stepper(_steps(done=set())) + pages = lambda: [ui.is_visible(page) for page in stepper.pages] # noqa: E731 + assert stepper.current == 0 and pages() == [True, False, False, False] + assert not ui.is_visible(stepper.back) and stepper.next.description == " Next: S1" + stepper.next.click() # nothing is done: Next still moves on + stepper.next.click() + assert stepper.current == 2 and pages() == [False, False, True, False] + assert stepper.back.description == " Back: S1" + stepper.back.click() + assert stepper.current == 1 + stepper.go(3) + assert not ui.is_visible(stepper.next) and ui.is_visible(stepper.back) + + +def test_the_strip_marks_done_current_and_to_do_and_a_chip_opens_its_step(): + stepper = Stepper(_steps(done={0, 2})) + stepper.chips[2].click() + assert stepper.current == 2 + marks = [[c for c in chip._dom_classes if c.startswith("mb-chip-")] for chip in stepper.chips] + assert marks == [["mb-chip-done"], ["mb-chip-todo"], ["mb-chip-current"], ["mb-chip-todo"]] + assert [chip.description for chip in stepper.chips] == ["✓ 1 S0", "2 S1", "✓ 3 S2", "4 S3"] + assert stepper.chips[1].tooltip == "line 1" + + +def test_the_overview_restates_each_step_with_a_change_button(): + stepper = Stepper(_steps(done={1})) + card = stepper.overview(3) + rows = card.children[1:] + assert len(rows) == 3 + assert "S1 · line 1" in rows[1].children[0].value and "✓" in rows[1].children[0].value + rows[2].children[1].click() + assert stepper.current == 2 + + +# --- the notebook's 13 steps -------------------------------------------------------------------- + + +def test_the_steps_are_in_the_decided_order_with_one_page_on_screen(exp): # noqa: F811 + nb = stepped(*exp, axes=False) + assert [step.title for step in nb._stepper.steps] == ORDER + assert nb.steps() is nb._stepper.widget # built once + assert shown(nb) == 0 and nb._stepper.current == 0 # a first check stays on its results + assert kinds(nb)[:3] == ["current", "todo", "done"] # axes to confirm; sizes are read + nb._stepper.chips[1].click() + assert shown(nb) == 1 and kinds(nb)[0] == "done" + page = nb._stepper.pages[1] + assert "Step 2 of 13 · Axes" in page.children[1].children[0].value + + +def test_the_pages_hold_the_existing_cards_in_the_new_places(exp): # noqa: F811 + nb = stepped(*exp) + contains = lambda i, w: _holds(nb._stepper.pages[i], w) # noqa: E731 + assert contains(0, nb.check_button) and contains(0, nb.masks_card) + assert contains(1, nb.axis_box) and contains(2, nb.calibration_card) + assert contains(3, nb.condition_rows) and not contains(3, nb.replicate_rows) + assert contains(4, nb.design_card) and contains(4, nb.replicate_rows) + assert contains(5, nb.references_card) and contains(6, nb.type_rows.widget) + assert contains(7, nb.channel_labels.widget) and contains(8, nb.channel_grid.widget) + assert contains(9, nb.masks_box) and contains(10, nb.check_card) + assert contains(10, nb.overview_box) and contains(11, nb.run_button) + assert contains(12, nb.mask_step_card) and not contains(11, nb.mask_step_card) + # The names rows are split between steps 4 and 5, and still the names card's grid. + (conditions,) = nb.condition_rows.children + assert "Condition" in conditions.children[0].children[0].value + assert list(nb.names_grid.children) == [conditions, *nb.replicate_rows.children] + + +def _holds(widget, target): + if widget is target: + return True + return any(_holds(child, target) for child in getattr(widget, "children", ())) + + +def test_the_image_type_has_its_own_step_and_the_grid_no_type_column(exp): # noqa: F811 + nb = stepped(*exp) + for row, text in zip(nb.channel_labels.rows, TAGS, strict=True): + row.id_box.value = text + nb.channel_labels.confirm.button.click() + assert len(nb.channel_types.radios) == 4 # one per folder, on the image-type step + assert not any(_holds(nb.channel_grid.widget, r) for r in nb.channel_types.radios.values()) + assert "Not finished: 4 image type(s)" in nb.type_rows.status.value + key = next(iter(nb.channel_types.radios)) + nb.channel_types.radios[key].value = "channels" + assert nb.draft_answers.units[key].image_type == "channels" + assert "Not finished: 3 image type(s)" in nb.type_rows.status.value + assert nb.channel_types.radios[key].value == "channels" # rebuilt from the answer + + +def test_moving_through_every_page_keeps_every_answer(exp): # noqa: F811 + nb = stepped(*exp) + answer_all(nb) + nb.name_inputs[("conditions", "drug")].value = "Drug A" + before = snapshot(nb) + stepper = nb._stepper + for _ in ORDER: + stepper.next.click() + for _ in ORDER: + stepper.back.click() + for chip in reversed(stepper.chips): + chip.click() + assert snapshot(nb) == before + assert nb.draft_answers.conditions["drug"].display_name == "Drug A" + assert all(k == "done" for k in kinds(nb)[1:10]) and kinds(nb)[0] == "current" + assert kinds(nb)[10] == "todo" # not saved yet + assert nb.save_experiment() + assert kinds(nb)[10] == "done" + + +def test_a_reopened_experiment_opens_on_the_axes_its_only_open_step(exp): # noqa: F811 + first = stepped(*exp) + answer_all(first) + assert first.save_experiment() + nb = stepped(*exp, axes=False) + assert nb._stepper.current == 1 and shown(nb) == 1 + nb.preview_checked.set(True) + nb.show_preview() + nb.confirm_button.click() + assert all(k == "done" for k in kinds(nb)[:11] if k != "current") + assert "saved " in nb._stepper.chips[10].tooltip + # The saved reopening folds the cards; a page that shows one unfolds it. + assert nb._folded and not ui.is_visible(nb.design_card) + nb._stepper.chips[4].click() + assert not nb._folded and ui.is_visible(nb.design_card) + + +def test_a_changed_reopen_turns_the_affected_steps_orange(tmp_path): + root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out" + for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1"]): + add_folder(root, masks, folder, i + 1) + first = stepped(root, masks, out) + answer_all(first) + assert first.save_experiment() + add_folder(root, masks, "drug/rep2", 9) + nb = stepped(root, masks, out) + states = nb._stepper.states() + assert "since saved: 1 new, 0 gone" in states[0].text + assert states[8].changed and states[10].changed # the new folder's tags; save again + assert not states[3].changed and states[3].done # the conditions did not change + assert kinds(nb)[8] == "changed" and kinds(nb)[10] == "changed" + assert nb._stepper.current == 1 # landed on the axes: each session confirms them again + + +def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path): + root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out" + for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/day2"]): + add_folder(root, masks, folder, i + 1) + nb = stepped(root, masks, out) + assert _holds(nb._stepper.pages[4], nb.consistency_card) + assert not _holds(nb._stepper.pages[0], nb.consistency_card) + assert ui.is_visible(nb.consistency_slot) # rep2 and day2 are each in one condition only + assert "on Replicates (step 5)" in nb._stepper.waits[0].value # step 1 says where + (dropdown,) = [ + row.children[2] + for row in nb.pairing_grid.children[1:] + if "day2" in row.children[1].children[0].value + ] + dropdown.value = "rep2" + assert not ui.is_visible(nb.consistency_slot) + assert nb._stepper.waits[0].value == "" + assert "drug/day2 = rep2" in nb._stepper.states()[4].text + + +def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(exp): # noqa: F811 + nb = stepped(*exp, axes=False) + text = "".join(getattr(w, "value", "") for w in nb._stepper.pages[1].children[1].children) + assert "fluorescence or RGB" not in text + assert not ui.is_visible(nb.preview_image) # nothing to show before Show preview + nb.show_preview() + assert ui.is_visible(nb.preview_image) and nb.preview_image.value + described = nb.axis_step() + assert "fluorescence or RGB" not in described.children[1].children[2].value + plain = PreprocessingNotebook().axis_step() # embedders keep their words + assert "fluorescence or RGB" in plain.children[1].children[2].value diff --git a/tests/test_run_page_labels.py b/tests/test_run_page_labels.py index 81406675..335a78d5 100644 --- a/tests/test_run_page_labels.py +++ b/tests/test_run_page_labels.py @@ -132,6 +132,16 @@ def test_a_described_run_uses_the_replicate_id_not_the_folder_name(tmp_path, fig assert not any("day2" in t for t in boxes + curves) +def test_replicates_within_a_condition_are_ordered_by_replicate_id(tmp_path, figures): + # drug/day2 is replicate rep2: by folder name it came before drug/rep1 (PR #241 kept that). + nb, _ = described(tmp_path) + assert nb.run().written + ticks = [t.get_text() for t in figures[-2].axes[1].get_xticklabels()] + assert ticks == ["ctrl / rep1", "ctrl / rep2", "drug / rep1", "drug / rep2"] + (drug,) = [axes for axes in figures[-1].axes if axes.get_title() == "drug"] + assert [t.get_text() for t in drug.get_legend().get_texts()] == ["rep1", "rep2"] + + def test_an_undescribed_run_keeps_the_folder_names(tmp_path, figures): nb = confirmed(bright_tree(tmp_path / "exp"), output_folder=str(tmp_path / "out")) nb.run()