Part 2 was about DotMorph's conversion engine. This one is about the part people actually see: a two-ring radial menu that blooms around a file, written in QML and driven from Python with PySide6.
Why QML and not widgets
A radial menu is all curves, glows and motion. In QWidgets that means hand-painting every frame with QPainter and running your own animation timers. In QML, an animation is a property with a Behavior, and the scene graph draws everything on the GPU. The Python side (a Bridge QObject) exposes the file, the targets, progress and status, and QML just reacts.
Drawing a ring sector
My first attempt drew triangles instead of ring sectors, and the labels on top were invisible because of a colour-mixing bug. The version that works describes each sector as an SVG path: the outer arc clockwise, a line inward, the inner arc back. QtQuick.Shapes renders it with the curve renderer:
// ring sector as an SVG path: outer arc clockwise, inner arc back function pt(r, deg) { var a = deg * Math.PI / 180 return (s.cx + r * Math.cos(a)).toFixed(2) + " " + (s.cy + r * Math.sin(a)).toFixed(2) } readonly property string d: { var bigO = sweepO > 180 ? 1 : 0, bigI = sweepI > 180 ? 1 : 0 return "M " + pt(oR, a0) + " A " + oR + " " + oR + " 0 " + bigO + " 1 " + pt(oR, a0 + sweepO) + " L " + pt(iR, b0) + " A " + iR + " " + iR + " 0 " + bigI + " 0 " + pt(iR, b0 - sweepI) + " Z" } ShapePath { strokeColor: s.mix(s.theme.sectorEdge, Qt.lighter(s.hot, 1.35), s.lift) fillGradient: RadialGradient { /* sector colour → category colour, by s.lift */ } PathSvg { path: shape.d } } The gaps between sectors are constant in pixels, not degrees (gapPx / radius converted to an angle), so the inner and outer rings line up visually even though their circumferences differ.
One number drives the hover
Each sector has a single animated property, lift: 0 when idle, about 0.55 when its category is open, 1 when hovered. Colour, outward push and outer radius all read from it, so they can never get out of sync:
property real lift: busy ? 0 : (hovered ? 1 : (selected ? 0.55 : 0)) Behavior on lift { NumberAnimation { duration: s.theme.ms(170); easing.type: Easing.OutCubic } } transform: Translate { x: Math.cos(s.midRad) * 7 * s.lift y: Math.sin(s.midRad) * 7 * s.lift } theme.ms() scales every duration, and returns 0 when reduce motion is on. That makes the accessibility setting a single switch instead of a hunt through fifty animations.
The intro uses the same trick: every sector has a reveal property that animates from 0 to 1 with a per-sector delay (order * stagger). Scale, rotation and opacity are all functions of it, which gives you a staggered fly-out with a small spin. Labels counter-rotate (rotation: -s.rotation) so the text stays upright while the sector spins in.
The success burst
When a conversion finishes, a check mark draws itself in the hub and particles fly out. That's QtQuick.Particles with an emitter that does nothing until asked:
ParticleSystem { id: particles; running: wheel.theme.motion > 0 } Emitter { id: burst system: particles x: wheel.cx; y: wheel.cy; width: 1; height: 1 emitRate: 0 lifeSpan: 1100; lifeSpanVariation: 350 size: 14; sizeVariation: 8; endSize: 2 velocity: AngleDirection { angleVariation: 360; magnitude: 300; magnitudeVariation: 160 } acceleration: PointDirection { y: 240 } } burst.burst(110) on success, and the slight downward acceleration makes it fall like confetti instead of expanding like a balloon. Failure gets a cross and a short SequentialAnimation shake instead.
0.1 seconds, thanks to a daemon
Starting Python, importing PySide6 and compiling the QML takes about a second. That's fine for an app and terrible for a right-click menu. So DotMorph keeps a daemon running (started by XDG autostart) with Qt and the wheel already loaded.
The trick is that the client side imports no Qt at all. dotmorph FILE checks for the daemon's socket in $XDG_RUNTIME_DIR and sends one line of JSON:
def request(message: dict, timeout: float = 2.0) -> dict | None: """Send one request; returns the reply, or None when no daemon answers.""" path = socket_path() if not path.exists(): return None try: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: sock.settimeout(timeout) sock.connect(str(path)) sock.sendall((json.dumps(message) + "\n").encode()) with sock.makefile("r", encoding="utf-8") as reader: line = reader.readline() return json.loads(line) if line else None except (OSError, ValueError): return None If nobody answers, it falls back to opening its own window. The wheel appears in about 0.1 s. Two bugs from building this:
- The daemon didn't answer. Its socket server was created before the Qt event loop existed, so connections were accepted into the void.
- Long socket paths. Unix socket paths are limited to 108 bytes. An unusual
$XDG_RUNTIME_DIRblew past that, so there's now a short private fallback path.
The hotkey that made the wheel shake
Super+Shift+M opens the wheel with whatever files you copied. When I tested it, holding the keys made the wheel shake. Key repeat fires about every 30 ms, each repeat sent a new "open", and each "open" restarted the bloom animation. The fix is a debounce in the daemon:
repeat = now - self._last_summon < SUMMON_DEBOUNCE # 0.6 s self._last_summon = now if visible and (repeat or [p for p in paths if p.is_file()] == self._files): return False # already showing these files: leave the wheel alone A drag is not a click
The wheel is a frameless window you can grab anywhere and move. Early on, releasing a drag over a sector started a conversion. Now a press that moves past a small threshold becomes a move, never a click, and the wheel turns see-through while you move it.
The edge dock
Drag the wheel against the left or right edge of the screen and it turns into a slim glowing tab. Hover it, click it, or drag files over it, and a half wheel slides out of the edge, like Android's edge panels. It folds back when the pointer leaves, after a conversion, or on Esc.
The dock is a small state machine (free → docked → peek) in Python that resizes and moves the window. QML switches the wheel into half mode. Some details took more time than the feature itself:
- Mirroring. On the right edge the half wheel is mirrored so the first category is still on top. The hidden half behind the screen edge must never react to clicks.
- Sliding. Dragging the tab up and down moves it along the edge; pulling it away gives you the full wheel back.
- Memory. The side and height are saved, and the daemon brings the tab back at login.
- Progress while folded. A conversion that keeps running fills the tab from the bottom up.
Wayland, honestly
A menu that opens at a precise spot and stays above everything is exactly what Wayland doesn't let a normal client do. DotMorph runs the wheel through XWayland on Wayland sessions:
if os.environ.get("XDG_SESSION_TYPE") == "wayland" and "QT_QPA_PLATFORM" not in os.environ: os.environ["QT_QPA_PLATFORM"] = "xcb" The cost: it opens centred, because Wayland doesn't expose the pointer position, and it's X11 under the hood. A native version using the layer-shell protocol (KDE and wlroots compositors) is on the roadmap.
Right to left
The settings window and the options panel are fully translated to Arabic. For layout, QML does most of the work:
LayoutMirroring.enabled: theme.rtl LayoutMirroring.childrenInherit: true That mirrors anchors and layouts. Sliders and toggles needed manual flipping, and an Arabic font (Noto Kufi Arabic, or another Noto Arabic face) is picked automatically. Format names, commands and engine errors stay English on purpose.
Testing a UI that moves
The test suite loads the real QML offscreen and drives it like a user would: arrow keys and Enter to pick a format, a drag that must not convert, the options panel, the dock modes, hit-testing on both edges. A message handler collects QML warnings, and the tests fail if any show up.
The GIFs in this post come from the same idea. A script opens the real wheel, presses the keys, grabs every frame with QQuickWindow.grabWindow(), and ffmpeg turns the frames into the GIF. One lesson from that: Qt's offscreen platform has no OpenGL, so the Shapes simply weren't drawn. Running under Xvfb with Mesa's software OpenGL fixed it.
In part 4, the last one: shipping all of this to Linux users five different ways from one signed git tag, and what AppImageHub's test taught me about "works on my machine".
- GitHub: kareem2099/DotMorph
- Project page: dotsuite.dev/en/product/dotmorph






