Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CSE Machine: Fix some UI & animations issues, and add tests #2936

Draft
wants to merge 30 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d09fa11
Improve frame positioning and fix some bugs with arrow animations
CZX123 Apr 14, 2024
274d0cf
Merge branch 'master' into cse-uiux3
CZX123 Apr 14, 2024
e0f718e
Ensure global frame default text is always the first binding
CZX123 Apr 14, 2024
8f0fd43
Fix animation function logic
CZX123 Apr 15, 2024
3c67340
Merge branch 'master' into cse-uiux3
CZX123 Apr 15, 2024
ab8772b
Merge branch 'master' into cse-uiux3
CZX123 Apr 18, 2024
c499b30
Changes to arrays and arrows, add layout test cases
CZX123 Apr 19, 2024
42e5149
Merge branch 'master' into cse-uiux3
CZX123 Apr 19, 2024
042b395
Add unit tests for AnimationComponent
CZX123 Apr 20, 2024
f59cee2
Merge branch 'master' into cse-uiux3
CZX123 Apr 20, 2024
6bd070a
Fix imports
CZX123 Apr 20, 2024
dbb4a44
Fix statement sequences display and account for empty environments
CZX123 Apr 21, 2024
e27442f
Fix statement sequence display logic again
CZX123 Apr 21, 2024
c22a210
Correct frame creation animation conditions
CZX123 Apr 21, 2024
9818e86
Add more animation unit tests
CZX123 Apr 25, 2024
7e2aaf2
Merge branch 'master' into cse-uiux3
CZX123 Apr 25, 2024
cce757f
Revert frontend fix for extra indentation
CZX123 Apr 25, 2024
64b5103
Change test case step number to actually test truncated control
CZX123 Apr 25, 2024
e22637d
Minor fixes
CZX123 Apr 25, 2024
7542084
Merge branch 'master' into cse-uiux3
RichDom2185 May 3, 2024
f9ed69c
Merge branch 'master' into cse-uiux3
CZX123 May 3, 2024
dbd2130
Merge branch 'master' into cse-uiux3
RichDom2185 May 5, 2024
40a9987
Merge branch 'master' into cse-uiux3
RichDom2185 May 6, 2024
a008724
Merge branch 'master' into cse-uiux3
RichDom2185 May 12, 2024
e87c278
Merge branch 'master' into cse-uiux3
RichDom2185 May 13, 2024
496ca6b
Merge branch 'master' into cse-uiux3
RichDom2185 May 15, 2024
7c08d72
Merge branch 'master' into cse-uiux3
martin-henz Jul 19, 2024
e1f8327
Merge branch 'master' into cse-uiux3
RichDom2185 Aug 11, 2024
588e810
Merge branch 'master' of https://github.com/source-academy/frontend i…
RichDom2185 Aug 17, 2024
4bdc858
Reformat post-merge
RichDom2185 Aug 17, 2024
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
39 changes: 21 additions & 18 deletions src/features/cseMachine/CseMachineAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { Frame } from './components/Frame';
import { ArrayValue } from './components/values/ArrayValue';
import CseMachine from './CseMachine';
import { Layout } from './CseMachineLayout';
import { isBuiltInFn, isStreamFn } from './CseMachineUtils';
import { isBuiltInFn, isEnvEqual, isStreamFn } from './CseMachineUtils';

export class CseAnimation {
static readonly animations: Animatable[] = [];
Expand All @@ -49,11 +49,12 @@ export class CseAnimation {
}

static setCurrentFrame(frame: Frame) {
CseAnimation.previousFrame = CseAnimation.currentFrame;
CseAnimation.previousFrame = CseAnimation.currentFrame ?? frame;
CseAnimation.currentFrame = frame;
}

private static clearAnimationComponents(): void {
CseAnimation.animations.forEach(a => a.destroy());
CseAnimation.animations.length = 0;
}

Expand All @@ -70,21 +71,26 @@ export class CseAnimation {
const currStashComponent = Layout.stashComponent.stashItemComponents.at(-1)!;
switch (node.type) {
case 'Program':
CseAnimation.animations.push(
new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems())
);
if (CseMachine.getCurrentEnvId() !== '-1') {
case 'BlockStatement':
case 'StatementSequence':
if (node.body.length === 1) {
CseAnimation.handleNode(node.body[0]);
} else {
CseAnimation.animations.push(
new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame)
new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems())
);
if (
!isEnvEqual(
CseAnimation.currentFrame.environment,
CseAnimation.previousFrame.environment
)
) {
CseAnimation.animations.push(
new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame)
);
}
}
break;
case 'BlockStatement':
CseAnimation.animations.push(
new ControlExpansionAnimation(lastControlComponent, CseAnimation.getNewControlItems()),
new FrameCreationAnimation(lastControlComponent, CseAnimation.currentFrame)
);
break;
case 'Literal':
CseAnimation.animations.push(
new ControlToStashAnimation(lastControlComponent, currStashComponent!)
Expand Down Expand Up @@ -120,7 +126,6 @@ export class CseAnimation {
case 'IfStatement':
case 'MemberExpression':
case 'ReturnStatement':
case 'StatementSequence':
case 'UnaryExpression':
case 'VariableDeclaration':
case 'FunctionDeclaration':
Expand All @@ -136,7 +141,6 @@ export class CseAnimation {
}

static updateAnimation() {
CseAnimation.animations.forEach(a => a.destroy());
CseAnimation.clearAnimationComponents();

if (!Layout.previousControlComponent) return;
Expand Down Expand Up @@ -288,16 +292,15 @@ export class CseAnimation {

static async playAnimation() {
if (!CseAnimation.animationEnabled) {
CseAnimation.animations.forEach(a => a.destroy());
CseAnimation.clearAnimationComponents();
return;
}
CseAnimation.disableAnimations();
// Get the actual HTML <canvas> element and set the pointer events to none, to allow for
// mouse events to pass through the animation layer, and be handled by the actual CSE Machine.
// mouse events to pass through the animation layer and be handled by the actual CSE Machine.
// Setting the listening property to false on the Konva Layer does not seem to work, so
// this a workaround.
const canvasElement = CseAnimation.getLayer()?.getCanvas()._canvas;
const canvasElement = CseAnimation.getLayer()?.getNativeCanvasElement();
if (canvasElement) canvasElement.style.pointerEvents = 'none';
// Play all the animations
await Promise.all(this.animations.map(a => a.animate()));
Expand Down
2 changes: 2 additions & 0 deletions src/features/cseMachine/CseMachineConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ export const Config = Object.freeze({
FrameMinWidth: 100,
FramePaddingX: 20,
FramePaddingY: 30,
FrameMinGapX: 80,
FrameMarginX: 30,
FrameMarginY: 10,
FrameCornerRadius: 3,

FnRadius: 15,
FnInnerRadius: 3,
FnTooltipOpacity: 0.3,
FnTooltipTextPadding: 5,

DataMinWidth: 20,
DataUnitWidth: 40,
Expand Down
57 changes: 32 additions & 25 deletions src/features/cseMachine/CseMachineLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import Heap from 'js-slang/dist/cse-machine/heap';
import { Control, Stash } from 'js-slang/dist/cse-machine/interpreter';
import { Chapter, Frame } from 'js-slang/dist/types';
import { KonvaEventObject } from 'konva/lib/Node';
import { Stage } from 'konva/lib/Stage';
import React, { RefObject } from 'react';
import { Layer, Rect, Stage } from 'react-konva';
import { Layer as KonvaLayer, Rect as KonvaRect, Stage as KonvaStage } from 'react-konva';
import classes from 'src/styles/Draggable.module.scss';

import { Binding } from './components/Binding';
Expand Down Expand Up @@ -96,17 +97,19 @@ export class Layout {
static currentStackTruncDark: React.ReactNode;
static currentStackLight: React.ReactNode;
static currentStackTruncLight: React.ReactNode;
static stageRef: RefObject<any> = React.createRef();
static stageRef: RefObject<Stage> = React.createRef();

// buffer for faster rendering of diagram when scrolling
static invisiblePaddingVertical: number = 300;
static invisiblePaddingHorizontal: number = 300;
static scrollContainerRef: RefObject<any> = React.createRef();
static scrollContainerRef: RefObject<HTMLDivElement> = React.createRef();

static updateDimensions(width: number, height: number) {
// update the size of the scroll container and stage given the width and height of the sidebar content.
Layout.visibleWidth = width;
Layout.visibleHeight = height;
Layout._width = Math.max(Layout.visibleWidth, Layout.stageWidth);
Layout._height = Math.max(Layout.visibleHeight, Layout.stageHeight);
if (
Layout.stageRef.current !== null &&
(Math.min(Layout.width(), window.innerWidth) > Layout.stageWidth ||
Expand All @@ -120,16 +123,14 @@ export class Layout {
Layout.stageRef.current.height(Layout.stageHeight);
CseMachine.redraw();
}
if (Layout.stageHeight > Layout.visibleHeight) {
}
Layout.invisiblePaddingVertical =
Layout.stageHeight > Layout.visibleHeight
? (Layout.stageHeight - Layout.visibleHeight) / 2
: 0;
Layout.invisiblePaddingHorizontal =
Layout.stageWidth > Layout.visibleWidth ? (Layout.stageWidth - Layout.visibleWidth) / 2 : 0;

const container: HTMLElement | null = this.scrollContainerRef.current as HTMLDivElement;
const container = this.scrollContainerRef.current;
if (container) {
container.style.width = `${Layout.visibleWidth}px`;
container.style.height = `${Layout.visibleHeight}px`;
Expand Down Expand Up @@ -181,12 +182,13 @@ export class Layout {
// calculate height and width by considering lowest and widest level
const lastLevel = Layout.levels[Layout.levels.length - 1];
Layout._height = Math.max(
Layout.visibleHeight,
Config.CanvasMinHeight,
lastLevel.y() + lastLevel.height() + Config.CanvasPaddingY,
Layout.controlStashHeight ?? 0
);

Layout._width = Math.max(
Layout.visibleWidth,
Config.CanvasMinWidth,
Layout.levels.reduce<number>((maxWidth, level) => Math.max(maxWidth, level.width()), 0) +
Config.CanvasPaddingX * 2 +
Expand Down Expand Up @@ -403,10 +405,10 @@ export class Layout {
* Scrolls diagram to top left, resets the zoom, and saves the diagram as multiple images of width < MaxExportWidth.
*/
static exportImage = () => {
const container: HTMLElement | null = this.scrollContainerRef.current as HTMLDivElement;
container.scrollTo({ left: 0, top: 0 });
const container = this.scrollContainerRef.current;
container?.scrollTo({ left: 0, top: 0 });
Layout.handleScrollPosition(0, 0);
this.stageRef.current.scale({ x: 1, y: 1 });
this.stageRef.current?.scale({ x: 1, y: 1 });
const height = Layout.height();
const width = Layout.width();
const horizontalImages = Math.ceil(width / Config.MaxExportWidth);
Expand All @@ -420,13 +422,14 @@ export class Layout {
const y = Math.floor(n / horizontalImages);
const a = document.createElement('a');
a.style.display = 'none';
a.href = this.stageRef.current.toDataURL({
x: x * Config.MaxExportWidth + Layout.invisiblePaddingHorizontal,
y: y * Config.MaxExportHeight + Layout.invisiblePaddingVertical,
width: Math.min(width - x * Config.MaxExportWidth, Config.MaxExportWidth),
height: Math.min(height - y * Config.MaxExportHeight, Config.MaxExportHeight),
mimeType: 'image/jpeg'
});
a.href =
this.stageRef.current?.toDataURL({
x: x * Config.MaxExportWidth + Layout.invisiblePaddingHorizontal,
y: y * Config.MaxExportHeight + Layout.invisiblePaddingVertical,
width: Math.min(width - x * Config.MaxExportWidth, Config.MaxExportWidth),
height: Math.min(height - y * Config.MaxExportHeight, Config.MaxExportHeight),
mimeType: 'image/jpeg'
}) ?? '';

a.download = `diagram_${x}_${y}.jpg`;
document.body.appendChild(a);
Expand All @@ -448,6 +451,7 @@ export class Layout {
* @param y y position of the scroll container
*/
private static handleScrollPosition(x: number, y: number) {
if (!this.stageRef.current) return;
const dx = x - Layout.invisiblePaddingHorizontal;
const dy = y - Layout.invisiblePaddingVertical;
this.stageRef.current.container().style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
Expand All @@ -466,7 +470,10 @@ export class Layout {
if (Layout.stageRef.current !== null) {
const stage = Layout.stageRef.current;
const oldScale = stage.scaleX();
const { x: pointerX, y: pointerY } = stage.getPointerPosition();
const { x: pointerX, y: pointerY } = stage.getPointerPosition() ?? {
x: Layout.visibleWidth / 2 - stage.x(),
y: Layout.visibleHeight / 2 - stage.y()
};
const mousePointTo = {
x: (pointerX - stage.x()) / oldScale,
y: (pointerY - stage.y()) / oldScale
Expand Down Expand Up @@ -522,16 +529,16 @@ export class Layout {
backgroundColor: defaultBackgroundColor()
}}
>
<Stage
<KonvaStage
width={Layout.stageWidth}
height={Layout.stageHeight}
ref={Layout.stageRef}
draggable
onWheel={Layout.zoomStage}
className={classes['draggable']}
>
<Layer>
<Rect
<KonvaLayer>
<KonvaRect
{...ShapeDefaultProps}
x={0}
y={0}
Expand All @@ -544,11 +551,11 @@ export class Layout {
{Layout.levels.map(level => level.draw())}
{CseMachine.getControlStash() && Layout.controlComponent.draw()}
{CseMachine.getControlStash() && Layout.stashComponent.draw()}
</Layer>
<Layer ref={CseAnimation.layerRef} listening={false}>
</KonvaLayer>
<KonvaLayer ref={CseAnimation.layerRef} listening={false}>
{CseMachine.getControlStash() && CseAnimation.animations.map(c => c.draw())}
</Layer>
</Stage>
</KonvaLayer>
</KonvaStage>
</div>
</div>
</div>
Expand Down
28 changes: 15 additions & 13 deletions src/features/cseMachine/CseMachineUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export function setDifference<T>(set1: Set<T>, set2: Set<T>) {
* order is the first binding or array unit which shares the same environment with `value`.
*
* An exception is for a global function value, in which case the global frame binding is
* always prioritised over array units.
* always prioritised over other bindings or array units.
*/
export function isMainReference(value: Value, reference: ReferenceType) {
if (isGlobalFn(value.data)) {
Expand Down Expand Up @@ -433,8 +433,8 @@ export function getNonEmptyEnv(environment: Env): Env {

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

Expand Down Expand Up @@ -609,16 +609,13 @@ export function getControlItemComponent(
topItem
);
}

switch (controlItem.type) {
case 'Program':
// If the control item is the whole program
// add {} to represent the implicit block
const originalText = astToString(controlItem)
.trim()
.split('\n')
.map(line => `\t\t${line}`)
.join('\n');
const textP = `{\n${originalText}\n}`;
const originalText = astToString(controlItem).trim().replaceAll('\n', '\n\t\t');
const textP = `{\n\t\t${originalText}\n}`;
return new ControlItemComponent(
textP,
textP,
Expand Down Expand Up @@ -857,11 +854,16 @@ export function getStashItemComponent(
return new StashItemComponent(stashItem, stackHeight, index, arrowTo);
}

// Helper function to get environment ID. Accounts for the hidden prelude environment right
// after the global environment. Does not need to be used for frame environments, only for
// environments from the context.
// Helper function to get environment ID.
// Accounts for the hidden prelude environment and empty environments.
export const getEnvId = (environment: Environment): string => {
return environment.name === 'prelude' ? environment.tail!.id : environment.id;
while (
environment.tail &&
(environment.name === 'prelude' || Object.keys(environment.head).length === 0)
) {
environment = environment.tail;
}
return environment.id;
};

// Function that returns whether the stash item will be popped off in the next step
Expand Down
Loading