- This is a developer reference for building custom controls in MewUI.
- Layout uses DIP, rendering requires pixel‑aligned geometry.
- The sample below is a complete
NumericUpDown, and the comments explain what each spot is responsible for from a CustomControl perspective.
- Sizes are computed in DIP, rendering is pixel‑snapped.
- Measure/Arrange must operate in logical coordinates (DIP) only; pixel snapping is applied in Render.
- Use
GetDpi()/context.DpiScaleto respond to DPI changes. - Never do pixel math during Measure. Mixing pixel snapping into Measure causes layout mismatches.
MeasureContentis the single source of desired size.- This stage only computes how much space the control needs; it does not decide placement.
- Text is measured using the display string (format applied).
- The final size includes
Padding, chrome (button area), andGetBorderVisualInset(). - If the control should align with the theme’s baseline size, set
DefaultMinHeighttoTheme.Metrics.BaseControlHeight. - In that case,
MeasureContentcan return the natural content height; the framework appliesMinHeight. FormatandValuechanges can alter text width, so measure invalidation is required.- Even with caching, invalidate when inputs change (font, DPI, string, wrapping policy).
Example:
protected override double DefaultMinHeight => Theme.Metrics.BaseControlHeight;
protected override Size MeasureContent(Size available)
{
var textHeight = /* measure text height */;
double height = textHeight + Padding.VerticalThickness;
return new Size(Width, height);
}- This sample does not override
ArrangeContent; it computes internal layout using the finalBounds. - Controls with children must compute child rects here and call
Arrangefor each child. - Arrange defines where children go inside the allotted space.
- Always assume DesiredSize from Measure and actual Bounds can differ.
GetSnappedBorderBoundsandLayoutRounding.SnapBoundsRectToPixelsensure pixel alignment.- Render order: background → border → content.
- Structure code so layout math and rendering share the same rects.
- Render must not recompute measurement; it only consumes the final
Bounds.
- Interaction state (hover/pressed) is internal state of the control.
- Capture on MouseDown and release on MouseUp to guarantee input consistency.
- Hit‑test logic must use the same split geometry as rendering.
- When state changes, choose InvalidateVisual vs InvalidateMeasure correctly.
- Gate input using
IsEffectivelyEnabled, notIsEnabled.- If a parent is disabled, a child with
IsEnabled == truemust still ignore input. - For that reason, input handling, visual state, and color decisions should follow
IsEffectivelyEnabled.
- If a parent is disabled, a child with
- Colors and sizes come from
Theme.Palette.*andTheme.Metrics.*. - Theme changes invalidate text measurement caches.
- Theme changes can affect fonts, sizes, and padding rules, so re‑measuring is safer.
GetDpi()returns the effective DPI (uint). UsedpiScale = GetDpi() / 96.0when converting DIPs to device pixels.GetVisualState(...)creates a stable snapshot ofenabled/hot/focused/pressed/activefor the current frame.PickAccentBorder(theme, baseBorder, state, hoverMix)maps that state to a border color (accent on focused/pressed/active; tinted on hover).DrawBackgroundAndBorder(context, bounds, background, borderBrush, cornerRadiusDip)draws a consistent background + border using the current backend.GetBorderRenderMetrics(bounds, cornerRadiusDip)returns pixel-snapped border thickness and corner radius so rendering matches layout.LayoutRoundinghelpers keep geometry stable across fractional DPI and avoid 1px clipping artifacts:LayoutRounding.SnapBoundsRectToPixels(...)for background/border/layout boxes.LayoutRounding.SnapViewportRectToPixels(...)for viewports and clip rectangles (won’t shrink).LayoutRounding.SnapThicknessToPixels(...)for border thickness that must be whole pixels.LayoutRounding.ExpandClipByDevicePixels(...)for clip rects that must include the last pixel row/col.
Example: state-driven border + pixel snapping
var dpiScale = GetDpi() / 96.0;
var state = GetVisualState(isPressed: isPressed, isActive: isActive);
var border = PickAccentBorder(Theme, BorderBrush, state, hoverMix: 0.6);
var bounds = LayoutRounding.SnapBoundsRectToPixels(Bounds, dpiScale);
DrawBackgroundAndBorder(context, bounds, Background, border, cornerRadiusDip: 0);Formatchange:InvalidateMeasure()+InvalidateVisual()Valuechange: text width may change →InvalidateMeasure()- Hover/Pressed change:
InvalidateVisual()
public sealed class NumericUpDown : RangeBase
{
// This enum keeps interaction state in one place.
// Custom controls should keep non‑public UI state internal so
// input handling and rendering share the same state.
private enum ButtonPart
{
None,
Decrement,
Increment
}
// Display format can affect desired size.
// In a custom control, any state that changes the rendered text
// must be treated as layout‑affecting.
private string _format = "0.##";
// Interaction step does not affect layout but is required for input logic.
// Keep it as state used by interaction handlers.
private double _step = 1;
// Cache text measurement to reduce Measure cost.
// Measure can be called often; caching stabilizes layout.
private TextMeasureCache _measureCache;
// Visual states; they affect rendering but not layout.
// Keep them internal and drive invalidation accordingly.
private ButtonPart _hoverPart;
private ButtonPart _pressedPart;
public NumericUpDown()
{
// Establish default size rules.
// Border participates in Measure; set a safe default.
BorderThickness = 1;
// Set default range.
Maximum = 100;
// Separate content from chrome.
// Custom controls should keep content and chrome areas distinct.
Padding = new Thickness(8, 4, 8, 4);
}
// Provide theme defaults for consistent styling.
protected override Color DefaultBackground => Theme.Palette.ControlBackground;
protected override Color DefaultBorderBrush => Theme.Palette.ControlBorder;
protected override double DefaultMinHeight => Theme.Metrics.BaseControlHeight;
// Controls that accept keyboard input must be focusable.
public override bool Focusable => true;
public string Format
{
get => _format;
set
{
// Avoid redundant invalidation.
if (_format == value)
{
return;
}
// Apply display change.
_format = value;
// Text metrics are now stale.
_measureCache.Invalidate();
// Desired size may change.
InvalidateMeasure();
// Visual update is required.
InvalidateVisual();
}
}
public double Step
{
get => _step;
set
{
if (_step.Equals(value))
{
return;
}
// Update interaction step.
_step = value;
// No invalidation when it doesn't affect layout or visuals.
}
}
protected override void OnThemeChanged(Theme oldTheme, Theme newTheme)
{
base.OnThemeChanged(oldTheme, newTheme);
// Theme changes can alter text metrics, so invalidate cache.
_measureCache.Invalidate();
}
protected override void OnValueChanged(double value, bool fromUser)
{
// Displayed text may change width → re‑measure.
_measureCache.Invalidate();
InvalidateMeasure();
// Visual invalidation can be added if needed.
}
protected override Size MeasureContent(Size available)
{
// MeasureContent defines the desired size of a custom control.
// Compute in DIP only; pixel snapping belongs to Render.
var factory = GetGraphicsFactory();
var font = GetFont(factory);
// Measure the actual display string.
string text = Value.ToString(_format);
var textSize = _measureCache.Measure(factory, GetDpi(), font, text, TextWrapping.NoWrap, 0);
// Include chrome area in size.
double buttonAreaWidth = GetButtonAreaWidth();
// Content + padding + chrome.
double width = textSize.Width + Padding.HorizontalThickness + buttonAreaWidth;
// Use natural content height; MinHeight enforces the baseline size.
double height = textSize.Height + Padding.VerticalThickness;
// Include border inset in desired size.
return new Size(width, height).Inflate(new Thickness(GetBorderVisualInset()));
}
protected override void OnRender(IGraphicsContext context)
{
// Render runs after final Bounds are known.
// Use pixel‑snapped geometry to avoid jitter.
var bounds = GetSnappedBorderBounds(Bounds);
// Style values come from the theme.
double radius = Theme.Metrics.ControlCornerRadius;
// Resolve state‑dependent colors before drawing.
bool isEnabled = IsEffectivelyEnabled;
Color bg = isEnabled ? Background : Theme.Palette.DisabledControlBackground;
Color baseBorder = isEnabled ? BorderBrush : Theme.Palette.ControlBorder;
var state = GetVisualState(isPressed: _pressedPart != ButtonPart.None, isActive: _pressedPart != ButtonPart.None);
Color border = PickAccentBorder(Theme, baseBorder, state, hoverMix: 0.6);
// Draw chrome first.
DrawBackgroundAndBorder(context, bounds, bg, border, radius);
// Compute internal layout from final bounds.
var inner = bounds.Deflate(new Thickness(GetBorderVisualInset()));
// Split content and chrome areas.
double buttonAreaWidth = Math.Min(GetButtonAreaWidth(), inner.Width);
var buttonRect = new Rect(inner.Right - buttonAreaWidth, inner.Y, buttonAreaWidth, inner.Height);
var textRect = new Rect(
inner.X + Padding.Left,
inner.Y + Padding.Top,
Math.Max(0, inner.Width - buttonAreaWidth - Padding.HorizontalThickness),
Math.Max(0, inner.Height - Padding.VerticalThickness));
// Snap sub‑rects to pixels as well.
textRect = LayoutRounding.SnapBoundsRectToPixels(textRect, context.DpiScale);
buttonRect = LayoutRounding.SnapBoundsRectToPixels(buttonRect, context.DpiScale);
// Input and rendering must share the same split geometry.
var decRect = new Rect(buttonRect.X, buttonRect.Y, buttonRect.Width / 2, buttonRect.Height);
var incRect = new Rect(buttonRect.X + buttonRect.Width / 2, buttonRect.Y, buttonRect.Width / 2, buttonRect.Height);
// Resolve per‑region colors from state.
Color baseButton = Theme.Palette.ButtonFace;
Color hoverButton = Theme.Palette.ButtonHoverBackground;
Color pressedButton = Theme.Palette.ButtonPressedBackground;
Color disabledButton = Theme.Palette.ButtonDisabledBackground;
Color decBg = !isEnabled
? disabledButton
: _pressedPart == ButtonPart.Decrement ? pressedButton
: _hoverPart == ButtonPart.Decrement ? hoverButton
: baseButton;
Color incBg = !isEnabled
? disabledButton
: _pressedPart == ButtonPart.Increment ? pressedButton
: _hoverPart == ButtonPart.Increment ? hoverButton
: baseButton;
if (buttonRect.Width > 0)
{
// Draw chrome area.
context.FillRectangle(decRect, decBg);
var innerRadius = Math.Max(0, radius - GetBorderVisualInset());
context.Save();
context.SetClipRoundedRect(
LayoutRounding.MakeClipRect(inner, context.DpiScale, rightPx: 0, bottomPx: 0),
innerRadius,
innerRadius);
context.FillRectangle(incRect, incBg);
context.Restore();
// Visual separators for clarity.
var x = decRect.Right;
context.DrawLine(new Point(x, decRect.Y + 2), new Point(x, decRect.Bottom - 2), Theme.Palette.ControlBorder, 1);
x = decRect.Left;
context.DrawLine(new Point(x, decRect.Y), new Point(x, decRect.Bottom), Theme.Palette.ControlBorder, 1);
}
// Draw text last to sit above chrome.
var font = GetFont();
var textColor = isEnabled ? Foreground : Theme.Palette.DisabledText;
context.DrawText(Value.ToString(_format), textRect, font, textColor, TextAlignment.Left, TextAlignment.Center, TextWrapping.NoWrap);
if (buttonRect.Width > 0)
{
// Glyph sizes follow theme metrics.
var chevronSize = Theme.Metrics.BaseControlHeight / 6;
Glyph.Draw(context, decRect.Center, chevronSize, textColor, GlyphKind.ChevronDown);
Glyph.Draw(context, incRect.Center, chevronSize, textColor, GlyphKind.ChevronUp);
}
}
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
base.OnMouseWheel(e);
// Block input when disabled.
if (!IsEffectivelyEnabled)
{
return;
}
// Map wheel input to a value change.
double delta = e.Delta > 0 ? _step : -_step;
Value += delta;
// Value change must reflect visually.
InvalidateVisual();
}
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
// Input entry point.
// Decide what input to accept and establish focus/capture/state.
if (!IsEffectivelyEnabled || e.Button != MouseButton.Left)
{
return;
}
// Ensure keyboard focus for key handling.
Focus();
// Store hit‑test result as state.
var part = HitTestButtonPart(e.Position);
if (part == ButtonPart.None)
{
return;
}
_pressedPart = part;
// Capture guarantees MouseUp delivery.
var root = FindVisualRoot();
if (root is Window window)
{
window.CaptureMouse(this);
}
InvalidateVisual();
e.Handled = true;
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
// Update hover state for visual feedback.
var part = HitTestButtonPart(e.Position);
if (_hoverPart != part)
{
_hoverPart = part;
InvalidateVisual();
}
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
// Clear hover only when not captured.
if (_hoverPart != ButtonPart.None && !IsMouseCaptured)
{
_hoverPart = ButtonPart.None;
InvalidateVisual();
}
}
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
// Input exit point.
// Release capture and normalize state here.
if (e.Button != MouseButton.Left || _pressedPart == ButtonPart.None)
{
return;
}
// Release capture.
var root = FindVisualRoot();
if (root is Window window)
{
window.ReleaseMouseCapture();
}
// Commit action only if release is on the same region.
var releasedPart = HitTestButtonPart(e.Position);
if (releasedPart == _pressedPart && IsEffectivelyEnabled)
{
Value += _pressedPart == ButtonPart.Increment ? _step : -_step;
}
_pressedPart = ButtonPart.None;
InvalidateVisual();
e.Handled = true;
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
// Keyboard path is independent from mouse path.
// Check focus/enable state, then invalidate as needed.
if (!IsEffectivelyEnabled)
{
return;
}
if (e.Key == Key.Up)
{
Value += _step;
InvalidateVisual();
e.Handled = true;
}
else if (e.Key == Key.Down)
{
Value -= _step;
InvalidateVisual();
e.Handled = true;
}
}
// Centralize chrome width rule.
private double GetButtonAreaWidth() => Theme.Metrics.BaseControlHeight * 2;
private (Rect decRect, Rect incRect) GetButtonRects()
{
// Hit‑test and render must share the same geometry.
var inner = GetSnappedBorderBounds(Bounds).Deflate(new Thickness(GetBorderVisualInset()));
double buttonAreaWidth = Math.Min(GetButtonAreaWidth(), inner.Width);
var buttonRect = new Rect(inner.Right - buttonAreaWidth, inner.Y, buttonAreaWidth, inner.Height);
var decRect = new Rect(buttonRect.X, buttonRect.Y, buttonRect.Width / 2, buttonRect.Height);
var incRect = new Rect(buttonRect.X + buttonRect.Width / 2, buttonRect.Y, buttonRect.Width / 2, buttonRect.Height);
return (decRect, incRect);
}
private ButtonPart HitTestButtonPart(Point position)
{
// Reuse the same hit‑test logic across input handlers.
var (decRect, incRect) = GetButtonRects();
if (decRect.Contains(position))
{
return ButtonPart.Decrement;
}
if (incRect.Contains(position))
{
return ButtonPart.Increment;
}
return ButtonPart.None;
}
}