This document describes the layout/rendering contract used by MewUI, and the rules to keep layout stable across DPIs while avoiding 1px clipping artifacts. It targets developers writing custom controls or panels against Element / FrameworkElement / UIElement.
- DIP: Device-independent pixels (logical units). Most layout coordinates/sizes are expressed in DIP.
- Px: Device pixels (physical pixels).
px = dip * dpiScale. - dpiScale:
Dpi / 96.0(Window.DpiScale). - Constraint: The
Size availableSizepassed intoMeasure(...). - DesiredSize: The element's preferred size after
Measure, in DIP. - Bounds: The element's arranged rectangle after
Arrange, in DIP.
Element.Bounds is expressed in window-absolute coordinates, not the parent's local coordinate space. Panels arrange children by offsetting from their own absolute Bounds (e.g. child.Arrange(new Rect(Bounds.X + offsetX, Bounds.Y + offsetY, ...))); they do not reset to (0, 0) for each child. Element transforms are translate-only, so this stays consistent through the tree. Custom panels must pass window-absolute rects to Arrange.
If you want a parent-relative accessor, use RenderSize (just the Size, no origin) or convert between two elements' coordinate spaces with TranslatePoint / TranslateRect / TransformToAncestor / TransformToDescendant.
MewUI keeps the layout/control tree between frames for hit-testing and for reusing Measure/Arrange results, and it also keeps what each element drew. An element is drawn again only after it is invalidated; a frame reuses the kept drawing of every other element and repaints only the part of the window that changed.
The pass order is:
- Measure: computes
DesiredSize, recursing top-down (a parent'sMeasureOverride/MeasureContentcallsMeasureon its children before returning its own size). - Arrange: assigns
Bounds, recursing top-down the same way. - Render: draws invalidated elements using
Boundsand current visual state (OnRenderfor the element's own visuals, thenRenderSubtreefor children), and reuses the kept drawing of the rest.
InvalidateMeasure(): marksIsMeasureDirtyandIsArrangeDirtytrue, then propagates toParent.InvalidateMeasure()unconditionally (even if this element was already dirty - a stale ancestor flag must still be re-notified, and this is also what wakes theWindow). It then callsInvalidateVisual().InvalidateArrange(): marks onlyIsArrangeDirtytrue and propagates toParent.InvalidateArrange()the same way. Does not imply Measure is invalid.InvalidateVisual(): says that this element's own drawing changed. The element is drawn again on the next frame and its window schedules a repaint; it does not schedule a layout pass. Ancestors are not told that their own drawing changed, so anInvalidateVisual()override on an ancestor is not called for a descendant's invalidation.Window.InvalidateVisual()schedules a repaint of the window (RequestRender()).InvalidateVisualState()(onUIElement/Control): queues the element for visual-state reconciliation (style triggers, state transitions) and callsWindow.RequestUpdatePass(). This is distinct from the two above: a property that onlyAffectsVisualState(not layout or render) still needs the update pass to run, because trigger-resolved values may feed into layout or render that hasn't happened yet.
Both Window.InvalidateMeasure() and Window.InvalidateArrange() additionally call RequestUpdatePass().
Rule of thumb:
- Scrolling should be Arrange + Render only - see Scrolling.
- Content/size-affecting property changes require Measure.
RequestUpdatePass() posts a merged, single callback to the dispatcher at DispatcherPriority.Layout (repeated calls within the same tick coalesce into one PerformLayout() call, followed by a render request). PerformLayout() does, in order:
UpdateVisualStates(): resolves queued visual-state changes (style triggers/animations) before layout reads state-dependent values (e.g. a trigger changing Padding).- Resolve the window's own style and apply its template (the
Windowitself bypassesMeasureOverride, so template application happens here). - For
FitContent*window-sizing modes, measure content against the max constraints first and resize the window to match. - Skip check: if the client size, padding, and content reference are unchanged, and no element in the tree has
IsMeasureDirty/IsArrangeDirtyset (a full tree walk, since a container may clear its own dirty flag while a virtualized descendant stays dirty), and no popup/adorner/overlay layout is dirty, the pass returns without re-running Measure/Arrange. - Otherwise, Measure and Arrange run in a loop capped at 8 passes, stopping early once nothing is left dirty. This lets a re-arrange triggered from inside the same pass (e.g.
ScrollIntoView) settle without a full extra frame - see Anti-patterns for what happens if it never settles. - Adorners, popups, and the overlay layer are laid out last (they hang off
Window.Parentbut are not part of theContenttree, so they need their own dirty check).
A custom control that composes private child elements outside its own template (e.g. an internal presenter, similar to what ScrollViewer/virtualizing item presenters do) should implement the marker interface ISubtreeInvalidationHost (in addition to IVisualTreeHost). When present, InvalidateMeasure()/InvalidateArrange() cascade the dirty flag into the private visual subtree unconditionally, so those children are not silently skipped by Measure's same-constraint short-circuit.
Measure calculates how large an element wants to be under a given constraint.
- Input:
availableSizein DIP. - Output:
DesiredSizein DIP - clamped toavailableSizeon each axis that is finite (an infinite constraint axis passes the measured value through unclamped), then pixel-rounded (see below).
- Measure must be pure with respect to layout state:
- Avoid unconditionally setting layout-affecting properties inside
MeasureOverride/MeasureContent. If you do it during Measure and it dirties the element again, the re-dirty happens beforeMeasure()unconditionally clearsIsMeasureDirtyat the end of the call - so the new dirty flag is silently discarded, not looped forever. The practical failure mode is a missed re-measure, not an infinite loop. - It should not depend on previously arranged
Bounds.
- Avoid unconditionally setting layout-affecting properties inside
- Skip condition (exact): a call to
Measure(availableSize)returns the cachedDesiredSizewithout invokingMeasureCore/MeasureOverridewhen all of: the element is not measure-dirty, a previous constraint was recorded, and it equalsavailableSize. New elements default to measure-dirty, so the first call always measures. - Templated controls: when a
Controlhas an applied template,MeasureContent/ArrangeContentforward to the template's visual root (root.Measure(availableSize)/ returnroot.DesiredSize) instead of running the control's own content-measure logic. Control authors overridingMeasureContentonly need to worry about the untemplated (code-only) path.
DesiredSize is automatically pixel-rounded by the framework: Element.Measure rounds the clamped size via a size-only rounding helper, driven by Window.DpiScale, whenever Window.UseLayoutRounding is true (the default). Control/panel authors do not need to round their own MeasureOverride/MeasureContent return value.
What is the author's responsibility is rounding intermediate values computed for children, so the numbers a Measure pass hands to a child's constraint match what Arrange later computes for the same child. ScrollViewer.MeasureContent does this for its viewport size before measuring content, using the same dpiScale it will reuse in ArrangeContent - without that, the viewport computed in Measure can differ from the one computed in Arrange by a device pixel at fractional DPI, causing content clipping.
Arrange assigns the final position/size (Bounds, in window-absolute coordinates) for each element and places its children.
- Input:
finalRect, window-absolute, in DIP. - Output:
Bounds, window-absolute, in DIP.
- Skip condition (exact):
Arrange(finalRect)recomputes the element's arranged rect fromfinalRect(viaGetArrangedBounds, which resolvesMargin/alignment/Min/MaxonFrameworkElement) and rounds it; if the element is not arrange-dirty and the result equals the currentBounds,ArrangeCoreis skipped. Note this comparison always recomputes the candidate rect first, soGetArrangedBoundsruns on every call regardless of the dirty flag. - Arrange is responsible for placing children (
ArrangeContent/ArrangeCoreon panels/content hosts).
Bounds is automatically pixel-rounded by the framework, the same way DesiredSize is: Element.Arrange rounds the arranged rect using Window.DpiScale whenever UseLayoutRounding is true. Importantly, this rounding treats position and size independently (round x, y, width, height each on their own), not by rounding the left/right edges and deriving width from the difference - the latter would shrink or grow the size by up to 1px depending on where the edges land, which shows up as jitter as an element moves across the tree. Custom Arrange/ArrangeCore overrides do not need to re-round their own Bounds; the framework already did it.
What authors do need to snap by hand is any additional rect they compute for painting or clipping - the framework provides distinct helpers on LayoutRounding for distinct intents, because "round the edges" and "never let it shrink" are different requirements:
| Helper | Rounding | Use for |
|---|---|---|
SnapBoundsRectToPixels(rect, dpiScale) |
Rounds each edge independently (may shrink/grow by up to 1px) | Border/background paint geometry, e.g. FrameworkElement.GetSnappedBorderBounds |
SnapConstraintRectToPixels(rect, dpiScale) |
Same algorithm as above | Measure-time constraint rects (see ScrollViewer.MeasureContent) |
SnapViewportRectToPixels(rect, dpiScale) |
Floors left/top, ceils right/bottom (never shrinks) | Scroll viewports and clip rects, e.g. ScrollViewer.GetContentViewportBounds |
MakeClipRect(rect, dpiScale, rightPx = 0, bottomPx = 0) |
Outward snap, optionally expanded by whole device pixels on right/bottom | Render-time clip rects; used with the defaults (pure outward snap) by TextBase, ContextMenu, GridView |
SnapThicknessToPixels(thicknessDip, dpiScale, minPixels) |
Rounds to an integer pixel count with a floor | Border/stroke thickness that must stay visible at fractional DPI |
RoundSizeToPixels / RoundRectToPixels |
Position and size rounded independently | What the framework uses internally for DesiredSize/Bounds |
Render draws the element using Bounds and current visual state.
- Render must not perform layout:
UIElement.Renderissealedand never callsMeasure/Arrange. Avoid triggeringInvalidateMeasure()/InvalidateArrange()from insideOnRender- it won't corrupt the current frame, but it schedules another update pass right after this one finishes, which can turn into continuous re-layout every frame. OnRenderdoes not run on every frame. EverythingOnRenderreads has to invalidate the element when it changes. A MewProperty registered withAffectsRenderdoes so by itself (see Property System); any other input, such as a field, a model value or a timer, needs an explicitInvalidateVisual(). Without it the screen keeps showing the previous drawing.- An element that draws from the state of its parent is invalidated by the parent when that state changes; the parent's own invalidation does not redraw its children.
- Content that changes continuously, such as a particle effect, calls
InvalidateVisual()on every frame it changes. - Render should draw using already-snapped geometry whenever possible (see the Arrange rounding table above).
- Elements outside the window's client viewport are culled:
Renderreleases any bitmap cache and returns without callingOnRenderwhen the element'sBoundsdon't intersectnew Rect(root.ClientSize). SetSkipViewportCull(an inherited property) on subtrees rendered under a parent-applied transform, since theirBoundsdon't reflect their actual visible area and would otherwise get culled incorrectly.
Text, strokes, and antialiasing can extend half a pixel outside logical bounds. If an ancestor clips exactly at the child's bounds, that overhang gets cut and looks like a missing right/bottom pixel.
The pattern used in practice (see ScrollViewer.GetContentClipBounds):
- Compute the viewport/content rect in DIP.
- Snap it outward (
SnapViewportRectToPixels, never shrinks). - If overhang is expected (e.g. a child's border stroke can sit exactly on the viewport edge), expand the rect by up to 1 device pixel into whatever unused padding/room is available on that side, then snap outward again via
MakeClipRect. Bound the expansion by the actual room available (Math.Min(onePx, room)) - expanding past the outer chrome bounds, or into negative coordinates, can shift the clip and eat a different pixel instead. - Apply the resulting rect as the clip.
MakeClipRect's own rightPx/bottomPx parameters can also perform the expansion directly, but the codebase's current callers all pass the defaults (0, 0, i.e. a pure outward snap) and do any expansion by pre-inflating the rect as in step 3.
Window.Dpi(default 96) andWindow.DpiScale => Dpi / 96.0are the source of truth.- Elements resolve their effective DPI by walking up to the owning
Window(GetDpiCached/FrameworkElement.GetDpi()), caching the result per-context-version so repeated calls in a render loop are O(1); the cache only re-walks the chain after the element's parent chain has actually changed. When detached from anyWindow, it falls back to the OS system DPI. - When the OS reports a DPI change,
Window.RaiseDpiChangedclears the cached DPI for the whole visual tree, then callsNotifyDpiChanged(which callsOnDpiChangedthenInvalidateMeasure()+InvalidateVisual()) on every attachedFrameworkElement, plus popups, adorners, and the overlay layer.
TextMeasureCache's cache key is (text, family, size, weight, wrapping, maxWidthDip, dpi): any change to text, font, wrapping, the wrap constraint, or DPI naturally invalidates the cache by producing a different key - there is no separate manual invalidation to remember. Separately, TextBlock.OnDpiChanged disposes its native IFont so glyphs are rebuilt at the new DPI (a resource-lifecycle concern, not the measure cache).
ScrollViewer.HorizontalOffset/VerticalOffset setters call InvalidateArrange() only, never InvalidateMeasure(): pure offset changes stay Arrange+Render only. Extent/viewport (which do require Measure) are only recomputed when content or available size actually changes.
ScrollViewer.MeasureContent deliberately does not mutate _scroll state (metrics, offset) or the scrollbars' IsVisible/ViewportSize/Max: Measure can run with a hypothetical/unconstrained size (e.g. a popup owner probing natural size every frame), and mutating shared scroll state there would corrupt the displayed scrollbar or reset the user's scroll offset. All of that mutation happens in ArrangeContent, where the viewport reflects the size actually being displayed.
ArrangeContentarranges the child atviewport - offset(for plain content), or callsIScrollContent.SetViewport/SetOffsetand arranges the child at the viewport rect unchanged (for virtualizing/scroll-aware content, which positions itself internally from the given offset rather than being translated byArrange).- Content renders under a viewport clip (see Clipping rules above).
- Scrollbar ranges/values are synced to the current offset/viewport (
SyncBars).
- Setting layout-affecting properties during Measure/Arrange without comparing old/new values first: at best a wasted re-measure is discarded (see Measure rules); at worst the property never stabilizes and the element re-dirties itself every frame, one full update pass at a time.
- Triggering
InvalidateMeasure()/InvalidateArrange()fromOnRender: safe for the current frame (Render can't be interrupted), but schedules a fresh update pass immediately after, which repeats every frame if done unconditionally. - Re-implementing DPI resolution by walking
Parentin a hot path instead of calling the cachedGetDpi()/GetDpiCached(). - Calling
InvalidateMeasure()on every scroll tick when only the offset changed - use offset-only mutation (InvalidateArrange()) asScrollViewerdoes.