Your first tool
Your first tool
A complete two-anchor trend line, end to end (metadata, render, hit-test), showing how little code a real drawing tool needs once the base class and render context do their part.
SIMPLE A trend line
The simplest useful tool has two anchors and draws one segment between them. The shape of every drawing
tool is the same three steps: declare the [DrawingToolMetadata] attribute, pass the anchor
count to the base constructor, then fill in OnRender and HitTest.
In OnRender you convert both anchors to pixels with AnchorToPixel, draw the
line with the base's DrawLine helper (which applies the tool's Color,
LineWidthPx and LineStyle), and, only when selected, paint a handle at each end. In HitTest you convert the same anchors and ask
DrawingGeometry whether the cursor is within the tolerance of the segment. That is the whole
tool.
AcmeTrendLine.csusing SabrTrader.Pipeline.Drawing;
[DrawingToolMetadata("Acme Trend Line",
Category = "Lines", Hotkey = "Alt+Shift+L", IconKey = "TrendLine",
AnchorCount = 2, Order = 0, Description = "Straight line between two points.")]
public sealed class AcmeTrendLine : DrawingToolBase
{
public AcmeTrendLine() : base(anchorCount: 2) { }
public override void OnRender(IDrawingToolRenderContext ctx)
{
var (x1, y1) = ctx.AnchorToPixel(Anchors[0]);
var (x2, y2) = ctx.AnchorToPixel(Anchors[1]);
DrawLine(ctx, x1, y1, x2, y2); // Color, LineWidthPx and LineStyle
if (ctx.IsSelected)
{
ctx.DrawHandle(x1, y1);
ctx.DrawHandle(x2, y2);
}
}
public override bool HitTest(double px, double py, IDrawingToolRenderContext ctx, double tol = 6)
{
var (x1, y1) = ctx.AnchorToPixel(Anchors[0]);
var (x2, y2) = ctx.AnchorToPixel(Anchors[1]);
return DrawingGeometry.DistanceToSegment(px, py, x1, y1, x2, y2) <= tol;
}
}
A line that carries a label
Line tools that can be NAMED ("Support", "VAH", "London open") derive from
LabeledLineToolBase instead of DrawingToolBase. The label is a property of the
line, not a separate text drawing, so it moves, locks, copies, persists and syncs across charts with the
line it names.
LabeledLine.csusing SabrTrader.Pipeline.Drawing;
[DrawingToolMetadata("My Support Line",
Category = "Lines", IconKey = "HorizontalLine", AnchorCount = 1,
Description = "Horizontal line with an optional label.")]
public sealed class MySupportLine : LabeledLineToolBase
{
public MySupportLine() : base(anchorCount: 1) { }
// Only the anchor's Price positions this tool, so the coordinate editor hides Time.
public override DrawingAnchorCoordinates AnchorCoordinates => DrawingAnchorCoordinates.PriceOnly;
public override void OnRender(IDrawingToolRenderContext ctx)
{
double y = ctx.Viewport.PriceToPixelY(Anchors[0].Price);
double x1 = ctx.PlotArea.X;
double x2 = ctx.PlotArea.Right;
DrawLine(ctx, x1, y, x2, y, Color, LineWidthPx);
// Everything about the label - measuring, aligning to LabelPosition / LabelSide,
// keeping the glyphs inside the plot area - happens in the base.
DrawLabel(ctx, x1, y, x2, y);
}
}
You inherit the editable properties the trader expects: Text,
LabelPosition (Start / Middle / End),
LabelSide (Above / Below), LabelFontSizePx,
LabelColor, LabelBold and LabelItalic, all grouped under a "Label"
category in the properties dialog. An empty Text renders nothing, so a line that was never
named costs no pixels.
A text box that wraps
A note tool combines two small interfaces. IDrawingToolTextContent opts the tool into the
chart's inline editor: placing it drops a caret on the chart and double-clicking it re-opens the caret.
Its WrapWidthPx tells that caret where your text wraps. IDrawingToolWidthGrip
gives the box a grip on its right edge that the trader drags to set the width, in screen pixels, so
zooming the chart never re-wraps the note.
The chart asks TryHitWidthGrip only while the drawing is selected and unlocked. You return
true when the pointer is on the grip and hand back originXPx, the pixel X the
width is measured from (here the text's left edge). While the trader drags, the chart shows a left-right
resize cursor and calls ResizeFromGrip with the pointer's distance right of that origin, and
you turn it into a width.
Note.csusing System;
using System.Collections.Generic;
using SabrTrader.Pipeline.Charts.Rendering;
using SabrTrader.Pipeline.Drawing;
[DrawingToolMetadata("Note", Category = "Text", AnchorCount = 1,
Description = "Text in a box whose width you drag; the text wraps inside it.")]
public sealed class Note : DrawingToolBase, IDrawingToolTextContent, IDrawingToolWidthGrip
{
private const double Padding = 4;
private static readonly string[] LineBreaks = { "\r\n", "\n", "\r" };
private string _text = "Note";
private double _wrapWidthPx;
public Note() : base(anchorCount: 1) { }
[DrawingToolProperty(DisplayName = "Text", Category = "Content", Order = 5, Multiline = true)]
public string Text
{
get => _text;
set => _text = value ?? string.Empty;
}
[DrawingToolProperty(DisplayName = "Font Size (px)", Category = "Style", Order = 35,
MinValue = 6.0, MaxValue = 96.0, Step = 1.0)]
public double FontSizePx { get; set; } = 14;
public bool Bold => false;
public bool Italic => false;
// 0 = no wrapping. Tagged, so it is editable and saved with the drawing.
[DrawingToolProperty(DisplayName = "Box Width (px)", Category = "Box", Order = 80,
MinValue = 0.0, MaxValue = 2000.0, Step = 10.0)]
public double WrapWidthPx
{
get => _wrapWidthPx;
set => _wrapWidthPx = value > 0 ? Math.Clamp(value, 20, 2000) : 0;
}
private double LinePitch => FontSizePx * 1.25;
public override void OnRender(IDrawingToolRenderContext ctx)
{
var (x, y) = ctx.AnchorToPixel(Anchors[0]); // baseline-left of the first line
var lines = Wrap(ctx.Renderer);
for (int i = 0; i < lines.Count; i++)
ctx.Renderer.DrawText(x, y + i * LinePitch, lines[i], Color, FontSizePx);
if (ctx.IsSelected)
{
ctx.DrawHandle(x, y);
var (gx, gy) = GripPoint(ctx.Renderer, lines, x, y);
ctx.DrawHandle(gx, gy);
}
}
public override bool HitTest(double px, double py, IDrawingToolRenderContext ctx, double tol = 6)
{
var (x, y) = ctx.AnchorToPixel(Anchors[0]);
var lines = Wrap(ctx.Renderer);
double right = x + BoxWidth(ctx.Renderer, lines) + Padding;
double top = y - FontSizePx - Padding;
double bottom = y + (lines.Count - 1) * LinePitch + Padding;
return px >= x - Padding - tol && px <= right + tol && py >= top - tol && py <= bottom + tol;
}
public bool TryHitWidthGrip(double pixelX, double pixelY, IDrawingToolRenderContext ctx,
out double originXPx, double tolerancePx = 8)
{
var (x, y) = ctx.AnchorToPixel(Anchors[0]);
var (gx, gy) = GripPoint(ctx.Renderer, Wrap(ctx.Renderer), x, y);
originXPx = x; // the width is measured from the text's left edge
double dx = pixelX - gx, dy = pixelY - gy;
return dx * dx + dy * dy <= tolerancePx * tolerancePx;
}
// The grip sits one padding right of the text, so the text gets the rest.
public void ResizeFromGrip(double offsetFromOriginPx)
=> WrapWidthPx = Math.Max(offsetFromOriginPx - Padding, 20);
private (double X, double Y) GripPoint(IChartRenderer r, List<string> lines, double x, double y)
=> (x + BoxWidth(r, lines) + Padding, y - FontSizePx * 0.4);
private double BoxWidth(IChartRenderer r, List<string> lines)
{
if (WrapWidthPx > 0) return WrapWidthPx;
double widest = 0;
foreach (var line in lines) widest = Math.Max(widest, r.MeasureText(line, FontSizePx).WidthPx);
return widest;
}
// Split on every line-break form, then wrap each paragraph between words.
// A production tool measures once per change of text, width or font, not per frame.
private List<string> Wrap(IChartRenderer r)
{
var result = new List<string>();
foreach (var paragraph in _text.Split(LineBreaks, StringSplitOptions.None))
{
if (WrapWidthPx <= 0) { result.Add(paragraph); continue; }
string line = "";
foreach (var word in paragraph.Split(' '))
{
string candidate = line.Length == 0 ? word : line + " " + word;
if (line.Length > 0 && r.MeasureText(candidate, FontSizePx).WidthPx > WrapWidthPx)
{
result.Add(line);
line = word;
}
else line = candidate;
}
result.Add(line);
}
return result;
}
}
Because WrapWidthPx carries [DrawingToolProperty], the width the trader drags is
saved with the drawing and also appears as a number in the properties dialog. The Text
property uses Multiline = true, so the dialog's editor accepts line breaks just like the
inline caret (Shift+Enter there). Both paths can store "\r\n", "\n" or
"\r", which is why Wrap splits on all three.
The metadata attribute
[DrawingToolMetadata] is what makes the tool discoverable. The catalog reads it at
assembly-scan time to populate the toolbar menu, hotkeys, icons and anchor counts, with no registration
boilerplate. Exactly one attribute per tool class.
| Field | Meaning | Default |
|---|---|---|
Name (ctor arg, required)
|
Display name in the toolbar and context menus. Also becomes ToolName, and it is the key a saved drawing is matched back to its tool by, so it must be unique across all loaded tools and stay stable once shipped (see Discovery). |
(none) |
Category |
Toolbar submenu group ("Lines", "Shapes", "Fibonacci", …). | "Lines" |
Hotkey |
Optional global hotkey string (e.g. "Alt+Shift+L") the toolbar keymap arms. Must not repeat a hotkey another tool binds. |
null |
IconKey |
Key into the toolbar's icon-resource dictionary. | null |
AnchorCount |
Number of clicks the user makes to finalize (the minimum for variable tools). | 2 |
Order |
Display order within the category; equal values fall back to alphabetical. | 0 |
Description |
Tooltip text in the toolbar. | null |
AnchorCount appears twice: keep them consistent. The metadata's
AnchorCount drives the placement gesture; the base constructor's anchorCount
pre-allocates the storage. For a fixed-anchor tool they must match (both 2 here). The catalog
requires a public parameterless constructor, which the trend line satisfies.Anchors[0] and Anchors[1] are safe to read in OnRender from the very
first frame (they are zero-valued until the user clicks, and the host doesn't render a tool until enough
anchors are placed). Write anchors only through SetAnchor; never mutate
Anchors directly, since it is exposed as read-only.