Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/morphobricks_preprocessing/notebook_axis_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)."""
Expand Down
37 changes: 31 additions & 6 deletions src/morphobricks_preprocessing/notebook_axis_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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())
Expand Down Expand Up @@ -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",
[
Expand All @@ -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,
],
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -354,20 +360,39 @@ 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)
self._render_preview()
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
3 changes: 3 additions & 0 deletions src/morphobricks_preprocessing/notebook_channels_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?"
Expand Down Expand Up @@ -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 -------------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions src/morphobricks_preprocessing/notebook_check_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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).

Expand Down
22 changes: 21 additions & 1 deletion src/morphobricks_preprocessing/notebook_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
34 changes: 27 additions & 7 deletions src/morphobricks_preprocessing/notebook_description.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand All @@ -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"<b>{html.escape(condition)}</b>"),
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."""
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions src/morphobricks_preprocessing/notebook_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<div class='mb-blocker'>🔒 <b>Not available yet:</b> {text}.</div>"


def issue_count_chips(issues: Sequence[ValidationIssue]) -> str:
"""Red error and orange warning counts, shown outside a folded list of issues."""
errors = [i for i in issues if i.is_error]
Expand Down
7 changes: 6 additions & 1 deletion src/morphobricks_preprocessing/notebook_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
VERIFY_CONTENT_HELP,
)
from .notebook_plots import IntensityPanel
from .notebook_render import _bins_help, _label, change_lines, issues_html
from .notebook_render import _bins_help, _label, blocked_line, change_lines, issues_html
from .planes import DEFAULT_MEMORY_LIMIT
from .report import issue_counts, rebuild_line
from .run import RESTART, Preflight, RunMode, RunResult, run_experiment
Expand Down Expand Up @@ -57,6 +57,7 @@ def _build_run_step(self) -> None:
value=0, min=0, max=1, bar_style="info", layout=widgets.Layout(width="99%")
)
ui.set_visible(self.progress, False)
self.run_blocker = widgets.HTML()
self.run_status = widgets.HTML()
self.run_next = widgets.HTML()
self.run_tiles = widgets.HTML()
Expand Down Expand Up @@ -266,6 +267,10 @@ def _refresh_run_step(self) -> None:
title = ui.status("ok", "Ready to run.")
self.readiness.value = title + ui.checklist(items)
self.run_button.disabled = bool(missing)
self.run_blocker.value = blocked_line(
[f"still to do: {text[0].lower()}{text[1:]}" for text in missing[:1]]
+ ([f"and {len(missing) - 1} more (see Before you run)"] if len(missing) > 1 else [])
)
self.plan.value = self._plan_html(review if folder_ok else None)
self._refresh_plan_card()
self._refresh_mask_step()
Expand Down
1 change: 1 addition & 0 deletions src/morphobricks_preprocessing/notebook_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ def _run_page(self) -> list[widgets.Widget]:
self.settings_card,
self.plan_card,
self.run_button,
self.run_blocker,
self.progress,
self.run_status,
self.run_lock.card,
Expand Down
Loading
Loading