Drawing Tool that draws ‘shadow’ to the right of an rectangle with custom color and text. The text was the hardest to implement because Ninjatrader like to steal all keypresses.
Older version demonstrating menu action:

Part 1/2
// ExtendedRect code is an extension of built in Shapes by NinjaTrader.
//
#region Using declarations
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Xml.Serialization;
using NinjaTrader.Data;
using NinjaTrader.Gui;
using NinjaTrader.Gui.Chart;
#endregion
namespace NinjaTrader.NinjaScript.DrawingTools
{
/// <summary>
/// Determines where the extended shadow stops.
/// </summary>
public enum ExtendedRectEndMode
{
ToCurrentBar,
AfterCrossCount
}
/// <summary>
/// Determines what counts as a "cross" of the shadow zone.
/// Touches: any candle whose range overlaps the zone.
/// Engulfs: price traversing the zone from one side to the other, over any number of candles.
/// </summary>
public enum ExtendedRectCrossDefinition
{
Touches,
Engulfs
}
/// <summary>
/// Represents a rectangle IDrawingTool whose right side is extended to the current bar.
/// The extended shadow inherits the rectangle's color with an independent opacity, and can
/// optionally end at a set number of zone crossings. An optional label next to the right side
/// shows the rectangle's height in ticks and the crossing counter.
/// </summary>
public class ExtendedRect : ShapeBase
{
private int extensionOpacity;
private Color builtExtensionColor;
private int builtExtensionOpacity = -1;
private readonly DeviceBrush extensionBrushDevice = new();
// Incremental crossing scan state. The scan resumes from the last bar it evaluated and
// only does work when a new bar has closed, so a chart with many bars is walked once.
// It is invalidated only when the rectangle's position or shape changes.
private bool scanInitialized;
private DateTime scanRightTime;
private double scanBandMin;
private double scanBandMax;
private int scanThrough = -1;
// Both crossing streams are tracked on every scanned bar, so switching the definition
// or the count never forces a rescan - it just reads (or resumes) the relevant stream.
private readonly List<int> touchCrossIndices = new List<int>();
private readonly List<int> engulfCrossIndices = new List<int>();
private ZoneSide engulfZoneSide;
// The rectangle's session cap, resolved while scanning. Used only when StopAtSessionEnd
// is enabled; tracked on every scan so toggling that option never forces a rescan.
private bool scanSessionFound;
private int scanSessionEndIndex = -1;
// Tracks which side of the zone price was last seen on. A crossing is scored when
// price then registers on the opposite side, whether within one candle or across many.
private enum ZoneSide
{
None,
Above,
Below
}
// device-bound resources are cached and rebuilt only when the key changes; all wrappers
// release the render target on Dispose, and cached WPF brushes are frozen for the render thread.
private SharpDX.DirectWrite.TextFormat textFormat;
private float builtTextSize = -1f;
private readonly DeviceBrush textBrushDevice = new();
private Color builtTextColor;
private SharpDX.DirectWrite.TextFormat descTextFormat;
private float builtDescTextSize = -1f;
private readonly DeviceBrush descBrushDevice = new();
private Color builtDescColor;
private Brush descriptionColor;
// selection-time menu: 3 mode cells, 5 count cells, 4 shadow-color cells and 1 text-edit cell
private const int MenuModeCells = 3;
private const int MenuCountCells = 5;
private const int MenuColorCells = 4;
private const int MenuActionCells = 1;
private const int MenuCells = MenuModeCells + MenuCountCells + MenuColorCells + MenuActionCells;
private const int MenuColorOffset = MenuModeCells + MenuCountCells;
private static readonly string[] MenuLabels = { "CurBar", "Touches", "Engulfs", "1", "2", "3", "4", "5", " ", " ", " ", " ", "Text" };
private static readonly Color[] DefaultShadowPresets = { Colors.CornflowerBlue, Colors.ForestGreen, Colors.Crimson, Colors.Gold };
private readonly SharpDX.RectangleF[] menuCellRects = new SharpDX.RectangleF[MenuCells];
private readonly DeviceBrush menuActiveTextBrushDevice = new();
private Color builtMenuActiveTextColor;
private readonly DeviceBrush[] menuColorBrushDevices = new DeviceBrush[MenuColorCells];
private readonly Color[] builtMenuPresetColors = new Color[MenuColorCells];
private Brush shadowPreset1;
private Brush shadowPreset2;
private Brush shadowPreset3;
private Brush shadowPreset4;
// the Text menu cell opens a small dialog to edit the description; it renders at the shadow's end
private bool editingDescription;
private ChartControl keyChartControl;
[Range(0, 100)]
[Display(Name = "Shadow Opacity", Description = "Opacity of the shadow fill. The shadow inherits the rectangle's Area color", GroupName = "NinjaScriptGeneral", Order = 4)]
public int ExtensionOpacity
{
get => extensionOpacity;
set
{
extensionOpacity = Math.Max(0, Math.Min(100, value));
extensionBrushDevice.Brush = null;
}
}
[Display(Name = "Shadow End", Description = "Where the shadow stops: at the current bar, or after a set number of zone crossings (use a count of 1 to stop at the first crossing)", GroupName = "NinjaScriptGeneral", Order = 5)]
public ExtendedRectEndMode EndMode { get; set; }
[Display(Name = "Cross Definition", Description = "What counts as a crossing: any candle trading into the zone (Touches), or price traversing the whole zone from one side to the other (Engulfs)", GroupName = "NinjaScriptGeneral", Order = 6)]
public ExtendedRectCrossDefinition CrossDefinition { get; set; }
[Range(1, 5)]
[Display(Name = "Cross Count", Description = "Number of crossings (1-5) after which the shadow stops when Shadow End is set to AfterCrossCount", GroupName = "NinjaScriptGeneral", Order = 7)]
public int CrossCount { get; set; }
[Display(Name = "Show Ticks", Description = "Show a label next to the rectangle's right side with the height in ticks and the crossing counter", GroupName = "NinjaScriptGeneral", Order = 8)]
public bool ShowTicks { get; set; }
[Range(6f, 24f)]
[Display(Name = "Text Size", Description = "Font size of the info label", GroupName = "NinjaScriptGeneral", Order = 9)]
public float TextSize { get; set; }
[Display(Name = "Stop At Session End", Description = "Limit the scan and the shadow to the trading session containing the rectangle's right edge, instead of continuing into later sessions", GroupName = "NinjaScriptGeneral", Order = 10)]
public bool StopAtSessionEnd { get; set; }
[Display(Name = "Description", Description = "Free text shown at the end of the shadow. Edit interactively with the Text cell of the selection menu, or type it here.", GroupName = "NinjaScriptGeneral", Order = 11)]
public string Description { get; set; }
[Range(6f, 48f)]
[Display(Name = "Description Size", Description = "Font size of the custom text label", GroupName = "NinjaScriptGeneral", Order = 12)]
public float DescriptionSize { get; set; }
[XmlIgnore]
[Display(Name = "Description Color", Description = "Color of the custom text label", GroupName = "NinjaScriptGeneral", Order = 13)]
public Brush DescriptionColor
{
get => descriptionColor;
set
{
descriptionColor = value;
if (descriptionColor != null)
{
if (descriptionColor.IsFrozen)
descriptionColor = descriptionColor.Clone();
descriptionColor.Freeze();
}
descBrushDevice.Brush = null;
}
}
[Browsable(false)]
public string DescriptionColorSerialize
{
get => Serialize.BrushToString(DescriptionColor);
set => DescriptionColor = Serialize.StringToBrush(value);
}
[Range(0, 200)]
[Display(Name = "Description Offset", Description = "Horizontal offset in pixels from the shadow end. The label is right-justified, so its right edge sits this far left of where the shadow stops", GroupName = "NinjaScriptGeneral", Order = 14)]
public int DescriptionOffset { get; set; }
[XmlIgnore]
[Display(Name = "Shadow Preset 1", Description = "First shadow color swatch in the selection menu", GroupName = "NinjaScriptGeneral", Order = 15)]
public Brush ShadowPreset1
{
get => shadowPreset1;
set => SetPresetBrush(ref shadowPreset1, value);
}
[Browsable(false)]
public string ShadowPreset1Serialize
{
get => Serialize.BrushToString(ShadowPreset1);
set => ShadowPreset1 = Serialize.StringToBrush(value);
}
[XmlIgnore]
[Display(Name = "Shadow Preset 2", Description = "Second shadow color swatch in the selection menu", GroupName = "NinjaScriptGeneral", Order = 16)]
public Brush ShadowPreset2
{
get => shadowPreset2;
set => SetPresetBrush(ref shadowPreset2, value);
}
[Browsable(false)]
public string ShadowPreset2Serialize
{
get => Serialize.BrushToString(ShadowPreset2);
set => ShadowPreset2 = Serialize.StringToBrush(value);
}
[XmlIgnore]
[Display(Name = "Shadow Preset 3", Description = "Third shadow color swatch in the selection menu", GroupName = "NinjaScriptGeneral", Order = 17)]
public Brush ShadowPreset3
{
get => shadowPreset3;
set => SetPresetBrush(ref shadowPreset3, value);
}
[Browsable(false)]
public string ShadowPreset3Serialize
{
get => Serialize.BrushToString(ShadowPreset3);
set => ShadowPreset3 = Serialize.StringToBrush(value);
}
[XmlIgnore]
[Display(Name = "Shadow Preset 4", Description = "Fourth shadow color swatch in the selection menu", GroupName = "NinjaScriptGeneral", Order = 18)]
public Brush ShadowPreset4
{
get => shadowPreset4;
set => SetPresetBrush(ref shadowPreset4, value);
}
[Browsable(false)]
public string ShadowPreset4Serialize
{
get => Serialize.BrushToString(ShadowPreset4);
set => ShadowPreset4 = Serialize.StringToBrush(value);
}
public override object Icon => Gui.Tools.Icons.DrawRectangle;
// fired when the chart's series itself changes (instrument / timeframe); the cached
// scan is meaningless then, so start over on the next render
public override void OnBarsChanged()
{
scanInitialized = false;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
extensionBrushDevice.RenderTarget = null;
textBrushDevice.RenderTarget = null;
descBrushDevice.RenderTarget = null;
menuActiveTextBrushDevice.RenderTarget = null;
for (int i = 0; i < menuColorBrushDevices.Length; i++)
if (menuColorBrushDevices[i] != null)
menuColorBrushDevices[i].RenderTarget = null;
keyChartControl = null;
textFormat?.Dispose();
textFormat = null;
builtTextSize = -1f;
descTextFormat?.Dispose();
descTextFormat = null;
builtDescTextSize = -1f;
}
public override void OnRender(ChartControl chartControl, ChartScale chartScale)
{
keyChartControl = chartControl;
// render the extension first so the rectangle fill/outline stays on top of it,
// then the label, description and selection menu last so they sit above everything
if (!IsInHitTest)
RenderExtension(chartControl, chartScale);
base.OnRender(chartControl, chartScale);
if (!IsInHitTest)
{
RenderInfoLabel(chartControl, chartScale);
RenderDescription(chartControl, chartScale);
RenderMenu(chartControl, chartScale);
}
}
// Shared pixel geometry of the rectangle in chart space.
private struct RectPixels
{
public ChartPanel Panel;
public double Left;
public double Right;
public double Top;
public double Bottom;
}
private bool TryGetRectPixels(ChartControl chartControl, ChartScale chartScale, out RectPixels pixels)
{
pixels = default(RectPixels);
if (ShapeType == ChartShapeType.Unset || StartAnchor == null || EndAnchor == null)
return false;
ChartPanel chartPanel = chartControl.ChartPanels[PanelIndex];
Point startPoint = StartAnchor.GetPoint(chartControl, chartPanel, chartScale);
Point endPoint = EndAnchor.GetPoint(chartControl, chartPanel, chartScale);
double left = Math.Min(startPoint.X, endPoint.X);
double right = Math.Max(startPoint.X, endPoint.X);
double top = Math.Min(startPoint.Y, endPoint.Y);
double bottom = Math.Max(startPoint.Y, endPoint.Y);
if (bottom <= top)
return false;
pixels = new RectPixels { Panel = chartPanel, Left = left, Right = right, Top = top, Bottom = bottom };
return true;
}
private Color OutlineColor => (OutlineStroke != null ? OutlineStroke.Brush as SolidColorBrush : null)?.Color ?? Colors.White;
private Color ShadowBaseColor => (AreaBrush as SolidColorBrush)?.Color ?? Colors.CornflowerBlue;
private bool IsMultiCross => EndMode == ExtendedRectEndMode.AfterCrossCount;
private int NeededCrossCount => Math.Max(1, CrossCount);
private int ScanTarget => IsMultiCross ? NeededCrossCount : int.MaxValue;
// Draws one line of text just past anchorX, flipping to the other side when it would
// run off the panel, vertically centred on centerY.
private void DrawText(string text, SharpDX.DirectWrite.TextFormat format, double anchorX, double centerY, ChartPanel chartPanel, SharpDX.Direct2D1.Brush brush)
{
using (SharpDX.DirectWrite.TextLayout layout = new SharpDX.DirectWrite.TextLayout(Core.Globals.DirectWriteFactory, text, format, 1000f, 100f))
{
float width = layout.Metrics.Width + layout.Metrics.Left;
float height = layout.Metrics.Height;
float x = (float)anchorX + 6f;
if (x + width > chartPanel.X + chartPanel.W)
x = (float)anchorX - 6f - width;
float y = (float)centerY - height / 2f;
RenderTarget.DrawTextLayout(new SharpDX.Vector2(x, y), layout, brush);
}
}
// Draws one line of text ending at rightX, vertically centred on centerY. No left-edge
// clamp: a short (Touches) shadow ends far left, so clamping x to the panel edge would
// push the text right past the shadow end and look left-justified.
private void DrawRightJustifiedText(string text, double rightX, double centerY, SharpDX.Direct2D1.Brush brush)
{
using (SharpDX.DirectWrite.TextLayout layout = new SharpDX.DirectWrite.TextLayout(Core.Globals.DirectWriteFactory, text, descTextFormat, 1000f, 100f))
{
float x = (float)rightX - (layout.Metrics.Width + layout.Metrics.Left);
float y = (float)centerY - layout.Metrics.Height / 2f;
RenderTarget.DrawTextLayout(new SharpDX.Vector2(x, y), layout, brush);
}
}
private void RenderExtension(ChartControl chartControl, ChartScale chartScale)
{
if (!TryGetRectPixels(chartControl, chartScale, out RectPixels px))
return;
ChartBars chartBars = GetPrimaryChartBars(chartControl);
if (chartBars == null)
return;
int endIndex = GetExtensionEndIndex(chartControl, chartBars);
if (endIndex < 0)
return;
double currentX = chartControl.GetXByBarIndex(chartBars, endIndex);
if (currentX <= px.Right)
return;
Color baseColor = ShadowBaseColor;
int opacity = Math.Max(0, Math.Min(100, extensionOpacity));
if (extensionBrushDevice.Brush == null || !ColorsEqual(baseColor, builtExtensionColor) || opacity != builtExtensionOpacity)
{
builtExtensionColor = baseColor;
builtExtensionOpacity = opacity;
Brush brushCopy = new SolidColorBrush(baseColor);
brushCopy.Opacity = opacity / 100d;
brushCopy.Freeze();
extensionBrushDevice.Brush = brushCopy;
}
extensionBrushDevice.RenderTarget = RenderTarget;
if (extensionBrushDevice.BrushDX == null)
return;
// align to full pixel the same way the base class does
double strokePixAdjust = OutlineStroke != null && OutlineStroke.Width % 2 == 0 ? 0.5d : 0;
SharpDX.RectangleF extensionRect = new(
(float)(px.Right + strokePixAdjust),
(float)(px.Top + strokePixAdjust),
(float)(currentX - px.Right),
(float)(px.Bottom - px.Top));
RenderTarget.FillRectangle(extensionRect, extensionBrushDevice.BrushDX);
}
// Draws the height-in-ticks label and the crossing counter next to the rectangle's right
// side, vertically centered, in the outline color. Counter form "(<set><t|e>/<count>)":
// set is the target, or "-" when the shadow runs to the current bar; count is clamped to
// the target once it is reached.
private void RenderInfoLabel(ChartControl chartControl, ChartScale chartScale)
{
if (!ShowTicks || !TryGetRectPixels(chartControl, chartScale, out RectPixels px))
return;
ChartBars chartBars = GetPrimaryChartBars(chartControl);
Bars bars = chartBars != null ? chartBars.Bars : null;
if (bars == null || bars.Instrument == null || bars.Instrument.MasterInstrument == null)
return;
double tickSize = bars.Instrument.MasterInstrument.TickSize;
if (tickSize <= 0)
return;
long ticks = (long)Math.Round(Math.Abs(StartAnchor.Price - EndAnchor.Price) / tickSize);
int found = CountUpTo(SelectedCrossIndices(), StopAtSessionEnd && scanSessionFound ? scanSessionEndIndex : int.MaxValue);
string setText = IsMultiCross ? NeededCrossCount.ToString() : "-";
string count = (IsMultiCross ? Math.Min(found, NeededCrossCount) : found).ToString();
string letter = CrossDefinition == ExtendedRectCrossDefinition.Engulfs ? "e" : "t";
// e.g. "8T (4t/4)" or, running to the current bar, "8T (-t/5)"
string text = string.Format("{0}T ({1}{2}/{3})", ticks, setText, letter, count);
EnsureTextFormat();
if (textFormat == null)
return;
SharpDX.Direct2D1.Brush brush = EnsureOutlineTextBrush(OutlineColor);
if (brush == null)
return;
DrawText(text, textFormat, px.Right, (px.Top + px.Bottom) / 2d, px.Panel, brush);
}
// A small clickable menu shown while the tool is selected: three mode cells, five count
// cells, four shadow-color swatches and a Text cell; the active cell is highlighted.
private void RenderMenu(ChartControl chartControl, ChartScale chartScale)
{
Array.Clear(menuCellRects, 0, menuCellRects.Length);
if (!IsSelected || DrawingState == DrawingState.Building || !TryGetRectPixels(chartControl, chartScale, out RectPixels px))
return;
EnsureTextFormat();
if (textFormat == null)
return;
SharpDX.Direct2D1.Brush textBrush = EnsureOutlineTextBrush(OutlineColor);
SharpDX.Direct2D1.Brush activeTextBrush = EnsureActiveTextBrush(OutlineColor);
if (OutlineStroke != null)
OutlineStroke.RenderTarget = RenderTarget;
SharpDX.Direct2D1.Brush activeFill = OutlineStroke != null ? OutlineStroke.BrushDX : null;
if (textBrush == null || activeTextBrush == null)
return;
var layouts = new SharpDX.DirectWrite.TextLayout[MenuCells];
try
{
float[] widths = new float[MenuCells];
float cellHeight = 0f;
for (int i = 0; i < MenuCells; i++)
{
layouts[i] = new SharpDX.DirectWrite.TextLayout(Core.Globals.DirectWriteFactory, MenuLabels[i], textFormat, 200f, 100f);
widths[i] = layouts[i].Metrics.Width + layouts[i].Metrics.Left + 10f;
if (IsMenuColorCell(i) && widths[i] < 24f)
widths[i] = 24f;
if (layouts[i].Metrics.Height > cellHeight)
cellHeight = layouts[i].Metrics.Height;
}
cellHeight += 6f;
float totalWidth = 0f;
for (int i = 0; i < MenuCells; i++)
totalWidth += widths[i] + MenuGap(i);
float menuY = (float)px.Bottom + 8f;
if (menuY + cellHeight > px.Panel.Y + px.Panel.H)
menuY = (float)px.Top - 8f - cellHeight;
float menuX = (float)px.Left;
if (menuX + totalWidth > px.Panel.X + px.Panel.W)
menuX = px.Panel.X + px.Panel.W - totalWidth;
float x = menuX;
for (int i = 0; i < MenuCells; i++)
{
var rect = new SharpDX.RectangleF(x, menuY, widths[i], cellHeight);
bool active = IsMenuCellActive(i);
if (IsMenuColorCell(i))
{
SharpDX.Direct2D1.Brush swatch = EnsureMenuColorBrush(i);
if (swatch != null)
RenderTarget.FillRectangle(rect, swatch);
}
else if (active && activeFill != null)
RenderTarget.FillRectangle(rect, activeFill);
RenderTarget.DrawRectangle(rect, textBrush, active ? 2f : 1f);
RenderTarget.DrawTextLayout(
new SharpDX.Vector2(rect.X + 5f - layouts[i].Metrics.Left, rect.Y + 3f),
layouts[i],
active ? activeTextBrush : textBrush);
menuCellRects[i] = rect;
x += widths[i] + MenuGap(i + 1);
}
}
finally
{
for (int i = 0; i < MenuCells; i++)
layouts[i]?.Dispose();
}
}
private bool IsMenuCellActive(int cell)
{
switch (cell)
{
case 0: return EndMode == ExtendedRectEndMode.ToCurrentBar;
case 1: return EndMode == ExtendedRectEndMode.AfterCrossCount && CrossDefinition == ExtendedRectCrossDefinition.Touches;
case 2: return EndMode == ExtendedRectEndMode.AfterCrossCount && CrossDefinition == ExtendedRectCrossDefinition.Engulfs;
default:
if (cell < MenuModeCells + MenuCountCells)
return cell - MenuModeCells + 1 == CrossCount;
if (cell < MenuColorOffset + MenuColorCells)
return IsShadowColorActive(GetPresetColor(cell - MenuColorOffset));
return editingDescription;
}
}
private static bool IsMenuColorCell(int cell)
{
return cell >= MenuColorOffset && cell < MenuColorOffset + MenuColorCells;
}
private static bool ColorsEqual(Color a, Color b)
{
return a.R == b.R && a.G == b.G && a.B == b.B && a.A == b.A;
}
private bool IsShadowColorActive(Color color)
{
return ColorsEqual(ShadowBaseColor, color);
}
private SharpDX.Direct2D1.Brush EnsureMenuColorBrush(int cell)
{
int slot = cell - MenuColorOffset;
if (slot < 0 || slot >= MenuColorCells)
return null;
if (menuColorBrushDevices[slot] == null)
{
menuColorBrushDevices[slot] = new DeviceBrush();
builtMenuPresetColors[slot] = Colors.Transparent;
}
Color preset = GetPresetColor(slot);
DeviceBrush device = menuColorBrushDevices[slot];
if (device.Brush == null || !ColorsEqual(preset, builtMenuPresetColors[slot]))
{
builtMenuPresetColors[slot] = preset;
device.Brush = new SolidColorBrush(preset);
}
device.RenderTarget = RenderTarget;
return device.BrushDX;
}
