---
title: "Why I Ditched Slint for egui (and Got a Rust Thermostat Dial Running on a Pi 5)"
date: "2026-05-15"
approved: true
publish_date: "2026-05-15"
excerpt: "A session spent fighting Slint's Path rendering — dynamic coordinates producing diagonal lines, a missing viewbox gotcha that was the root cause of everything — before pulling the plug and rebuilding in egui in a fraction of the time."
tags: ["rust", "egui", "slint", "raspberry-pi", "embedded", "gui", "thermostat"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-05-15-ditched-slint-for-egui-thermostat-dial"
repos:
  - name: "rotary-dial"
    url: "https://github.com/nakomis/rotary-dial"
---

# Why I Ditched Slint for egui (and Got a Rust Thermostat Dial Running on a Pi 5)

![A workshop split between an abandoned tangled loom in shadow and a craftsman shaping a glowing round dial in light](./images/ditched-slint-for-egui-thermostat-dial-header.png)

{{image prompt="A glowing circular thermostat dial on a small round screen wired to a Raspberry Pi on a workbench, warm amber arc, dark moody background, cinematic, shallow depth of field" negative-prompt="text, watermark, signature, logos, brand names, distorted geometry, lowres, jpeg artifacts"}}

I'm building a thermostat. Not because I couldn't buy one, but because I have a Raspberry Pi 5 with a 7-inch touchscreen sat on a shelf, a perfectly good heating system that speaks to nothing, and the pathological belief that the correct response to that situation is to write Rust.

The display part — a rotary dial widget, 240° arc, draggable setpoint, current-temperature marker — seemed like it should be the easy bit. I had Slint in mind: it's a proper Rust UI framework with a clean component model, a declarative DSL, and marketing material that made it look like exactly the right tool.

That was before I met Slint's `Path` element.

## What Slint Promises

Slint's pitch is reasonable: write your UI in a declarative `.slint` DSL, get native rendering on desktop and embedded targets, keep your business logic in Rust. The `Path` element lets you draw arbitrary vector shapes using SVG-style commands. A rotary dial is basically two arcs and a couple of dots — this should be three lines.

The component I was building had a static background track (always 150° to 30°, covering the full 240° sweep) and a dynamic setpoint arc (150° to wherever the user has dragged to). That dynamic arc is the interesting part. Its endpoint changes as you drag.

{{image prompt="A perfect smooth glowing arc on the left versus the same arc collapsed into jagged diagonal lines on the right, dark background, conceptual diagram aesthetic" negative-prompt="text, watermark, signature, logos, brand names, distorted geometry, lowres, jpeg artifacts"}}

## Dynamic Coordinates Are Broken

The first wall: `MoveTo` and `ArcTo` child elements in a Slint `Path` accept x/y coordinates as properties. Those coordinates should be bindable — you'd expect to be able to write something like:

```slint
Path {
    MoveTo { x: root.cx + root.radius * cos(150deg); y: root.cy + root.radius * sin(150deg); }
    ArcTo  { x: root.cx + root.radius * cos(root.setpoint-angle * 1deg);
              y: root.cy + root.radius * sin(root.setpoint-angle * 1deg);
              ... }
}
```

What you get instead is diagonal lines. The coordinates are accepted without complaint, the arc renders, and it is completely wrong — not slightly wrong, not a rounding error, but a slash across the widget that bears no geometric relationship to the numbers you put in. The rendering engine is doing something, it's just not computing an arc.

After enough attempts to convince myself I was misunderstanding the coordinate system, I couldn't get dynamic values in `MoveTo`/`ArcTo` child elements to work correctly in any configuration I tried. I can't say with certainty whether this is a bug, a documented limitation I missed, or something subtler — but after a full session of attempts, I gave up on child elements entirely. The only Slint `Path` property I found reliably safe to drive with a dynamic binding was `visible`.

![A brass thermostat dial glowing on a workshop wall, its needle sweeping a clean arc](./images/ditched-slint-for-egui-thermostat-dial-1.png)

## The viewbox-width / viewbox-height Gotcha

The `commands` property on `Path` takes an SVG path data string, which sidesteps child elements entirely. You construct the string yourself:

```slint
Path {
    commands: "M " + (root.cx / 1px + root.radius / 1px * cos(150deg)) + " "
              + (root.cy / 1px + root.radius / 1px * sin(150deg))
              + " A " + (root.radius / 1px) + " " + (root.radius / 1px)
              + " 0 " + (root.setpoint-angle - 180.0 > 0 ? "1" : "0")
              + " 1 " + ...;
}
```

This compiles. It also produces wrong geometry. The arc renders in the wrong place, the wrong size, clipped oddly at the edges. I spent a while convinced I had the SVG arc parameters wrong (the large-arc-flag and sweep-flag are famously confusing). The maths was fine. The problem was the coordinate system itself.

The fix — which I did not find in any Slint documentation I read, and which I eventually discovered by trial and error — is `viewbox-width` and `viewbox-height`. Without them, Slint's `Path` element has no idea how to map your coordinate values to the element's actual pixel dimensions. The coordinate system is undefined, so everything lands in the wrong place.

```slint
Path {
    viewbox-width: root.width / 1px;
    viewbox-height: root.height / 1px;
    commands: "M " + ...;
}
```

Adding those two properties to every `Path` element is the root cause fix. It's not mentioned alongside the `commands` property in the docs — it's documented for `Path` generally, but its absence doesn't produce an error or a warning. It just silently produces wrong output.

With `viewbox-width` and `viewbox-height` set, the static background track rendered correctly. The dynamic setpoint arc still wouldn't align properly no matter what I tried. After several more approaches — commands strings with explicit viewbox, splitting into two half-arcs to avoid the large-arc-flag ambiguity, and a few variations besides — I had a correct static arc and a dynamic one I couldn't make reliably track. I committed the code as it stood and started looking for the exit.

## What Are Our Options for Ditching Slint

The candidate list for Rust GUI on Linux/ARM was short:

- **egui** — immediate-mode, pure Rust, `eframe` handles the windowing. Arc drawing is a polyline tessellation: generate N points, hand them to a `PathShape`. No coordinate system ambiguity, no DSL, no property binding gotchas.
- **iced** — retained-mode, Elm-style, genuinely nice architecture. More complex to get custom drawing working cleanly.
- **GTK4 via gtk-rs** — works everywhere, C library underneath, more system dependencies than I wanted on a Pi.

I'd used egui in passing before. The `Painter` API is direct: ask for a painter, call `painter.add(Shape::Path(...))`, done. No intermediate representation, no framework to argue with.

The decision took about thirty seconds.

## Rebuilding in egui

The library crate — `rotary-dial` — is a single `lib.rs`. The widget is a struct:

```rust
pub struct RotaryDial {
    pub current_temperature: f32,
    pub setpoint: f32,
    pub min_temperature: f32,
    pub max_temperature: f32,
}
```

`show(&mut self, ui: &mut Ui) -> f32` draws the widget and returns the (possibly updated) setpoint. Everything is immediate-mode: the function is called on every frame, it draws, it handles input, it returns.

Arc drawing is a polyline. Generate 64 points along the arc, hand them to `PathShape`:

```rust
fn arc_points(cx: f32, cy: f32, radius: f32, start_deg: f32, end_deg: f32, segments: usize) -> Vec<Pos2> {
    (0..=segments)
        .map(|i| {
            let t = i as f32 / segments as f32;
            let angle = (start_deg + t * (end_deg - start_deg)).to_radians();
            Pos2::new(cx + radius * angle.cos(), cy + radius * angle.sin())
        })
        .collect()
}
```

The background track is `arc_points(cx, cy, radius, 150.0, 390.0, 64)` — 390° being 30° expressed without a negative value, so the arc sweeps clockwise through the gap at the bottom. The setpoint arc is `arc_points(cx, cy, radius, 150.0, setpoint_angle, 64)` where `setpoint_angle` is computed from the current setpoint temperature.

Both arcs go into `painter.add(Shape::Path(PathShape { ... }))`. No viewbox. No coordinate system negotiation. The numbers are pixel coordinates in the widget's allocated rect. They go where you put them.

The drag interaction maps pointer position to angle, normalises into the arc's range, and snaps to 0.5° increments:

```rust
let dx = pos.x - cx;
let dy = pos.y - cy;
let angle_deg = dy.atan2(dx).to_degrees();
let norm = if angle_deg < 0.0 { angle_deg + 360.0 } else { angle_deg };
let shifted = norm - 150.0;
let adj = if shifted < -60.0 { shifted + 360.0 } else { shifted };
let clamped = adj.clamp(0.0, 240.0);
let raw = self.min_temperature + clamped / 240.0 * (self.max_temperature - self.min_temperature);
self.setpoint = (raw * 2.0).round() / 2.0;
```

The `-60.0` threshold handles the gap at the bottom of the arc: if the shifted angle is negative by more than 60°, it's in the gap region, and adding 360° puts it back in range. Clamping to `[0, 240]` prevents dragging into the gap from producing a valid temperature.

The whole widget — arcs, dots, text, drag handling — is about 120 lines. It took less time to write than a single one of the Slint debugging iterations.

## What I Actually Learned

**Slint's `Path` element requires `viewbox-width` and `viewbox-height`.** Without them, your coordinate values are unmapped and the output is wrong. This is not prominently documented and there's no compile-time warning. If you're using Slint and your paths look wrong, add `viewbox-width: root.width / 1px; viewbox-height: root.height / 1px;` to every `Path` element first.

**Dynamic coordinates in `MoveTo`/`ArcTo` child elements may not work as expected.** The `commands` string property sidesteps child elements entirely, but only produces correct output once you've also fixed the viewbox. Even then, getting a dynamic arc to track correctly was something I couldn't make work reliably. If you need a path that changes shape at runtime, `commands` with string interpolation is your only real option, and it's fiddly. I'm framing this as "what I couldn't get working" rather than a definitive framework verdict — your mileage may vary, but if you hit the same wall, this is why.

**egui's `Painter` API is just arithmetic.** You compute pixel coordinates, pass them in, they render there. The indirection is zero. For a custom widget that's mostly geometry, this matters: there's no framework to reason about, no binding system to debug, no coordinate space to negotiate.

**`cargo zigbuild` is the right answer for Mac→Linux cross-compilation with asdf.** It uses Zig as the linker backend, so you don't need a separate GCC cross-toolchain installed. `cross` would work too but adds Docker dependency and asdf version alignment friction. zigbuild sidesteps both.

**`libgl1-mesa-glx` is dead in Bookworm.** Use `libgl1`.

## Cross-Compiling from Mac to Pi

The workspace has two binary crates: `test-app` (desktop development, Mac) and `rpi-test-app` (Pi target, full-screen, no decorations). Building for both from the Mac is `cargo zigbuild`:

```bash
# Pi binary
cargo zigbuild --release --target aarch64-unknown-linux-gnu --bin rpi-test-app

# Mac binary (for local testing)
cargo zigbuild --release --target aarch64-apple-darwin --bin test-app
```

`cargo zigbuild` uses Zig as the linker backend, which means you don't need a GCC cross-compiler installed for each target — Zig handles the linking itself. The alternative — `cross` — runs a Docker container per target. `cross` has friction with `asdf`-managed Rust because the Rust version in the container doesn't inherit from `.tool-versions`, and getting the two to agree is more effort than it's worth.

The Pi binary targets `aarch64-unknown-linux-gnu`. The glibc version on Raspberry Pi OS Bookworm is new enough that the default target works. The build script also has a commented-out line for `aarch64-unknown-linux-gnu.2.36` — that's the target with an explicit glibc minimum, useful if you need to ensure the binary runs on older systems. Bookworm doesn't need it, so it stays commented.

## Getting It Running on the Pi

The RC070S is an Elecrow 7-inch 1024×600 IPS panel connected via HDMI and USB. The Pi runs Raspberry Pi OS Bookworm headless — no desktop environment. The binary needs a Wayland compositor to talk to.

`cage` is a single-application Wayland compositor: it starts, shows your application full-screen, and exits when the application exits. Install it once on the Pi:

```bash
sudo apt install -y cage libgl1 libfontconfig1 libxkbcommon0
```

Running it directly from an SSH session requires `XDG_RUNTIME_DIR` to be set:

```bash
XDG_RUNTIME_DIR=/run/user/$(id -u) cage ./rpi-test-app
```

The `libgl1-mesa-glx` package that older guides mention doesn't exist in Bookworm — it was renamed to `libgl1`. That's twenty minutes you won't get back if you follow old documentation.

The `rpi-test-app` binary itself is configured for the RC070S dimensions: 1024×600, no window decorations, not resizable. On the Pi it fills the screen exactly; on a Mac you get a fixed-size window that's comically large on a modern display but useful enough for checking that the layout doesn't look terrible.

```rust
let options = eframe::NativeOptions {
    viewport: egui::ViewportBuilder::default()
        .with_inner_size([1024.0, 600.0])
        .with_decorations(false)
        .with_resizable(false)
        .with_title("Thermostat"),
    ..Default::default()
};
```

The rotary dial itself — the thing I set out to build — is on GitHub at [github.com/nakomis/rotary-dial](https://github.com/nakomis/rotary-dial). It's a reusable egui widget crate with a test app for desktop and a Pi-specific full-screen app. MQTT, sensors, and talking to the boiler are not in scope for the widget; those get wired at the application layer.

---

*Martin Harris writes about connecting real hardware to abstract cloud infrastructure. If you've ever soldered something to an API Gateway, you're in the right place.*

{{donate}}