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