Deep Understanding Google Blockly - Part 2: Rendering System
A comprehensive technical deep-dive into Blockly's SVG rendering pipeline
Table of Contents
- Introduction
- Rendering Architecture
- The Renderer System
- RenderInfo: The Measurement Phase
- Drawer: The Drawing Phase
- PathObject: SVG Path Management
- Constants and Theming
- Multiple Renderers
Introduction
Blockly's rendering system is one of its most sophisticated components. Unlike simple shape libraries, Blockly dynamically generates SVG paths for each block, accounting for:
- Variable block dimensions based on field content
- Connection notches and puzzle tabs for different connection types
- Nested inputs with proper spacing and alignment
- RTL (Right-to-Left) language support
- Multiple visual styles (Geras, Thrasos, Zelos)
- Inline vs stacked layouts
This article explores how Blockly measures and renders blocks with pixel-perfect precision.
Rendering Architecture
The Rendering Pipeline
Every time a block needs to be rendered, it goes through a three-phase pipeline:
┌─────────────────────────────────────────┐
│ 1. MEASURE PHASE │
│ │
│ RenderInfo.measure() │
│ ├─ createRows_() │
│ ├─ addElemSpacing_() │
│ ├─ addRowSpacing_() │
│ ├─ computeBounds_() │
│ ├─ alignRowElements_() │
│ └─ finalize_() │
│ │
│ Output: Complete sizing information │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 2. DRAW PHASE │
│ │
│ Drawer.draw() │
│ ├─ drawOutline_() │
│ │ ├─ drawTop_() │
│ │ ├─ drawValueInput_() │
│ │ ├─ drawStatementInput_() │
│ │ └─ drawBottom_() │
│ ├─ drawInternals_() │
│ │ ├─ Position fields │
│ │ ├─ Position icons │
│ │ └─ Position connections │
│ └─ updateConnectionHighlights() │
│ │
│ Output: SVG path strings │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 3. UPDATE PHASE │
│ │
│ PathObject.setPath() │
│ ├─ Update SVG path element │
│ ├─ Apply RTL flipping if needed │
│ └─ Update block dimensions │
│ │
│ Output: Rendered block on screen │
└─────────────────────────────────────────┘
Trigger Points
Rendering is triggered when:
- Block is created - Initial render via
initSvg() - Field value changes -
field.setValue()→block.render() - Input is added/removed -
block.appendInput()→block.render() - Block is moved - Position update (doesn't re-render shape)
- Connection is made/broken - Triggers render on both blocks
- Block is collapsed/expanded - Complete re-render
Entry Point
From core/block_svg.ts, rendering is initiated via:
export class BlockSvg extends Block {
render(opt_bubble?: boolean) {
if (!this.workspace.rendered) {
return; // Headless workspace
}
// Get the renderer for this workspace
const renderer = this.workspace.getRenderer();
// Render this block
renderer.render(this);
// Optionally render connected blocks
if (opt_bubble !== false) {
const parent = this.getParent();
if (parent) {
parent.render(true);
} else {
this.workspace.resizeContents();
}
}
}
}
The Renderer System
Renderer Base Class
From core/renderers/common/renderer.ts:
export class Renderer implements IRegistrable {
protected constants_!: ConstantProvider;
protected name: string;
protected overrides: object | null = null;
constructor(name: string) {
this.name = name;
}
init(theme: Theme, opt_rendererOverrides?: {[key: string]: any}) {
// Create constants provider
this.constants_ = this.makeConstants_();
// Apply any overrides
if (opt_rendererOverrides) {
this.overrides = opt_rendererOverrides;
Object.assign(this.constants_, opt_rendererOverrides);
}
// Set theme and initialize
this.constants_.setTheme(theme);
this.constants_.init();
}
// Main render method
render(block: BlockSvg) {
// 1. Measure phase
const info = this.makeRenderInfo_(block);
info.measure();
// 2. Draw phase
this.makeDrawer_(block, info).draw();
}
protected makeRenderInfo_(block: BlockSvg): RenderInfo {
return new RenderInfo(this, block);
}
protected makeDrawer_(block: BlockSvg, info: RenderInfo): Drawer {
return new Drawer(block, info);
}
makePathObject(root: SVGElement, style: BlockStyle): IPathObject {
return new PathObject(root, style, this.constants_);
}
getConstants(): ConstantProvider {
return this.constants_;
}
}
Renderer Registration
Blockly uses a registry pattern for renderers:
// From core/renderers/common/block_rendering.ts
export function register(
name: string,
rendererClass: new (name: string) => Renderer,
) {
registry.register(registry.Type.RENDERER, name, rendererClass);
}
// Built-in renderers are registered at startup:
register('geras', Geras); // Default renderer
register('thrasos', Thrasos); // Simplified renderer
register('zelos', Zelos); // Scratch-style renderer
// Initialize a renderer:
export function init(
name: string,
theme: Theme,
opt_rendererOverrides?: {[key: string]: any},
): Renderer {
const rendererClass = registry.getClass(registry.Type.RENDERER, name);
const renderer = new rendererClass!(name);
renderer.init(theme, opt_rendererOverrides);
return renderer;
}
RenderInfo: The Measurement Phase
RenderInfo Class
The RenderInfo class is responsible for measuring all block components. From core/renderers/common/info.ts:
export class RenderInfo {
block_: BlockSvg;
protected constants_: ConstantProvider;
protected readonly renderer_!: Renderer;
// Measurement results
height = 0;
widthWithChildren = 0;
width = 0;
statementEdge = 0;
// Row data
rows: Row[] = [];
inputRows: InputRow[] = [];
topRow: TopRow;
bottomRow: BottomRow;
// Block properties
outputConnection: OutputConnection | null;
isInline: boolean;
isCollapsed: boolean;
RTL: boolean;
constructor(renderer: Renderer, block: BlockSvg) {
this.renderer_ = renderer;
this.block_ = block;
this.constants_ = renderer.getConstants();
// Create output connection measurable if exists
this.outputConnection = block.outputConnection
? new OutputConnection(this.constants_, block.outputConnection)
: null;
// Determine layout
this.isInline = block.getInputsInline() && !block.isCollapsed();
this.isCollapsed = block.isCollapsed();
this.RTL = block.RTL;
// Create top and bottom rows
this.topRow = new TopRow(this.constants_);
this.bottomRow = new BottomRow(this.constants_);
}
measure() {
this.createRows_();
this.addElemSpacing_();
this.addRowSpacing_();
this.computeBounds_();
this.alignRowElements_();
this.finalize_();
}
}
Measurement Phases
Phase 1: Create Rows
protected createRows_() {
this.populateTopRow_();
this.rows.push(this.topRow);
let activeRow = new InputRow(this.constants_);
this.inputRows.push(activeRow);
// Add icons to first row
const icons = this.block_.getIcons();
for (let i = 0; i < icons.length; i++) {
const iconInfo = new Icon(this.constants_, icons[i]);
if (!this.isCollapsed || icons[i].isShownWhenCollapsed()) {
activeRow.elements.push(iconInfo);
}
}
// Process all inputs
let lastInput = undefined;
for (let i = 0; i < this.block_.inputList.length; i++) {
const input = this.block_.inputList[i];
if (!input.isVisible()) continue;
// Start new row if needed
if (this.shouldStartNewRow_(input, lastInput)) {
this.rows.push(activeRow);
activeRow = new InputRow(this.constants_);
this.inputRows.push(activeRow);
}
// Add fields
for (let j = 0; j < input.fieldRow.length; j++) {
const field = input.fieldRow[j];
activeRow.elements.push(
new Field(this.constants_, field, input)
);
}
this.addInput_(input, activeRow);
lastInput = input;
}
// Add final rows
if (activeRow.elements.length || activeRow.hasStatement) {
this.rows.push(activeRow);
}
this.populateBottomRow_();
this.rows.push(this.bottomRow);
}
Phase 2: Add Element Spacing
protected addElemSpacing_() {
for (let i = 0; i < this.rows.length; i++) {
const row = this.rows[i];
const elements = row.elements;
for (let j = 0; j < elements.length; j++) {
const elem = elements[j];
// Add spacing before element
if (j > 0) {
const prevElem = elements[j - 1];
const spacing = this.getSpacerWidth_(prevElem, elem);
if (spacing > 0) {
elements.splice(j, 0, new InRowSpacer(this.constants_, spacing));
j++; // Skip the spacer we just added
}
}
}
}
}
Phase 3: Compute Bounds
protected computeBounds_() {
let widthWithChildren = 0;
let width = 0;
let height = 0;
for (let i = 0; i < this.rows.length; i++) {
const row = this.rows[i];
row.measure();
height += row.height;
widthWithChildren = Math.max(widthWithChildren, row.widthWithConnectedBlocks);
width = Math.max(width, row.width);
}
this.height = height;
this.widthWithChildren = widthWithChildren;
this.width = width;
// Calculate positions
let cursorY = 0;
for (let i = 0; i < this.rows.length; i++) {
const row = this.rows[i];
row.yPos = cursorY;
cursorY += row.height;
}
}
Measurable Objects
Every visual element becomes a "measurable" object:
// Base measurable
export class Measurable {
width = 0;
height = 0;
type: number;
xPos = 0;
centerline = 0;
constructor(constants: ConstantProvider) {
this.constants_ = constants;
}
}
// Field measurable
export class Field extends Measurable {
field: Blockly.Field;
parentInput: Input;
constructor(constants: ConstantProvider, field: Blockly.Field, input: Input) {
super(constants);
this.field = field;
this.parentInput = input;
this.type = Types.FIELD;
// Get field size
const size = field.getSize();
this.width = size.width;
this.height = size.height;
}
}
// Icon measurable
export class Icon extends Measurable {
icon: IIcon;
constructor(constants: ConstantProvider, icon: IIcon) {
super(constants);
this.icon = icon;
this.type = Types.ICON;
this.width = constants.ICON_SIZE;
this.height = constants.ICON_SIZE;
}
}
// Input measurable
export class ExternalValueInput extends Measurable {
connection: RenderedConnection;
shape: PuzzleTab | DynamicShape;
constructor(constants: ConstantProvider, input: ValueInput) {
super(constants);
this.type = Types.EXTERNAL_VALUE_INPUT;
this.connection = input.connection as RenderedConnection;
this.shape = constants.PUZZLE_TAB;
this.width = this.shape.width;
this.height = constants.TAB_HEIGHT;
}
}
Drawer: The Drawing Phase
Drawer Class
The Drawer class converts measurements into SVG paths. From core/renderers/common/drawer.ts:
export class Drawer {
block_: BlockSvg;
info_: RenderInfo;
topLeft_: Coordinate;
outlinePath_ = '';
inlinePath_ = '';
protected constants_: ConstantProvider;
constructor(block: BlockSvg, info: RenderInfo) {
this.block_ = block;
this.info_ = info;
this.topLeft_ = block.getRelativeToSurfaceXY();
this.constants_ = info.getRenderer().getConstants();
}
draw() {
this.drawOutline_();
this.drawInternals_();
this.updateConnectionHighlights();
// Set the path on the block
this.block_.pathObject.setPath(
this.outlinePath_ + '\n' + this.inlinePath_
);
// Handle RTL
if (this.info_.RTL) {
this.block_.pathObject.flipRTL();
}
this.recordSizeOnBlock_();
}
protected recordSizeOnBlock_() {
this.block_.height = this.info_.height;
this.block_.width = this.info_.widthWithChildren;
this.block_.childlessWidth = this.info_.width;
}
}
Drawing the Outline
protected drawOutline_() {
this.drawTop_();
// Draw middle rows
for (let r = 1; r < this.info_.rows.length - 1; r++) {
const row = this.info_.rows[r];
if (row.hasJaggedEdge) {
this.drawJaggedEdge_(row);
} else if (row.hasStatement) {
this.drawStatementInput_(row);
} else if (row.hasExternalInput) {
this.drawValueInput_(row);
} else {
this.drawRightSideRow_(row);
}
}
this.drawBottom_();
this.drawLeft_();
}
Drawing Specific Elements
Top of Block:
protected drawTop_() {
const topRow = this.info_.topRow;
const elements = topRow.elements;
this.positionPreviousConnection_();
this.outlinePath_ += svgPaths.moveBy(topRow.xPos, this.info_.startY);
for (let i = 0; i < elements.length; i++) {
const elem = elements[i];
if (Types.isLeftRoundedCorner(elem)) {
this.outlinePath_ += this.constants_.OUTSIDE_CORNERS.topLeft;
} else if (Types.isRightRoundedCorner(elem)) {
this.outlinePath_ += this.constants_.OUTSIDE_CORNERS.topRight;
} else if (Types.isPreviousConnection(elem)) {
this.outlinePath_ += (elem.shape as Notch).pathLeft;
} else if (Types.isHat(elem)) {
this.outlinePath_ += this.constants_.START_HAT.path;
} else if (Types.isSpacer(elem)) {
this.outlinePath_ += svgPaths.lineOnAxis('h', elem.width);
}
}
this.outlinePath_ += svgPaths.lineOnAxis(
'v',
topRow.height - topRow.ascenderHeight
);
}
Value Input (Puzzle Tab):
protected drawValueInput_(row: Row) {
const input = row.getLastInput() as ExternalValueInput;
this.positionExternalValueConnection_(row);
const pathDown = isDynamicShape(input.shape)
? input.shape.pathDown(input.height)
: (input.shape as PuzzleTab).pathDown;
this.outlinePath_ +=
svgPaths.lineOnAxis('H', input.xPos + input.width) +
pathDown +
svgPaths.lineOnAxis('v', row.height - input.connectionHeight);
}
Statement Input (C-shape):
protected drawStatementInput_(row: Row) {
const input = row.getLastInput();
if (!input) return;
const x = input.xPos + input.notchOffset + (input.shape as Notch).width;
const innerTopLeftCorner =
(input.shape as Notch).pathRight +
svgPaths.lineOnAxis('h', -(input.notchOffset - this.constants_.INSIDE_CORNERS.width)) +
this.constants_.INSIDE_CORNERS.pathTop;
const innerHeight = row.height - 2 * this.constants_.INSIDE_CORNERS.height;
this.outlinePath_ +=
svgPaths.lineOnAxis('H', x) +
innerTopLeftCorner +
svgPaths.lineOnAxis('v', innerHeight) +
this.constants_.INSIDE_CORNERS.pathBottom +
svgPaths.lineOnAxis('H', row.xPos + row.width);
this.positionStatementInputConnection_(row);
}
Drawing Internals
protected drawInternals_() {
// Position all fields
for (let i = 0; i < this.info_.inputRows.length; i++) {
const row = this.info_.inputRows[i];
for (let j = 0; j < row.elements.length; j++) {
const elem = row.elements[j];
if (Types.isField(elem)) {
const fieldElem = elem as Field;
this.positionField_(fieldElem);
} else if (Types.isIcon(elem)) {
const iconElem = elem as Icon;
this.positionIcon_(iconElem);
}
}
}
// Position connections
this.positionOutputConnection_();
this.positionNextConnection_();
}
protected positionField_(fieldInfo: Field) {
const field = fieldInfo.field;
const xPos = fieldInfo.xPos + this.topLeft_.x;
const yPos = fieldInfo.centerline - fieldInfo.height / 2 + this.topLeft_.y;
field.getSvgRoot().setAttribute(
'transform',
'translate(' + xPos + ',' + yPos + ')'
);
}
PathObject: SVG Path Management
PathObject Class
The PathObject manages the actual SVG elements:
export class PathObject implements IPathObject {
svgRoot: SVGGElement;
svgPath: SVGPathElement;
style: BlockStyle;
constants: ConstantProvider;
constructor(root: SVGElement, style: BlockStyle, constants: ConstantProvider) {
this.style = style;
this.constants = constants;
// Create SVG group
this.svgRoot = dom.createSvgElement(Svg.G, {}, root);
// Create path element
this.svgPath = dom.createSvgElement(
Svg.PATH,
{'class': 'blocklyPath'},
this.svgRoot
);
// Apply colors
this.updateColour();
}
setPath(pathString: string) {
this.svgPath.setAttribute('d', pathString);
}
flipRTL() {
// Mirror the block horizontally
const transform = this.svgRoot.getAttribute('transform') || '';
this.svgRoot.setAttribute(
'transform',
transform + ' scale(-1 1)'
);
}
updateColour() {
this.svgPath.setAttribute('fill', this.style.colourPrimary);
this.svgPath.setAttribute('stroke', this.style.colourTertiary);
}
}
Constants and Theming
ConstantProvider
The ConstantProvider defines all visual constants:
export class ConstantProvider {
// Dimensions
NOTCH_WIDTH = 15;
NOTCH_HEIGHT = 4;
CORNER_RADIUS = 8;
TAB_HEIGHT = 20;
TAB_WIDTH = 8;
// Spacing
MIN_BLOCK_WIDTH = 12;
EMPTY_BLOCK_SPACER_HEIGHT = 16;
FIELD_TEXT_BASELINE = 13;
FIELD_BORDER_RECT_RADIUS = 4;
// Shapes (generated during init)
PUZZLE_TAB: PuzzleTab;
NOTCH: Notch;
START_HAT: StartHat;
OUTSIDE_CORNERS: OutsideCorners;
INSIDE_CORNERS: InsideCorners;
init() {
// Generate shapes
this.PUZZLE_TAB = this.makePuzzleTab();
this.NOTCH = this.makeNotch();
this.START_HAT = this.makeStartHat();
this.OUTSIDE_CORNERS = this.makeOutsideCorners();
this.INSIDE_CORNERS = this.makeInsideCorners();
}
setTheme(theme: Theme) {
// Extract colors and styles from theme
this.blockStyles = theme.blockStyles;
}
getBlockStyle(blockStyle: BlockStyle | null): BlockStyle {
return blockStyle || this.defaultBlockStyle;
}
}
Multiple Renderers
Geras Renderer (Default)
Classic Blockly appearance with rounded corners:
export class Geras extends Renderer {
constructor(name: string) {
super(name);
}
protected makeConstants_(): ConstantProvider {
return new GerasConstantProvider();
}
}
Zelos Renderer (Scratch-style)
export class Zelos extends Renderer {
constructor(name: string) {
super(name);
}
protected makeConstants_(): ConstantProvider {
return new ZelosConstantProvider();
}
}
// Zelos has different shapes
class ZelosConstantProvider extends ConstantProvider {
NOTCH_WIDTH = 40; // Wider notches
NOTCH_HEIGHT = 10;
CORNER_RADIUS = 1; // Square corners
// ... different visual constants
}
Selecting a Renderer
const workspace = Blockly.inject('blocklyDiv', {
renderer: 'zelos', // or 'geras', 'thrasos'
theme: Blockly.Themes.Dark,
});
Summary
In Part 2, we've explored:
✅ Rendering Pipeline - Three-phase measure-draw-update process
✅ Renderer System - Pluggable architecture with multiple renderers
✅ RenderInfo - Sophisticated measurement and layout calculation
✅ Drawer - SVG path generation for block shapes
✅ PathObject - SVG element management
✅ Constants - Theming and visual customization
In Part 3, we'll explore how Blockly transforms visual blocks into executable code in multiple programming languages.
This article is based on analysis of Blockly v12.3.1 source code. For the latest information, refer to the official Blockly documentation.