Monomer — the seam as a value: 45 fields, no optional ones
Category: record-of-functions seam. Last reviewed: August 23, 2026. Pinned at 5c623936 (monomer 1.6.0.1, monomer.cabal).
A retained desktop widget toolkit in Haskell whose renderer seam is neither a type class nor an interface but a plain data record whose fields are functions — so a backend is a value that can be constructed, passed, stored in a sum type, or swapped at runtime. It is the dictionary-passing encoding of the same contract Slint states as a trait and sparkles:ui states as the Design-by-Introspection concept isCanvas!T, and reading all three together separates what the contract says from how it is spelled.
| Field | Value |
|---|---|
| Language | Haskell (GHC), C shim under cbits/ |
| License | BSD-3-Clause (monomer.cabal) |
| Repository | fjvallarino/monomer |
| Documentation | Hackage; in-tree docs/design-decisions.md |
| Category | record-of-functions seam |
| Pinned revision | 5c6239365e3a37686b6547bc91c29270ae5487c6 |
| Target range | desktop GPU only — one window, one OpenGL context, via SDL2 |
| Backends shipped | one: NanoVGRenderer.makeRenderer (NanoVGRenderer.hs) |
| Seam size | Renderer = 45 fields; FontManager = 6; Widget = 13 |
Overview
What it solves
Monomer is an Elm-architecture toolkit: an immutable widget tree is rebuilt from a model, laid out, and painted. Painting is the only part of the widget interface that is effectful, and the project says so explicitly — the entry in design-decisions.md titled "Why, except for render, is the widget interface non monadic?" answers:
I find using regular functions nicer than using bind or do syntax and, given those functions do not rely on any effect, I preferred to keep them as such.
So the seam's job is not portability across devices. It is to be the one place effects enter, with everything above it — layout, event handling, size negotiation — staying pure functions of a WidgetEnv.
Design philosophy
The record-of-functions choice is stated, and its stated reason is about widgets rather than renderers (design-decisions.md, "Why records of functions instead of typeclasses?"):
Since all widgets end up inside a list, the instances of those typeclasses would need to be wrapped in an existential, losing their specific types in the process.
Renderer follows the same house style as Widget, which is itself a 13-field record of functions (WidgetTypes.hs). The result is uniform: everything polymorphic in Monomer is a record, and "an implementation" is always a value you can hold.
The same document defends the unmaintained nanovg dependency partly on the grounds that "It's also a kind of small library, which makes porting it to other backends feasible" — portability as a latent property, not an exercised one.
How it works
Renderer is declared once, in full, in Monomer/Graphics/Types.hs. Every field is a function returning IO (); there is no class, no instance, no existential wrapper:
-- | Low level rendering definitions.
data Renderer = Renderer {
-- | Begins a new frame.
beginFrame :: Double -> Double -> IO (),
-- | Sets, or intersects, a scissor which will limit the visible area.
intersectScissor :: Rect -> IO (),
-- | Sets the color of the next fill actions.
setFillColor :: Color -> IO (),
-- ... 41 further fields ...
renderText :: Point -> Font -> FontSize -> FontSpace -> Text -> IO (),
addImage :: Text -> Size -> ByteString -> [ImageFlag] -> IO ()
}A backend is a function returning that value: makeRenderer creates the NanoVG GL3 context plus an IORef Env of bookkeeping, then RecordWildCards turns a where block of closures into the record (NanoVGRenderer.hs):
newRenderer :: VG.Context -> Double -> IORef Env -> Renderer
newRenderer c rdpr envRef = Renderer {..} where
beginPath = VG.beginPath c
stroke = VG.stroke c
setFillColor color = VG.fillColor c (colorToPaint color)Two structural facts follow.
The seam is nanovg's API transcribed. beginPath, closePath, setPathWinding, renderQuadTo, renderBezierTo, setFillBoxGradient, setStrokeImagePattern are nanovg entry points with Monomer names; nearly every field body is a single VG.* call plus unit conversion. The record is a purity and interception boundary, not an abstraction over drawing models — it was derived from one.
The seam does not own the frame. renderWidgets (Main/Core.hs) calls glViewport, glClearColor, glClear and SDL.glSwapWindow itself, bracketing beginFrame / widgetRender / endFrame and then two further passes (renderRawTasks, then renderOverlays inside a second frame, then renderRawOverlays). A second Renderer implementation would still be executed by a hard-coded OpenGL/SDL driver. The pinned tree holds exactly one production implementation and one mockRenderer in the test utilities (TestUtil.hs).
Q1 — measurement units, and who answers
Measurement is not on the renderer. It is a second record, FontManager, declared in the same module (Types.hs):
data FontManager = FontManager {
computeTextMetrics :: Font -> FontSize -> TextMetrics,
computeTextMetrics_ :: Double -> Font -> FontSize -> TextMetrics,
computeTextSize :: Font -> FontSize -> FontSpace -> Text -> Size,
computeTextSize_ :: Double -> Font -> FontSize -> FontSpace -> Text -> Size,
computeGlyphsPos :: Font -> FontSize -> FontSpace -> Text -> Seq GlyphPos,
computeGlyphsPos_ :: Double -> Font -> FontSize -> FontSpace -> Text -> Seq GlyphPos
}Note the types: no IO. Measurement is pure, which is what lets the pure widgetGetSizeReq call it. The purity is bought with unsafePerformIO around an FFI call (FontManager.hs), and the context it drives is not the renderer's: fmInit returns an FMContext (FFI.chs) backed by cbits/fontmanager.c, whose header says "Based on code from memononen's ... nanovg" and whose struct FMcontext holds a bare FONScontext (fontmanager.h). Monomer forked nanovg's text path, deleted the GL half and kept the shaping half, so layout measures without a graphics context at all.
The consumer side is equally explicit: WidgetEnv carries _weFontManager :: FontManager and has no renderer field (WidgetTypes.hs) — the Renderer appears only as the third argument of widgetRender. Layout reaches metrics through the environment (Widgets/Util/Text.hs, Label.hs):
getSizeReq wenv node = (sizeW, sizeH) where
Size w h = getTextSize_ wenv style mode trim targetW maxLines captionThe unit is Double — the same unit as Point, Rect and Size (BasicTypes.hs) — so there is no translation problem, because the measurer is the authority and layout adopts its answer.
This is an independent confirmation of F1, in the strongest form the survey records: not merely "measurement lives elsewhere" but "measurement is a separate backend, with its own C implementation, deliberately severed from the painter's context".
IMPORTANT
The split is not free. Two font stacks must agree or text is laid out at one size and drawn at another, so every measurement field is doubled into a scaled variant under a documented warning (Types.hs): applying a scale on the Renderer "it is recommended to apply the scale here too (otherwise there will be differences in size and positioning)". Six fields where three would do, plus a coherence obligation the type system does not enforce — which any sparkles:ui move on F1 inherits.
Q2 — is the contract stated in one place?
Maximally yes, and at the cost of having no optional primitives whatsoever.
The Renderer declaration is the contract: 45 fields, all mandatory, all concrete types. There is no Maybe field, no Default instance for Renderer, no capability enum, no feature query, and nothing corresponding to Qt's hasFeature (Qt deep-dive) or to our __traits(compiles) probes. A backend author reads one data declaration and knows the whole surface — the thing friction §2 says isCanvas fails to provide.
Four encodings of one contract are now on the table, and they differ exactly here. Iced decomposes its renderer into a base Renderer trait plus supertrait-extending text::Renderer, image::Renderer and svg::Renderer (core/src/text.rs), so a backend implements the subset it supports and the type system records which. Slint uses one trait with defaults, so absence is silent but recoverable. Monomer uses one record, so absence is not expressible at all. isCanvas is closest to Slint: it names five methods, and four further primitives — rule, scrollbar, pushClip, popClip — are probed with __traits(compiles) at their interpreter call sites, each with a degradation the interpreter spells out beside the probe (ruleEndpoints plus a cell-aligned line; paintScrollbarCells glyph-per-cell; and nothing at all for the clip pair, since the display list has already culled the hidden subtrees). Absence is therefore recoverable, as in Slint. What the concept has is neither Iced's decomposition nor Monomer's exhaustiveness: the four are recoverable but undeclared.
The price is visible in the test utilities, where mockRenderer must spell out all 45 fields to say "do nothing" (TestUtil.hs):
mockRenderer :: Renderer
mockRenderer = Renderer {
beginFrame = \w h -> return (),
intersectScissor = \rect -> return (),
-- ... 42 more ...
deleteImage = \name -> return ()
}There is no partial implementation and no graceful floor: a backend that cannot do radial gradients must supply a field that lies. Monomer's escape valve is not a capability but a hole — createRawTask and createRawOverlay take an IO () closure and run it between passes, documented in Types.hs as "Well suited for pure OpenGL/Vulkan/Metal" and demonstrated in docs/examples/05-opengl.md. A widget needing something the record lacks bypasses the record and talks to the GPU API directly, which silently welds the seam to whichever backend is live.
This sharpens F5 rather than contradicting it. Monomer's floor is the entire surface, its defaulted set is empty, nothing is refusable, and its degradation story is "escape to raw GL". That is a coherent point in the design space, and it is only liveable because exactly one backend exists.
Q3 — semantic widgets or primitives?
Primitives, with the semantics one layer above — as free functions over the record. Nothing in Renderer knows what a scrollbar is. Scroll's renderAfter paints four rectangles itself (Scroll.hs):
renderAfter wenv node renderer = do
when hScrollRequired $ drawRect renderer hScrollRect barColorH Nothing
when vScrollRequired $ drawRect renderer vScrollRect barColorV Nothing
when hScrollRequired $ drawRect renderer hThumbRect thumbColorH thumbRadius
when vScrollRequired $ drawRect renderer vThumbRect thumbColorV thumbRadiusdrawRect is not a field. It lives in Monomer.Widgets.Util.Drawing, a module of ~20 helpers (drawRectBorder, drawArcBorder, drawArrowUp, drawStyledAction, drawInScissor, …) each taking the Renderer as its first argument and lowering to fields. Its module header states the layering:
Utility drawing functions. Built on top the lower level primitives provided by "Monomer.Graphics.Types.Renderer".
This is the mechanical advantage of a record over a trait or an interface: because the seam is a value, a new semantic operation is a new function taking that value, not a method every implementation must grow — drawArrowUp cost no backend anything. The counterpart is that no backend can specialize one. boxShadow is a single setFillBoxGradient call (BoxShadow.hs) — a nanovg primitive promoted into the record — so a hypothetical cell backend would receive "box gradient", not "shadow", and could not degrade it.
NOTE
comparison.md's F4 puts the question not at semantic-versus-primitive but at where the lowering lives, and Monomer occupies the cheapest arm on that list: nobody lowers in the seam, because the helper layer is written once against a fixed primitive set that is assumed present. It is available only to a single-backend project. sparkles:ui cannot take it, but it should notice that the choice of encoding (value vs. vtable) is what decides whether semantic operations must sit inside the seam. Slint puts draw_box_shadow in the trait partly because a trait is where a Rust project puts overridable behaviour; a record lets the same operation sit outside without losing dispatch. scrollbarThumb lives in sparkles.ui.state and not in any backend, so the toolkit takes Monomer's arrangement for the geometry and Slint's for the primitive that paints it.
Q4 — command shape
No reified command values — but not virtual dispatch either. widgetRender returns IO () (WidgetTypes.hs); a frame is a call tree, and nothing exists afterwards to inspect.
The one exception is worth naming precisely, because it is a shape none of the surveyed subjects has. Deferred passes are queues of closures, held in the backend's own mutable environment (NanoVGRenderer.hs):
data Env = Env {
overlays :: Seq (IO ()),
tasksRaw :: Seq (IO ()),
overlaysRaw :: Seq (IO ()),
validFonts :: Set Text,
imagesMap :: ImagesMap
}
createOverlay overlay =
modifyIORef envRef $ \env -> env { overlays = overlays env |> overlay }
renderOverlays = do
env <- readIORef envRef
sequence_ $ overlays env
writeIORef envRef env { overlays = Seq.empty }So Monomer does reify drawing — as opaque actions. That buys reordering (a dropdown's popup paints last) and nothing else: the queue cannot be compared, diffed, culled, serialised, golden-tested or moved to another thread. It is exactly what RecordingCanvas and the op-stream parity harness rely on, minus the data.
That supports F3 from the other side. F3 holds that reifying the stream is the right call and leaves the encoding a live trade; Monomer marks the floor of that trade. Slint, Qt and Notcurses show that not reifying is workable; egui shows that reifying as a closed sum gives you everything, which is the shape DrawOp has — a SumType over eight plain-data payloads; Monomer shows the degenerate middle. Reification whose payload is a function is reification you cannot use, which is exactly the boundary F12 draws.
Q5 — sub-unit placement
Does not arise. Point, Size and Rect are pairs of Double (BasicTypes.hs), and scale is explicit: WidgetEnv carries _weDpr :: Double, the renderer has setScale :: Point -> IO (), and makeRenderer/makeFontManager each take the device pixel rate. There is no RuleEdge analogue because no position needs naming; a one-pixel rule is a drawLine with a Double width, and drawTextLine computes its underline offset arithmetically as max 1.5 (unFontSize _tlFontSize / 20) (Drawing.hs).
Same verdict as Slint, Qt and egui, and it is F6 in miniature: continuous coordinates do not dissolve the sub-unit question, they relocate it. Monomer needs no RuleEdge analogue because no position has to be named at the seam — but the fidelity policy still has to be written down, and it lands in a helper as max 1.5 (…), a clamp chosen so a hairline survives at small sizes. The seam sheds the enumerators; the decision they encode moves one layer up.
Q6 — resolved appearance, semantic role, or both
Resolved, exclusively, and resolved above the seam. The record's colour parameters are Color — a four-field RGBA record (Types.hs) — plus gradient and image-pattern setters. StyleState never crosses; the helper layer consumes it and hands down computed values (Drawing.hs):
drawTextLine renderer style textLine = do
setFillColor renderer fontColor
renderText renderer txtOrigin _tlFont _tlFontSize _tlFontSpaceH _tlText
where
fontColor = styleFontColor styleThere is no slot analogue and no re-resolving consumer, because there is no HTML-like backend. _weTheme sits in WidgetEnv, so role-to-colour resolution happens in the widget, once, per frame.
Monomer therefore pays for one representation, as Slint and Qt do. It is a third confirmation that carrying a resolved appearance beside a semantic role (friction §6) is a cost our HTML interpreter incurs specifically, not one the general problem imposes. The toolkit already keeps the cheaper half of that bargain — a payload stores only the resolved fields its own primitive paints from, and DrawOp.visual reconstructs a Visual on demand rather than storing one — but Slot rides on six of the eight payloads, and Monomer's answer is that a project with a single target class never needs the second representation at all.
Q7 — payload ownership, and crossing the frame
The most transferable section of this subject, because Monomer already has the threading arrangement friction §7 anticipates.
Text is Haskell's immutable, GC-managed Text: shared, not borrowed, free to outlive the frame. Images are owned by the backend, keyed by name, and reference-counted inside the renderer's Env (NanoVGRenderer.hs):
imgDelete name imagesMap = newImageMap where
deleteInstance img
| _imCount img > 1 = Just $ img { _imCount = _imCount img - 1 }
| otherwise = NothingThe seam's image fields are a cache protocol, not a data channel: getImage asks whether an upload exists, addImage uploads, deleteImage releases one reference. On allocation failure the backend clears the whole map and retries, under the comment "Ideally only LRU should be removed" — the honest version of a policy the seam does not specify.
Lifetime crosses threads by routing requests, never handles. The runtime holds _mcRenderMethod :: Either Renderer (TChan (RenderMsg s e)) (Main/Types.hs) — possible only because a backend is a value — and when rendering runs on its own OS thread, what crosses the channel is the model, not the ops:
data RenderMsg s e
= MsgInit (WidgetEnv s e) (WidgetNode s e)
| MsgRender (WidgetEnv s e) (WidgetNode s e)
| MsgResize Size
| MsgRemoveImage Text
| forall i . MsgRunInRender (TChan i) (IO i)An immutable WidgetNode travels; the Renderer never leaves the GL thread ("The render function of a widget will be invoked from this thread, but any other functions will not" — 05-opengl.md); image disposal becomes a MsgRemoveImage, handled as deleteImage renderer name (Main/Core.hs), which a widget asks for with a RemoveRendererImage request (Image.hs) rather than by calling the backend.
This is a complete, working answer to the question M7/T5 raises, and it answers it by not moving a command stream across the thread boundary at all. Combined with F8: every ownership mechanism in the survey copies, reference-counts or arena-allocates, and none hands a payload across a frame. CmdBuffer.textRun copies each run into a frame arena, which puts the toolkit squarely inside that set; what Monomer adds is the arrangement above it — keep the GPU handles on one thread, and move the immutable scene rather than the commands.
Q8 — extent query
Absent from the renderer, present in layout — two of F7's three questions, answered in different modules. The surface's size is pushed in: beginFrame :: Double -> Double -> IO (), called from renderWidgets with values obtained from SDL (getViewportSize window dpr, Main/Core.hs). WidgetEnv separately carries _weWindowSize and _weViewport, and a resize reaches the render thread as MsgResize Size.
Content extent is a first-class layout query instead: widgetGetSizeReq :: WidgetEnv s e -> WidgetNode s e -> (SizeReq, SizeReq), where a SizeReq is fixed/flex/extra/factor in Double (StyleTypes.hs). So "how big is this content" is answerable without a painter, "how big is the surface" is answered by whoever created the surface, and ink extent is asked by nobody. F7 is confirmed by a subject that keeps the questions in different modules and different type signatures. Both of its answers are maintained at construction; neither is derived by a scan. That is F7's second axis, and it is the one sparkles:ui answers the other way — nothing on CmdBuffer, the display list or the arena reports the extent of a built stream, so a caller that needs painted bounds folds op.rect itself (friction §8).
Strengths
- The contract is one declaration. A backend author reads 45 lines and has the whole surface — no probing, no discovery at call sites, no gap between the declared concept and the real requirement.
- A backend is a value. It can sit in an
Either, travel a channel, be chosen at runtime or wrapped, without a type parameter infecting the widget tree — which is what makes the optional render thread expressible. - Semantic operations cost nothing. Helpers are functions of the record, so
Util/Drawing.hsgrows without widening the seam. - Measurement is severed from painting at the C level, so layout is pure and needs no graphics context.
- Payload lifetime is explicit and backend-owned, with disposal routed as a request rather than a direct call.
Weaknesses
- No optionality of any kind. Every field is mandatory; a backend that cannot do something must supply a field that lies.
- The abstraction is one library's API. The record is nanovg's surface with Monomer names, so it constrains a future backend to nanovg's drawing model.
- The seam is not the whole boundary.
glViewport/glClear/glSwapWindowsit outside it, so swapping the record does not swap the backend. - The interception the encoding buys is unexercised.
mockRendererexists and, at the pinned revision, is referenced by no spec undertest/unit: the widget suite tests layout and events throughmockFontManagerand never paints. - Two font stacks are kept coherent by convention, with a doubled API and a documentation note in place of enforcement.
- Reified overlays carry closures, so ordering is the only property gained.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Record of functions, not a type class | Widgets live in heterogeneous lists; classes would need existentials (design-decisions.md) | No defaults, no partial instances, no capability query — every field mandatory forever |
Renderer mirrors nanovg 1:1 | Smallest possible mapping layer; nanovg already covers every use (design-decisions.md) | The seam encodes one backend's model; a second must emulate nanovg, not merely draw |
Measurement in a separate FontManager record | Layout stays pure and off-thread; needs no GL context | Two font stacks; scaled/unscaled duplicates; agreement is a convention |
Pure signatures via unsafePerformIO | Lets widgetGetSizeReq stay a pure function of WidgetEnv | Purity is asserted, not proven; a stateful FFI context hides behind a pure type |
| Semantics in helper functions, not fields | Adding drawArrowUp costs no implementation anything | No backend can specialize or degrade a semantic operation |
Escape hatch (createRawTask, createRawOverlay) | Widgets needing real GL/Vulkan/Metal are not blocked by the record | Bypassing widgets bind to the concrete API; the abstraction is voidable per widget |
| Backend-owned, name-keyed, ref-counted image cache | The party that knows GPU lifetime owns it; disposal is a routed request | Eviction policy unspecified; allocation failure clears the entire map |
Extent pushed via beginFrame, content via SizeReq | Surface size belongs to whoever made the surface | An offscreen consumer sizing to content goes through layout, not the painter |
Bearing on the proposal
- F1 holds, and Monomer prices it. Moving
measureoffisCanvas(friction §1) has the weight of the survey behind it. The warning Monomer adds: once measurement and painting are different objects, keeping them coherent under scale is an unenforced obligation. Put the scale/DPR argument in the metrics type rather than repeating it at call sites. - A stated contract and optional primitives are in direct tension, and
isCanvasstates one of them. Monomer answers friction §2 perfectly by abolishing optionality; Qt buys optionality by declaring features. A DbI concept can have both — the four optional primitives each carry a stated degradation already, written beside the probe; what is absent is the declaration naming which four they are, rather than leaving them to be discovered from__traits(compiles)at interpreter call sites. That is the concrete shape of F5's floor / defaulted / refusable ladder, and D has a construct for every rung. - Reject the escape hatch.
createRawTaskis what a seam grows when it cannot say "this backend can do more"; an equivalent would destroy the backend parity the op-stream harness exists to defend. - Semantic operations need not live inside the seam — which is F4's point that the axis is where the lowering lives. Monomer keeps
scrollbarout entirely and still shares one implementation, because helpers take the backend as a parameter.scrollbarThumbcomputes the geometry once insparkles.ui.state, so a free functionpaintScrollbar(ref canvas, …)over the primitive ops would retire a whole arm — the fourteen-fieldScrollbarpayload, the eight accessors no other arm can answer, and onematch!arm in every walker (friction §3). It would not make an operation narrower, since the widest payload isTextRunand the budget is measured against that (§4); the saving is arm count, not bytes. The price is Monomer's — no per-backend degradation — and that is the real decision, not "semantic versus primitive". A cell backend that must degrade differently is the argument for keeping it in, and should be made on those terms. - Do not reify actions; reify data or nothing. The overlay queue is the cautionary case for F3: closures give ordering and foreclose everything else.
DrawOp's eight payloads are plain data with no callable member, and that is the property to defend — F12's point that reification pays only while the value stays inspectable. - For M7/T5, move the scene, not the commands.
Either Renderer (TChan (RenderMsg s e))keeps the GPU handle on one thread and ships an immutable widget tree to it, with disposal as a routed request. That dissolves friction §7 without widening the borrow or makingDrawOpsendable — available to us because our display list is already built by a pure function.UI-O4asks the retain-boundary question directly, and this is one shape of answer to it. - A value-shaped seam is worth only what you do with it. The strongest argument for a record — trivial mocking, decoration, recording — is unexercised here, while
RecordingCanvasdelivers the same property under a structural concept, interns its text on the collected heap so the ops outlive the call that drew them, and is actually used. Keep the DbI encoding; the friction log is right that this is not the problem. - F7's split is confirmed again: surface extent belongs to whoever made the surface, content extent to layout, and Monomer maintains both at construction rather than scanning for either.
widgetGetSizeReqis the layout querycomparison.mdrecommends, already shipping; friction §8 is where the toolkit still scans instead.
Sources
Graphics/Types.hs— theRendererandFontManagerrecords,Color,TextLine, the scale note;NanoVGRenderer.hs— the only production implementation (overlay/raw queues, image cache, ref counting).Graphics/FontManager.hs,Graphics/FFI.chs,cbits/fontmanager.h,cbits/fontmanager.c— the measurement backend, its separateFMContext, the GL-free nanovg text fork.Core/WidgetTypes.hs,Core/StyleTypes.hs,Common/BasicTypes.hs—WidgetEnv,Widget,SizeReq, units.Main/Core.hs,Main/Types.hs— frame driver,RenderMsg,_mcRenderMethod.Widgets/Util/Drawing.hs,Widgets/Util/Text.hs,Scroll.hs,BoxShadow.hs,Label.hs,Image.hs— the helper layer and widget-side use.test/unit/Monomer/TestUtil.hs—mockRenderer,mockFontManager.docs/design-decisions.md,docs/examples/05-opengl.md,monomer.cabal— stated rationale, threading rules, version/licence.