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
19 changes: 19 additions & 0 deletions gently/ui/web/static/css/operate.css
Original file line number Diff line number Diff line change
Expand Up @@ -1074,3 +1074,22 @@
max-width: 52ch;
line-height: 1.45;
}

/* The primary member of the selection is the one the instrument panes act on;
membership of the set is a lighter state. With one embryo selected the two
coincide, so the common case looks exactly as it did. */
.rp-row.is-primary {
border-color: var(--accent);
}

.rp-row.is-sel:not(.is-primary) {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 6%, transparent);
}

/* Disabled until something is selected, so it cannot be switched to an empty
target set. */
#op-target-scope .op-segbtn:disabled {
opacity: 0.45;
cursor: default;
}
110 changes: 101 additions & 9 deletions gently/ui/web/static/js/operate.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ const OperateManager = (function () {

// ── emitters / run ──────────────────────────────────────────────────────
let _acquiring = false;
// The selection set. `_selected` is the primary member of it.
let _targets = [];
// Which embryos a run targets. 'all' is the default and is what this pane
// has always done — a plain click must never silently narrow a timelapse
// from every subject to one, so the narrowing is a thing you say.
let _targetScope = 'all';
// Read back from the Light panel's device read, never remembered here.
const ledIsOpen = () => (SharedState.get('light') || {}).led === 'Open';
let _galvo = 0.0, _piezo = 50.0;
Expand Down Expand Up @@ -1004,14 +1010,52 @@ const OperateManager = (function () {
}

// ══ ACQUISITION PANE ════════════════════════════════════════════════════
function selectEmbryo(id) {
_selected = id;
/**
* Select an embryo, and maintain the selection SET around it.
*
* `_selected` is the primary — the one the instrument panes act on, which
* is what it has always meant. `_targets` is every member. A plain click
* makes them the same, so single-embryo work is unchanged; the modifiers
* are the only way to grow the set, and growing it changes nothing until
* Acquisition is switched to "Selected".
*
* Selection is NOT role. Excluding an embryo from tonight's run by marking
* it a reference also changes expression_monitoring's scope and its
* photodose budget multiplier in the orchestrator's `_is_eligible` — those
* are facts about what the embryo is for, not about what is running.
*/
function selectEmbryo(id, mode) {
if (!id) return;
const order = _embryos.map(e => e.id);

if (mode === 'toggle') {
const at = _targets.indexOf(id);
if (at >= 0 && _targets.length > 1) {
_targets.splice(at, 1);
// Deselecting the primary hands it to a remaining member rather
// than leaving the instrument panes pointed at nothing.
if (_selected === id) _selected = _targets[0];
} else if (at < 0) {
_targets.push(id);
_selected = id;
}
} else if (mode === 'range' && _selected && order.includes(_selected)) {
const a = order.indexOf(_selected), b = order.indexOf(id);
_targets = order.slice(Math.min(a, b), Math.max(a, b) + 1);
_selected = id;
} else {
_targets = [id];
_selected = id;
}

// Publish AFTER the local assignment: the subscription in wire() sees
// _selected already equal and no-ops, so our own click renders exactly
// once, as before. The set() is what makes the other copies of this
// cursor follow.
SharedState.set('selectedEmbryoId', id);
SharedState.set('selectedEmbryoIds', _targets.slice());
SharedState.set('selectedEmbryoId', _selected);
publishRoster(); renderSpimTarget(); renderCalTarget(); renderSingle();
renderTargetScope();
}

// Shared embryo list, left of every instrument surface. Reads the canonical
Expand Down Expand Up @@ -1142,10 +1186,46 @@ const OperateManager = (function () {
*
* Empty now, and the caller says so.
*/
function subjectIds() {
function allSubjectIds() {
return _embryos.filter(e => e.role !== 'calibration').map(e => e.id);
}

/** The embryos a run will image, per the declared scope. */
function subjectIds() {
if (_targetScope !== 'selected') return allSubjectIds();
// Never a reference, whatever is selected: role still decides what an
// embryo is for.
const refs = new Set(_embryos.filter(e => e.role === 'calibration').map(e => e.id));
return _targets.filter(id => !refs.has(id));
}

// The pane says which set it will use, and how big it is. The modifier keys
// that grow the set do not have to be discovered for this to be readable.
function renderTargetScope() {
const el = $('op-target-scope');
if (!el) return;
const all = allSubjectIds().length;
const sel = subjectIds().length;
el.querySelectorAll('[data-scope]').forEach(b => {
const on = b.dataset.scope === _targetScope;
b.classList.toggle('is-on', on);
b.setAttribute('aria-pressed', String(on));
});
const a = el.querySelector('[data-scope="all"]');
const s2 = el.querySelector('[data-scope="selected"]');
if (a) a.textContent = `All subjects (${all})`;
if (s2) {
s2.textContent = `Selected (${_targetScope === 'selected' ? sel : _targets.length})`;
s2.disabled = !_targets.length;
}
}

function setTargetScope(scope) {
_targetScope = scope === 'selected' ? 'selected' : 'all';
renderTargetScope();
renderSingle();
}

// Every embryo marked as a reference means there is nothing to image. Say
// so once, here, rather than at each mode — and say which state it is in,
// because "no embryos" and "no subjects among your embryos" need
Expand Down Expand Up @@ -1315,7 +1395,7 @@ const OperateManager = (function () {
acquire: {
onEnter() { renderRun(); },
onLeave() {},
render() { publishRoster(); renderSingle(); },
render() { publishRoster(); renderSingle(); renderTargetScope(); },
},
};
function stopBottom() {
Expand Down Expand Up @@ -1430,11 +1510,17 @@ const OperateManager = (function () {
function onEmbryosUpdate(p) {
_embryos = (p && Array.isArray(p.embryos)) ? p.embryos.slice() : [];
if (_selected && !_embryos.some(e => e.id === _selected)) _selected = null;
// Drop members that no longer exist, or the set silently targets ghosts
// — the same class of bug as the phantom roster row in #126.
const known = new Set(_embryos.map(e => e.id));
_targets = _targets.filter(id => known.has(id));
// The embryo list is shared across all three panes; keep a live
// selection whenever it is non-empty so SPIM/Acquire aren't a dead-end
// ("No embryo selected") right after registering. The operator can still
// switch by clicking a registered embryo (bottom) or a roster row.
if (!_selected && _embryos.length) _selected = _embryos[0].id;
if (!_targets.length && _selected) _targets = [_selected];
SharedState.set('selectedEmbryoIds', _targets.slice());
SharedState.set('selectedEmbryoId', _selected);
// Render the shared rail even when the Operate view isn't the active tab,
// so switching to it (or refreshing) shows the list immediately rather
Expand Down Expand Up @@ -1477,9 +1563,10 @@ const OperateManager = (function () {
// of them looking for buttons the panel no longer emits, is how a
// select fires twice.
//
// Everything below was collateral of that deletion: the slice that
// removed the roster's own listeners ran past them, and eight controls
// went dead — including the interlock banner's back-off button.
// Everything below was collateral of that deletion in the roster
// refactor: the slice that removed the roster's own listeners ran past
// them. Eight controls went dead, including the interlock banner's
// back-off button. Restored, and pinned by a test that counts them.
const sp = $('op-spim-toggle'); if (sp) sp.addEventListener('click', toggleSpim);
const cal = $('op-calibrate'); if (cal) cal.addEventListener('click', calibrateSelected);
document.querySelectorAll('[data-gv]').forEach(b =>
Expand Down Expand Up @@ -1510,6 +1597,11 @@ const OperateManager = (function () {
if (b) { _selectedLib = b.dataset.lib; loadLibrary(); }
});
}
const scope = $('op-target-scope');
if (scope) scope.addEventListener('click', e => {
const b = e.target.closest('[data-scope]');
if (b && !b.disabled) setTargetScope(b.dataset.scope);
});
const start = $('op-run-start'); if (start) start.addEventListener('click', startRun);
const pause = $('op-run-pause'); if (pause) pause.addEventListener('click', pauseRun);
const stopb = $('op-run-stop'); if (stopb) stopb.addEventListener('click', stopRun);
Expand Down Expand Up @@ -1617,7 +1709,7 @@ const OperateManager = (function () {
// panels/roster.js renders SharedState.embryos and calls these. The
// list, the selection and the endpoints stay here.
roster: {
select: id => selectEmbryo(id),
select: (id, mode) => selectEmbryo(id, mode),
remove: id => deleteEmbryo(id),
centre: id => {
const emb = _embryos.find(e => e.id === id);
Expand Down
22 changes: 18 additions & 4 deletions gently/ui/web/static/js/panels/roster.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,13 @@ const RosterPanel = (() => {
function render() {
const embryos = SharedState.get('embryos') || [];
const selected = SharedState.get('selectedEmbryoId');
const inSet = new Set(SharedState.get('selectedEmbryoIds') || []);

mounts.forEach((opts, hostId) => {
const host = document.getElementById(hostId);
if (!host) return;
host.innerHTML = embryos.length
? embryos.map(e => row(e, selected, opts)).join('')
? embryos.map(e => row(e, selected, inSet, opts)).join('')
: empty(opts);
wire(host);
});
Expand All @@ -127,7 +128,7 @@ const RosterPanel = (() => {
then register.${cta}</div>`;
}

function row(emb, selected, opts) {
function row(emb, selected, inSet, opts) {
const xy = xyOf(emb);
const role = (emb.role && emb.role !== 'unassigned') ? emb.role : 'test';
const isRef = role === 'calibration';
Expand All @@ -146,7 +147,13 @@ const RosterPanel = (() => {
data-verb="${a.verb}" data-id="${esc(emb.id)}">${a.text}</button>`;
}).join('');

return `<div class="rp-row${emb.id === selected ? ' is-sel' : ''}" tabindex="0"
// Two states, deliberately distinct: `is-sel` is membership of the
// target set, `is-primary` is the one the instrument panes act on. With
// one embryo selected they coincide, which is why the common case looks
// exactly as it did.
const cls = (inSet.has(emb.id) || emb.id === selected ? ' is-sel' : '')
+ (emb.id === selected ? ' is-primary' : '');
return `<div class="rp-row${cls}" tabindex="0"
data-embryo="${esc(emb.id)}">
<span class="rp-main">
<span class="rp-label">Embryo ${esc(labelOf(emb))}${fitBadge(emb, opts)}</span>
Expand Down Expand Up @@ -179,7 +186,14 @@ const RosterPanel = (() => {
const goto = e.target.closest('[data-goto]');
if (goto) { if (v && v.goTo) v.goTo(goto.dataset.goto); return; }
const row = e.target.closest('[data-embryo]');
if (row && v && v.select) v.select(row.dataset.embryo);
if (!row || !v || !v.select) return;
// The interaction every file manager, DAW and photo library already
// teaches, so the set needs no chrome of its own. A plain click
// replaces, which keeps single-embryo work identical to before.
const mode = (e.metaKey || e.ctrlKey) ? 'toggle'
: e.shiftKey ? 'range'
: 'replace';
v.select(row.dataset.embryo, mode);
};
host.onkeydown = e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
Expand Down
5 changes: 5 additions & 0 deletions gently/ui/web/static/js/status-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ const SharedState = (() => {
const s = {
stageXY: null, // {x, y} in um, or null if unknown
selectedEmbryoId: null,
// The selection SET. `selectedEmbryoId` stays the primary — the one the
// instrument panes act on — and this is every member. One embryo
// selected means the two agree, which is the normal case and behaves
// exactly as it always did.
selectedEmbryoIds: [],
sessionId: null,
agentBusy: false,
hasControl: false,
Expand Down
11 changes: 11 additions & 0 deletions gently/ui/web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,17 @@ <h2 class="devices-title"><span class="devices-title-script">Device</span> <em c

<div class="op-col">
<div class="op-block-head">What to run</div>
<!-- Which embryos, said out loud. "All subjects" is the
default and is what this pane has always done; a plain
click in the roster must never silently narrow a
timelapse to one embryo. Ctrl/Cmd-click and Shift-click
in the embryo list grow the selection. -->
<div class="op-seg" id="op-target-scope" role="group" aria-label="Run targets">
<button class="op-segbtn is-on" data-scope="all" type="button"
aria-pressed="true">All subjects</button>
<button class="op-segbtn" data-scope="selected" type="button"
aria-pressed="false" disabled>Selected</button>
</div>
<div class="op-seg" id="op-modes" role="tablist" aria-label="Run mode">
<button class="op-segbtn is-on" data-mode="single" role="tab" type="button">Single volume</button>
<button class="op-segbtn" data-mode="adaptive" role="tab" type="button">Adaptive timelapse</button>
Expand Down
1 change: 1 addition & 0 deletions tests/test_operate_controls_are_wired.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"op-confirm": "$('op-confirm')",
"op-clear": "$('op-clear')",
"op-cam-toggle": "$('op-cam-toggle')",
"op-target-scope": "$('op-target-scope')",
"[data-gv]": "[data-gv]",
"[data-pz]": "[data-pz]",
"[data-backoff]": "[data-backoff]",
Expand Down
Loading
Loading