Skip to content

Commit

Permalink
Simulator embedding (very incomplete, behind flag) (#816)
Browse files Browse the repository at this point in the history
The simulator is deployed separately from https://github.com/microbit-foundation/micropython-microbit-v2-simulator and included via an iframe.

It is not ready for use and is being merged behind a flag to enable iterative development of the UI and simulator features.
  • Loading branch information
microbit-matt-hillsdon authored Jul 26, 2022
1 parent 8bd6039 commit 90b7f07
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 10 deletions.
7 changes: 6 additions & 1 deletion src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export type Flag =
* Added to support user-testing and has the nice side-effect of disabling
* the dialog for local development so is worth keeping for that use alone.
*/
| "noWelcome";
| "noWelcome"
/**
* Simulator. Implementation is very incomplete.
*/
| "simulator";

interface FlagMetadata {
defaultOnStages: string[];
Expand All @@ -52,6 +56,7 @@ const allFlags: FlagMetadata[] = [
{ name: "dndDebug", defaultOnStages: [] },
{ name: "livePreview", defaultOnStages: ["local", "REVIEW"] },
{ name: "noWelcome", defaultOnStages: ["local", "REVIEW"] },
{ name: "simulator", defaultOnStages: [] },
];

type Flags = Record<Flag, boolean>;
Expand Down
70 changes: 70 additions & 0 deletions src/simulator/RangeSensor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Icon } from "@chakra-ui/icons";
import {
HStack,
Slider,
SliderFilledTrack,
SliderMark,
SliderThumb,
SliderTrack,
} from "@chakra-ui/react";
import { useCallback } from "react";
import { RiQuestionFill } from "react-icons/ri";
import { Sensor, sensorIcons } from "./model";

interface RangeSensorProps {
value: Sensor;
onSensorChange: (id: string, value: number) => void;
}

const RangeSensor = ({
value: { id, min, max, value, unit },
onSensorChange,
}: RangeSensorProps) => {
const handleChange = useCallback(
(value: number) => {
onSensorChange(id, value);
},
[onSensorChange, id]
);
const valueText = unit ? `${value} ${unit}` : value.toString();
return (
<HStack pt={5}>
<Icon
as={sensorIcons[id] || RiQuestionFill}
aria-label={id}
color="blimpTeal.400"
boxSize="6"
/>
<Slider
aria-label={id}
value={value}
min={min}
max={max}
onChange={handleChange}
my={5}
>
<SliderTrack>
<SliderFilledTrack bgColor="blimpTeal.600" />
</SliderTrack>
<SliderThumb />
<SliderMark value={min} mt="1" fontSize="xs">
{min}
</SliderMark>
<SliderMark value={max} mt="1" ml="-3ch" fontSize="xs">
{max}
</SliderMark>
<SliderMark
value={value}
textAlign="center"
mt="-8"
ml={-valueText.length / 2 + "ch"}
fontSize="xs"
>
{valueText}
</SliderMark>
</Slider>
</HStack>
);
};

export default RangeSensor;
31 changes: 31 additions & 0 deletions src/simulator/Sensors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { BoxProps, Stack } from "@chakra-ui/react";
import { Sensor } from "./model";
import RangeSensor from "./RangeSensor";

interface SensorsProps extends BoxProps {
value: Sensor[];
onSensorChange: (id: string, value: number) => void;
}

const Sensors = ({ value, onSensorChange, ...props }: SensorsProps) => {
return (
<Stack {...props} height="100%" width="100%" p={5} spacing={3}>
{value.map((sensor) => {
switch (sensor.type) {
case "range":
return (
<RangeSensor
key={sensor.id}
value={sensor}
onSensorChange={onSensorChange}
/>
);
default:
return null;
}
})}
</Stack>
);
};

export default Sensors;
48 changes: 48 additions & 0 deletions src/simulator/Simulator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { AspectRatio, Box, HStack, IconButton, VStack } from "@chakra-ui/react";
import { useRef } from "react";
import { RiPlayFill, RiStopFill } from "react-icons/ri";
import Sensors from "./Sensors";
import { useSimulator } from "./simulator-hooks";

const Simulator = () => {
const ref = useRef<HTMLIFrameElement>(null);
const { play, stop, sensors, onSensorChange } = useSimulator(ref);
return (
<VStack spacing={5}>
<Box width="100%">
<AspectRatio ratio={191.27 / 155.77} width="100%">
<Box
ref={ref}
as="iframe"
// This needs changing before we remove the flag.
src="https://stage-python-simulator.microbit.org/simulator.html"
title="Simulator"
frameBorder="no"
scrolling="no"
/>
</AspectRatio>
<HStack justifyContent="center">
<IconButton
variant="solid"
onClick={play}
icon={<RiPlayFill />}
aria-label="Run"
/>
<IconButton
variant="outline"
onClick={stop}
icon={<RiStopFill />}
aria-label="Stop"
/>
</HStack>
</Box>
<Sensors
value={sensors}
flex="1 1 auto"
onSensorChange={onSensorChange}
/>
</VStack>
);
};

export default Simulator;
16 changes: 16 additions & 0 deletions src/simulator/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { IconType } from "react-icons";
import { RiSunFill, RiTempHotFill } from "react-icons/ri";

export const sensorIcons: Record<string, IconType> = {
temperature: RiTempHotFill,
lightLevel: RiSunFill,
};

export interface Sensor {
type: "range";
id: string;
min: number;
max: number;
value: number;
unit?: string;
}
99 changes: 99 additions & 0 deletions src/simulator/simulator-hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFileSystem } from "../fs/fs-hooks";
import { Sensor } from "./model";

export const useSimulator = (ref: React.RefObject<HTMLIFrameElement>) => {
const fs = useFileSystem();
const [sensors, setSensors] = useState<Record<string, Sensor>>({});
const readyCallbacks = useRef([] as Array<() => void>);
useEffect(() => {
if (ref.current) {
const messageListener = (e: MessageEvent) => {
const simulator = ref.current!.contentWindow;
if (e.source === simulator) {
switch (e.data.kind) {
case "ready": {
setSensors(
Object.fromEntries(
e.data.sensors.map((json: Sensor) => {
return [json.id, json];
})
)
);
while (readyCallbacks.current.length) {
readyCallbacks.current.pop()!();
}
break;
}
case "serial_output": {
// TODO: serial
break;
}
}
}
};
window.addEventListener("message", messageListener);
return () => {
window.removeEventListener("message", messageListener);
};
}
}, [ref, readyCallbacks]);

const onSensorChange = useCallback(
(id: string, value: number) => {
setSensors((sensors) => ({
...sensors,
[id]: { ...sensors[id], value },
}));
const simulator = ref.current!.contentWindow!;
simulator.postMessage(
{
kind: "sensor_set",
sensor: id,
value,
},
"*"
);
},
[ref, setSensors]
);

const play = useCallback(async () => {
const filesystem: Record<string, Uint8Array> = Object.fromEntries(
await Promise.all(
fs.project.files.map(async (f) => [
f.name,
(await fs.read(f.name)).data,
])
)
);
const simulator = ref.current!.contentWindow!;
simulator.postMessage(
{
kind: "flash",
filesystem,
},
"*"
);
}, [ref, fs]);

const stop = useCallback(async () => {
const simulator = ref.current!.contentWindow!;
simulator.postMessage(
{
kind: "serial_input",
// Ctrl-C to interrupt.
// A specific message would be useful as probably best to clear display etc. here.
data: `\x03`,
},
"*"
);
}, [ref]);

return {
play,
stop,
sensors: Object.values(sensors),
onSensorChange,
};
};
38 changes: 29 additions & 9 deletions src/workbench/Workbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import { SizedMode } from "../common/SplitView/SplitView";
import { ConnectionStatus } from "../device/device";
import { useConnectionStatus } from "../device/device-hooks";
import EditorArea from "../editor/EditorArea";
import { flags } from "../flags";
import { MAIN_FILE } from "../fs/fs";
import { useProject } from "../project/project-hooks";
import ProjectActionBar from "../project/ProjectActionBar";
import SerialArea from "../serial/SerialArea";
import Simulator from "../simulator/Simulator";
import Overlay from "./connect-dialogs/Overlay";
import SideBar from "./SideBar";
import { useSelection } from "./use-selection";
Expand Down Expand Up @@ -57,6 +59,18 @@ const Workbench = () => {
useState<SizedMode>("compact");
const serialSizedMode = connected ? serialStateWhenOpen : "collapsed";
const [sidebarShown, setSidebarShown] = useState<boolean>(true);
const editor = (
<Box height="100%" as="section">
{selection && fileVersion !== undefined && (
<EditorArea
key={selection.file + "/" + fileVersion}
selection={selection}
onSelectedFileChanged={setSelectedFile}
/>
)}
</Box>
);

return (
<>
<Flex className="Workbench">
Expand Down Expand Up @@ -99,15 +113,21 @@ const Workbench = () => {
mode={serialSizedMode}
>
<SplitViewRemainder>
<Box height="100%" as="section">
{selection && fileVersion !== undefined && (
<EditorArea
key={selection.file + "/" + fileVersion}
selection={selection}
onSelectedFileChanged={setSelectedFile}
/>
)}
</Box>
{flags.simulator ? (
<SplitView
direction="row"
minimums={[300, 300]}
height="100%"
>
<SplitViewRemainder>{editor}</SplitViewRemainder>
<SplitViewDivider />
<SplitViewSized>
<Simulator />
</SplitViewSized>
</SplitView>
) : (
editor
)}
</SplitViewRemainder>
<SplitViewDivider />
<SplitViewSized>
Expand Down

0 comments on commit 90b7f07

Please sign in to comment.