lib/PSYamlTUI.Native.cs
|
// PSYamlTUI native types.
// Compiled on first module import via Add-Type. Provides: // - MenuNode: typed replacement for the PSCustomObject menu node. Property // access is direct field/property reads (no ETS) so the render hot path // stops paying the PSCustomObject lookup tax. // - AnsiFrameBuilder: static frame composer. Builds the full bordered ANSI // menu string in C# instead of through StringBuilder-in-PowerShell, which // removes per-line scriptblock dispatch and per-call cmdlet binding. // // All public methods are pure: input -> string. They never read module-scope // state and never write to the console. The PowerShell layer keeps owning IO // and signal flags. using System; using System.Collections; using System.Collections.Generic; using System.Text; namespace PSYamlTUI { // Walks YamlDotNet's deserializer output (Dictionary<object,object> / List<object>) // and produces PowerShell-native containers (Hashtable / object[]) in one // compiled pass. Replaces the prior recursive PS Convert-YamlNode function, // which paid the PS function-call + dispatch tax on every node in the file. // String coercion of map keys matches the prior PS behaviour so downstream // consumers (Read-MenuFile, Assert-MenuItem) see exactly what they did before. public static class YamlConverter { public static object ToNative(object node) { if (node == null) { return null; } var map = node as IDictionary<object, object>; if (map != null) { var ht = new Hashtable(map.Count); foreach (var kv in map) { string key = kv.Key as string ?? (kv.Key == null ? null : kv.Key.ToString()); ht[key] = ToNative(kv.Value); } return ht; } var list = node as IList<object>; if (list != null) { var arr = new object[list.Count]; for (int i = 0; i < list.Count; i++) { arr[i] = ToNative(list[i]); } return arr; } return node; } } public sealed class MenuNode { public string NodeType { get; set; } public string Label { get; set; } public string Description { get; set; } public string Details { get; set; } public string Hotkey { get; set; } public string Call { get; set; } public IDictionary Params { get; set; } public bool Confirm { get; set; } public object[] Before { get; set; } public MenuNode[] Children { get; set; } } public static class AnsiFrameBuilder { // -- ANSI helpers ---------------------------------------------------- private const string Esc = ""; private static readonly string EraseEol = Esc + "[K"; private static readonly string NewLine = Environment.NewLine; // Truncate to maxLen, replacing the tail with '...' when needed. // Matches the behaviour of Get-TruncatedLabel in Show-MenuFrame.ps1. public static string Truncate(string text, int maxLen) { if (text == null) { return string.Empty; } if (text.Length <= maxLen) { return text; } if (maxLen <= 3) { return "..."; } return text.Substring(0, maxLen - 3) + "..."; } private static string Repeat(string s, int n) { if (n <= 0 || string.IsNullOrEmpty(s)) { return string.Empty; } var sb = new StringBuilder(s.Length * n); for (int i = 0; i < n; i++) { sb.Append(s); } return sb.ToString(); } // Mirrors Get-HRule -- one full horizontal rule line. private static string HRule(string left, string right, int innerWidth, string horizontal) { return left + Repeat(horizontal, innerWidth) + right; } // Bordered content line: "│ <styled-text><padding> │" with vis-length aware padding. private static string MakeLine( string visText, string styledText, int contentWidth, string vertical, string abrdr, string rst) { int pad = Math.Max(0, contentWidth - visText.Length); return abrdr + vertical + rst + " " + styledText + new string(' ', pad) + " " + abrdr + vertical + rst; } // -- Item line builder ---------------------------------------------- // Replaces the PS Get-AnsiItemLine function. Same input/output contract. public static string BuildItemLine( MenuNode item, bool isSelected, int itemIndex, int itemCount, bool indexNavigation, int contentWidth, IDictionary chars, string abrdr, string aitem, string asel, string ahk, string rst) { string vertical = (string)chars["Vertical"]; string arrow = (string)chars["Arrow"]; string selectorGlyph = (string)chars["Selected"]; // Build the trailing branch-arrow / hotkey decoration first. string suffixVis = string.Empty; if (string.Equals(item.NodeType, "BRANCH", StringComparison.Ordinal)) { suffixVis += " " + arrow; } if (!indexNavigation && !string.IsNullOrEmpty(item.Hotkey)) { suffixVis += " [" + item.Hotkey.ToUpperInvariant() + "]"; } string itemVisRaw; string styledItem; if (indexNavigation) { int indexPrefixLen = (itemCount >= 10) ? 4 : 3; string indexPrefix = (itemCount >= 10) ? ((itemIndex + 1).ToString() + ". ").PadLeft(4) : (itemIndex + 1).ToString() + ". "; int maxLabelLen = Math.Max(0, contentWidth - indexPrefixLen - suffixVis.Length); string labelVis = Truncate(item.Label, maxLabelLen); itemVisRaw = indexPrefix + labelVis + suffixVis; string styledSuffix = (suffixVis.Length > 0) ? (ahk + suffixVis + rst) : string.Empty; styledItem = ahk + indexPrefix + rst + aitem + labelVis + rst + styledSuffix; } else { string selector = isSelected ? selectorGlyph : " "; int maxLabelLen = Math.Max(0, contentWidth - 2 - suffixVis.Length); string labelVis = Truncate(item.Label, maxLabelLen); itemVisRaw = selector + " " + labelVis + suffixVis; string styledSuffix = (suffixVis.Length > 0) ? (ahk + suffixVis + rst) : string.Empty; if (isSelected) { styledItem = asel + selector + rst + " " + asel + labelVis + rst + styledSuffix; } else { styledItem = " " + aitem + labelVis + rst + styledSuffix; } } int pad = Math.Max(0, contentWidth - itemVisRaw.Length); return abrdr + vertical + rst + " " + styledItem + new string(' ', pad) + " " + abrdr + vertical + rst; } // -- Full frame builder --------------------------------------------- // Replaces the PS Build-AnsiFrame function. All values explicit; no module-scope reads. public static string BuildFrame( string title, MenuNode[] items, int selectedIndex, string[] breadcrumb, int innerWidth, IDictionary chars, string footerText, IDictionary statusData, bool indexNavigation, IDictionary ansiCodes) { string rst = (string)ansiCodes["Reset"]; string abrdr = (string)ansiCodes["Border"]; string atitle = (string)ansiCodes["Title"]; string acrumb = (string)ansiCodes["Breadcrumb"]; string aitem = (string)ansiCodes["ItemDefault"]; string asel = (string)ansiCodes["ItemSelected"]; string ahk = (string)ansiCodes["ItemHotkey"]; string adesc = (string)ansiCodes["ItemDescription"]; string aslbl = (string)ansiCodes["StatusLabel"]; string asval = (string)ansiCodes["StatusValue"]; string aftr = (string)ansiCodes["FooterText"]; string vertical = (string)chars["Vertical"]; string horizontal = (string)chars["Horizontal"]; string topLeft = (string)chars["TopLeft"]; string topRight = (string)chars["TopRight"]; string bottomLeft = (string)chars["BottomLeft"]; string bottomRight = (string)chars["BottomRight"]; string leftT = (string)chars["LeftT"]; string rightT = (string)chars["RightT"]; string arrow = (string)chars["Arrow"]; // ESC[K (erase to end-of-line) before every newline keeps the right // edge clean when the terminal is wider than the frame. string nl = EraseEol + NewLine; int cw = innerWidth - 2; var sb = new StringBuilder(2048); // -- Top border -------------------------------------------------- sb.Append(abrdr).Append(HRule(topLeft, topRight, innerWidth, horizontal)).Append(rst).Append(nl); // -- Title ------------------------------------------------------- string titleVis = Truncate(title, cw); sb.Append(MakeLine(titleVis, atitle + titleVis + rst, cw, vertical, abrdr, rst)).Append(nl); // -- Breadcrumb -------------------------------------------------- if (breadcrumb != null && breadcrumb.Length > 0) { string crumbVis = string.Join(" " + arrow + " ", breadcrumb); crumbVis = Truncate(crumbVis, cw); sb.Append(MakeLine(crumbVis, acrumb + crumbVis + rst, cw, vertical, abrdr, rst)).Append(nl); } // -- Separator + spacer ------------------------------------------ sb.Append(abrdr).Append(HRule(leftT, rightT, innerWidth, horizontal)).Append(rst).Append(nl); sb.Append(MakeLine(string.Empty, string.Empty, cw, vertical, abrdr, rst)).Append(nl); // -- Items ------------------------------------------------------- int itemCount = (items != null) ? items.Length : 0; for (int i = 0; i < itemCount; i++) { MenuNode item = items[i]; bool isSelected = (i == selectedIndex); sb.Append(BuildItemLine(item, isSelected, i, itemCount, indexNavigation, cw, chars, abrdr, aitem, asel, ahk, rst)).Append(nl); // Description sub-line for the selected item (suppressed in index mode). if (!indexNavigation && isSelected && !string.IsNullOrEmpty(item.Description)) { string descPfx = " "; string descVis = descPfx + Truncate(item.Description, cw - descPfx.Length); sb.Append(MakeLine(descVis, adesc + descVis + rst, cw, vertical, abrdr, rst)).Append(nl); } } // -- Empty line -------------------------------------------------- sb.Append(MakeLine(string.Empty, string.Empty, cw, vertical, abrdr, rst)).Append(nl); // -- Status bar (optional) --------------------------------------- if (statusData != null && statusData.Count > 0) { int maxLblLen = 0; foreach (object k in statusData.Keys) { string ks = k as string ?? (k == null ? string.Empty : k.ToString()); if (ks.Length > maxLblLen) { maxLblLen = ks.Length; } } // Cap label column at half content width so the value column always has space. int half = (int)Math.Floor((double)cw / 2); if (maxLblLen > half) { maxLblLen = half; } int valMaxLen = Math.Max(1, cw - maxLblLen - 2); sb.Append(abrdr).Append(HRule(leftT, rightT, innerWidth, horizontal)).Append(rst).Append(nl); foreach (object k in statusData.Keys) { string ks = k as string ?? (k == null ? string.Empty : k.ToString()); string lblVis = Truncate(ks, maxLblLen).PadRight(maxLblLen); object rawVal = statusData[k]; string valVis = Truncate(rawVal == null ? string.Empty : rawVal.ToString(), valMaxLen); string rowVis = lblVis + " " + valVis; string rowStyled = aslbl + lblVis + rst + " " + asval + valVis + rst; sb.Append(MakeLine(rowVis, rowStyled, cw, vertical, abrdr, rst)).Append(nl); } } // -- Footer separator + content + bottom border ------------------ sb.Append(abrdr).Append(HRule(leftT, rightT, innerWidth, horizontal)).Append(rst).Append(nl); string footVis = Truncate(footerText ?? string.Empty, cw); sb.Append(MakeLine(footVis, aftr + footVis + rst, cw, vertical, abrdr, rst)).Append(nl); sb.Append(abrdr).Append(HRule(bottomLeft, bottomRight, innerWidth, horizontal)).Append(rst).Append(nl); return sb.ToString(); } } } |