How would you build block-based programming visuals in Typst/CeTZ?

Hi everyone,

I’m experimenting with recreating a block-based programming style (like Scratch or Blockly) to visualize algorithms directly in Typst using CeTZ.

So far, I’ve managed to get a pretty good result by merging multiple paths. The tricky part was the small “notch” at the top of each block — that puzzle-piece connector. Without CeTZ, I couldn’t think of a clean way to draw it. Using grids alone works fine for simple layouts, but rounded corners and custom shapes get messy quickly, so I ended up drawing everything in CeTZ.

Before I go further, I’d love your thoughts on two things:

  1. Do you think this is a reasonable approach, or is there a smarter way to do it in Typst/CeTZ?
  2. How could I make the block’s height adjust automatically when more inner blocks are added?

CeTZ doesn’t play nicely with context or measure, since those return content instead of arrays, so I haven’t found a clean way to calculate the height dynamically.

I’ve attached:

#import "@preview/cetz:0.4.2": *

#let stroke-width = 1pt
#let darken-amount = 25%
#let lighten-amount = 33%

#canvas(length: 1cm, {
  import draw: *
  let corner-radius = 3mm
  let arm-width = 0.75

  // Helper-Funktionen
  let x(dist) = line((), (rel: (dist, 0)))
  let y(dist) = line((), (rel: (0, dist)))

  let corner(from, to) = {
    let angles = (
      l: 180deg, // left
      r: 0deg, // right
      t: 90deg, // top
      b: 270deg, // bottom
    )
    arc((), anchor: "start", start: angles.at(from), stop: angles.at(to), radius: corner-radius)
  }

  let nase = {
    line((), (rel: (0.25, -0.15)))
    line((), (rel: (0.1, 0)))
    line((), (rel: (0.25, 0.15)))
  }

  let hauptprogramm(height: 5, body) = {
    merge-path(
      fill: rgb("#FF8001").darken(darken-amount),
      stroke: none,
      {
        corner("t", "l")
        y(-height)
        corner("l", "b")
        x(5)
        y(0.5)
        x(-5 + arm-width)
        corner("b", "l")
        y(height - 1.5)
        corner("l", "t")
        x(0.5)
        nase
        x(3.15)
        y(1)
      },
      close: true,
    )
    translate((-1pt, 1pt))
    merge-path(
      fill: rgb("#FF8001"),
      stroke: none,
      {
        content((0, 0), [])
        corner("t", "l")
        y(-height)
        corner("l", "b")
        x(5)
        y(0.5)
        line((), (rel: (-5 + arm-width, 0)), name: "bottom-arm-top")
        corner("b", "l")
        y(height - 1.5)
        corner("l", "t")
        x(0.5)
        nase
        x(3.15)
        y(1)
      },
      close: true,
    )
    translate((0pt, 0.2pt))
    merge-path(
      stroke: (paint: rgb("#FF8001").lighten(lighten-amount), thickness: stroke-width),
      {
        line((5, 0), (rel: (-5cm + 0.5pt, 0)))
        corner("t", "l")
        y(-height)
        arc((), anchor: "start", start: 180deg, stop: 270deg - 45deg, radius: corner-radius)
      },
    )
    merge-path(
      stroke: (paint: rgb("#FF8001").lighten(lighten-amount), thickness: stroke-width),
      {
        line((rel: (0, 0), to: "bottom-arm-top.start"), (rel: (0, 0), to: "bottom-arm-top.end"))
        arc((), anchor: "start", start: 270deg, stop: 270deg - 45deg, radius: corner-radius)
      },
    )
    content((3, -0.5), text(font: "Nimbus Sans", white, 15pt, [Hauptprogramm]))
  }
  hauptprogramm(height: 2, box(height: 3cm, width: 4cm, fill: green, radius: 3mm))
})

In LaTeX, there’s actually a dedicated package for Scratch 3 blocks (scratch3 on CTAN), which inspired me to try building something similar in Typst.

Really curious how you’d approach this!

Thanks,
Alex

I also want to make a complex and composable diagram layout. The first hurdle is putting labels nicely, so text is not overlapped with anything. Since I use Fletcher, I can’t really use all of the CeTZ tricks that might help, until the new version is released. So I only have one viable option, is to measure everything prior, and then compose/generate a diagram by taking advantage of measured labels and stuff.

So at the very least, I think having an AST with critical info for laying things out is necessary. That is, unless you can smartly juggle with relative positioning/sizing in CeTZ.

Since Typst don’t have a canvas, it’s much more messy to pull it off with all the places and stuff. It also lacks all the advanced coordinate features, so native building blocks are just harder to work with. At least until you develop some sort of framework/basis, after which all blocks are more or less similar in behavior etc.

Funnily enough, I had the idea to create Scratch-like blocks as well, but never got to realising any part of it.

To answer the question posed in the title, I would treat them as regular Typst block elements. Thinking about it for about an hour now, I could only up with the following approach:

#set curve.line(relative: true)

#let program(title: "⚙ Program", child: none) = {
	set text(11pt, white, font: "Fira Sans")
	title = title + sym.space.nobreak
	rect(
		fill: orange,
		radius: 1pt,
		stroke: orange.darken(10%),
		inset: (right: 0pt),
		outset: (right: 1pt),
		context {
			let title-width = measure(title).width
			title
			v(-0.75em)
			if child == none {
				rect(
					fill: white,
					radius: 1pt,
					stroke: (right: none, rest: orange.darken(10%)),
					height: 1.5em,
					width: title-width,
					outset: (right: 2pt),
					curve(
						fill: white,
						stroke: (paint: orange.darken(10%), join: "round", cap: "round"),
						curve.move((0.7em, 0.99em)),
						curve.line((0.3em, 0.3em)),
						curve.line((0.3em, -0.3em)),
					)
				)
			} else {
				child
			}
		}
	)
}

#let loop(title: "⟳ Loop", child: none) = {
	set text(11pt, white, font: "Fira Sans")
	title = title + sym.space.nobreak
	rect(
		fill: orange,
		radius: 1pt,
		stroke: orange.darken(10%),
		inset: (right: 0pt),
		outset: (right: 1pt),
		context {
			let title-width = measure("⚙ Program").width
			place(
				curve(
					fill: orange,
					stroke: (paint: orange.darken(10%), join: "round", cap: "round"),
					curve.move((1.1em, -0.51em)),
					curve.line((0.3em, 0.3em)),
					curve.line((0.3em, -0.3em)),
				)
			)
			title
			v(-0.75em)
			if child == none {
				rect(
					fill: white,
					radius: 1pt,
					stroke: (right: none, rest: orange.darken(10%)),
					height: 1.5em,
					width: title-width,
					outset: (right: 2pt),
					curve(
						fill: white,
						stroke: (paint: orange.darken(10%), join: "round", cap: "round"),
						curve.move((0.7em, 0.99em)),
						curve.line((0.3em, 0.3em)),
						curve.line((0.3em, -0.3em)),
					)
				)
			} else {
				child
			}
		}
	)
}

#program(child: program(child: program()))
#program(child: loop())

Many portions of the code repeat, so it could easily be shortened when more blocks are added. The most apparent issues here are the slightly misaligned notches and somewhat hardcoded block widths, specifically the inner white block’s. On top of that, I haven’t added any of those loop count fields, for which I’m guessing similar problems would arise.

For some reason I got the feeling that custom types would be of great help in making the notch placement more flexible, as you could conditionally use something like child.with() in a given block function if that makes sense.

1 Like

Hi everyone,

Thanks a lot for all the input – it really helped!

I ended up spending the whole day working with Typst’s path and curve functions instead of CeTZ, and that turned out to be the right decision. Using measure made it much easier to calculate the width and height of the content dynamically, so the blocks can now grow automatically depending on their inner elements.

Right now, I have a 1,538-line file that includes almost all Scratch-style blocks. They’re not pixel-perfect yet (especially some insets and indents), but overall I’m really happy with how it looks.

I’ve linked a Typst project and added a few screenshots so you can take a look and try it out yourself.

#ereignis[Wenn Flagge angeklickt][
  #setze-variable-auf(name: "Punkte", wert: 0)
  #verstecke-variable(name: "Punkte")
  #sage(text: "Quiz startet!", sekunden: 2)
  #frage(text: "Was ist 7 × 8?")
  #falls(
    gleich(antwort(), 56),
    dann-body: block[
      #ändere-variable-um(name: "Punkte", wert: 1)
      #sage(text: "Richtig!", sekunden: 2)
    ],
    sonst-body: sage(text: "Falsch! Es war 56.", sekunden: 2),
  )
  #frage(text: "Hauptstadt von Frankreich?")
  #falls(
    gleich(antwort(), "Paris"),
    dann-body: block[
      #ändere-variable-um(name: "Punkte", wert: 1)
      #sage(text: "Sehr gut!", sekunden: 2)
    ],
    sonst-body: sage(text: "Falsch! Es ist Paris.", sekunden: 2),
  )
  #sage(text: "Quiz beendet! Punkte anzeigen")
  #zeige-variable(name: "Punkte")
]

https://typst.app/project/R9Cs72LSOMsOe1rvz1OFCt

What I’d love to get feedback on now is how to best localize it – currently, all blocks are in German, and I’d prefer not to maintain separate 1,300+ line files for each language.

Thanks again for all your help — really excited to hear your thoughts!

Alex

8 Likes

That’s incredibly awesome! I imagine in the end you went with Scratch blocks specifically, because they fit together without leaving white spaces unlike the ones in Blockly? I haven’t taken the time to fully explore this yet, so before diving into it, I’m curious:

  • how the blocks behave without any children, not even any of the condition pills, did you solve this with white placeholder shapes, something like I did?
  • what’s the mechanism behind notch overlapping the child, or if it’s part of the child, how does it recognise the parent’s colour? Or is it just completely different shape for when it includes a child and when it doesn’t? Edit: nevermind, I think I see now that you indeed used only curve which is then properly aligned instead of curve attached to a block element.

I noticed also some of the blocks get quite tall, is that to be expected?

If I get the time, if you’re open to this I might also help out on improving the blocks by:

  • exactly matching the ones from Scratch like you mentioned. I see you already found the correct font. Optionally the exact loop icon could be used instead of the symbol.

  • improving the spacing on larger blocks by using 0.14.0’s character-level justification.

  • converting to English-first, then add translations on top of that if you’re comfortable. There has to be a Scratch translation file available somewhere, which would add to why Scratch blocks might be preferred over Blockly in a case like this. I found:

1 Like

For localization, I use a json dictionary in my package bookly. I call the required dictionary depending on the language. Otherwise, you can use linguify.

1 Like

That’s exactly it! I went with the Scratch blocks mainly because their shape was a bit simpler to draw. Blockly has those two-tone strokes and subtle gradients around the edges, which makes the outlines a lot more complex. I’ll definitely add Blockly-style blocks later though — I actually need both for my teaching materials — but starting with Scratch made the first version a lot more manageable.

About the blocks without children: in my setup, every block technically does have children. For example, the standard statement blocks have a minimum height, and even if no parameters are passed, I automatically insert an empty “pill” (like in Scratch itself) with the same default values. So there are no special placeholder shapes — just the same elements that would appear in Scratch, rendered empty.

The notch is part of the parent block’s path. I’m not using rect at all, but draw the full shape manually using curve — including the notches and rounded corners. That way, the whole parent block is one continuous path with the correct outline.

And yes, the blocks can get quite tall — but that’s actually consistent with how Scratch behaves. When you start nesting multiple conditions or loops, the height grows quickly, especially when you put conditional blocks inside each other. It looks chaotic, but that’s kind of the charm of it!

I’d absolutely be open to collaboration — I’m planning to upload the current version to GitHub soon, so you (and anyone else interested) could jump in. Starting with an English version and then building localization support on top sounds like a great idea.

I haven’t yet figured out the best approach for localization though, because the tricky part is that word order changes across languages. Each block is a mix of text and placeholders, and in German, for example, a sentence might be text → placeholder → text, while in English it could just be text → placeholder. So I’ll probably need some kind of template mechanism that handles those placeholders dynamically, especially for things like “set x to 12,” where the values also appear inside the structure. That’s going to be an interesting challenge!

Once it’s a bit more polished, I definitely want to publish it on Typst Universe — but for now, GitHub feels like the perfect place to collaborate and refine things together.

There you can find the GitHub repo:

2 Likes