Extended rectangle for FVG, support, resistance annotations {code}

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.

https://discourse.ninjatrader.com/uploads/default/original/2X/6/69b49da30e512aec734f615aeb88791d8156a952.png

Older version demonstrating menu action:

toolMenu

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

Part 2/2

	private void SetPresetBrush(ref Brush field, Brush value)
	{
		field = value;
		if (field != null)
		{
			if (field.IsFrozen)
				field = field.Clone();
			field.Freeze();
		}
	}

	private Color GetPresetColor(int slot)
	{
		Brush preset = null;
		switch (slot)
		{
			case 0: preset = shadowPreset1; break;
			case 1: preset = shadowPreset2; break;
			case 2: preset = shadowPreset3; break;
			case 3: preset = shadowPreset4; break;
		}
		Color color = (preset as SolidColorBrush)?.Color ?? Colors.Transparent;
		if (color == Colors.Transparent && slot >= 0 && slot < DefaultShadowPresets.Length)
			color = DefaultShadowPresets[slot];
		return color;
	}

	// a wider gap separates the mode group, the count group, the color group and the text cell
	private static float MenuGap(int index)
	{
		if (index <= 0)
			return 0f;
		return index == MenuModeCells || index == MenuColorOffset || index == MenuColorOffset + MenuColorCells ? 12f : 2f;
	}

	public override void OnMouseDown(ChartControl chartControl, ChartPanel chartPanel, ChartScale chartScale, ChartAnchor dataPoint)
	{
		if (IsSelected && DrawingState != DrawingState.Building
			&& HandleMenuClick(chartControl, chartPanel, chartScale, dataPoint))
			return;

		base.OnMouseDown(chartControl, chartPanel, chartScale, dataPoint);
	}

	private bool HandleMenuClick(ChartControl chartControl, ChartPanel chartPanel, ChartScale chartScale, ChartAnchor dataPoint)
	{
		if (dataPoint == null)
			return false;

		Point point = dataPoint.GetPoint(chartControl, chartPanel, chartScale);
		for (int i = 0; i < MenuCells; i++)
		{
			SharpDX.RectangleF r = menuCellRects[i];
			if (r.Width <= 0f || r.Height <= 0f)
				continue;
			if (point.X < r.X || point.X > r.X + r.Width || point.Y < r.Y || point.Y > r.Y + r.Height)
				continue;

			ApplyMenuAction(chartControl, i);
			return true;
		}
		return false;
	}

	private void ApplyPresetColor(int slot)
	{
		Color color = GetPresetColor(slot);
		Brush fill = new SolidColorBrush(color);
		fill.Freeze();
		AreaBrush = fill;
		// Stroke has no live color setter path here, so a new one carries the preset forward;
		// width and dash are preserved and NT rebuilds its device brush from the new stroke.
		if (OutlineStroke != null)
			OutlineStroke = new Stroke(fill, OutlineStroke.DashStyleHelper, OutlineStroke.Width) { RenderTarget = OutlineStroke.RenderTarget };
		else
			OutlineStroke = new Stroke(fill, DashStyleHelper.Solid, 2f);
		extensionBrushDevice.Brush = null;
	}

	private void ApplyMenuAction(ChartControl chartControl, int cell)
	{
		switch (cell)
		{
			case 0:
				EndMode = ExtendedRectEndMode.ToCurrentBar;
				break;
			case 1:
				EndMode			= ExtendedRectEndMode.AfterCrossCount;
				CrossDefinition	= ExtendedRectCrossDefinition.Touches;
				break;
			case 2:
				EndMode			= ExtendedRectEndMode.AfterCrossCount;
				CrossDefinition	= ExtendedRectCrossDefinition.Engulfs;
				break;
			default:
				if (cell < MenuModeCells + MenuCountCells)
					CrossCount = cell - MenuModeCells + 1;
				else if (IsMenuColorCell(cell))
					ApplyPresetColor(cell - MenuColorOffset);
				else if (!editingDescription)
				{
					editingDescription = true;
					if (chartControl != null)
						chartControl.Dispatcher.BeginInvoke(new Action(ShowDescriptionEditor));
				}
				break;
		}

		if (chartControl != null)
			chartControl.InvalidateVisual();
	}

	// --- description: rendered at the shadow's end, edited in its own small window ---

	private void InvalidateChart()
	{
		if (keyChartControl != null)
			keyChartControl.InvalidateVisual();
	}

	// The editor is a real WPF TextBox in its own window. A separate window is not a chart window,
	// so NinjaTrader's "type to search the instrument" overlay never fires for it - which is the
	// problem with trying to type directly on the chart.
	private void ShowDescriptionEditor()
	{
		try
		{
			TextBox	box		= new TextBox { Text = Description ?? string.Empty, AcceptsReturn = false, VerticalContentAlignment = VerticalAlignment.Center };
			Button	ok		= new Button { Content = "OK", Width = 70, Margin = new Thickness(4), IsDefault = true };
			Button	cancel	= new Button { Content = "Cancel", Width = 70, Margin = new Thickness(4), IsCancel = true };

			StackPanel buttons = new StackPanel
			{
				Orientation			= Orientation.Horizontal,
				HorizontalAlignment	= HorizontalAlignment.Right,
				Margin				= new Thickness(0, 6, 0, 0)
			};
			buttons.Children.Add(ok);
			buttons.Children.Add(cancel);

			DockPanel panel = new DockPanel { Margin = new Thickness(8) };
			DockPanel.SetDock(buttons, Dock.Bottom);
			panel.Children.Add(buttons);
			panel.Children.Add(box);

			Window win = new Window
			{
				Title					= "Description",
				Width					= 340,
				Height					= 150,
				WindowStartupLocation	= WindowStartupLocation.CenterScreen,
				ShowInTaskbar			= false,
				ResizeMode				= System.Windows.ResizeMode.NoResize,
				Content					= panel
			};

			Window owner = keyChartControl != null ? Window.GetWindow(keyChartControl) : null;
			if (owner != null)
			{
				win.Owner					= owner;
				win.WindowStartupLocation	= WindowStartupLocation.CenterOwner;
			}

			ok.Click		+= (s, e) => { win.DialogResult = true; };
			cancel.Click	+= (s, e) => { win.DialogResult = false; };
			win.Loaded		+= (s, e) => { box.Focus(); box.SelectAll(); };
			win.ShowDialog();

			if (win.DialogResult == true)
				Description = box.Text;
		}
		catch { }
		finally
		{
			editingDescription = false;
			InvalidateChart();
		}
	}

	private void RenderDescription(ChartControl chartControl, ChartScale chartScale)
	{
		string text = Description;
		if (string.IsNullOrEmpty(text) || !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 endX = Math.Max(px.Right, chartControl.GetXByBarIndex(chartBars, endIndex));

		// right-justified: the label's right edge sits offset pixels left of the shadow end
		double rightX = endX - Math.Max(0, DescriptionOffset);

		EnsureDescTextFormat();
		if (descTextFormat == null)
			return;

		SharpDX.Direct2D1.Brush brush = EnsureDescBrush(OutlineColor);
		if (brush == null)
			return;

		DrawRightJustifiedText(text, rightX, (px.Top + px.Bottom) / 2d, brush);
	}

	private SharpDX.Direct2D1.Brush EnsureSolidBrush(DeviceBrush device, ref Color built, Color color)
	{
		if (device.Brush == null || !ColorsEqual(color, built))
		{
			built			= color;
			device.Brush	= new SolidColorBrush(color);
		}
		device.RenderTarget = RenderTarget;
		return device.BrushDX;
	}

	private SharpDX.Direct2D1.Brush EnsureDescBrush(Color fallback)
	{
		return EnsureSolidBrush(descBrushDevice, ref builtDescColor, (DescriptionColor as SolidColorBrush)?.Color ?? fallback);
	}

	private SharpDX.Direct2D1.Brush EnsureOutlineTextBrush(Color color)
	{
		return EnsureSolidBrush(textBrushDevice, ref builtTextColor, color);
	}

	private SharpDX.Direct2D1.Brush EnsureActiveTextBrush(Color fillColor)
	{
		return EnsureSolidBrush(menuActiveTextBrushDevice, ref builtMenuActiveTextColor, ContrastColor(fillColor));
	}

	private static Color ContrastColor(Color color)
	{
		double luminance = (color.R * 0.299 + color.G * 0.587 + color.B * 0.114) / 255d;
		return luminance > 0.55 ? Colors.Black : Colors.White;
	}

	private void EnsureFormat(ref SharpDX.DirectWrite.TextFormat format, ref float builtSize, float size)
	{
		size = size > 0f ? size : 11f;
		if (format != null && builtSize == size)
			return;

		format?.Dispose();
		builtSize = size;
		format = new SharpDX.DirectWrite.TextFormat(
			Core.Globals.DirectWriteFactory, "Consolas",
			SharpDX.DirectWrite.FontWeight.Bold,
			SharpDX.DirectWrite.FontStyle.Normal,
			SharpDX.DirectWrite.FontStretch.Normal,
			size)
		{
			TextAlignment		= SharpDX.DirectWrite.TextAlignment.Leading,
			ParagraphAlignment	= SharpDX.DirectWrite.ParagraphAlignment.Near,
			WordWrapping		= SharpDX.DirectWrite.WordWrapping.NoWrap,
		};
	}

	private void EnsureTextFormat()
	{
		EnsureFormat(ref textFormat, ref builtTextSize, TextSize);
	}

	private void EnsureDescTextFormat()
	{
		EnsureFormat(ref descTextFormat, ref builtDescTextSize, DescriptionSize);
	}

	private List<int> SelectedCrossIndices()
	{
		return CrossDefinition == ExtendedRectCrossDefinition.Engulfs ? engulfCrossIndices : touchCrossIndices;
	}

	private static bool IsFirstBarOfSession(Bars bars, int barIndex)
	{
		try
		{
			return bars.IsFirstBarOfSessionByIndex(barIndex);
		}
		catch
		{
			return false;
		}
	}

	// lists are appended in ascending bar order, so this counts the crossings at or before a cap
	private static int CountUpTo(List<int> indices, int limit)
	{
		int count = 0;
		for (int i = 0; i < indices.Count; i++)
		{
			if (indices[i] > limit)
				break;
			count++;
		}
		return count;
	}

	// Resolves the bar the shadow should stop on. The crossing scan is incremental: it
	// resumes from the last bar it evaluated (scanThrough) and only does work when a new bar
	// has closed, so it effectively runs once per bar. The cache is rebuilt only when the
	// rectangle's right edge or price band changes; changing the mode, definition or count
	// reuses it. In AfterCrossCount mode the scan stops as soon as the selected stream
	// reaches the target, so no further bars are examined.
	private int GetExtensionEndIndex(ChartControl chartControl, ChartBars chartBars)
	{
		int lastIndex = chartBars.ToIndex;
		if (StartAnchor == null || EndAnchor == null)
			return lastIndex;
		if (EndMode == ExtendedRectEndMode.ToCurrentBar && !ShowTicks && !StopAtSessionEnd)
			return lastIndex;

		Bars bars = chartBars.Bars;
		if (bars == null)
			return lastIndex;

		DateTime	rightTime	= StartAnchor.Time >= EndAnchor.Time ? StartAnchor.Time : EndAnchor.Time;
		double		bandMax		= Math.Max(StartAnchor.Price, EndAnchor.Price);
		double		bandMin		= Math.Min(StartAnchor.Price, EndAnchor.Price);
		int			rightIndex	= chartBars.GetBarIdxByTime(chartControl, rightTime);
		if (rightIndex < 0)
			return lastIndex;

		// invalidated by a change of position or shape only, never by a display setting
		if (!scanInitialized
			|| scanRightTime != rightTime
			|| scanBandMin != bandMin
			|| scanBandMax != bandMax)
		{
			scanInitialized	= true;
			scanRightTime	= rightTime;
			scanBandMin		= bandMin;
			scanBandMax		= bandMax;
			scanThrough		= rightIndex;
			touchCrossIndices.Clear();
			engulfCrossIndices.Clear();
			engulfZoneSide	= ZoneSide.None;
			scanSessionFound	= false;
			scanSessionEndIndex	= -1;
		}

		// Bars.Count - 1 is the bar still forming; -2 is the most recent closed bar. Holding
		// the cursor at the last closed bar keeps a partially built bar out of the cache.
		int lastClosedIndex = bars.Count - 2;
		if (lastClosedIndex <= rightIndex)
			return ResolveEndIndex(lastIndex);

		List<int>	selected	= SelectedCrossIndices();
		int			needed		= ScanTarget;

		// target already met, the session ended, or nothing new until the next bar closes
		if (selected.Count >= needed || scanThrough >= lastClosedIndex || (StopAtSessionEnd && scanSessionFound))
			return ResolveEndIndex(lastIndex);

		for (int i = scanThrough + 1; i <= lastClosedIndex; i++)
		{
			// the rectangle's session ends at the last bar before the next session starts
			if (!scanSessionFound && IsFirstBarOfSession(bars, i))
			{
				scanSessionFound	= true;
				scanSessionEndIndex	= i - 1;
				if (StopAtSessionEnd)
					break;
			}

			double high, low;
			if (TryGetBarRange(bars, i, out high, out low))
			{
				if (TouchesStep(high, low, bandMin, bandMax))
					touchCrossIndices.Add(i);
				if (EngulfStep(high, low, bandMin, bandMax, ref engulfZoneSide))
					engulfCrossIndices.Add(i);
			}
			scanThrough = i;

			// in multi mode the shadow only needs the target, so stop scanning there
			if (selected.Count >= needed)
				break;
		}

		return ResolveEndIndex(lastIndex);
	}

	private int ResolveEndIndex(int lastIndex)
	{
		int end = lastIndex;
		if (IsMultiCross)
		{
			List<int> selected = SelectedCrossIndices();
			if (selected.Count >= NeededCrossCount)
				end = selected[NeededCrossCount - 1];
		}

		// a session-scoped shadow never runs past the end of the rectangle's session
		if (StopAtSessionEnd && scanSessionFound)
			end = Math.Min(end, scanSessionEndIndex);

		return Math.Min(end, lastIndex);
	}

	private static bool TryGetBarRange(Bars bars, int barIndex, out double high, out double low)
	{
		high = 0;
		low = 0;
		if (barIndex < 0 || barIndex >= bars.Count)
			return false;

		try
		{
			high	= bars.GetHigh(barIndex);
			low		= bars.GetLow(barIndex);
		}
		catch
		{
			return false;
		}

		return !double.IsNaN(high) && !double.IsNaN(low);
	}

	// Touches: the candle's range overlaps the zone at all.
	private static bool TouchesStep(double high, double low, double bandMin, double bandMax)
	{
		return high >= bandMin && low <= bandMax;
	}

	// Engulfs: score a crossing when price reaches the side opposite the one it last
	// registered on. A candle touching both sides spans the zone by itself.
	private static bool EngulfStep(double high, double low, double bandMin, double bandMax, ref ZoneSide side)
	{
		bool atOrAbove = high >= bandMax;
		bool atOrBelow = low <= bandMin;

		switch (side)
		{
			case ZoneSide.None:
				if (atOrAbove && atOrBelow)
					return true;
				if (atOrAbove)
					side = ZoneSide.Above;
				else if (atOrBelow)
					side = ZoneSide.Below;
				return false;

			case ZoneSide.Above:
				if (!atOrBelow)
					return false;
				side = atOrAbove ? ZoneSide.None : ZoneSide.Below;
				return true;

			case ZoneSide.Below:
				if (!atOrAbove)
					return false;
				side = atOrBelow ? ZoneSide.None : ZoneSide.Above;
				return true;
		}
		return false;
	}

	public override bool IsVisibleOnChart(ChartControl chartControl, ChartScale chartScale, DateTime firstTimeOnChart, DateTime lastTimeOnChart)
	{
		if (base.IsVisibleOnChart(chartControl, chartScale, firstTimeOnChart, lastTimeOnChart))
			return true;

		// the base check only looks at the anchors; the shadow can extend past them, so the tool
		// stays visible when the rectangle itself scrolled off to the left
		if (!TryGetRectPixels(chartControl, chartScale, out RectPixels px))
			return false;

		ChartBars chartBars = GetPrimaryChartBars(chartControl);
		if (chartBars == null)
			return false;

		int		endIndex	= GetExtensionEndIndex(chartControl, chartBars);
		double	endX		= endIndex >= 0 ? chartControl.GetXByBarIndex(chartBars, endIndex) : px.Right;

		DateTime	leftTime	= chartControl.GetTimeByX((int)px.Left);
		DateTime	rightTime	= chartControl.GetTimeByX((int)Math.Max(px.Left, endX));

		return leftTime <= lastTimeOnChart && rightTime >= firstTimeOnChart;
	}

	private static ChartBars GetPrimaryChartBars(ChartControl chartControl)
	{
		try
		{
			if (chartControl == null || chartControl.BarsArray == null || chartControl.BarsArray.Count == 0)
				return null;
			return chartControl.BarsArray[0];
		}
		catch
		{
			return null;
		}
	}

	protected override void OnStateChange()
	{
		base.OnStateChange();
		if (State == State.SetDefaults)
		{
			Name				= "ExtendedRect";
			ShapeType			= ChartShapeType.Rectangle;
			ExtensionOpacity	= 40;
			EndMode				= ExtendedRectEndMode.ToCurrentBar;
			CrossDefinition		= ExtendedRectCrossDefinition.Touches;
			CrossCount			= 3;
			ShowTicks			= true;
			TextSize			= 11f;
			StopAtSessionEnd	= false;
			Description			= string.Empty;
			DescriptionSize		= 11f;
			DescriptionColor	= Brushes.White;
			DescriptionOffset	= 6;
			ShadowPreset1		= new SolidColorBrush(Colors.CornflowerBlue);
			ShadowPreset2		= new SolidColorBrush(Colors.ForestGreen);
			ShadowPreset3		= new SolidColorBrush(Colors.Crimson);
			ShadowPreset4		= new SolidColorBrush(Colors.Gold);
		}
	}
}

}


Hi, nice.

I usually like adding the timeframe reference, ie, H4 FVG, Last Friday’s high etc,…

That is really helpful when setting up settups based on multiple timeframe analysis; ie, when you spot a H4 FVG and then wait for LTF confirmation for refined entries.