Skip to content

Commit a8cf5c1

Browse files
committed
Merge phase 3 history coalescing
2 parents cd9451b + 0b1ea78 commit a8cf5c1

30 files changed

Lines changed: 647 additions & 54 deletions

molsysviewer/annotations.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ._private.arg_digestion import digest
99
from .layers import Annotation, Layer
10+
from .scene_history import records_scene_history
1011

1112

1213
class AnnotationsManager:
@@ -169,6 +170,7 @@ def summarize(record: dict[str, Any]) -> dict[str, Any]:
169170

170171
raise ValueError(f"No annotation record found for tag {tag!r}.")
171172

173+
@records_scene_history
172174
@signal(tags=["annotation"])
173175
@digest()
174176
def add_annotation(
@@ -229,10 +231,12 @@ def add_annotation(
229231
self._view._send({"op": "add_label", "tag": object_tag, "options": options}) # noqa: SLF001
230232
return layer
231233

234+
@records_scene_history
232235
def add(self, *args, **kwargs) -> Layer:
233236
"""Create an annotation; canonical manager alias for :meth:`add_annotation`."""
234237
return self.add_annotation(*args, **kwargs)
235238

239+
@records_scene_history
236240
@signal(tags=["annotation"])
237241
@digest()
238242
def add_label(
@@ -269,6 +273,7 @@ def add_label(
269273
skip_digestion=True,
270274
)
271275

276+
@records_scene_history
272277
@signal(tags=["annotation", "selection"])
273278
@digest()
274279
def add_label_from_active_selection(
@@ -302,6 +307,7 @@ def add_label_from_active_selection(
302307
skip_digestion=True,
303308
)
304309

310+
@records_scene_history
305311
@signal(tags=["annotation", "visibility"])
306312
@digest()
307313
def show(self, tag: str, skip_digestion: bool = False) -> Layer:
@@ -310,6 +316,7 @@ def show(self, tag: str, skip_digestion: bool = False) -> Layer:
310316
layer.show(skip_digestion=True)
311317
return layer
312318

319+
@records_scene_history
313320
@signal(tags=["annotation", "visibility"])
314321
@digest()
315322
def hide(self, tag: str, skip_digestion: bool = False) -> Layer:
@@ -318,13 +325,15 @@ def hide(self, tag: str, skip_digestion: bool = False) -> Layer:
318325
layer.hide(skip_digestion=True)
319326
return layer
320327

328+
@records_scene_history
321329
@signal(tags=["annotation"])
322330
@digest()
323331
def delete(self, tag: str, skip_digestion: bool = False) -> None:
324332
"""Delete the annotation layer for ``tag``."""
325333
layer = self._require_annotation_layer(tag)
326334
layer.delete(skip_digestion=True)
327335

336+
@records_scene_history
328337
@signal(tags=["annotation"])
329338
@digest()
330339
def set_tag(self, tag: str, new_tag: str, skip_digestion: bool = False) -> Layer:
@@ -333,6 +342,7 @@ def set_tag(self, tag: str, new_tag: str, skip_digestion: bool = False) -> Layer
333342
layer.set_tag(new_tag, skip_digestion=True)
334343
return layer
335344

345+
@records_scene_history
336346
@signal(tags=["annotation"])
337347
@digest()
338348
def set_layer_tag(self, tag: str, new_layer_tag: str, skip_digestion: bool = False) -> Layer:
@@ -341,6 +351,7 @@ def set_layer_tag(self, tag: str, new_layer_tag: str, skip_digestion: bool = Fal
341351
layer.set_layer_tag(new_layer_tag, skip_digestion=True)
342352
return layer
343353

354+
@records_scene_history
344355
@signal(tags=["annotation"])
345356
@digest()
346357
def clear(self, tag: str | None = None, skip_digestion: bool = False) -> None:
@@ -350,6 +361,7 @@ def clear(self, tag: str | None = None, skip_digestion: bool = False) -> None:
350361
return
351362
self.delete(tag, skip_digestion=True)
352363

364+
@records_scene_history
353365
@signal(tags=["annotation"])
354366
@digest()
355367
def set_text(self, tag: str, text: str, skip_digestion: bool = False) -> Layer:
@@ -376,6 +388,7 @@ def set_text(self, tag: str, text: str, skip_digestion: bool = False) -> Layer:
376388
)
377389
return layer
378390

391+
@records_scene_history
379392
@signal(tags=["annotation"])
380393
@digest()
381394
def set_style(self, tag: str, style: dict[str, Any], skip_digestion: bool = False) -> Layer:
@@ -401,6 +414,7 @@ def set_style(self, tag: str, style: dict[str, Any], skip_digestion: bool = Fals
401414
)
402415
return layer
403416

417+
@records_scene_history
404418
@signal(tags=["annotation"])
405419
@digest()
406420
def set_anchor(
@@ -442,6 +456,7 @@ def set_anchor(
442456
)
443457
return layer
444458

459+
@records_scene_history
445460
@signal(tags=["annotation"])
446461
@digest()
447462
def set_group_index(self, tag: str, group_index: Any, skip_digestion: bool = False) -> Layer:

molsysviewer/js/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"probe:per-atom-color-decorator": "npm run build:harness && npx esbuild tests/perf/per-atom-color-decorator.probe.ts --bundle --platform=node --format=esm --outfile=tests/perf/per-atom-color-decorator.probe.js --external:playwright --external:chromium-bidi/* && node tests/perf/per-atom-color-decorator.probe.js",
2626
"probe:region-order-ownership": "npm run build:harness && npx esbuild tests/perf/region-order-ownership.probe.ts --bundle --platform=node --format=esm --outfile=tests/perf/region-order-ownership.probe.js --external:playwright --external:chromium-bidi/* && node tests/perf/region-order-ownership.probe.js",
2727
"build:harness": "npx esbuild tests/e2e/harness.ts --bundle --format=iife --global-name=Harness --outfile=tests/e2e/harness.bundle.js",
28-
"build:e2e:all": "npx esbuild tests/e2e/annotations-interaction.e2e.ts tests/e2e/export-replay.e2e.ts tests/e2e/group-panel-interaction.e2e.ts tests/e2e/hierarchy-interaction.e2e.ts tests/e2e/measurements-interaction.e2e.ts tests/e2e/range-selection.e2e.ts tests/e2e/region-hide.e2e.ts tests/e2e/region-subpanel.e2e.ts tests/e2e/selection-subpanel.e2e.ts tests/e2e/layers-subpanel.e2e.ts tests/e2e/shape-trajectory.e2e.ts tests/e2e/workflow-integration.e2e.ts tests/e2e/scientific-workflow.e2e.ts tests/e2e/scene-contracts.e2e.ts tests/e2e/scene-object-identity.e2e.ts tests/e2e/scene-object-panel-roundtrip.e2e.ts --bundle --platform=node --format=esm --outdir=tests/e2e --external:playwright --external:chromium-bidi/*",
28+
"build:e2e:all": "npx esbuild tests/e2e/annotations-interaction.e2e.ts tests/e2e/export-replay.e2e.ts tests/e2e/group-panel-interaction.e2e.ts tests/e2e/hierarchy-interaction.e2e.ts tests/e2e/history-coalescing.e2e.ts tests/e2e/measurements-interaction.e2e.ts tests/e2e/range-selection.e2e.ts tests/e2e/region-hide.e2e.ts tests/e2e/region-subpanel.e2e.ts tests/e2e/selection-subpanel.e2e.ts tests/e2e/layers-subpanel.e2e.ts tests/e2e/shape-trajectory.e2e.ts tests/e2e/workflow-integration.e2e.ts tests/e2e/scientific-workflow.e2e.ts tests/e2e/scene-contracts.e2e.ts tests/e2e/scene-object-identity.e2e.ts tests/e2e/scene-object-panel-roundtrip.e2e.ts --bundle --platform=node --format=esm --outdir=tests/e2e --external:playwright --external:chromium-bidi/*",
2929
"test:e2e:annotations": "npm run build:harness && npx esbuild tests/e2e/annotations-interaction.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/annotations-interaction.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/annotations-interaction.e2e.js",
3030
"test:e2e:export": "npm run build:harness && npx esbuild tests/e2e/export-replay.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/export-replay.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/export-replay.e2e.js",
3131
"test:e2e:group-panel": "npm run build:harness && npx esbuild tests/e2e/group-panel-interaction.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/group-panel-interaction.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/group-panel-interaction.e2e.js",
@@ -40,10 +40,11 @@
4040
"test:e2e:workflow": "npm run build:harness && npx esbuild tests/e2e/workflow-integration.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/workflow-integration.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/workflow-integration.e2e.js",
4141
"test:e2e:scientific": "npm run build:harness && npx esbuild tests/e2e/scientific-workflow.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/scientific-workflow.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/scientific-workflow.e2e.js",
4242
"test:e2e": "npm run test:e2e:region-hide",
43-
"test:e2e:all": "npm run build:harness && npm run build:e2e:all && node tests/e2e/annotations-interaction.e2e.js && node tests/e2e/export-replay.e2e.js && node tests/e2e/group-panel-interaction.e2e.js && node tests/e2e/hierarchy-interaction.e2e.js && node tests/e2e/measurements-interaction.e2e.js && node tests/e2e/range-selection.e2e.js && node tests/e2e/region-hide.e2e.js && node tests/e2e/region-subpanel.e2e.js && node tests/e2e/selection-subpanel.e2e.js && node tests/e2e/layers-subpanel.e2e.js && node tests/e2e/shape-trajectory.e2e.js && node tests/e2e/workflow-integration.e2e.js && node tests/e2e/scientific-workflow.e2e.js && node tests/e2e/scene-contracts.e2e.js && node tests/e2e/scene-object-identity.e2e.js && node tests/e2e/scene-object-panel-roundtrip.e2e.js",
43+
"test:e2e:all": "npm run build:harness && npm run build:e2e:all && node tests/e2e/annotations-interaction.e2e.js && node tests/e2e/export-replay.e2e.js && node tests/e2e/group-panel-interaction.e2e.js && node tests/e2e/hierarchy-interaction.e2e.js && node tests/e2e/history-coalescing.e2e.js && node tests/e2e/measurements-interaction.e2e.js && node tests/e2e/range-selection.e2e.js && node tests/e2e/region-hide.e2e.js && node tests/e2e/region-subpanel.e2e.js && node tests/e2e/selection-subpanel.e2e.js && node tests/e2e/layers-subpanel.e2e.js && node tests/e2e/shape-trajectory.e2e.js && node tests/e2e/workflow-integration.e2e.js && node tests/e2e/scientific-workflow.e2e.js && node tests/e2e/scene-contracts.e2e.js && node tests/e2e/scene-object-identity.e2e.js && node tests/e2e/scene-object-panel-roundtrip.e2e.js",
4444
"test:e2e:scene-contracts": "npm run build:harness && npx esbuild tests/e2e/scene-contracts.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/scene-contracts.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/scene-contracts.e2e.js",
4545
"test:e2e:scene-object-identity": "npm run build:harness && npx esbuild tests/e2e/scene-object-identity.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/scene-object-identity.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/scene-object-identity.e2e.js",
46-
"test:e2e:scene-object-panel-roundtrip": "npm run build:harness && npx esbuild tests/e2e/scene-object-panel-roundtrip.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/scene-object-panel-roundtrip.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/scene-object-panel-roundtrip.e2e.js"
46+
"test:e2e:scene-object-panel-roundtrip": "npm run build:harness && npx esbuild tests/e2e/scene-object-panel-roundtrip.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/scene-object-panel-roundtrip.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/scene-object-panel-roundtrip.e2e.js",
47+
"test:e2e:history-coalescing": "npm run build:harness && npx esbuild tests/e2e/history-coalescing.e2e.ts --bundle --platform=node --format=esm --outfile=tests/e2e/history-coalescing.e2e.js --external:playwright --external:chromium-bidi/* && node tests/e2e/history-coalescing.e2e.js"
4748
},
4849
"dependencies": {
4950
"molstar": "^5.4.1"

molsysviewer/js/src/managers/viewer-controller.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,14 @@ export class MolSysViewerController {
999999
emitInteractionEvent({ event: "scene_history_redo" });
10001000
return;
10011001
}
1002+
if (action === "begin_scene_history_coalescing") {
1003+
emitInteractionEvent({ event: "scene_history_coalescing_begin" });
1004+
return;
1005+
}
1006+
if (action === "end_scene_history_coalescing") {
1007+
emitInteractionEvent({ event: "scene_history_coalescing_end" });
1008+
return;
1009+
}
10021010
if (action === "selection_query_preview_request") {
10031011
emitInteractionEvent({
10041012
event: "selection_query_preview_request",

molsysviewer/js/src/ui/panels/regions-panel.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { RegionDetails, RegionSummary, SavedSelectionSummary, SelectionQuer
44
import { BasePanel } from "./base-panel";
55
import { PanelAction, PanelContext } from "./types";
66
import { makeButton, makeSectionHeader, makeStyledSelect } from "./ui-helpers";
7-
import { FALLBACK_PRESETS, createStyleDraftControls, makeStyleControlRow } from "./style-composer";
7+
import { FALLBACK_PRESETS, bindContinuousHistory, createStyleDraftControls, makeStyleControlRow } from "./style-composer";
88

99
/**
1010
* Studio -> Regions subpanel.
@@ -51,6 +51,7 @@ export class RegionsPanel extends BasePanel {
5151
private nextRegionDetailsRequest = 1;
5252
private regionBooleanAttention = false;
5353
private regionBooleanComposerElement: HTMLDivElement | null = null;
54+
private continuousHistoryEdit = false;
5455

5556
constructor(
5657
private readonly ctx: PanelContext,
@@ -59,6 +60,10 @@ export class RegionsPanel extends BasePanel {
5960
super();
6061
}
6162

63+
private scheduleExternalRender(): void {
64+
if (!this.continuousHistoryEdit) this.scheduleRender();
65+
}
66+
6267
setRegions(items: RegionSummary[]): void {
6368
this.regions = [...items];
6469
const tags = this.regions.map(item => item.tag);
@@ -82,7 +87,7 @@ export class RegionsPanel extends BasePanel {
8287
}
8388
}
8489
this.ctx.setBadge(String(items.length));
85-
this.scheduleRender();
90+
this.scheduleExternalRender();
8691
}
8792

8893
setStyleOptions(options: { representations: string[]; presets: string[]; wholeHidden?: boolean }): void {
@@ -92,7 +97,7 @@ export class RegionsPanel extends BasePanel {
9297
if (!this.regionCreateRepresentation && this.wholeHidden) {
9398
this.regionCreateRepresentation = "inherit";
9499
}
95-
this.scheduleRender();
100+
this.scheduleExternalRender();
96101
}
97102

98103
setSavedSelections(items: SavedSelectionSummary[]): void {
@@ -102,7 +107,7 @@ export class RegionsPanel extends BasePanel {
102107
this.regionSavedSelectionTag = tags[0] ?? "";
103108
}
104109
if (this.regionCreateOrigin === "saved") {
105-
this.scheduleRender();
110+
this.scheduleExternalRender();
106111
}
107112
}
108113

@@ -116,7 +121,7 @@ export class RegionsPanel extends BasePanel {
116121
return;
117122
}
118123
this.regionDetails.set(details.tag, details);
119-
this.scheduleRender();
124+
this.scheduleExternalRender();
120125
}
121126

122127
/** Route a query preview; returns true if it belonged to this panel's composer. */
@@ -147,7 +152,7 @@ export class RegionsPanel extends BasePanel {
147152
setCurrentSelection(selection: ActiveSelectionPayload): void {
148153
this.currentSelection = selection;
149154
if (this.regionCreateOrigin === "active") {
150-
this.scheduleRender();
155+
this.scheduleExternalRender();
151156
}
152157
}
153158

@@ -1106,7 +1111,19 @@ export class RegionsPanel extends BasePanel {
11061111
},
11071112
};
11081113
};
1109-
opacity.addEventListener("change", () => {
1114+
bindContinuousHistory(
1115+
opacity,
1116+
() => {
1117+
this.continuousHistoryEdit = true;
1118+
this.ctx.onAction("begin_scene_history_coalescing");
1119+
},
1120+
() => {
1121+
this.ctx.onAction("end_scene_history_coalescing");
1122+
this.continuousHistoryEdit = false;
1123+
this.scheduleRender();
1124+
},
1125+
);
1126+
opacity.addEventListener("input", () => {
11101127
if (!this.regionHasOwnVisual(item)) return;
11111128
this.ctx.onAction("set_region_representation", {
11121129
tag,

molsysviewer/js/src/ui/panels/style-composer.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,32 @@ export type StyleDraftControls = {
7474
opacityRow: HTMLDivElement;
7575
};
7676

77+
export function bindContinuousHistory(
78+
input: HTMLInputElement,
79+
onStart: () => void,
80+
onEnd: () => void,
81+
): void {
82+
let active = false;
83+
const start = () => {
84+
if (active) return;
85+
active = true;
86+
onStart();
87+
};
88+
const end = () => {
89+
if (!active) return;
90+
active = false;
91+
onEnd();
92+
};
93+
input.addEventListener("pointerdown", start);
94+
input.addEventListener("focus", start);
95+
input.addEventListener("pointerup", end);
96+
input.addEventListener("pointercancel", end);
97+
input.addEventListener("blur", end);
98+
input.addEventListener("keydown", event => {
99+
if (event.key === "Enter" || event.key === "Escape") end();
100+
});
101+
}
102+
77103
export function createStyleDraftControls(options: {
78104
id: string;
79105
dataPrefix: string;

molsysviewer/js/src/ui/panels/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export type PanelAction =
3030
| "create_label_from_saved_selection"
3131
| "undo_active_selection"
3232
| "redo_active_selection"
33+
| "begin_scene_history_coalescing"
34+
| "end_scene_history_coalescing"
3335
// Selection -> Region / Region creation
3436
| "create_region_from_selection"
3537
| "create_region_from_saved_selection"

molsysviewer/js/src/ui/panels/whole-panel.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { WholeDetails, WholeSummary } from "../group-panel";
22
import { makeButton, makeSectionHeader, makeStyledSelect } from "./ui-helpers";
33
import { PanelContext, StudioPanel } from "./types";
4-
import { FALLBACK_PRESETS, FALLBACK_REPRESENTATIONS, createStyleDraftControls } from "./style-composer";
4+
import { FALLBACK_PRESETS, FALLBACK_REPRESENTATIONS, bindContinuousHistory, createStyleDraftControls } from "./style-composer";
55

66
function labelFromToken(value: string): string {
77
return value
@@ -64,6 +64,7 @@ export class WholePanel implements StudioPanel {
6464
private summary: WholeSummary | null = null;
6565
private details: WholeDetails | null = null;
6666
private requestId = 0;
67+
private continuousHistoryEdit = false;
6768

6869
constructor(private readonly ctx: PanelContext) {}
6970

@@ -80,7 +81,7 @@ export class WholePanel implements StudioPanel {
8081
setSummary(summary: WholeSummary | null): void {
8182
this.summary = summary;
8283
this.ctx.setBadge(summary ? (summary.visible ? "Visible" : "Hidden") : "None");
83-
this.render();
84+
if (!this.continuousHistoryEdit) this.render();
8485
}
8586

8687
updateDetails(details: WholeDetails): void {
@@ -175,7 +176,19 @@ export class WholePanel implements StudioPanel {
175176
const quality = controls.qualitySelect;
176177
section.appendChild(controls.representationRow);
177178
section.appendChild(controls.presetRow);
178-
opacity.addEventListener("change", () => {
179+
bindContinuousHistory(
180+
opacity,
181+
() => {
182+
this.continuousHistoryEdit = true;
183+
this.ctx.onAction("begin_scene_history_coalescing");
184+
},
185+
() => {
186+
this.ctx.onAction("end_scene_history_coalescing");
187+
this.continuousHistoryEdit = false;
188+
this.render();
189+
},
190+
);
191+
opacity.addEventListener("input", () => {
179192
this.ctx.onAction("set_whole_representation", {
180193
...(preset.value ? { preset: preset.value } : representation.value ? { representation: representation.value } : {}),
181194
params: { ...params, alpha: Number(opacity.value) },
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import sys
5+
6+
from molsysviewer.demo import demo
7+
8+
9+
def main() -> None:
10+
events = json.load(sys.stdin)
11+
view = demo["dialanine"]
12+
view.widget.send = lambda _message: None
13+
region = view.regions.add(atom_indices=[0, 1, 2], tag="pocket", skip_digestion=True)
14+
region.set_representation("line", alpha=0.2, skip_digestion=True)
15+
view.history.clear()
16+
17+
depth_before_undo = None
18+
alpha_before_undo = None
19+
for event in events:
20+
if event.get("event") == "scene_history_undo":
21+
depth_before_undo = len(view.history._undo) # noqa: SLF001
22+
alpha_before_undo = view.regions["pocket"].repr_params.get("alpha")
23+
view._handle_frontend_event(event) # noqa: SLF001
24+
25+
print(json.dumps({
26+
"depth_before_undo": depth_before_undo,
27+
"alpha_before_undo": alpha_before_undo,
28+
"alpha_after_undo": view.regions["pocket"].repr_params.get("alpha"),
29+
}))
30+
31+
32+
if __name__ == "__main__":
33+
main()

0 commit comments

Comments
 (0)