From 0d58a30b20394bec42d257c819326d3766668cba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 08:59:12 +0000 Subject: [PATCH 1/4] Notebook: fold unchanged rows on reopen (PR 17b-2) On a reopened experiment, a row whose folders are found exactly as saved and whose question is answered folds behind one line with an open toggle (pairing, names, image types, tag blocks, Check and save replicates). New or changed rows stay open and are marked orange. Display only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- AGENTS.md | 1 + CHANGELOG.md | 7 + pyproject.toml | 1 + .../notebook_channels_grid.py | 20 ++- .../notebook_channels_type.py | 14 +- .../notebook_check_save.py | 20 +-- .../notebook_described.py | 9 +- .../notebook_design.py | 15 ++- .../notebook_names.py | 38 +++++- .../notebook_row_fold.py | 127 ++++++++++++++++++ .../notebook_steps.py | 1 + tests/test_notebook_row_fold.py | 123 +++++++++++++++++ 12 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 src/morphobricks_preprocessing/notebook_row_fold.py create mode 100644 tests/test_notebook_row_fold.py diff --git a/AGENTS.md b/AGENTS.md index a0635600..c26d56fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -311,6 +311,7 @@ src/morphobricks_preprocessing/ 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_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 notebook_channels_rows.py the tag grid's folder rows: pictures (colour for RGB), Enlarge notebook_folder.py step 1: folder, check, issues, labels (_FolderStep) notebook_design.py step 1: the experiment design card (_DesignCard) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59267d41..75d47d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the ## Unreleased +- **Notebook: unchanged rows fold on reopen (PR 17b-2).** When a saved experiment is reopened, a + row whose folders are found exactly as saved and whose question is answered folds behind one line + ("3 unchanged folders, answered as saved") with an **open** toggle: the pairing, the condition, + replicate and technical-repeat names, the image types, the tag blocks and the replicate blocks of + *Check and save*. A row with a new folder, or a folder whose image count changed, stays open and + is marked orange ("new since saved", "changed since saved"). Display only (`notebook_row_fold`): + the answers, the draft, the save and the run are unchanged. - **Notebook: tags per header block, colour pictures, one-click axes (PR 17b-1).** *Tag channels* shows folders whose image headers are identical, exactly (channel count, every raw header name present and equal, colour flag), as one block tagged once for all its folders (owner decision Q5), diff --git a/pyproject.toml b/pyproject.toml index 876c5cfb..98bfc52e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,6 +153,7 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.notebook_description_saved" = "ui" "morphobricks_preprocessing.notebook_description_view" = "ui" "morphobricks_preprocessing.notebook_names" = "ui" +"morphobricks_preprocessing.notebook_row_fold" = "ui" "morphobricks_preprocessing.notebook_step_states" = "ui" "morphobricks_preprocessing.notebook_stepper" = "ui" "morphobricks_preprocessing.notebook_steps" = "ui" diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py index c6b69e39..963c00f1 100644 --- a/src/morphobricks_preprocessing/notebook_channels_grid.py +++ b/src/morphobricks_preprocessing/notebook_channels_grid.py @@ -24,7 +24,7 @@ from __future__ import annotations import html -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence import ipywidgets as widgets @@ -50,6 +50,7 @@ columns, quoted, ) +from .notebook_row_fold import RowFold, row_status from .notebook_values import natural_key @@ -90,6 +91,9 @@ def __init__( self.split_buttons: dict[UnitKey, widgets.Button] = {} # Folders the user took out of their block (this session; never saved). self.alone: set[UnitKey] = set() + # The status of each unit against a saved experiment (None without one): rows fold. + self.reopen: Callable[[], Mapping[UnitKey, str] | None] = lambda: None + self.fold = RowFold("folder blocks") self.message = "" self._building = False self.blocks = widgets.VBox() @@ -265,13 +269,23 @@ def refresh(self) -> None: ) blocks = self.block_list() shared = [b[0].signature for b in blocks if named(b[0])] - self.blocks.children = [header] + [ - self._block(units, width, shared.count(units[0].signature) > 1) for units in blocks + built = [ + ( + self._block(units, width, shared.count(units[0].signature) > 1), + self._status(units), + ) + for units in blocks ] + self.blocks.children = [header, *self.fold.arrange(built)] finally: self._building = False self._summary() + def _status(self, units: Sequence[UnitLayout]) -> str: + """Return whether a block's folders are as saved; it folds once fully answered.""" + answered = remaining(units, self.store.answers().units, self.labels).done + return row_status(self.reopen(), [u.key for u in units], answered) + def _block(self, units: Sequence[UnitLayout], width: int, apart: bool) -> widgets.Widget: """Build one block: what its header says, its answer, and the rows of its folders.""" first = units[0] diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py index 2ebc5ea5..3f4f0698 100644 --- a/src/morphobricks_preprocessing/notebook_channels_type.py +++ b/src/morphobricks_preprocessing/notebook_channels_type.py @@ -12,7 +12,7 @@ from __future__ import annotations import html -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence import ipywidgets as widgets @@ -23,6 +23,7 @@ from .notebook_channel_thumbs import Thumbnails from .notebook_channels_rows import picture from .notebook_channels_view import AnswerStore, blank_chip, quoted, unit_name +from .notebook_row_fold import RowFold, row_status from .notebook_values import natural_key OPTIONS = [("separate channels", "channels"), ("colour (RGB)", "rgb")] @@ -145,6 +146,9 @@ def __init__( self, types: ImageTypeColumn, thumbs: Callable[[], Thumbnails | None] = lambda: None ) -> None: self.types, self._thumbs = types, thumbs + # The status of each unit against a saved experiment (None without one): rows fold. + self.reopen: Callable[[], Mapping[UnitKey, str] | None] = lambda: None + self.fold = RowFold("folders") self.rows = widgets.VBox() self.status = widgets.HTML() self.problem = widgets.HTML() @@ -183,7 +187,13 @@ 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 ordered = sorted(self.types.layouts, key=lambda u: tuple(map(natural_key, u.key))) - rows = [self._row(u) for u in ordered] + units = self.reopen() + rows = self.fold.arrange( + [ + (self._row(u), row_status(units, [u.key], self.types.saved(u.key) is not None)) + for u in ordered + ] + ) proposed = [u.key for u in self.types.layouts if self.types.proposed(u)] if proposed: button = ui.button( diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 13f3a5c1..d3f64cb3 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -54,6 +54,7 @@ todo_html, ) from .notebook_description import _with_ids +from .notebook_row_fold import RowFold, row_status from .run import OutputLockedError from .run.description_file import PublicationRefused from .run.draft_file import publish_draft @@ -240,6 +241,7 @@ def _candidate_check(self, answers: object) -> CandidateCheck | None: def _show_replicates(self, applied: object, answers: object, labels: list[str]) -> None: """One box per replicate: folders, pictures, channels, and its own question.""" boxes, toggles = [], {} + units = self._reopen_units() for view in replicates(applied, answers): toggle = ui.ConfirmToggle( SAME_WAY, @@ -261,16 +263,18 @@ def _show_replicates(self, applied: object, answers: object, labels: list[str]) for m in view.members ] body = [widgets.HTML(replicate_title(view)), *members, widgets.HTML(missing_html(view))] - boxes.append( - widgets.VBox( - [*body, toggle.widget], - layout=widgets.Layout( - border="1px solid #E2E8F0", padding="8px 12px", margin="0 0 10px 0" - ), - ) + box = widgets.VBox( + [*body, toggle.widget], + layout=widgets.Layout( + border="1px solid #E2E8F0", padding="8px 12px", margin="0 0 10px 0" + ), ) + keys = [u.key for m in view.members for u in m.units] + boxes.append((box, row_status(units, keys, view.confirmed_on is not None))) self.same_way = toggles - self.replicates_box.children = boxes + if not hasattr(self, "_replicate_fold"): + self._replicate_fold = RowFold("replicates") + self.replicates_box.children = self._replicate_fold.arrange(boxes) def _member_picture(self, key: tuple[str, str]) -> widgets.Widget: """Return the picture of a folder's first image (channel 1, or in colour when RGB).""" diff --git a/src/morphobricks_preprocessing/notebook_described.py b/src/morphobricks_preprocessing/notebook_described.py index a1819d9a..3d9f77aa 100644 --- a/src/morphobricks_preprocessing/notebook_described.py +++ b/src/morphobricks_preprocessing/notebook_described.py @@ -24,6 +24,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import replace from datetime import date from pathlib import Path @@ -35,8 +36,9 @@ from .design_derive import derive_design from .design_edit import DesignEdit, DesignEditError, apply_design_edit from .draft import Draft, DraftError, draft_from_discovery -from .draft_answers import DraftAnswers, apply_answers, carry_over +from .draft_answers import DraftAnswers, UnitKey, apply_answers, carry_over from .fingerprint import current_fingerprint +from .notebook_row_fold import reopen_units from .published_answers import published_answers, reopened, retired_ids from .run import Preflight @@ -66,6 +68,11 @@ def _published(self) -> PublishedState | None: ) return self._published_cache + def _reopen_units(self) -> Mapping[UnitKey, str] | None: + """Return each unit's status against the saved file (None without one): rows fold.""" + state = self._published() if self._describe else None + return None if state is None else reopen_units(state.reconciliation) + def _forget_published(self) -> None: self._published_key = None diff --git a/src/morphobricks_preprocessing/notebook_design.py b/src/morphobricks_preprocessing/notebook_design.py index 681c3b2a..495f5743 100644 --- a/src/morphobricks_preprocessing/notebook_design.py +++ b/src/morphobricks_preprocessing/notebook_design.py @@ -23,6 +23,7 @@ from .discovery import check_output_folder, default_output_folder from .notebook_options import _PAIRING_COLUMNS from .notebook_render import _group_explanation, _group_question +from .notebook_row_fold import RowFold, row_status from .run import OutputLockedError from .run.design_file import write_design from .run.output_guard import OutputInsideMasksError @@ -224,6 +225,8 @@ def _build_design_editor(self) -> None: layout=_PAIRING_COLUMNS, ) ] + units = self._reopen_units() + folded: list[tuple[widgets.Widget, str]] = [] for row in design.rows if pairing else (): dropdown = widgets.Dropdown( options=options, value=row.repeat_group, layout=widgets.Layout(width="99%") @@ -231,7 +234,8 @@ def _build_design_editor(self) -> None: dropdown.observe( lambda change, key=row.key: self._pair(key, change["new"]), names="value" ) - rows.append( + keys = [k for k in units or () if k[:2] == row.key] + folded.append(( widgets.GridBox( [ widgets.HTML(html.escape(row.condition)), @@ -243,9 +247,12 @@ def _build_design_editor(self) -> None: dropdown, ], layout=_PAIRING_COLUMNS, - ) - ) - self.pairing_grid.children = rows + ), + row_status(units, keys, answered=True), + )) # fmt: skip + if not hasattr(self, "_pairing_fold"): + self._pairing_fold = RowFold("folders") + self.pairing_grid.children = rows + self._pairing_fold.arrange(folded) self.control_checks = {} for condition in design.conditions: check = widgets.Checkbox( diff --git a/src/morphobricks_preprocessing/notebook_names.py b/src/morphobricks_preprocessing/notebook_names.py index 92e5e858..028434bc 100644 --- a/src/morphobricks_preprocessing/notebook_names.py +++ b/src/morphobricks_preprocessing/notebook_names.py @@ -27,8 +27,16 @@ text_box, why_no_proposal, ) +from .notebook_row_fold import RowFold, row_status from .notebook_values import natural_key +FOLD_WORDS = { + "conditions": "conditions", + "repeats": "replicates", + "technical_repeats": "technical-repeat folders", +} +_KEY_PART = {"conditions": 0, "repeats": 1, "technical_repeats": 2} + def _natural(item: tuple[str, object]) -> tuple: """Return the display order of a replicate's row: its folder label, numbers as numbers.""" @@ -83,8 +91,14 @@ def _build_names(self) -> None: 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) + entry_rows = [ + ( + widgets.VBox(self._entry_row(entry, typed, asks_name=asks_name)), + self._entry_status(entry), + ) + for entry in shown + ] + rows += self._name_fold(kind).arrange(entry_rows) blocks.append(widgets.VBox(rows)) kinds.append(kind) self.single_note.value = single_note(entries) @@ -93,6 +107,26 @@ def _build_names(self) -> None: 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 _name_fold(self, kind: str) -> RowFold: + """Return the fold of one kind of names (kept while the notebook lives).""" + if not hasattr(self, "_name_folds"): + self._name_folds: dict[str, RowFold] = {} + return self._name_folds.setdefault(kind, RowFold(FOLD_WORDS[kind])) + + def _entry_status(self, entry: Entry) -> str: + """Return whether a name's folders are as saved (it folds once its ID is given).""" + keys = [r for r in self._unit_keys() if r[_KEY_PART[entry.kind]] == entry.key] + if entry.kind == "repeats": + repeat = self.draft.repeats[entry.key] + keys = [(m.condition, m.source_repeat, u.source_folder) for m in repeat.members + for u in m.units] # fmt: skip + return row_status(self._reopen_units(), keys, entry.slot.confirmed is not None) + + def _unit_keys(self) -> list[tuple[str, str, str]]: + return [ + (r.condition, r.biological_repeat, r.technical_repeat) for r in self.draft.unit_rows() + ] + 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) diff --git a/src/morphobricks_preprocessing/notebook_row_fold.py b/src/morphobricks_preprocessing/notebook_row_fold.py new file mode 100644 index 00000000..bfd36b1d --- /dev/null +++ b/src/morphobricks_preprocessing/notebook_row_fold.py @@ -0,0 +1,127 @@ +"""Folding the unchanged rows of a reopened experiment (owner decision, UX proposal 2026-11-09). + +When a saved experiment is reopened, a row whose folders are found exactly as saved and whose +question is already answered folds behind one line ("4 unchanged folders, answered as saved") +with an **open** toggle. A row with a new folder, or a folder whose image count changed, stays +open and is marked orange. Without a saved experiment nothing folds. Display only: a folded row +keeps its widgets and answers, and opening it shows them as they are. + +:func:`reopen_units` reads the status of every unit from the reconciliation of the published +file with the folders (units matched by their exact folder labels); :func:`row_status` gives a +row's status from the units it holds; :class:`RowFold` arranges a card's rows. +""" + +from __future__ import annotations + +import html +from collections.abc import Iterable, Mapping, Sequence + +import ipywidgets as widgets + +from . import notebook_style as ui +from .description_reconcile import Reconciliation, Status +from .draft_answers import UnitKey +from .notebook_channels_view import ORANGE, ORANGE_FILL + +UNCHANGED, NEW, CHANGED = "unchanged", "new", "changed" +WORDS = {NEW: "new since saved", CHANGED: "changed since saved"} + + +def reopen_units(rec: Reconciliation | None) -> Mapping[UnitKey, str] | None: + """Return each unit's status against the saved file, or None without a saved file. + + ``unchanged`` for a unit found as saved, ``changed`` for one whose member's image count + changed, ``new`` for a unit the saved file does not have. + """ + if rec is None: + return None + counted = {(c.repeat_id, c.condition_id) for c in rec.counts} + out: dict[UnitKey, str] = {} + for unit in rec.units: + key = (unit.folders.condition, unit.folders.source_repeat, unit.folders.technical) + if unit.status == Status.NEW: + out[key] = NEW + elif unit.status == Status.UNCHANGED: + changed = (unit.repeat_id, unit.condition_id) in counted + out[key] = CHANGED if changed else UNCHANGED + return out + + +def row_status(units: Mapping[UnitKey, str] | None, keys: Iterable[UnitKey], answered: bool) -> str: + """Return a row's status: empty without a saved file, else unchanged, new or changed. + + A row is ``unchanged`` (it folds) only when every unit it holds is unchanged and its + question is answered; an unknown unit counts as new. + """ + if units is None: + return "" + found = [units.get(key, NEW) for key in keys] + if not found: + return "" + if all(s == UNCHANGED for s in found): + return UNCHANGED if answered else "" + return NEW if NEW in found else CHANGED + + +def mark(widget: widgets.Widget, status: str) -> widgets.Widget: + """Return a row marked orange with what changed, or the row itself.""" + if status not in WORDS: + return widget + word = widgets.HTML( + f"" + f"{html.escape(WORDS[status])}" + ) + return widgets.VBox( + [word, widget], + layout=widgets.Layout(border_left=f"3px solid {ORANGE}", padding="0 0 0 8px"), + ) + + +class RowFold: + """One card's fold: the unchanged rows behind a line and an open toggle. + + Once opened it stays open for the card's later rebuilds. + """ + + def __init__(self, what: str) -> None: + self.what = what + self.opened = False + self.toggle = ui.button(" open", icon="chevron-down", tooltip="Show the unchanged rows") + self.toggle.on_click(lambda _: self._flip()) + self.folded = widgets.VBox() + self.line = widgets.HTML() + self.bar = widgets.HBox( + [self.line, self.toggle], layout=widgets.Layout(align_items="center") + ) + self.count = 0 + + def _flip(self) -> None: + self.opened = not self.opened + self._show() + + def _show(self) -> None: + ui.set_visible(self.folded, self.opened) + self.toggle.description = " close" if self.opened else " open" + self.toggle.icon = "chevron-up" if self.opened else "chevron-down" + + def arrange(self, rows: Sequence[tuple[widgets.Widget, str]]) -> list[widgets.Widget]: + """Return the rows to show, then the fold line and the unchanged rows under it. + + The other rows keep their order; new or changed ones are marked orange. + """ + shown = [mark(row, status) for row, status in rows if status != UNCHANGED] + folded = [row for row, status in rows if status == UNCHANGED] + self.count = len(folded) + if not folded: + return shown + self.folded.children = folded + self.line.value = ( + f"✓ {len(folded)} unchanged " + f"{html.escape(self.what)} answered as saved" + ) + self._show() + return [*shown, self.bar, self.folded] + + +__all__ = ["CHANGED", "NEW", "UNCHANGED", "RowFold", "mark", "reopen_units", "row_status"] diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index 72e63957..e14e3252 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -79,6 +79,7 @@ def steps(self) -> widgets.Widget: 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, lambda: self._channel_thumbs) + self.type_rows.reopen = self.channel_grid.reopen = self._reopen_units self.overview_box = widgets.VBox() self._build_quick_axes() self.consistency_slot = widgets.VBox([self.consistency_card]) diff --git a/tests/test_notebook_row_fold.py b/tests/test_notebook_row_fold.py new file mode 100644 index 00000000..b01db150 --- /dev/null +++ b/tests/test_notebook_row_fold.py @@ -0,0 +1,123 @@ +"""Reopening folds the unchanged, answered rows; new or changed ones stay open, in orange (17b-2). + +Owner decision (UX proposal 2026-11-09): inside the pairing, the names, the image types, the tag +blocks and the replicate blocks of *Check and save*, a row whose folders are found exactly as +saved and whose question is answered folds behind one line with an **open** toggle. Display +only: the answers and the draft are those of an unfolded page. +""" + +import pytest + +from morphobricks_preprocessing.description_reconcile import ( + CountChange, + Reconciliation, + Status, + UnitChange, + UnitFolders, +) +from morphobricks_preprocessing.notebook_row_fold import ( + CHANGED, + NEW, + UNCHANGED, + reopen_units, + row_status, +) +from test_notebook_check_save import add_folder, answer_all + +pytest.importorskip("ipywidgets") +pytest.importorskip("matplotlib") + +from morphobricks_preprocessing import notebook_style as ui # noqa: E402 +from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402 + + +def test_a_row_folds_only_when_every_folder_is_as_saved_and_it_is_answered(): + rec = Reconciliation( + ( + UnitChange(UnitFolders("ctrl", "rep1", "single"), Status.UNCHANGED, "rep1", "ctrl"), + UnitChange(UnitFolders("drug", "rep1", "single"), Status.UNCHANGED, "rep1", "drug"), + UnitChange(UnitFolders("drug", "rep2", "single"), Status.NEW), + UnitChange(UnitFolders("ctrl", "rep2", "single"), Status.UNCHANGED, "rep2", "ctrl"), + ), + {}, + (CountChange("rep2", "ctrl", 2, 3),), + ) + units = reopen_units(rec) + assert units == { + ("ctrl", "rep1", "single"): UNCHANGED, + ("drug", "rep1", "single"): UNCHANGED, + ("drug", "rep2", "single"): NEW, + ("ctrl", "rep2", "single"): CHANGED, # its image count changed + } + rep1 = [("ctrl", "rep1", "single"), ("drug", "rep1", "single")] + assert row_status(units, rep1, answered=True) == UNCHANGED + assert row_status(units, rep1, answered=False) == "" # still to answer: not folded + assert row_status(units, [("ctrl", "rep2", "single")], True) == CHANGED + assert row_status(units, [("ctrl", "rep2", "single"), ("drug", "rep2", "single")], True) == NEW + assert row_status(units, [("other", "x", "single")], True) == NEW # unknown: never folded + assert row_status(None, rep1, True) == "" and reopen_units(None) is None + + +def stepper(root, masks, out): + nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True) + nb.welcome() + nb.steps() + nb.masks_folder.value = str(masks) + nb.check_button.click() + nb.quick_confirm.click() + return nb + + +@pytest.fixture +def reopened(tmp_path): + """Three folders saved, then drug/rep2 added: a changed reopen.""" + 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 = stepper(root, masks, out) + answer_all(first) + assert first.save_experiment() + add_folder(root, masks, "drug/rep2", 9) + return stepper(root, masks, out) + + +def _text(widget): + out = getattr(widget, "value", "") + out = out if isinstance(out, str) else "" + return out + "".join(_text(c) for c in getattr(widget, "children", ())) + + +def test_a_changed_reopen_folds_the_unchanged_rows_and_marks_the_new_ones(reopened): + nb = reopened + grid, types = nb.channel_grid, nb.type_rows + assert grid.fold.count == 1 and not ui.is_visible(grid.fold.folded) + assert "1 unchanged folder blocks" in grid.fold.line.value + assert "new since saved" in _text(grid.blocks) # drug/rep2's block stays open + assert types.fold.count == 3 and "new since saved" in _text(types.rows) + conditions = nb._name_folds["conditions"] + assert conditions.count == 1 # ctrl; drug gained a folder + assert nb._name_folds["repeats"].count == 1 # rep1; rep2 gained drug/rep2 + assert nb._replicate_fold.count == 1 # rep1 is asked nothing again + assert "new since saved" in _text(nb.replicates_box) + + +def test_opening_a_fold_shows_the_rows_and_changes_no_answer(reopened): + nb = reopened + before = (nb.draft_answers, {k: d.value for k, d in nb.channel_grid.cells.items()}) + nb.channel_grid.fold.toggle.click() + assert ui.is_visible(nb.channel_grid.fold.folded) + assert "close" in nb.channel_grid.fold.toggle.description + nb.channel_grid.refresh() # stays open while the page is rebuilt + assert ui.is_visible(nb.channel_grid.fold.folded) + assert (nb.draft_answers, {k: d.value for k, d in nb.channel_grid.cells.items()}) == before + + +def test_an_unsaved_experiment_folds_nothing(tmp_path): + root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out" + for i, folder in enumerate(["ctrl/rep1", "drug/rep1"]): + add_folder(root, masks, folder, i + 1) + nb = stepper(root, masks, out) + answer_all(nb) + assert nb.channel_grid.fold.count == 0 and nb.type_rows.fold.count == 0 + assert nb._replicate_fold.count == 0 + assert "unchanged" not in _text(nb.channel_grid.blocks) From ec180621537fa5003aec63b861ae9328bf52c266 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 09:08:31 +0000 Subject: [PATCH 2/4] Say "1 unchanged folder" / "3 unchanged folders" on the fold line Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- src/morphobricks_preprocessing/notebook_channels_grid.py | 2 +- src/morphobricks_preprocessing/notebook_channels_type.py | 2 +- src/morphobricks_preprocessing/notebook_check_save.py | 2 +- src/morphobricks_preprocessing/notebook_design.py | 2 +- src/morphobricks_preprocessing/notebook_names.py | 6 +++--- src/morphobricks_preprocessing/notebook_row_fold.py | 7 +++++-- tests/test_notebook_row_fold.py | 2 +- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py index 963c00f1..24f15c8e 100644 --- a/src/morphobricks_preprocessing/notebook_channels_grid.py +++ b/src/morphobricks_preprocessing/notebook_channels_grid.py @@ -93,7 +93,7 @@ def __init__( self.alone: set[UnitKey] = set() # The status of each unit against a saved experiment (None without one): rows fold. self.reopen: Callable[[], Mapping[UnitKey, str] | None] = lambda: None - self.fold = RowFold("folder blocks") + self.fold = RowFold("folder block") self.message = "" self._building = False self.blocks = widgets.VBox() diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py index 3f4f0698..6131141f 100644 --- a/src/morphobricks_preprocessing/notebook_channels_type.py +++ b/src/morphobricks_preprocessing/notebook_channels_type.py @@ -148,7 +148,7 @@ def __init__( self.types, self._thumbs = types, thumbs # The status of each unit against a saved experiment (None without one): rows fold. self.reopen: Callable[[], Mapping[UnitKey, str] | None] = lambda: None - self.fold = RowFold("folders") + self.fold = RowFold("folder") self.rows = widgets.VBox() self.status = widgets.HTML() self.problem = widgets.HTML() diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index d3f64cb3..4237490b 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -273,7 +273,7 @@ def _show_replicates(self, applied: object, answers: object, labels: list[str]) boxes.append((box, row_status(units, keys, view.confirmed_on is not None))) self.same_way = toggles if not hasattr(self, "_replicate_fold"): - self._replicate_fold = RowFold("replicates") + self._replicate_fold = RowFold("replicate") self.replicates_box.children = self._replicate_fold.arrange(boxes) def _member_picture(self, key: tuple[str, str]) -> widgets.Widget: diff --git a/src/morphobricks_preprocessing/notebook_design.py b/src/morphobricks_preprocessing/notebook_design.py index 495f5743..2c026496 100644 --- a/src/morphobricks_preprocessing/notebook_design.py +++ b/src/morphobricks_preprocessing/notebook_design.py @@ -251,7 +251,7 @@ def _build_design_editor(self) -> None: row_status(units, keys, answered=True), )) # fmt: skip if not hasattr(self, "_pairing_fold"): - self._pairing_fold = RowFold("folders") + self._pairing_fold = RowFold("folder") self.pairing_grid.children = rows + self._pairing_fold.arrange(folded) self.control_checks = {} for condition in design.conditions: diff --git a/src/morphobricks_preprocessing/notebook_names.py b/src/morphobricks_preprocessing/notebook_names.py index 028434bc..d500f3bc 100644 --- a/src/morphobricks_preprocessing/notebook_names.py +++ b/src/morphobricks_preprocessing/notebook_names.py @@ -31,9 +31,9 @@ from .notebook_values import natural_key FOLD_WORDS = { - "conditions": "conditions", - "repeats": "replicates", - "technical_repeats": "technical-repeat folders", + "conditions": "condition", + "repeats": "replicate", + "technical_repeats": "technical-repeat folder", } _KEY_PART = {"conditions": 0, "repeats": 1, "technical_repeats": 2} diff --git a/src/morphobricks_preprocessing/notebook_row_fold.py b/src/morphobricks_preprocessing/notebook_row_fold.py index bfd36b1d..12c8db1d 100644 --- a/src/morphobricks_preprocessing/notebook_row_fold.py +++ b/src/morphobricks_preprocessing/notebook_row_fold.py @@ -22,6 +22,7 @@ from .description_reconcile import Reconciliation, Status from .draft_answers import UnitKey from .notebook_channels_view import ORANGE, ORANGE_FILL +from .validation import plural UNCHANGED, NEW, CHANGED = "unchanged", "new", "changed" WORDS = {NEW: "new since saved", CHANGED: "changed since saved"} @@ -85,6 +86,7 @@ class RowFold: """ def __init__(self, what: str) -> None: + # ``what`` is the singular noun of a row ("folder"); the line says "3 unchanged folders". self.what = what self.opened = False self.toggle = ui.button(" open", icon="chevron-down", tooltip="Show the unchanged rows") @@ -117,8 +119,9 @@ def arrange(self, rows: Sequence[tuple[widgets.Widget, str]]) -> list[widgets.Wi return shown self.folded.children = folded self.line.value = ( - f"✓ {len(folded)} unchanged " - f"{html.escape(self.what)} answered as saved" + f"✓ " + f"{html.escape(plural(len(folded), 'unchanged ' + self.what))} " + "answered as saved" ) self._show() return [*shown, self.bar, self.folded] diff --git a/tests/test_notebook_row_fold.py b/tests/test_notebook_row_fold.py index b01db150..6d1283b7 100644 --- a/tests/test_notebook_row_fold.py +++ b/tests/test_notebook_row_fold.py @@ -91,7 +91,7 @@ def test_a_changed_reopen_folds_the_unchanged_rows_and_marks_the_new_ones(reopen nb = reopened grid, types = nb.channel_grid, nb.type_rows assert grid.fold.count == 1 and not ui.is_visible(grid.fold.folded) - assert "1 unchanged folder blocks" in grid.fold.line.value + assert "1 unchanged folder block<" in grid.fold.line.value assert "new since saved" in _text(grid.blocks) # drug/rep2's block stays open assert types.fold.count == 3 and "new since saved" in _text(types.rows) conditions = nb._name_folds["conditions"] From ea458ed46f2cc716778bd735298952ccef1ea5d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 09:38:53 +0000 Subject: [PATCH 3/4] Row fold: never fold a changed header or a row with a warning - reopen_units ignored the reconciliation's reasons, so a folder whose header channel names no longer fit the saved channels (channels_changed), or a renamed folder, folded as "unchanged". Such a unit is now "changed" and stays open, in orange. - An image-type row folded once answered, hiding the warning under its choice (a type the header contradicts, which the run refuses) and an unusable folder's note; it now folds only when answered, usable and with no warning (ImageTypeColumn.note is public for this). - A Check-and-save replicate block folded with its orange A_SCOPE_INCOMPLETE line; a block with a missing label now stays open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- .../notebook_channels_type.py | 14 ++--- .../notebook_check_save.py | 4 +- .../notebook_row_fold.py | 6 ++- tests/test_notebook_row_fold.py | 51 +++++++++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py index 6131141f..26a0abec 100644 --- a/src/morphobricks_preprocessing/notebook_channels_type.py +++ b/src/morphobricks_preprocessing/notebook_channels_type.py @@ -64,12 +64,12 @@ def cell(self, layout: UnitLayout) -> widgets.Widget: options=OPTIONS, value=self.shown(layout), layout=widgets.Layout(width="99%") ) radio.disabled = not layout.usable - note = widgets.HTML(self._note(layout)) + note = widgets.HTML(self.note(layout)) radio.observe(lambda change, u=layout: self._chosen(u, change["new"]), names="value") self.radios[layout.key], self.notes[layout.key] = radio, note return widgets.VBox([radio, note]) - def _note(self, layout: UnitLayout) -> str: + def note(self, layout: UnitLayout) -> str: """Return what is said under a unit's radio buttons.""" saved, shown = self.saved(layout.key), self.shown(layout) if not layout.usable: @@ -189,10 +189,7 @@ def refresh(self) -> None: ordered = sorted(self.types.layouts, key=lambda u: tuple(map(natural_key, u.key))) units = self.reopen() rows = self.fold.arrange( - [ - (self._row(u), row_status(units, [u.key], self.types.saved(u.key) is not None)) - for u in ordered - ] + [(self._row(u), row_status(units, [u.key], self._settled(u))) for u in ordered] ) proposed = [u.key for u in self.types.layouts if self.types.proposed(u)] if proposed: @@ -213,6 +210,11 @@ def refresh(self) -> None: else ui.status("warn", f"Not finished: {usable - done} image type(s) to choose.") ) + def _settled(self, layout: UnitLayout) -> bool: + """Return True when a row may fold: answered, with no warning under its choice.""" + saved = self.types.saved(layout.key) is not None + return layout.usable and saved and not self.types.note(layout) + def _confirm(self, keys: Sequence[UnitKey]) -> None: self.types.confirm_header(keys) diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 4237490b..4c193fde 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -270,7 +270,9 @@ def _show_replicates(self, applied: object, answers: object, labels: list[str]) ), ) keys = [u.key for m in view.members for u in m.units] - boxes.append((box, row_status(units, keys, view.confirmed_on is not None))) + # A box with a warning (an acquisition lacking a label) never folds. + settled = view.confirmed_on is not None and not view.missing + boxes.append((box, row_status(units, keys, settled))) self.same_way = toggles if not hasattr(self, "_replicate_fold"): self._replicate_fold = RowFold("replicate") diff --git a/src/morphobricks_preprocessing/notebook_row_fold.py b/src/morphobricks_preprocessing/notebook_row_fold.py index 12c8db1d..a77f80c4 100644 --- a/src/morphobricks_preprocessing/notebook_row_fold.py +++ b/src/morphobricks_preprocessing/notebook_row_fold.py @@ -32,7 +32,9 @@ def reopen_units(rec: Reconciliation | None) -> Mapping[UnitKey, str] | None: """Return each unit's status against the saved file, or None without a saved file. ``unchanged`` for a unit found as saved, ``changed`` for one whose member's image count - changed, ``new`` for a unit the saved file does not have. + changed or that the reconciliation gives a reason for (its header's channels no longer fit + the saved channels, or its folder was renamed), ``new`` for a unit the saved file does not + have. """ if rec is None: return None @@ -43,7 +45,7 @@ def reopen_units(rec: Reconciliation | None) -> Mapping[UnitKey, str] | None: if unit.status == Status.NEW: out[key] = NEW elif unit.status == Status.UNCHANGED: - changed = (unit.repeat_id, unit.condition_id) in counted + changed = (unit.repeat_id, unit.condition_id) in counted or bool(unit.reasons) out[key] = CHANGED if changed else UNCHANGED return out diff --git a/tests/test_notebook_row_fold.py b/tests/test_notebook_row_fold.py index 6d1283b7..5cd7a15d 100644 --- a/tests/test_notebook_row_fold.py +++ b/tests/test_notebook_row_fold.py @@ -6,6 +6,8 @@ only: the answers and the draft are those of an unfolded page. """ +from dataclasses import replace + import pytest from morphobricks_preprocessing.description_reconcile import ( @@ -121,3 +123,52 @@ def test_an_unsaved_experiment_folds_nothing(tmp_path): assert nb.channel_grid.fold.count == 0 and nb.type_rows.fold.count == 0 assert nb._replicate_fold.count == 0 assert "unchanged" not in _text(nb.channel_grid.blocks) + + +def test_a_folder_whose_header_channels_changed_is_changed_not_folded(): + # Regression (17b-2 review): the reconciliation's reasons were ignored, so a folder whose + # header names no longer fit the saved channels folded as "unchanged". + rec = Reconciliation( + ( + UnitChange(UnitFolders("ctrl", "rep1", "single"), Status.UNCHANGED, "rep1", "ctrl"), + UnitChange( + UnitFolders("drug", "rep1", "single"), + Status.UNCHANGED, + "rep1", + "drug", + reasons=("channels_changed",), + ), + ), + {"rep1": ("channels_changed",)}, + (), + ) + units = reopen_units(rec) + assert units[("ctrl", "rep1", "single")] == UNCHANGED + assert units[("drug", "rep1", "single")] == CHANGED + assert row_status(units, [("drug", "rep1", "single")], answered=True) == CHANGED + + +def test_a_row_with_a_warning_never_folds(reopened, monkeypatch): + # Regression (17b-2 review): a folded row hid the warning under an image type (a type the + # header contradicts) and the orange A_SCOPE_INCOMPLETE line of a replicate block. + nb = reopened + key = ("ctrl", "rep1", "single") + warn = "The header says colour (RGB)" + plain = nb.channel_types.note + monkeypatch.setattr(nb.channel_types, "note", lambda u: warn if u.key == key else plain(u)) + nb.type_rows.refresh() + assert nb.type_rows.fold.count == 2 # ctrl/rep1 stays open, with its warning + assert "The header says colour (RGB)" not in _text(nb.type_rows.fold.folded) + + from morphobricks_preprocessing import notebook_check_save + + real = notebook_check_save.replicates + + def lacking(applied, answers): + views = real(applied, answers) + return [replace(v, missing=((key, "DNA"),)) if v.repeat_id == "rep1" else v for v in views] + + monkeypatch.setattr(notebook_check_save, "replicates", lacking) + nb._refresh_check_save() + assert nb._replicate_fold.count == 0 + assert "A_SCOPE_INCOMPLETE" in _text(nb.replicates_box) From 1bd8ccd863ae631149c56b694cff06c056e474b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 09:39:05 +0000 Subject: [PATCH 4/4] CHANGELOG: what keeps a reopened row open Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3qp9AxjpGPw9xfWAUJNqc --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d47d71..c38cdfeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,10 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the row whose folders are found exactly as saved and whose question is answered folds behind one line ("3 unchanged folders, answered as saved") with an **open** toggle: the pairing, the condition, replicate and technical-repeat names, the image types, the tag blocks and the replicate blocks of - *Check and save*. A row with a new folder, or a folder whose image count changed, stays open and - is marked orange ("new since saved", "changed since saved"). Display only (`notebook_row_fold`): - the answers, the draft, the save and the run are unchanged. + *Check and save*. A row with a new folder, a folder whose image count changed, or one whose + header channels no longer fit the saved channels, stays open and is marked orange ("new since + saved", "changed since saved"); a row with a warning never folds. Display only + (`notebook_row_fold`): the answers, the draft, the save and the run are unchanged. - **Notebook: tags per header block, colour pictures, one-click axes (PR 17b-1).** *Tag channels* shows folders whose image headers are identical, exactly (channel count, every raw header name present and equal, colour flag), as one block tagged once for all its folders (owner decision Q5),