diff --git a/Pansies.sln b/Pansies.sln new file mode 100644 index 0000000..719cbb1 --- /dev/null +++ b/Pansies.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pansies", "Pansies.csproj", "{9037884B-464E-7D76-D4CA-093F85B25B46}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9037884B-464E-7D76-D4CA-093F85B25B46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9037884B-464E-7D76-D4CA-093F85B25B46}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9037884B-464E-7D76-D4CA-093F85B25B46}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9037884B-464E-7D76-D4CA-093F85B25B46}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B791020B-78E4-4968-B1D3-1EB8DB93C7C8} + EndGlobalSection +EndGlobal diff --git a/Source/Assembly/ColorSpaceConfiguration.cs b/Source/Assembly/ColorSpaceConfiguration.cs new file mode 100644 index 0000000..eea8a56 --- /dev/null +++ b/Source/Assembly/ColorSpaceConfiguration.cs @@ -0,0 +1,26 @@ +using PoshCode.Pansies.ColorSpaces; +using PoshCode.Pansies.ColorSpaces.Conversions; + +namespace PoshCode.Pansies +{ + /// + /// Provides helpers for configuring color space defaults exposed to PowerShell consumers. + /// + public static class ColorSpaceConfiguration + { + public static IXyz GetWhiteReference() + { + return XyzConverter.GetWhiteReference(); + } + + public static void SetWhiteReference(IXyz whiteReference) + { + XyzConverter.SetWhiteReference(whiteReference); + } + + public static void ResetWhiteReference() + { + XyzConverter.ResetWhiteReference(); + } + } +} diff --git a/Source/Assembly/ColorSpaces/Conversions/XyzConverter.cs b/Source/Assembly/ColorSpaces/Conversions/XyzConverter.cs index 9748039..a5848f9 100644 --- a/Source/Assembly/ColorSpaces/Conversions/XyzConverter.cs +++ b/Source/Assembly/ColorSpaces/Conversions/XyzConverter.cs @@ -4,17 +4,70 @@ namespace PoshCode.Pansies.ColorSpaces.Conversions { internal static class XyzConverter { + private static readonly object WhiteReferenceLock = new object(); + private static readonly Xyz DefaultWhiteReference = new Xyz + { + X = 95.047, + Y = 100.000, + Z = 108.883 + }; + #region Constants/Helper methods for Xyz related spaces - internal static IXyz WhiteReference { get; private set; } // TODO: Hard-coded! + internal static IXyz WhiteReference { get; private set; } internal const double Epsilon = 0.008856; // Intent is 216/24389 internal const double Kappa = 903.3; // Intent is 24389/27 + static XyzConverter() { - WhiteReference = new Xyz + ResetWhiteReference(); + } + + public static IXyz GetWhiteReference() + { + lock (WhiteReferenceLock) + { + return Clone(WhiteReference ?? DefaultWhiteReference); + } + } + + public static void SetWhiteReference(IXyz whiteReference) + { + if (whiteReference is null) + { + throw new ArgumentNullException(nameof(whiteReference)); + } + + lock (WhiteReferenceLock) + { + WhiteReference = Clone(whiteReference); + } + } + + public static void ResetWhiteReference() + { + lock (WhiteReferenceLock) + { + WhiteReference = Clone(DefaultWhiteReference); + } + } + + private static Xyz Clone(IXyz source) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is Xyz xyz) + { + return new Xyz(xyz.X, xyz.Y, xyz.Z); + } + + return new Xyz { - X = 95.047, - Y = 100.000, - Z = 108.883 + X = source.X, + Y = source.Y, + Z = source.Z }; } @@ -72,4 +125,4 @@ private static double PivotRgb(double n) return (n > 0.04045 ? Math.Pow((n + 0.055) / 1.055, 2.4) : n / 12.92) * 100.0; } } -} \ No newline at end of file +} diff --git a/Source/Assembly/Commands/ExpandVariableCommand.cs b/Source/Assembly/Commands/ExpandVariableCommand.cs new file mode 100644 index 0000000..55b83e2 --- /dev/null +++ b/Source/Assembly/Commands/ExpandVariableCommand.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Text; + +namespace PoshCode.Pansies.Commands +{ + [Cmdlet("Expand", "Variable", DefaultParameterSetName = ParameterSetContent)] + [OutputType(typeof(string))] + public sealed class ExpandVariableCommand : PSCmdlet + { + private const string ParameterSetPath = "Path"; + private const string ParameterSetContent = "Content"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, ParameterSetName = ParameterSetPath)] + [Alias("PSPath")] + public string Path { get; set; } = string.Empty; + + [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = ParameterSetContent)] + public string Content { get; set; } = string.Empty; + + [Parameter] + public SwitchParameter Unescaped { get; set; } + + /// + /// Specifies which drives to use for variable expansion. Each drive represents a different source or type of variable: + /// + /// bgBackground color variables. + /// emojiEmoji variables. + /// escEscape sequence variables. + /// extraExtra or custom variables. + /// fgForeground color variables. + /// nfNerd Font icon variables. + /// variableStandard PowerShell variables. + /// + /// By specifying one or more drives, you control which sources are used during variable expansion. + /// + [Parameter] + public string[] Drive { get; set; } = new[] { "bg", "emoji", "esc", "extra", "fg", "nf", "variable" }; + + [Parameter(ParameterSetName = ParameterSetPath)] + public SwitchParameter InPlace { get; set; } + + [Parameter(ParameterSetName = ParameterSetPath)] + public SwitchParameter Passthru { get; set; } + + protected override void ProcessRecord() + { + if (ParameterSetName == ParameterSetContent) + { + var result = ExpandVariable(Content ?? string.Empty, "Content"); + + if (result != null) + { + WriteObject(result); + } + + return; + } + + var resolvedPaths = GetResolvedProviderPathFromPSPath(Path, out var providerInfo); + + foreach (var resolvedPath in resolvedPaths) + { + var fullName = BuildProviderQualifiedPath(providerInfo, resolvedPath); + var value = GetVariableValue(fullName); + var replacement = ExpandVariable(value?.ToString() ?? string.Empty, fullName); + + if (replacement is null) + { + continue; + } + + var isVariableProvider = string.Equals(providerInfo.Name, "Variable", StringComparison.OrdinalIgnoreCase); + var passthruPath = fullName; + + if (isVariableProvider && fullName.IndexOf(':') < 0) + { + passthruPath = providerInfo.Name + ":" + fullName; + } + + if (InPlace) + { + if (isVariableProvider) + { + var variableName = passthruPath; + + var providerSeparator = variableName.IndexOf(':'); + + if (providerSeparator >= 0) + { + variableName = variableName.Substring(providerSeparator + 1); + } + + var variable = SessionState.PSVariable.Get(variableName); + + if (variable != null) + { + variable.Value = replacement; + SessionState.PSVariable.Set(variable); + } + else + { + SessionState.PSVariable.Set(variableName, replacement); + } + } + else + { + SessionState.InvokeProvider.Item.Set(passthruPath, replacement); + } + + if (Passthru) + { + if (isVariableProvider) + { + WriteObject(replacement); + } + else + { + WriteObject(SessionState.InvokeProvider.Item.Get(passthruPath), true); + } + } + } + else + { + WriteObject(replacement); + } + } + } + + private static string BuildProviderQualifiedPath(ProviderInfo providerInfo, string path) + { + if (providerInfo.Name == "Variable" || providerInfo.Name == "FileSystem" || path.Contains(':')) + { + return path; + } + + return $"{providerInfo.Name}:{path}"; + } + + private string ExpandVariable(string code, string source) + { + var replacements = new List(); + var ast = Parser.ParseInput(code, source, out _, out var errors); + + if (errors.Length > 0) + { + WriteError(new ErrorRecord( + new ParseException($"{errors.Length} Parse Errors in {source}, cannot expand."), + "ParseErrors", + ErrorCategory.InvalidOperation, + source)); + + return null; + } + + var drives = new HashSet(Drive, StringComparer.OrdinalIgnoreCase); + + var variables = ast + .FindAll(node => node is VariableExpressionAst, searchNestedScriptBlocks: true) + .OfType() + .Where(variable => ShouldExpand(variable, drives)); + + foreach (var variable in variables) + { + try + { + var replacement = GetVariableValue(variable.VariablePath.UserPath)?.ToString() ?? string.Empty; + + if (!Unescaped) + { + replacement = replacement.ToPsEscapedString(); + } + + if (variable.Parent is ExpandableStringExpressionAst) + { + replacements.Add(new TextReplacement(replacement, variable.Extent)); + } + else + { + replacements.Add(new TextReplacement("\"" + replacement + "\"", variable.Extent)); + } + } + catch + { + WriteWarning($"VariableNotFound: '{variable.VariablePath.UserPath}' at {source}:{variable.Extent.StartLineNumber}:{variable.Extent.StartColumnNumber}"); + } + } + + var builder = new StringBuilder(code); + + foreach (var replacement in replacements.OrderByDescending(r => r.StartOffset)) + { + builder.Remove(replacement.StartOffset, replacement.Length) + .Insert(replacement.StartOffset, replacement.Text); + } + + return builder.ToString(); + } + + private bool ShouldExpand(VariableExpressionAst variable, HashSet drives) + { + if (!variable.VariablePath.IsDriveQualified) + { + return drives.Contains("variable"); + } + + return drives.Contains(variable.VariablePath.DriveName ?? "variable"); + } + + private sealed class TextReplacement + { + public TextReplacement(string text, IScriptExtent extent) + { + Text = text; + StartOffset = extent.StartOffset; + EndOffset = extent.EndOffset; + } + + public string Text { get; } + + public int StartOffset { get; } + + public int EndOffset { get; } + + public int Length => EndOffset - StartOffset; + } + + private sealed class ParseException : Exception + { + public ParseException(string message) + : base(message) + { + } + } + } +} diff --git a/Source/Assembly/Commands/RestoreCursorPosition.cs b/Source/Assembly/Commands/RestoreCursorPosition.cs new file mode 100644 index 0000000..83b46b7 --- /dev/null +++ b/Source/Assembly/Commands/RestoreCursorPosition.cs @@ -0,0 +1,14 @@ +using System; +using System.Management.Automation; + +namespace PoshCode.Pansies.Commands +{ + [Cmdlet("Restore", "CursorPosition")] + public sealed class RestoreCursorPositionCommand : Cmdlet + { + protected override void EndProcessing() + { + Console.Write("\u001b8"); + } + } +} diff --git a/Source/Assembly/Commands/SaveCursorPosition.cs b/Source/Assembly/Commands/SaveCursorPosition.cs new file mode 100644 index 0000000..3c18821 --- /dev/null +++ b/Source/Assembly/Commands/SaveCursorPosition.cs @@ -0,0 +1,14 @@ +using System; +using System.Management.Automation; + +namespace PoshCode.Pansies.Commands +{ + [Cmdlet("Save", "CursorPosition")] + public sealed class SaveCursorPositionCommand : Cmdlet + { + protected override void EndProcessing() + { + Console.Write("\u001b[s"); + } + } +} diff --git a/Source/Assembly/Commands/SetCursorPosition.cs b/Source/Assembly/Commands/SetCursorPosition.cs new file mode 100644 index 0000000..1f8ea91 --- /dev/null +++ b/Source/Assembly/Commands/SetCursorPosition.cs @@ -0,0 +1,24 @@ +using System.Management.Automation; + +namespace PoshCode.Pansies.Commands +{ + [Cmdlet("Set", "CursorPosition")] + [OutputType(typeof(string))] + public sealed class SetCursorPositionCommand : Cmdlet + { + [Parameter(Position = 0)] + public int? Line { get; set; } + + [Parameter(Position = 1)] + public int? Column { get; set; } + + [Parameter] + public SwitchParameter Absolute { get; set; } + + protected override void EndProcessing() + { + var position = new Position(Line, Column, Absolute); + WriteObject(position.ToString()); + } + } +} diff --git a/Source/Assembly/IPsMetadataSerializable.cs b/Source/Assembly/IPsMetadataSerializable.cs new file mode 100644 index 0000000..432f2e3 --- /dev/null +++ b/Source/Assembly/IPsMetadataSerializable.cs @@ -0,0 +1,11 @@ +namespace PoshCode +{ + // Two rules for IPsMetadataSerializable implementations: + // 1. Provide a public parameterless constructor + // 2. Ensure FromPsMetadata can interpret the value produced by ToPsMetadata + public interface IPsMetadataSerializable + { + string ToPsMetadata(); + void FromPsMetadata(string metadata); + } +} diff --git a/Source/Assembly/Position.cs b/Source/Assembly/Position.cs new file mode 100644 index 0000000..5c4d1ba --- /dev/null +++ b/Source/Assembly/Position.cs @@ -0,0 +1,123 @@ +using System; +using System.Globalization; + +namespace PoshCode.Pansies +{ + public class Position : IPsMetadataSerializable + { + public bool Absolute { get; set; } + + public int? Line { get; set; } + + public int? Column { get; set; } + + public Position() + { + } + + public Position(int? line, int? column, bool absolute = false) + { + Line = line; + Column = column; + Absolute = absolute; + } + + public Position(string metadata) + { + FromPsMetadata(metadata); + } + + public override string ToString() + { + if (Line is null && Column is null) + { + return string.Empty; + } + + if (Absolute) + { + if (Line is null) + { + return "\u001b" + $"[{Column}G"; + } + + if (Column is null) + { + return "\u001b" + $"[{Line}d"; + } + + return "\u001b" + $"[{Line};{Column}H"; + } + + if (Column != null) + { + if (Column > 0) + { + return "\u001b" + $"[{Column}C"; + } + + if (Column < 0) + { + return "\u001b" + $"[{-Column}D"; + } + } + + if (Line != null) + { + if (Line > 0) + { + return "\u001b" + $"[{Line}B"; + } + + if (Line < 0) + { + return "\u001b" + $"[{-Line}A"; + } + } + + return string.Empty; + } + + public string ToPsMetadata() + { + return $"{Line};{Column}{(Absolute ? ";1" : string.Empty)}"; + } + + public void FromPsMetadata(string metadata) + { + if (string.IsNullOrEmpty(metadata)) + { + throw new ArgumentNullException(nameof(metadata)); + } + + var data = metadata.Split(';'); + + if (data.Length >= 3) + { + Absolute = data[2] == "1"; + } + else + { + Absolute = false; + } + + if (data.Length >= 1 && data[0].Length > 0) + { + Line = int.Parse(data[0], NumberFormatInfo.InvariantInfo); + } + else + { + Line = null; + } + + if (data.Length >= 2 && data[1].Length > 0) + { + Column = int.Parse(data[1], NumberFormatInfo.InvariantInfo); + } + else + { + Column = null; + } + } + } +} diff --git a/Source/Assembly/RgbColor.cs b/Source/Assembly/RgbColor.cs index 0cea479..a821ca1 100644 --- a/Source/Assembly/RgbColor.cs +++ b/Source/Assembly/RgbColor.cs @@ -16,12 +16,13 @@ public partial class RgbColor : Rgb, IEquatable private static ConsolePalette _consolePalette; private static XTermPalette _xTermPalette; private static X11Palette _x11Palette; + private static readonly ColorMode DefaultColorMode = GetRecommendedColorMode(); public static ConsolePalette ConsolePalette { get { - if(null == _consolePalette) + if (null == _consolePalette) { _consolePalette = new ConsolePalette(); } @@ -71,10 +72,7 @@ public static X11Palette X11Palette #region private ctors (to be removed?) private RgbColor(byte xTerm256Index) { - // TODO: Need a SetXTermColor to set the actual RGB values - _mode = ColorMode.XTerm256; - index = xTerm256Index; - Initialize(XTermPalette[index]); + SetXTermColor(xTerm256Index); } private RgbColor(int rgb) @@ -192,8 +190,7 @@ public void FromPsMetadata(string metadata) try { index = ParseXtermIndex(color.Substring(2)); - _mode = ColorMode.XTerm256; - RGB = XTermPalette[index].RGB; + SetXTermColor(index); return; } catch (Exception ex) @@ -208,8 +205,7 @@ public void FromPsMetadata(string metadata) try { index = ParseXtermIndex(color); - _mode = ColorMode.XTerm256; - RGB = XTermPalette[index].RGB; + SetXTermColor(index); return; } catch { } @@ -287,10 +283,14 @@ private static int ParseXtermIndex(string xTermIndex) public static RgbColor FromXTermIndex(string xTermIndex) { - // handle #rrggbb hex strings like CSS colors .... + if (string.IsNullOrEmpty(xTermIndex)) + { + throw new ArgumentException("xTermIndex cannot be null or empty", nameof(xTermIndex)); + } + if (xTermIndex[0] == 'x' || xTermIndex[0] == 'X') { - if (xTermIndex[1] == 't' || xTermIndex[1] == 'T') + if (s.StartsWith("xt", StringComparison.OrdinalIgnoreCase)) { xTermIndex = xTermIndex.Substring(2); } @@ -299,12 +299,9 @@ public static RgbColor FromXTermIndex(string xTermIndex) xTermIndex = xTermIndex.Substring(1); } } + var result = ParseXtermIndex(xTermIndex); - return new RgbColor - { - _mode = ColorMode.XTerm256, - index = result - }; + return CreateFromXTermIndex(result); } @@ -386,6 +383,107 @@ public static RgbColor ConvertFrom(object inputData) return new RgbColor(); } + private void SetXTermColor(int xTermIndex) + { + if (xTermIndex < 0 || xTermIndex > 255) + { + throw new ArgumentOutOfRangeException(nameof(xTermIndex), "xTerm index must be between 0 and 255"); + } + + _mode = ColorMode.XTerm256; + index = xTermIndex; + Initialize(XTermPalette[index]); + } + + private static RgbColor CreateFromXTermIndex(int xTermIndex) + { + var color = new RgbColor(); + color.SetXTermColor(xTermIndex); + return color; + } + + public static ColorMode GetRecommendedColorMode(IDictionary environment = null, OperatingSystem os = null) + { + if (environment == null) + { + environment = GetEnvironmentVariables(); + } + + if (os == null) + { + os = Environment.OSVersion; + } + + var isWindows = os.Platform == PlatformID.Win32NT; + + if (isWindows) + { + if (os.Version.Major >= 10) + { + return ColorMode.Rgb24Bit; + } + + return ColorMode.ConsoleColor; + } + + var colorTerm = GetEnvironmentValue(environment, "COLORTERM"); + if (!string.IsNullOrEmpty(colorTerm)) + { + if (colorTerm.IndexOf("truecolor", StringComparison.OrdinalIgnoreCase) >= 0 || + colorTerm.IndexOf("24bit", StringComparison.OrdinalIgnoreCase) >= 0) + { + return ColorMode.Rgb24Bit; + } + } + + var term = GetEnvironmentValue(environment, "TERM"); + if (!string.IsNullOrEmpty(term)) + { + if (term.IndexOf("truecolor", StringComparison.OrdinalIgnoreCase) >= 0 || + term.IndexOf("direct", StringComparison.OrdinalIgnoreCase) >= 0) + { + return ColorMode.Rgb24Bit; + } + + if (term.IndexOf("256color", StringComparison.OrdinalIgnoreCase) >= 0) + { + return ColorMode.XTerm256; + } + + if (term.IndexOf("color", StringComparison.OrdinalIgnoreCase) >= 0) + { + return ColorMode.ConsoleColor; + } + } + + return ColorMode.ConsoleColor; + } + + private static IDictionary GetEnvironmentVariables() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (entry.Key is string key) + { + result[key] = entry.Value?.ToString(); + } + } + + return result; + } + + private static string GetEnvironmentValue(IDictionary environment, string key) + { + if (environment != null && environment.TryGetValue(key, out var value)) + { + return value; + } + + return null; + } + private void SetConsoleColor(ConsoleColor color) { _mode = ColorMode.ConsoleColor; @@ -403,8 +501,7 @@ private void SetX11Color(X11ColorName color) /// /// The default ColorMode for the console /// - // TODO: Detect from platform. Should default to RGB on Windows 10, use TERM on others - public static ColorMode ColorMode { get; set; } = ColorMode.Automatic; + public static ColorMode ColorMode { get; set; } = DefaultColorMode; public ColorMode Mode { get => _mode; set => _mode = value; } @@ -519,7 +616,7 @@ public string ToVtEscapeSequence(bool background = false, ColorMode? mode = null { mode = RgbColor.ColorMode; } - else if(_mode != ColorMode.Automatic) + else if (_mode != ColorMode.Automatic) { mode = _mode; } @@ -532,63 +629,63 @@ public string ToVtEscapeSequence(bool background = false, ColorMode? mode = null switch (mode.Value) { case ColorMode.ConsoleColor: - { - switch (this.ConsoleColor) { - case ConsoleColor.Black: - return background ? "\u001B[40m" : "\u001B[30m"; - case ConsoleColor.Blue: - return background ? "\u001B[104m" : "\u001B[94m"; - case ConsoleColor.Cyan: - return background ? "\u001B[106m" : "\u001B[96m"; - case ConsoleColor.DarkBlue: - return background ? "\u001B[44m" : "\u001B[34m"; - case ConsoleColor.DarkCyan: - return background ? "\u001B[46m" : "\u001B[36m"; - case ConsoleColor.DarkGray: - return background ? "\u001B[100m" : "\u001B[90m"; - case ConsoleColor.DarkGreen: - return background ? "\u001B[42m" : "\u001B[32m"; - case ConsoleColor.DarkMagenta: - return background ? "\u001B[45m" : "\u001B[35m"; - case ConsoleColor.DarkRed: - return background ? "\u001B[41m" : "\u001B[31m"; - case ConsoleColor.DarkYellow: - return background ? "\u001B[43m" : "\u001B[33m"; - case ConsoleColor.Gray: - return background ? "\u001B[47m" : "\u001B[37m"; - case ConsoleColor.Green: - return background ? "\u001B[102m" : "\u001B[92m"; - case ConsoleColor.Magenta: - return background ? "\u001B[105m" : "\u001B[95m"; - case ConsoleColor.Red: - return background ? "\u001B[101m" : "\u001B[91m"; - case ConsoleColor.White: - return background ? "\u001B[107m" : "\u001B[97m"; - case ConsoleColor.Yellow: - return background ? "\u001B[103m" : "\u001B[93m"; - default: - return background ? "\u001B[49m" : "\u001B[39m"; + switch (this.ConsoleColor) + { + case ConsoleColor.Black: + return background ? "\u001B[40m" : "\u001B[30m"; + case ConsoleColor.Blue: + return background ? "\u001B[104m" : "\u001B[94m"; + case ConsoleColor.Cyan: + return background ? "\u001B[106m" : "\u001B[96m"; + case ConsoleColor.DarkBlue: + return background ? "\u001B[44m" : "\u001B[34m"; + case ConsoleColor.DarkCyan: + return background ? "\u001B[46m" : "\u001B[36m"; + case ConsoleColor.DarkGray: + return background ? "\u001B[100m" : "\u001B[90m"; + case ConsoleColor.DarkGreen: + return background ? "\u001B[42m" : "\u001B[32m"; + case ConsoleColor.DarkMagenta: + return background ? "\u001B[45m" : "\u001B[35m"; + case ConsoleColor.DarkRed: + return background ? "\u001B[41m" : "\u001B[31m"; + case ConsoleColor.DarkYellow: + return background ? "\u001B[43m" : "\u001B[33m"; + case ConsoleColor.Gray: + return background ? "\u001B[47m" : "\u001B[37m"; + case ConsoleColor.Green: + return background ? "\u001B[102m" : "\u001B[92m"; + case ConsoleColor.Magenta: + return background ? "\u001B[105m" : "\u001B[95m"; + case ConsoleColor.Red: + return background ? "\u001B[101m" : "\u001B[91m"; + case ConsoleColor.White: + return background ? "\u001B[107m" : "\u001B[97m"; + case ConsoleColor.Yellow: + return background ? "\u001B[103m" : "\u001B[93m"; + default: + return background ? "\u001B[49m" : "\u001B[39m"; + } } - } case ColorMode.XTerm256: - { - var format = string.Format(background ? "\u001B[48;5;{0}m" : "\u001B[38;5;{0}m", XTerm256Index); - return format; - } + { + var format = string.Format(background ? "\u001B[48;5;{0}m" : "\u001B[38;5;{0}m", XTerm256Index); + return format; + } case ColorMode.Rgb24Bit: default: - { - if (RGB < 0) { - return string.Empty; - } + if (RGB < 0) + { + return string.Empty; + } - var format = string.Format(background ? "\u001B[48;2;{0:n0};{1:n0};{2:n0}m" : "\u001B[38;2;{0:n0};{1:n0};{2:n0}m", R, G, B); - return format; - } + var format = string.Format(background ? "\u001B[48;2;{0:n0};{1:n0};{2:n0}m" : "\u001B[38;2;{0:n0};{1:n0};{2:n0}m", R, G, B); + return format; + } } } @@ -669,3 +766,4 @@ public static string VtEscapeSequence(int color, bool background) } } } + diff --git a/Source/Assembly/StringExtensions.cs b/Source/Assembly/StringExtensions.cs new file mode 100644 index 0000000..ce19ac3 --- /dev/null +++ b/Source/Assembly/StringExtensions.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace PoshCode.Pansies +{ + public static class StringExtensions + { + public static IEnumerable ToUtf32(this string value) + { + for (var i = 0; i < value.Length; i++) + { + if (char.IsHighSurrogate(value[i])) + { + if (value.Length <= i + 1 || !char.IsLowSurrogate(value[i + 1])) + { + throw new InvalidDataException("High surrogate must be followed by a low surrogate."); + } + + yield return char.ConvertToUtf32(value[i], value[++i]); + } + else + { + yield return value[i]; + } + } + } + + public static string ToPsEscapedString(this string value) + { + var builder = new StringBuilder(); + + for (var i = 0; i < value.Length; i++) + { + if (char.IsHighSurrogate(value[i])) + { + if (value.Length <= i + 1 || !char.IsLowSurrogate(value[i + 1])) + { + throw new InvalidDataException("High surrogate must be followed by a low surrogate."); + } + + builder.AppendFormat("`u{{{0:x6}}}", char.ConvertToUtf32(value[i], value[++i])); + continue; + } + + var current = value[i]; + + switch (current) + { + case '\u001b': + builder.Append("`e"); + break; + case '`': + builder.Append("``"); + break; + case '$': + builder.Append("`$"); + break; + case '"': + builder.Append("`\""); + break; + default: + if (current < 32 || current > 126) + { + builder.AppendFormat("`u{{{0:x4}}}", (int)current); + } + else + { + builder.Append(current); + } + break; + } + } + + return builder.ToString(); + } + } +} diff --git a/Source/Pansies.deps.json b/Source/Pansies.deps.json new file mode 100644 index 0000000..12a1502 --- /dev/null +++ b/Source/Pansies.deps.json @@ -0,0 +1,93 @@ +{ + "runtimeTarget": { + "name": ".NETStandard,Version=v2.0/", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETStandard,Version=v2.0": {}, + ".NETStandard,Version=v2.0/": { + "Pansies/1.0.0": { + "dependencies": { + "CodeOwls.PowerShell.Paths": "1.0.0", + "CodeOwls.PowerShell.Provider": "1.0.0" + }, + "runtime": { + "Pansies.dll": {} + } + }, + "System.Security.AccessControl/5.0.0": { + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + }, + "runtime": { + "lib/netstandard2.0/System.Security.AccessControl.dll": { + "assemblyVersion": "5.0.0.0", + "fileVersion": "5.0.20.51904" + } + } + }, + "System.Security.Principal.Windows/5.0.0": { + "runtime": { + "lib/netstandard2.0/System.Security.Principal.Windows.dll": { + "assemblyVersion": "5.0.0.0", + "fileVersion": "5.0.20.51904" + } + } + }, + "CodeOwls.PowerShell.Paths/1.0.0": { + "dependencies": { + "System.Security.AccessControl": "5.0.0" + }, + "runtime": { + "CodeOwls.PowerShell.Paths.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "CodeOwls.PowerShell.Provider/1.0.0": { + "dependencies": { + "CodeOwls.PowerShell.Paths": "1.0.0" + }, + "runtime": { + "CodeOwls.PowerShell.Provider.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + } + } + }, + "libraries": { + "Pansies/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "System.Security.AccessControl/5.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "path": "system.security.accesscontrol/5.0.0", + "hashPath": "system.security.accesscontrol.5.0.0.nupkg.sha512" + }, + "System.Security.Principal.Windows/5.0.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==", + "path": "system.security.principal.windows/5.0.0", + "hashPath": "system.security.principal.windows.5.0.0.nupkg.sha512" + }, + "CodeOwls.PowerShell.Paths/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "CodeOwls.PowerShell.Provider/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/Source/Pansies.psd1 b/Source/Pansies.psd1 index ce5e126..6d30d54 100644 --- a/Source/Pansies.psd1 +++ b/Source/Pansies.psd1 @@ -1,82 +1,93 @@ @{ -# Script module or binary module file associated with this manifest. -RootModule = 'Pansies.psm1' + # Script module or binary module file associated with this manifest. + RootModule = 'Pansies.psm1' -# Version number of this module. -ModuleVersion = '2.11.0' + # Version number of this module. + ModuleVersion = '2.11.0' -# Supported PSEditions -# CompatiblePSEditions = @() + # Supported PSEditions + # CompatiblePSEditions = @() -# ID used to uniquely identify this module -GUID = '6c376de1-1baf-4d52-9666-d46f6933bc16' + # ID used to uniquely identify this module + GUID = '6c376de1-1baf-4d52-9666-d46f6933bc16' -# Author of this module -Author = 'Joel Bennett' + # Author of this module + Author = 'Joel Bennett' -# Company or vendor of this module -CompanyName = 'HuddledMasses.org' + # Company or vendor of this module + CompanyName = 'HuddledMasses.org' -# Copyright statement for this module -Copyright = '(c) 2017 Joel Bennett. All rights reserved.' + # Copyright statement for this module + Copyright = '(c) 2017 Joel Bennett. All rights reserved.' -# Description of the functionality provided by this module -Description = 'A PowerShell module for handling color and cursor positioning via ANSI escape sequences' + # Description of the functionality provided by this module + Description = 'A PowerShell module for handling color and cursor positioning via ANSI escape sequences' -# Assemblies that must be loaded prior to importing this module -# RequiredAssemblies = + # Assemblies that must be loaded prior to importing this module + # RequiredAssemblies = -# Script files (.ps1) that are run in the caller's environment prior to importing this module. -# ScriptsToProcess = @() + # Script files (.ps1) that are run in the caller's environment prior to importing this module. + # ScriptsToProcess = @() -# Type files (.ps1xml) to be loaded when importing this module -# TypesToProcess = @() + # Type files (.ps1xml) to be loaded when importing this module + # TypesToProcess = @() -# Format files (.ps1xml) to be loaded when importing this module -FormatsToProcess = @("Pansies.format.ps1xml") + # Format files (.ps1xml) to be loaded when importing this module + FormatsToProcess = @('Pansies.format.ps1xml') -# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess -NestedModules = @( "lib/Pansies.dll" ) + # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess + NestedModules = @( 'lib/Pansies.dll' ) -# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. -FunctionsToExport = @() + # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. + FunctionsToExport = @() -# A default Prefix for for Cmdlets to export -# DefaultCommandPrefix = "Pansies" + # A default Prefix for for Cmdlets to export + # DefaultCommandPrefix = "Pansies" -# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. -CmdletsToExport = @('New-Text', 'New-Hyperlink', 'Write-Host', 'Get-Gradient', 'Get-Complement', 'Get-ColorWheel') + # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. + CmdletsToExport = @( + 'New-Text', + 'New-Hyperlink', + 'Write-Host', + 'Get-Gradient', + 'Get-Complement', + 'Get-ColorWheel', + 'Set-CursorPosition', + 'Save-CursorPosition', + 'Restore-CursorPosition', + 'Expand-Variable' + ) -# Variables to export from this module -VariablesToExport = 'RgbColorCompleter' + # Variables to export from this module + VariablesToExport = 'RgbColorCompleter' -# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. -AliasesToExport = 'Text', 'Url' + # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. + AliasesToExport = 'Text', 'Url' -# List of all files packaged with this module -# FileList = @() + # List of all files packaged with this module + # FileList = @() -# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. -PrivateData = @{ - PSData = @{ - # ModuleBuilder will set the pre-release value - Prerelease = "dev" + # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. + PrivateData = @{ + PSData = @{ + # ModuleBuilder will set the pre-release value + Prerelease = 'dev' - # Tags applied to this module. These help with module discovery in online galleries. - Tags = @("ANSI", "EscapeSequences", "VirtualTerminal", "Color") + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('ANSI', 'EscapeSequences', 'VirtualTerminal', 'Color') - # A URL to the license for this module. - LicenseUri = 'https://github.com/PoshCode/Pansies/blob/master/LICENSE' + # A URL to the license for this module. + LicenseUri = 'https://github.com/PoshCode/Pansies/blob/master/LICENSE' - # A URL to the main website for this project. - ProjectUri = 'https://github.com/PoshCode/Pansies' + # A URL to the main website for this project. + ProjectUri = 'https://github.com/PoshCode/Pansies' - # A URL to an icon representing this module. - IconUri = 'https://github.com/PoshCode/Pansies/blob/resources/Pansies_32.gif?raw=true' + # A URL to an icon representing this module. + IconUri = 'https://github.com/PoshCode/Pansies/blob/resources/Pansies_32.gif?raw=true' - # ReleaseNotes of this module - ReleaseNotes = ' + # ReleaseNotes of this module + ReleaseNotes = ' 2.11.0 Added a ColorCompleterAttribute (on PowerShell 7 only) On Windows PowerShell, you can still use [ArgumentCompleter([PoshCode.Pansies.Palettes.X11Palette])] Additionally, on module load, will register the ArgumentCompleter for all commands with RGBColor parameters @@ -84,16 +95,16 @@ PrivateData = @{ Updated the NerdFont characters and code points to deal with the migration of the MDI characters in 2.3.0+ ' - } # End of PSData hashtable + } # End of PSData hashtable -} # End of PrivateData hashtable + } # End of PrivateData hashtable -# HelpInfo URI of this module -# HelpInfoURI = '' + # HelpInfo URI of this module + # HelpInfoURI = '' # Minimum version of the Windows PowerShell engine required by this module - PowerShellVersion = '5.1' - CompatiblePSEditions = @('Core','Desktop') + PowerShellVersion = '5.1' + CompatiblePSEditions = @('Core', 'Desktop') } diff --git a/Tests/Commands.Tests.ps1 b/Tests/Commands.Tests.ps1 new file mode 100644 index 0000000..783e958 --- /dev/null +++ b/Tests/Commands.Tests.ps1 @@ -0,0 +1,283 @@ +<# + Pester tests validating new cursor commands, position helper, and Expand-Variable functionality. +#> + +BeforeAll { + $scriptPath = if ($PSCommandPath) { + $PSCommandPath + } elseif ($MyInvocation.MyCommand.Path) { + $MyInvocation.MyCommand.Path + } else { + $null + } + if (-not $scriptPath) { + throw 'Unable to determine script path for test execution.' + } + + $testsFolder = Split-Path -Parent $scriptPath + $moduleRoot = Split-Path -Parent $testsFolder + $script:PublishPath = Join-Path $moduleRoot ('.pester-publish-' + [Guid]::NewGuid().ToString('N')) + + New-Item -ItemType Directory -Path $script:PublishPath | Out-Null + + Push-Location $moduleRoot + try { + $publishResult = dotnet publish -c Release -o $script:PublishPath 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed:`n$publishResult" + } + } finally { + Pop-Location + } + + $path = Join-Path $script:PublishPath 'Pansies.dll' + Import-Module $path -Force + Set-Variable -Name ModuleAssemblyPath -Scope Script -Value $path +} + +AfterAll { + Remove-Module Pansies -Force -ErrorAction SilentlyContinue + [System.GC]::Collect() + [System.GC]::WaitForPendingFinalizers() + + if ($script:PublishPath -and (Test-Path -LiteralPath $script:PublishPath)) { + Remove-Item -LiteralPath $script:PublishPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Describe 'Position helper' { + It 'formats absolute positions with row and column' { + $position = [PoshCode.Pansies.Position]::new(3, 4, $true) + $position.ToString() | Should -Be (([char]0x1b) + '[3;4H') + } + + It 'formats absolute positions with only column' { + $position = [PoshCode.Pansies.Position]::new($null, 12, $true) + $position.ToString() | Should -Be (([char]0x1b) + '[12G') + } + + It 'formats relative movement' { + $position = [PoshCode.Pansies.Position]::new(-2, 5, $false) + $position.ToString() | Should -Be (([char]0x1b) + '[5C') # column takes precedence when positive + } + + It 'formats relative movement vertically' { + $position = [PoshCode.Pansies.Position]::new(-3, $null, $false) + $position.ToString() | Should -Be (([char]0x1b) + '[3A') + } + + It 'round trips metadata' { + $original = [PoshCode.Pansies.Position]::new(7, 9, $true) + $metadata = $original.ToPsMetadata() + $metadata | Should -Be '7;9;1' + + $roundTrip = [PoshCode.Pansies.Position]::new($metadata) + $roundTrip.Absolute | Should -BeTrue + $roundTrip.Line | Should -Be 7 + $roundTrip.Column | Should -Be 9 + } + + It 'throws when metadata is null' { + $position = [PoshCode.Pansies.Position]::new() + try { + $position.FromPsMetadata($null) + $true | Should -BeFalse -Because 'Expected a MethodInvocationException wrapping ArgumentNullException' + } catch [System.Management.Automation.MethodInvocationException] { + $_.Exception.InnerException | Should -BeOfType ([System.ArgumentNullException]) + } + } +} + +Describe 'Set-CursorPosition cmdlet' { + It 'emits absolute escape sequence' { + $result = Set-CursorPosition -Line 2 -Column 5 -Absolute + $result | Should -Be (([char]0x1b) + '[2;5H') + } + + It 'emits relative escape sequence when Line is negative' { + $result = Set-CursorPosition -Line -4 + $result | Should -Be (([char]0x1b) + '[4A') + } + + It 'emits relative escape sequence when Column is positive' { + $result = Set-CursorPosition -Column 6 + $result | Should -Be (([char]0x1b) + '[6C') + } +} + +Describe 'Save/Restore-CursorPosition cmdlets' { + It 'writes ESC[s to the console' { + $writer = New-Object System.IO.StringWriter + $original = [Console]::Out + try { + [Console]::SetOut($writer) + Save-CursorPosition + } finally { + [Console]::SetOut($original) + } + + $writer.ToString() | Should -Be (([char]0x1b) + '[s') + } + + It 'writes ESC8 to the console' { + $writer = New-Object System.IO.StringWriter + $original = [Console]::Out + try { + [Console]::SetOut($writer) + Restore-CursorPosition + } finally { + [Console]::SetOut($original) + } + + $writer.ToString() | Should -Be (([char]0x1b) + '8') + } +} + +Describe 'Expand-Variable cmdlet' { + BeforeEach { + Set-Variable -Name foo -Value 'rainbow' -Scope Script + } + + AfterEach { + Remove-Variable -Name foo -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name bar -Scope Script -ErrorAction SilentlyContinue + } + + It 'replaces variables outside expandable strings with quoted value' { + $result = Expand-Variable -Content 'Write-Host $variable:foo' -Drive variable + $result | Should -Be 'Write-Host "rainbow"' + } + + It 'replaces variables within expandable strings without extra quotes' { + $result = Expand-Variable -Content '"Color: $variable:foo"' -Drive variable + $result | Should -Be '"Color: rainbow"' + } + + It 'escapes characters by default' { + Set-Variable -Name foo -Value 'He said "Go!"' -Scope Script + $result = Expand-Variable -Content 'Write-Host $variable:foo' -Drive variable + $result | Should -Be 'Write-Host "He said `"Go!`""' + } + + It 'supports unescaped output when requested' { + Set-Variable -Name foo -Value "Line1`nLine2" -Scope Script + $result = Expand-Variable -Content 'Write-Host $variable:foo' -Drive variable -Unescaped + $expected = "Write-Host `"Line1`nLine2`"" + $result | Should -Be $expected + } + + It 'updates variables in place when requested' { + Set-Variable -Name bar -Value 'azure' -Scope Script + Set-Variable -Name foo -Value 'Write-Host $variable:bar' -Scope Script + + $result = Expand-Variable -Path 'variable:foo' -Drive variable -InPlace -Passthru + + $result | Should -Be 'Write-Host "azure"' + (Get-Variable -Name foo -Scope Script).Value | Should -Be 'Write-Host "azure"' + } +} + +Describe 'XyzConverter white reference' { + AfterEach { + [PoshCode.Pansies.ColorSpaceConfiguration]::ResetWhiteReference() + } + + It 'allows customizing the default white reference point' { + $custom = [PoshCode.Pansies.ColorSpaces.Xyz]::new(96.5, 101.2, 110.3) + [PoshCode.Pansies.ColorSpaceConfiguration]::SetWhiteReference($custom) + + $updated = [PoshCode.Pansies.ColorSpaceConfiguration]::GetWhiteReference() + $updated.X | Should -Be 96.5 + $updated.Y | Should -Be 101.2 + $updated.Z | Should -Be 110.3 + } + + It 'clones the input value when setting the white reference' { + $custom = [PoshCode.Pansies.ColorSpaces.Xyz]::new(90.1, 92.2, 93.3) + [PoshCode.Pansies.ColorSpaceConfiguration]::SetWhiteReference($custom) + $custom.X = 0 + + $stored = [PoshCode.Pansies.ColorSpaceConfiguration]::GetWhiteReference() + $stored.X | Should -Be 90.1 + } + + It 'returns a copy of the white reference when reading' { + $original = [PoshCode.Pansies.ColorSpaceConfiguration]::GetWhiteReference() + $copy = [PoshCode.Pansies.ColorSpaceConfiguration]::GetWhiteReference() + $copy.X = 0 + + $current = [PoshCode.Pansies.ColorSpaceConfiguration]::GetWhiteReference() + $current.X | Should -Be $original.X + } +} + +Describe 'RgbColor XTerm helpers' { + It 'creates full RGB values when converting from an xterm index' { + $expected = ([PoshCode.Pansies.RgbColor]::XTermPalette)[42] + $color = [PoshCode.Pansies.RgbColor]::FromXTermIndex('42') + + $color.RGB | Should -Be $expected.RGB + $color.Mode | Should -Be ([PoshCode.Pansies.ColorMode]::XTerm256) + } + + It 'parses prefixed xterm indexes' { + $expected = ([PoshCode.Pansies.RgbColor]::XTermPalette)[5] + $color = [PoshCode.Pansies.RgbColor]::FromXTermIndex('xt5') + + $color.RGB | Should -Be $expected.RGB + $color.Mode | Should -Be ([PoshCode.Pansies.ColorMode]::XTerm256) + } + + It 'populates RGB values when converting from a byte' { + $expected = ([PoshCode.Pansies.RgbColor]::XTermPalette)[12] + $color = [PoshCode.Pansies.RgbColor]::ConvertFrom([byte]12) + + $color.RGB | Should -Be $expected.RGB + $color.Mode | Should -Be ([PoshCode.Pansies.ColorMode]::XTerm256) + } +} + +Describe 'RgbColor color mode detection' { + It 'initializes ColorMode to the recommended default' { + $expected = [PoshCode.Pansies.RgbColor]::GetRecommendedColorMode() + [PoshCode.Pansies.RgbColor]::ColorMode | Should -Be $expected + } + + It 'prefers truecolor when COLORTERM advertises support' { + $env = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase) + $env['COLORTERM'] = 'truecolor' + $os = New-Object System.OperatingSystem ([System.PlatformID]::Unix), (New-Object System.Version 5, 4) + + [PoshCode.Pansies.RgbColor]::GetRecommendedColorMode($env, $os) | Should -Be ([PoshCode.Pansies.ColorMode]::Rgb24Bit) + } + + It 'selects xterm256 when TERM advertises 256 colors' { + $env = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase) + $env['TERM'] = 'xterm-256color' + $os = New-Object System.OperatingSystem ([System.PlatformID]::Unix), (New-Object System.Version 6, 2) + + [PoshCode.Pansies.RgbColor]::GetRecommendedColorMode($env, $os) | Should -Be ([PoshCode.Pansies.ColorMode]::XTerm256) + } + + It 'falls back to console colors when hints are absent' { + $env = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase) + $env['TERM'] = 'vt100' + $os = New-Object System.OperatingSystem ([System.PlatformID]::Unix), (New-Object System.Version 4, 19) + + [PoshCode.Pansies.RgbColor]::GetRecommendedColorMode($env, $os) | Should -Be ([PoshCode.Pansies.ColorMode]::ConsoleColor) + } + + It 'prefers RGB24 for Windows 10 and newer' { + $env = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase) + $os = New-Object System.OperatingSystem ([System.PlatformID]::Win32NT), (New-Object System.Version 10, 0) + + [PoshCode.Pansies.RgbColor]::GetRecommendedColorMode($env, $os) | Should -Be ([PoshCode.Pansies.ColorMode]::Rgb24Bit) + } + + It 'uses console colors for legacy Windows releases' { + $env = New-Object 'System.Collections.Generic.Dictionary[string,string]' ([System.StringComparer]::OrdinalIgnoreCase) + $os = New-Object System.OperatingSystem ([System.PlatformID]::Win32NT), (New-Object System.Version 6, 1) + + [PoshCode.Pansies.RgbColor]::GetRecommendedColorMode($env, $os) | Should -Be ([PoshCode.Pansies.ColorMode]::ConsoleColor) + } +}