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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ 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, 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),
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 17 additions & 3 deletions src/morphobricks_preprocessing/notebook_channels_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -50,6 +50,7 @@
columns,
quoted,
)
from .notebook_row_fold import RowFold, row_status
from .notebook_values import natural_key


Expand Down Expand Up @@ -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 block")
self.message = ""
self._building = False
self.blocks = widgets.VBox()
Expand Down Expand Up @@ -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]
Expand Down
20 changes: 16 additions & 4 deletions src/morphobricks_preprocessing/notebook_channels_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")]
Expand Down Expand Up @@ -63,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:
Expand Down Expand Up @@ -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("folder")
self.rows = widgets.VBox()
self.status = widgets.HTML()
self.problem = widgets.HTML()
Expand Down Expand Up @@ -183,7 +187,10 @@ 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._settled(u))) for u in ordered]
)
proposed = [u.key for u in self.types.layouts if self.types.proposed(u)]
if proposed:
button = ui.button(
Expand All @@ -203,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)

Expand Down
22 changes: 14 additions & 8 deletions src/morphobricks_preprocessing/notebook_check_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -261,16 +263,20 @@ 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]
# 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
self.replicates_box.children = boxes
if not hasattr(self, "_replicate_fold"):
self._replicate_fold = RowFold("replicate")
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)."""
Expand Down
9 changes: 8 additions & 1 deletion src/morphobricks_preprocessing/notebook_described.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
15 changes: 11 additions & 4 deletions src/morphobricks_preprocessing/notebook_design.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -224,14 +225,17 @@ 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%")
)
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)),
Expand All @@ -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("folder")
self.pairing_grid.children = rows + self._pairing_fold.arrange(folded)
self.control_checks = {}
for condition in design.conditions:
check = widgets.Checkbox(
Expand Down
38 changes: 36 additions & 2 deletions src/morphobricks_preprocessing/notebook_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "condition",
"repeats": "replicate",
"technical_repeats": "technical-repeat folder",
}
_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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading