Skip to content

Commit 72a7e51

Browse files
authored
CSE Machine: Fix some UI & animations issues, and add tests (#2936)
* Improve frame positioning and fix some bugs with arrow animations * Ensure global frame default text is always the first binding * Fix animation function logic * Changes to arrays and arrows, add layout test cases * Add unit tests for AnimationComponent * Fix imports * Fix statement sequences display and account for empty environments * Fix statement sequence display logic again * Correct frame creation animation conditions * Add more animation unit tests * Revert frontend fix for extra indentation * Change test case step number to actually test truncated control * Minor fixes * Reformat post-merge * Fix incorrect import * Fix jest error * Fix tests not detected by Vitest * Update snapshots
1 parent 66024de commit 72a7e51

23 files changed

+1321
-247
lines changed

src/features/cseMachine/CseMachineAnimation.tsx

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { Frame } from './components/Frame';
2626
import { ArrayValue } from './components/values/ArrayValue';
2727
import CseMachine from './CseMachine';
2828
import { Layout } from './CseMachineLayout';
29-
import { isBuiltInFn, isInstr, isStreamFn } from './CseMachineUtils';
29+
import { isBuiltInFn, isEnvEqual, isInstr, isStreamFn } from './CseMachineUtils';
3030
import { isList, isSymbol } from './utils/scheme';
3131

3232
export class CseAnimation {
@@ -51,11 +51,12 @@ export class CseAnimation {
5151
}
5252

5353
static setCurrentFrame(frame: Frame) {
54-
CseAnimation.previousFrame = CseAnimation.currentFrame;
54+
CseAnimation.previousFrame = CseAnimation.currentFrame ?? frame;
5555
CseAnimation.currentFrame = frame;
5656
}
5757

5858
private static clearAnimationComponents(): void {
59+
CseAnimation.animations.forEach(a => a.destroy());
5960
CseAnimation.animations.length = 0;
6061
}
6162

@@ -72,21 +73,26 @@ export class CseAnimation {
7273
const currStashComponent = Layout.stashComponent.stashItemComponents.at(-1)!;
7374
switch (node.type) {
7475
case 'Program':
75-
CseAnimation.animations.push(
76-
new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems())
77-
);
78-
if (CseMachine.getCurrentEnvId() !== '-1') {
76+
case 'BlockStatement':
77+
case 'StatementSequence':
78+
if (node.body.length === 1) {
79+
CseAnimation.handleNode(node.body[0]);
80+
} else {
7981
CseAnimation.animations.push(
80-
new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame)
82+
new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems())
8183
);
84+
if (
85+
!isEnvEqual(
86+
CseAnimation.currentFrame.environment,
87+
CseAnimation.previousFrame.environment
88+
)
89+
) {
90+
CseAnimation.animations.push(
91+
new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame)
92+
);
93+
}
8294
}
8395
break;
84-
case 'BlockStatement':
85-
CseAnimation.animations.push(
86-
new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems()),
87-
new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame)
88-
);
89-
break;
9096
case 'Literal':
9197
CseAnimation.animations.push(
9298
new ControlToStashAnimation(lastControlComponent, currStashComponent!)
@@ -127,7 +133,6 @@ export class CseAnimation {
127133
case 'IfStatement':
128134
case 'MemberExpression':
129135
case 'ReturnStatement':
130-
case 'StatementSequence':
131136
case 'UnaryExpression':
132137
case 'VariableDeclaration':
133138
case 'FunctionDeclaration':
@@ -143,7 +148,6 @@ export class CseAnimation {
143148
}
144149

145150
static updateAnimation() {
146-
CseAnimation.animations.forEach(a => a.destroy());
147151
CseAnimation.clearAnimationComponents();
148152

149153
if (!Layout.previousControlComponent) return;
@@ -393,16 +397,15 @@ export class CseAnimation {
393397

394398
static async playAnimation() {
395399
if (!CseAnimation.animationEnabled) {
396-
CseAnimation.animations.forEach(a => a.destroy());
397400
CseAnimation.clearAnimationComponents();
398401
return;
399402
}
400403
CseAnimation.disableAnimations();
401404
// Get the actual HTML <canvas> element and set the pointer events to none, to allow for
402-
// mouse events to pass through the animation layer, and be handled by the actual CSE Machine.
405+
// mouse events to pass through the animation layer and be handled by the actual CSE Machine.
403406
// Setting the listening property to false on the Konva Layer does not seem to work, so
404407
// this a workaround.
405-
const canvasElement = CseAnimation.getLayer()?.getCanvas()._canvas;
408+
const canvasElement = CseAnimation.getLayer()?.getNativeCanvasElement();
406409
if (canvasElement) canvasElement.style.pointerEvents = 'none';
407410
// Play all the animations
408411
await Promise.all(this.animations.map(a => a.animate()));

src/features/cseMachine/CseMachineConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ export const Config = Object.freeze({
1111
FrameMinWidth: 100,
1212
FramePaddingX: 20,
1313
FramePaddingY: 30,
14+
FrameMinGapX: 80,
1415
FrameMarginX: 30,
1516
FrameMarginY: 10,
1617
FrameCornerRadius: 3,
1718

1819
FnRadius: 15,
1920
FnInnerRadius: 3,
2021
FnTooltipOpacity: 0.3,
22+
FnTooltipTextPadding: 5,
2123

2224
DataMinWidth: 20,
2325
DataUnitWidth: 40,

src/features/cseMachine/CseMachineLayout.tsx

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import Heap from 'js-slang/dist/cse-machine/heap';
22
import { Control, Stash } from 'js-slang/dist/cse-machine/interpreter';
33
import { Chapter, Frame } from 'js-slang/dist/types';
44
import { KonvaEventObject } from 'konva/lib/Node';
5+
import { Stage } from 'konva/lib/Stage';
56
import React, { RefObject } from 'react';
6-
import { Layer, Rect, Stage } from 'react-konva';
7+
import { Layer as KonvaLayer, Rect as KonvaRect, Stage as KonvaStage } from 'react-konva';
78
import classes from 'src/styles/Draggable.module.scss';
89

910
import { Binding } from './components/Binding';
@@ -98,17 +99,19 @@ export class Layout {
9899
static currentStackTruncDark: React.ReactNode;
99100
static currentStackLight: React.ReactNode;
100101
static currentStackTruncLight: React.ReactNode;
101-
static stageRef: RefObject<any> = React.createRef();
102+
static stageRef: RefObject<Stage> = React.createRef();
102103

103104
// buffer for faster rendering of diagram when scrolling
104105
static invisiblePaddingVertical: number = 300;
105106
static invisiblePaddingHorizontal: number = 300;
106-
static scrollContainerRef: RefObject<any> = React.createRef();
107+
static scrollContainerRef: RefObject<HTMLDivElement> = React.createRef();
107108

108109
static updateDimensions(width: number, height: number) {
109110
// update the size of the scroll container and stage given the width and height of the sidebar content.
110111
Layout.visibleWidth = width;
111112
Layout.visibleHeight = height;
113+
Layout._width = Math.max(Layout.visibleWidth, Layout.stageWidth);
114+
Layout._height = Math.max(Layout.visibleHeight, Layout.stageHeight);
112115
if (
113116
Layout.stageRef.current !== null &&
114117
(Math.min(Layout.width(), window.innerWidth) > Layout.stageWidth ||
@@ -122,16 +125,14 @@ export class Layout {
122125
Layout.stageRef.current.height(Layout.stageHeight);
123126
CseMachine.redraw();
124127
}
125-
if (Layout.stageHeight > Layout.visibleHeight) {
126-
}
127128
Layout.invisiblePaddingVertical =
128129
Layout.stageHeight > Layout.visibleHeight
129130
? (Layout.stageHeight - Layout.visibleHeight) / 2
130131
: 0;
131132
Layout.invisiblePaddingHorizontal =
132133
Layout.stageWidth > Layout.visibleWidth ? (Layout.stageWidth - Layout.visibleWidth) / 2 : 0;
133134

134-
const container: HTMLElement | null = this.scrollContainerRef.current as HTMLDivElement;
135+
const container = this.scrollContainerRef.current;
135136
if (container) {
136137
container.style.width = `${Layout.visibleWidth}px`;
137138
container.style.height = `${Layout.visibleHeight}px`;
@@ -183,12 +184,13 @@ export class Layout {
183184
// calculate height and width by considering lowest and widest level
184185
const lastLevel = Layout.levels[Layout.levels.length - 1];
185186
Layout._height = Math.max(
187+
Layout.visibleHeight,
186188
Config.CanvasMinHeight,
187189
lastLevel.y() + lastLevel.height() + Config.CanvasPaddingY,
188190
Layout.controlStashHeight ?? 0
189191
);
190-
191192
Layout._width = Math.max(
193+
Layout.visibleWidth,
192194
Config.CanvasMinWidth,
193195
Layout.levels.reduce<number>((maxWidth, level) => Math.max(maxWidth, level.width()), 0) +
194196
Config.CanvasPaddingX * 2 +
@@ -412,10 +414,10 @@ export class Layout {
412414
* Scrolls diagram to top left, resets the zoom, and saves the diagram as multiple images of width < MaxExportWidth.
413415
*/
414416
static exportImage = () => {
415-
const container: HTMLElement | null = this.scrollContainerRef.current as HTMLDivElement;
416-
container.scrollTo({ left: 0, top: 0 });
417+
const container = this.scrollContainerRef.current;
418+
container?.scrollTo({ left: 0, top: 0 });
417419
Layout.handleScrollPosition(0, 0);
418-
this.stageRef.current.scale({ x: 1, y: 1 });
420+
this.stageRef.current?.scale({ x: 1, y: 1 });
419421
const height = Layout.height();
420422
const width = Layout.width();
421423
const horizontalImages = Math.ceil(width / Config.MaxExportWidth);
@@ -429,13 +431,14 @@ export class Layout {
429431
const y = Math.floor(n / horizontalImages);
430432
const a = document.createElement('a');
431433
a.style.display = 'none';
432-
a.href = this.stageRef.current.toDataURL({
433-
x: x * Config.MaxExportWidth + Layout.invisiblePaddingHorizontal,
434-
y: y * Config.MaxExportHeight + Layout.invisiblePaddingVertical,
435-
width: Math.min(width - x * Config.MaxExportWidth, Config.MaxExportWidth),
436-
height: Math.min(height - y * Config.MaxExportHeight, Config.MaxExportHeight),
437-
mimeType: 'image/jpeg'
438-
});
434+
a.href =
435+
this.stageRef.current?.toDataURL({
436+
x: x * Config.MaxExportWidth + Layout.invisiblePaddingHorizontal,
437+
y: y * Config.MaxExportHeight + Layout.invisiblePaddingVertical,
438+
width: Math.min(width - x * Config.MaxExportWidth, Config.MaxExportWidth),
439+
height: Math.min(height - y * Config.MaxExportHeight, Config.MaxExportHeight),
440+
mimeType: 'image/jpeg'
441+
}) ?? '';
439442

440443
a.download = `diagram_${x}_${y}.jpg`;
441444
document.body.appendChild(a);
@@ -457,6 +460,7 @@ export class Layout {
457460
* @param y y position of the scroll container
458461
*/
459462
private static handleScrollPosition(x: number, y: number) {
463+
if (!this.stageRef.current) return;
460464
const dx = x - Layout.invisiblePaddingHorizontal;
461465
const dy = y - Layout.invisiblePaddingVertical;
462466
this.stageRef.current.container().style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
@@ -475,7 +479,10 @@ export class Layout {
475479
if (Layout.stageRef.current !== null) {
476480
const stage = Layout.stageRef.current;
477481
const oldScale = stage.scaleX();
478-
const { x: pointerX, y: pointerY } = stage.getPointerPosition();
482+
const { x: pointerX, y: pointerY } = stage.getPointerPosition() ?? {
483+
x: Layout.visibleWidth / 2 - stage.x(),
484+
y: Layout.visibleHeight / 2 - stage.y()
485+
};
479486
const mousePointTo = {
480487
x: (pointerX - stage.x()) / oldScale,
481488
y: (pointerY - stage.y()) / oldScale
@@ -531,16 +538,16 @@ export class Layout {
531538
backgroundColor: defaultBackgroundColor()
532539
}}
533540
>
534-
<Stage
541+
<KonvaStage
535542
width={Layout.stageWidth}
536543
height={Layout.stageHeight}
537544
ref={Layout.stageRef}
538545
draggable
539546
onWheel={Layout.zoomStage}
540547
className={classes['draggable']}
541548
>
542-
<Layer>
543-
<Rect
549+
<KonvaLayer>
550+
<KonvaRect
544551
{...ShapeDefaultProps}
545552
x={0}
546553
y={0}
@@ -553,11 +560,11 @@ export class Layout {
553560
{Layout.levels.map(level => level.draw())}
554561
{CseMachine.getControlStash() && Layout.controlComponent.draw()}
555562
{CseMachine.getControlStash() && Layout.stashComponent.draw()}
556-
</Layer>
557-
<Layer ref={CseAnimation.layerRef} listening={false}>
563+
</KonvaLayer>
564+
<KonvaLayer ref={CseAnimation.layerRef} listening={false}>
558565
{CseMachine.getControlStash() && CseAnimation.animations.map(c => c.draw())}
559-
</Layer>
560-
</Stage>
566+
</KonvaLayer>
567+
</KonvaStage>
561568
</div>
562569
</div>
563570
</div>

src/features/cseMachine/CseMachineUtils.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ export function setDifference<T>(set1: Set<T>, set2: Set<T>) {
233233
* order is the first binding or array unit which shares the same environment with `value`.
234234
*
235235
* An exception is for a global function value, in which case the global frame binding is
236-
* always prioritised over array units.
236+
* always prioritised over other bindings or array units.
237237
*/
238238
export function isMainReference(value: Value, reference: ReferenceType) {
239239
if (isContinuation(value.data)) {
@@ -442,8 +442,8 @@ export function getNonEmptyEnv(environment: Env): Env {
442442

443443
/** Returns whether the given environments `env1` and `env2` refer to the same environment. */
444444
export function isEnvEqual(env1: Env, env2: Env): boolean {
445-
// Cannot check env references because of deep cloning and the step after where
446-
// property descriptors are copied over, so can only check id
445+
// Cannot check env references because of partial cloning of environment tree,
446+
// so we can only check id
447447
return env1.id === env2.id;
448448
}
449449

@@ -901,11 +901,16 @@ export function getStashItemComponent(
901901
return new StashItemComponent(stashItem, stackHeight, index, arrowTo);
902902
}
903903

904-
// Helper function to get environment ID. Accounts for the hidden prelude environment right
905-
// after the global environment. Does not need to be used for frame environments, only for
906-
// environments from the context.
904+
// Helper function to get environment ID.
905+
// Accounts for the hidden prelude environment and empty environments.
907906
export const getEnvId = (environment: Environment): string => {
908-
return environment.name === 'prelude' ? environment.tail!.id : environment.id;
907+
while (
908+
environment.tail &&
909+
(environment.name === 'prelude' || Object.keys(environment.head).length === 0)
910+
) {
911+
environment = environment.tail;
912+
}
913+
return environment.id;
909914
};
910915

911916
// Function that returns whether the stash item will be popped off in the next step

0 commit comments

Comments
 (0)