Deep Understanding Google Blockly - Part 2: Rendering System

A comprehensive technical deep-dive into Blockly's SVG rendering pipeline


Table of Contents

  1. Introduction
  2. Rendering Architecture
  3. The Renderer System
  4. RenderInfo: The Measurement Phase
  5. Drawer: The Drawing Phase
  6. PathObject: SVG Path Management
  7. Constants and Theming
  8. 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:

  1. Block is created - Initial render via initSvg()
  2. Field value changes - field.setValue() → block.render()
  3. Input is added/removed - block.appendInput() → block.render()
  4. Block is moved - Position update (doesn't re-render shape)
  5. Connection is made/broken - Triggers render on both blocks
  6. 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.

Subscribe to Root Logic

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe