diff --git a/CHANGELOG.md b/CHANGELOG.md index fb79697..9a8cf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,23 @@ OME-XML description is parsed (which images are marked is unchanged; a malformed debug line without a traceback), and while the capture is attached the package's DEBUG records go to the "Technical details" panel only, with WARNING and above still reaching the root handlers. +### Notebook redesign 1: fixes from the owner's test of v0.5.0 (10 November 2026) + +- **Lists of files in a message draw no empty bullets.** `notebook_style.path_list` returns + plain lines in a box (scrolling above about ten lines) instead of a nested list, and a status + card joins a list of paths to the line before it. The same paths are shown, folded behind + "Show N more" as before. +- **"Is compared against" keeps its dropdown.** Choosing an option rebuilt every dropdown of the + card (twice), so the control vanished while the answer was saved. The rows are now built once + per set of conditions and updated in place; the answer, its refusal and the saved draft are + unchanged. Measured on 28 images of 2048 x 2048: 0.58 s to 0.35 s per choice (the rest is the + refresh of the other pages). +- **Every disabled action says what blocks it, in a visible line beside it** + (`notebook_render.blocked_line`): *Confirm axis roles* (the axis error by name, the preview + not drawn, the preview not ticked), *Yes, the preview looks right*, *Compute histograms*, + *Yes, save the experiment description*, *Make named-axis copies* and its roles question, and + the stains confirmation. Nothing about when an action is allowed changed. Roles that are not + a mapping at all (two X) now also disable *Confirm axis roles* at once. ### Normalization pinned at v0.4.0 (#262) - **`morphobricks-normalization` moves from v0.3.4 to v0.4.0** (tag `c952263`). It is diff --git a/src/morphobricks_preprocessing/notebook_axis_preview.py b/src/morphobricks_preprocessing/notebook_axis_preview.py index 3c35d2a..c8596f8 100644 --- a/src/morphobricks_preprocessing/notebook_axis_preview.py +++ b/src/morphobricks_preprocessing/notebook_axis_preview.py @@ -10,7 +10,7 @@ from .image_type import review_image_type from .notebook_options import IMAGE_TYPE_NAMES from .notebook_plots import plane_png -from .notebook_render import preview_caption +from .notebook_render import blocked_line, preview_caption from .planes import Plane, PlaneReadError, read_plane from .preview import PreviewRefused, check_unchanged, checked_fingerprint from .validation import plural @@ -101,6 +101,13 @@ def _gate_preview_tick(self) -> None: if not shown and not locked and self.preview_checked.value: self.preview_checked.set(False) # a tick says nothing about a preview not shown self.preview_checked.disabled = locked or not shown + self.preview_blocker.value = ( + "" + if locked or shown + else blocked_line(["no preview of these roles is drawn: click Show preview"]) + ) + if not locked: + self._update_confirm_button() def _draw_preview(self) -> None: """Draw the chosen plane with the roles shown, labelled confirmed or not (PP-10).""" diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py index 5f9fd74..797855f 100644 --- a/src/morphobricks_preprocessing/notebook_axis_step.py +++ b/src/morphobricks_preprocessing/notebook_axis_step.py @@ -14,7 +14,7 @@ from .image_type import FLUORESCENCE from .notebook_axes import axis_check_html, header_marks from .notebook_options import IMAGE_TYPE_OPTIONS, ROLE_OPTIONS -from .notebook_render import issues_html, plane_summary_html, role_effect +from .notebook_render import blocked_line, issues_html, plane_summary_html, role_effect from .run import Preflight from .validation import plural @@ -40,6 +40,7 @@ def _build_axis_step(self) -> None: tooltip="Use these roles for every image of the experiment", main=True, ) + self.confirm_blocker = widgets.HTML() self.confirmed_status = widgets.HTML() self.axis_next = widgets.HTML() self.missing_as_singleton.observe(lambda _: self._mapping_changed()) @@ -102,6 +103,7 @@ def _build_preview_card(self) -> None: "Undo: the preview does not look right", ) self.preview_checked.disabled = True + self.preview_blocker = widgets.HTML() self.preview_card = ui.card( "Preview", [ @@ -115,6 +117,7 @@ def _build_preview_card(self) -> None: widgets.HBox([self.preview_image]), self.preview_caption, self.preview_checked.widget, + self.preview_blocker, ], ) @@ -184,6 +187,7 @@ def _refresh_axis_step(self) -> None: self.convert_card, self.preview_card, self.confirm_button, + self.confirm_blocker, self.confirmed_status, self.image_type_card, self.axis_next, @@ -322,6 +326,8 @@ def _mapping_changed(self) -> None: except ValueError as exc: self.axis_status.value = ui.status("error", str(exc)) self.preview_checked.disabled = True # the preview shown is not of these roles + self._review_ok, self._review_errors = False, () + self._update_confirm_button() return ranges = axis_length_ranges(self.mapping, self.preflight.metadata.files) marks = header_marks(self.mapping, self.preflight.metadata.files) @@ -354,6 +360,9 @@ def _mapping_changed(self) -> None: "Click Show preview and inspect a few planes before confirming.", ) self._review_ok = review.ok + self._review_errors = tuple( + dict.fromkeys(i.code.replace("_", " ") for i in review.issues if i.is_error) + ) self._refresh_convert_card(review) if self._preview_shown: self._configure_sliders(reset=False) @@ -361,13 +370,29 @@ def _mapping_changed(self) -> None: self._update_confirm_button() def _update_confirm_button(self) -> None: + """Enable *Confirm axis roles* when nothing blocks it; say beside it what does.""" if self.confirmed_mapping is not None: self.confirm_button.disabled = False + self.confirm_blocker.value = "" return - ready = getattr(self, "_review_ok", False) and self.preview_checked.value - self.confirm_button.disabled = not ready + blockers = self._confirm_blockers() + self.confirm_button.disabled = bool(blockers) + self.confirm_blocker.value = blocked_line(blockers) self.confirm_button.tooltip = ( - "Use these roles for every image of the experiment" - if ready - else "Fix the axis messages and check the preview first" + "Fix the axis messages and check the preview first" + if blockers + else "Use these roles for every image of the experiment" ) + + def _confirm_blockers(self) -> list[str]: + """Return what stops *Confirm axis roles* now, the first thing to do first.""" + blockers = [] + if not getattr(self, "_review_ok", False): + codes = getattr(self, "_review_errors", ()) + named = f" ({', '.join(codes)})" if codes else "" + blockers.append(f"these roles have an error{named}: see the red message above") + if not self._preview_of(self.mapping): + blockers.append("no preview of these roles is drawn: click Show preview") + elif not self.preview_checked.value: + blockers.append("the preview is not ticked: click Yes, the preview looks right") + return blockers diff --git a/src/morphobricks_preprocessing/notebook_channels_labels.py b/src/morphobricks_preprocessing/notebook_channels_labels.py index d4f59da..336dde7 100644 --- a/src/morphobricks_preprocessing/notebook_channels_labels.py +++ b/src/morphobricks_preprocessing/notebook_channels_labels.py @@ -35,6 +35,7 @@ ) from .notebook_channels_view import AnswerStore, chip from .notebook_description_view import plain +from .notebook_render import blocked_line ROW_COLUMNS = "minmax(150px, 1fr) minmax(150px, 1fr) minmax(200px, 2fr)" QUESTION = "Are these the labels you imaged?" @@ -265,6 +266,8 @@ def _validate(self) -> None: ui.status("error", "These labels cannot be used yet.", ["Fix the red entries."]) if by_slot else "" + if filled or self.confirm.value + else blocked_line(["no label is typed: name at least one stain"]) ) # Look-alike groups ------------------------------------------------------------------- diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 4c584d9..9828cb9 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -60,6 +60,7 @@ todo_html, ) from .notebook_description import _with_ids +from .notebook_render import blocked_line from .notebook_row_fold import RowFold, row_status from .notebook_thumb_tile import ThumbTile, show_detail from .preview_crop import TilePictures @@ -121,6 +122,7 @@ def _build_check_save(self) -> None: self.save_note = widgets.HTML(layout=widgets.Layout(margin="0 0 0 12px")) ui.set_visible(self.save_note, False) self.save_row = widgets.HBox([self.save_button, self.save_note]) + self.save_blocker = widgets.HTML() self.save_status = widgets.HTML() self.check_card = ui.card( "Your experiment, in words", @@ -134,6 +136,7 @@ def _build_check_save(self) -> None: self.todo, self.checks, self.save_row, + self.save_blocker, self.save_status, ], visible=False, @@ -233,8 +236,18 @@ def _refresh_check_save(self) -> None: self.save_button.disabled = not ready or self._saved_current() self.save_note.value = "" if self.save_button.disabled else run_stop_html(self._check) ui.set_visible(self.save_note, bool(self.save_note.value)) + self.save_blocker.value = blocked_line(self._save_blockers(todo)) self._show_saved_state() + def _save_blockers(self, todo: tuple[list, list[str]]) -> list[str]: + """Return what stops the save button now (shown beside it), or nothing when saved.""" + if not self.save_button.disabled or self._saved_current(): + return [] # saved already (the green line says so): nothing is blocked + missing = len(todo[0]) + len(todo[1]) + if missing: + return [f"{plural(missing, 'answer')} still missing (listed above the button)"] + return ["these answers are refused: see the red message above the button"] + def _blocked(self, state: object) -> str: """Say what already stops the run while the candidate cannot be checked (answers missing). diff --git a/src/morphobricks_preprocessing/notebook_convert.py b/src/morphobricks_preprocessing/notebook_convert.py index 490e2f9..7b73c82 100644 --- a/src/morphobricks_preprocessing/notebook_convert.py +++ b/src/morphobricks_preprocessing/notebook_convert.py @@ -39,7 +39,7 @@ from .conversion_record import RECORD_FILENAME from .notebook_options import _WIDE from .notebook_output import _LockCard -from .notebook_render import issues_html +from .notebook_render import blocked_line, issues_html from .planes import format_bytes from .run.output_guard import recorded_masks_of from .validation import plural @@ -78,6 +78,8 @@ def _build_convert_card(self) -> None: "Undo: the roles are not right", ) self.convert_plan = widgets.HTML() + self.convert_tick_blocker = widgets.HTML() + self.convert_blocker = widgets.HTML() self.convert_button = ui.button( _LABEL, style="success", @@ -140,8 +142,10 @@ def _convert_children(self) -> list[widgets.Widget]: ), self.convert_roles, self.convert_roles_checked.widget, + self.convert_tick_blocker, self.convert_plan, self.convert_button, + self.convert_blocker, self.convert_progress, self.convert_status, self.convert_lock.card, @@ -170,6 +174,11 @@ def _convert_roles_changed(self) -> None: roles = self._copy_roles() confirmed = self.confirmed_mapping is not None self.convert_roles_checked.disabled = not self.preview_checked.value + self.convert_tick_blocker.value = blocked_line( + [] + if confirmed or self.preview_checked.value + else ["the preview is not ticked: click Yes, the preview looks right first"] + ) ui.set_visible( self.convert_roles_checked.widget, not confirmed and self.mapping is not None ) @@ -229,11 +238,22 @@ def _runs_masks_folder(self) -> Path | None: return masks.folder return recorded_masks_of(self._output_path(self.preflight.discovery.root)) + def _convert_blocker(self, preview: ConversionPreview | None) -> str: + """Return what stops *Make named-axis copies* (the plan above gives the details).""" + if preview is None: + if self._copy_roles() is None: + return "the roles of the copies are not confirmed" + return "no folder for the copies is given" + if not preview.ok: + return "the plan above has an error to fix first" + return "the folder for the copies has too little free space (see the plan above)" + def _update_convert_button(self) -> None: """Enable the button once the plan shows nothing that stops the conversion.""" preview = self._convert_preview ready = preview is not None and preview.ok and (preview.fits or preview.earlier_conversion) self.convert_button.disabled = not ready + self.convert_blocker.value = blocked_line([] if ready else [self._convert_blocker(preview)]) self.convert_button.tooltip = ( "Write the copies into the folder above (the originals are only read)" if ready diff --git a/src/morphobricks_preprocessing/notebook_description.py b/src/morphobricks_preprocessing/notebook_description.py index 1dbad56..2a40380 100644 --- a/src/morphobricks_preprocessing/notebook_description.py +++ b/src/morphobricks_preprocessing/notebook_description.py @@ -86,6 +86,7 @@ def _build_description_cards(self) -> None: self.name_hints: dict[tuple[str, str], widgets.HTML] = {} self.single_note = widgets.HTML() self.reference_boxes: dict[str, widgets.Dropdown] = {} + self.reference_notes: dict[str, widgets.HTML] = {} self._id_states: dict[tuple[str, str], widgets.HTML] = {} self.names_status = widgets.HTML() self.names_grid = widgets.VBox() @@ -330,13 +331,33 @@ def _confirm_ids(self) -> bool: # The references card ----------------------------------------------------------------- def _build_references(self) -> None: - """Rebuild one dropdown per condition from the draft's references.""" + """Show one dropdown per condition from the draft's references. + + The dropdowns are built once for the conditions there are and then updated in place: a + choice must not replace the control the user is using (owner test, 10 November 2026). + """ conditions = sorted(self.draft.conditions) + if list(self.reference_boxes) != conditions or not self.references_grid.children: + self._new_reference_rows(conditions) references = self.draft.references + for condition in conditions: + box = self.reference_boxes[condition] + options, chosen = self._reference_options(condition), self._chosen_reference(condition) + if list(box.options) != options: + box.options = options + if box.value != chosen: + box.value = chosen + users = sorted(c for c, target in references.items() if target == condition) + note = f"the reference of {', '.join(users)}" if users else "" + self.reference_notes[condition].value = html.escape(note) + self.references_status.value = self._references_refusal + + def _new_reference_rows(self, conditions: list[str]) -> None: + """Build the rows of the references card: a dropdown and a note per condition.""" rows: list[widgets.Widget] = [ header_row("Condition", "Is compared against", "", layout=REFERENCE_COLUMNS) ] - self.reference_boxes = {} + self.reference_boxes, self.reference_notes = {}, {} for condition in conditions: box = widgets.Dropdown( options=self._reference_options(condition), @@ -347,20 +368,18 @@ def _build_references(self) -> None: lambda change, c=condition: self._reference_chosen(c, change["new"]), names="value" ) self.reference_boxes[condition] = box - users = sorted(c for c, target in references.items() if target == condition) - note = f"the reference of {', '.join(users)}" if users else "" + self.reference_notes[condition] = widgets.HTML() rows.append( widgets.GridBox( [ widgets.HTML(f"{html.escape(condition)}"), box, - widgets.HTML(html.escape(note)), + self.reference_notes[condition], ], layout=REFERENCE_COLUMNS, ) ) self.references_grid.children = rows - self.references_status.value = self._references_refusal def _chosen_reference(self, condition: str) -> str | None: """Return the saved choice: None if not answered, ``""`` for no comparison, a folder.""" @@ -409,7 +428,8 @@ def _reference_chosen(self, condition: str, target: str | None) -> None: return self._draft_note = "" new = _updated(answers, "conditions", condition, reference=target) - if self._answer(new): + # One refresh, of the cards as they are: the dropdown chosen in stays on the page. + if self._answer(new, rebuild=False): self._references_refusal = "" else: self._references_refusal = refusal_card( diff --git a/src/morphobricks_preprocessing/notebook_render.py b/src/morphobricks_preprocessing/notebook_render.py index 48f3ce4..99beb1e 100644 --- a/src/morphobricks_preprocessing/notebook_render.py +++ b/src/morphobricks_preprocessing/notebook_render.py @@ -41,6 +41,18 @@ def issues_html(issues: Iterable[ValidationIssue]) -> str: return "".join(parts) +def blocked_line(reasons: Sequence[str]) -> str: + """Return the visible line beside a disabled action saying what blocks it (HTML), or "". + + Every disabled button has one (owner test, 10 November 2026): a tooltip alone is a hidden + refusal. ``reasons`` are short plain-text phrases; the first is the one to act on. + """ + if not reasons: + return "" + text = "; ".join(html.escape(reason) for reason in reasons) + return f"