Editable properties

Drawing tools

Editable properties

Expose per-tool settings — a fill colour, a flag, a number — with one attribute, and the generic properties dialog renders an editor for it automatically.

What it is

Beyond the inherited Color, LineWidthPx and LineStyle (already annotated on the base class), you surface any extra setting by tagging a public, read/write property with [DrawingToolProperty]. There is no registration step: the discovery helper finds the attribute reflectively and the properties dialog renders an editor row for it.

It is opt-in: infrastructure properties (IsPlaced, PanelId, Anchors, …) stay hidden, and a tool controls exactly which knobs it exposes. The attribute lives in TradeStrike.Pipeline.Drawing.

DrawingToolPropertyAttribute.cs[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class DrawingToolPropertyAttribute : Attribute
{
    public string? DisplayName { get; set; }     // defaults to the property name
    public string? Description { get; set; }      // one-line tooltip
    public string Category { get; set; } = "General";
    public int Order { get; set; }                // lower renders first
    public object? MinValue { get; set; }         // advisory, for numeric editors
    public object? MaxValue { get; set; }         // advisory
    public object? Step { get; set; }             // spinner increment
}
Constraints are advisory. MinValue / MaxValue / Step guide the dialog's numeric editor, but programmatic writes are not silently clamped — enforce hard invariants in your setter (as the base does for LineWidthPx, which throws on a non-positive value).

Adding a property

Give each property a real default so a freshly placed tool looks right before the user touches anything. The example below adds an interior fill colour to a rectangle. The default fill uses an alpha of 0x33 so it is translucent; an alpha of 0 means "no fill", which the render code checks before painting.

Rectangle.csusing System;
using TradeStrike.Pipeline.Drawing;
using TradeStrike.Pipeline.Plots;

[DrawingToolMetadata("Rectangle", Category = "Shapes", AnchorCount = 2)]
public sealed class Rectangle : DrawingToolBase
{
    public Rectangle() : base(anchorCount: 2) { }

    [DrawingToolProperty(DisplayName = "Fill Color", Category = "Style", Order = 50,
        Description = "Interior fill. Alpha 0 = no fill.")]
    public ChartColor FillColor { get; set; } = new(0x1E, 0x90, 0xFF, 0x33);

    public override void OnRender(IDrawingToolRenderContext ctx)
    {
        var a = ctx.AnchorToPixel(Anchors[0]);
        var b = ctx.AnchorToPixel(Anchors[1]);
        double x = Math.Min(a.X, b.X), y = Math.Min(a.Y, b.Y);
        double w = Math.Abs(b.X - a.X), h = Math.Abs(b.Y - a.Y);

        if (FillColor.A > 0) ctx.Renderer.FillRect(x, y, w, h, FillColor);   // fill first
        ctx.Renderer.DrawLine(x, y, x + w, y, Color, LineWidthPx);            // then outline
        ctx.Renderer.DrawLine(x + w, y, x + w, y + h, Color, LineWidthPx);
        ctx.Renderer.DrawLine(x + w, y + h, x, y + h, Color, LineWidthPx);
        ctx.Renderer.DrawLine(x, y + h, x, y, Color, LineWidthPx);

        if (ctx.IsSelected) { ctx.DrawHandle(a.X, a.Y); ctx.DrawHandle(b.X, b.Y); }
    }

    public override bool HitTest(double px, double py, IDrawingToolRenderContext ctx, double tol = 6)
    {
        var a = ctx.AnchorToPixel(Anchors[0]);
        var b = ctx.AnchorToPixel(Anchors[1]);
        return DrawingGeometry.DistanceToRectangleOutline(px, py, a.X, a.Y, b.X, b.Y) <= tol;
    }
}
Property fields mirror the metadata. DisplayName, Category (groups rows under a heading), Order (sequence within the group) and Description (tooltip) work the same way they do on [DrawingToolMetadata], so the dialog stays consistent across tools.

How discovery works

The host enumerates editors via DrawingToolPropertyDiscovery.Discover(tool), which reflects over the tool type for [DrawingToolProperty] attributes and returns DrawingToolPropertyDescriptor entries sorted by (Category, Order, Name). Results are cached per type, so the per-dialog-open cost is a dictionary lookup. Each descriptor exposes the editor metadata plus Read(tool) / Write(tool, value) so the dialog and any headless code path share one value-coercion contract.

Because [DrawingToolProperty] is declared Inherited = true, the annotations on DrawingToolBase.Color / LineWidthPx / LineStyle flow to every subclass automatically — built-ins and plugin tools alike surface those three editors with zero boilerplate. Discovery skips indexers and any property whose getter or setter is not public, so a tool can opt a property out simply by making its setter non-public.

DrawingToolPropertyDescriptor.cspublic sealed class DrawingToolPropertyDescriptor
{
    public string Name { get; }            // CLR property name (stable across DisplayName renames)
    public string DisplayName { get; }
    public string Description { get; }
    public string Category { get; }
    public int Order { get; }
    public Type PropertyType { get; }      // double, LineStyle, ChartColor, ...
    public object? MinValue { get; }
    public object? MaxValue { get; }
    public object? Step { get; }

    public object? Read(IDrawingTool tool);
    public void Write(IDrawingTool tool, object? value);
}