Skip to content

Latest commit

 

History

History
497 lines (399 loc) · 17.9 KB

File metadata and controls

497 lines (399 loc) · 17.9 KB

Custom Controls

Overview

  • 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.

Detailed Explanation

Scope and Conventions

  • 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.DpiScale to respond to DPI changes.
  • Never do pixel math during Measure. Mixing pixel snapping into Measure causes layout mismatches.

Size Calculation (MeasureContent)

  • MeasureContent is 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), and GetBorderVisualInset().
  • If the control should align with the theme’s baseline size, set DefaultMinHeight to Theme.Metrics.BaseControlHeight.
  • In that case, MeasureContent can return the natural content height; the framework applies MinHeight.
  • Format and Value changes 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);
}

Internal Layout (ArrangeContent)

  • This sample does not override ArrangeContent; it computes internal layout using the final Bounds.
  • Controls with children must compute child rects here and call Arrange for each child.
  • Arrange defines where children go inside the allotted space.
  • Always assume DesiredSize from Measure and actual Bounds can differ.

Rendering (OnRender)

  • GetSnappedBorderBounds and LayoutRounding.SnapBoundsRectToPixels ensure 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.

State and Input

  • 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, not IsEnabled.
    • If a parent is disabled, a child with IsEnabled == true must still ignore input.
    • For that reason, input handling, visual state, and color decisions should follow IsEffectivelyEnabled.

Theme and Metrics

  • Colors and sizes come from Theme.Palette.* and Theme.Metrics.*.
  • Theme changes invalidate text measurement caches.
  • Theme changes can affect fonts, sizes, and padding rules, so re‑measuring is safer.

Utility Methods (State, Border, and DIP)

  • GetDpi() returns the effective DPI (uint). Use dpiScale = GetDpi() / 96.0 when converting DIPs to device pixels.
  • GetVisualState(...) creates a stable snapshot of enabled/hot/focused/pressed/active for 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.
  • LayoutRounding helpers 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);

Invalidation Rules

  • Format change: InvalidateMeasure() + InvalidateVisual()
  • Value change: text width may change → InvalidateMeasure()
  • Hover/Pressed change: InvalidateVisual()

Full Sample Code

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;
    }
}