core/AsciiFX.Core.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
 
namespace AsciiFX
{
    // Every animation implements this. The players (terminal and desktop) own the screen;
    // an animation only decides which character and color goes in each cell.
    public interface IAnimation
    {
        // The colors the animation draws with, as 0xRRGGBB. Frame refers to them by index.
        int[] Palette { get; }
 
        // Milliseconds between frames that the animation looks best at
        int FrameMs { get; }
 
        // Called before the first frame, and again whenever the drawing area changes size
        void Resize(int cols, int rows);
 
        // Draws the frame at t seconds: one character and one palette index (-1 = empty) per cell, row by row
        void Frame(double t, char[] chars, int[] colors);
    }
 
    public static class Cell
    {
        // Terminal cells are about 2.2 times taller than they are wide; animations use this to keep circles round
        public const double Aspect = 2.2;
    }
 
    // An animation's settings as passed from PowerShell, with a fallback for anything missing
    public sealed class Options
    {
        readonly Dictionary<string, object> values = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
 
        public Options(IDictionary source)
        {
            if (source != null)
                foreach (DictionaryEntry e in source) values[e.Key.ToString()] = e.Value;
        }
 
        public double Number(string name, double fallback)
        {
            object v;
            double d;
            if (!values.TryGetValue(name, out v) || v == null) return fallback;
            return double.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), NumberStyles.Float,
                                   CultureInfo.InvariantCulture, out d) ? d : fallback;
        }
 
        public bool Flag(string name)
        {
            object v;
            if (!values.TryGetValue(name, out v) || v == null) return false;
            string s = v.ToString();
            return s.Equals("True", StringComparison.OrdinalIgnoreCase) || s == "1";
        }
 
        public string Text(string name, string fallback)
        {
            object v;
            return values.TryGetValue(name, out v) && v != null && v.ToString() != "" ? v.ToString() : fallback;
        }
    }
 
    // Color helpers. Colors are plain ints, 0xRRGGBB.
    public static class Colors
    {
        // `count` colors blending smoothly through the given stops, first to last
        public static int[] Gradient(int count, params int[] stops)
        {
            int[] result = new int[count];
            for (int i = 0; i < count; i++) result[i] = At((double)i / Math.Max(1, count - 1), stops);
            return result;
        }
 
        // The color at position t (0..1) along the stops
        public static int At(double t, params int[] stops)
        {
            if (stops.Length == 1) return stops[0];
            double pos = Math.Max(0, Math.Min(1, t)) * (stops.Length - 1);
            int i = Math.Min((int)pos, stops.Length - 2);
            return Mix(stops[i], stops[i + 1], pos - i);
        }
 
        public static int Mix(int a, int b, double f)
        {
            int r = (int)(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * f);
            int g = (int)(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * f);
            int bl = (int)((a & 255) + ((b & 255) - (a & 255)) * f);
            return (r << 16) | (g << 8) | bl;
        }
 
        // "#rrggbb" (or "rrggbb") to 0xRRGGBB; anything else gives the fallback
        public static int Parse(string hex, int fallback)
        {
            int value;
            if (hex == null) return fallback;
            hex = hex.Trim().TrimStart('#');
            return hex.Length == 6 && int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value)
                ? value : fallback;
        }
    }
 
    // Plays an animation full-screen in the terminal until a key is pressed (or for a set time)
    public static class TerminalPlayer
    {
        [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int handle);
        [DllImport("kernel32.dll")] static extern bool GetConsoleMode(IntPtr handle, out uint mode);
        [DllImport("kernel32.dll")] static extern bool SetConsoleMode(IntPtr handle, uint mode);
 
        const string Esc = "\u001b";
 
        public static void Run(IAnimation anim, double seconds, int fps)
        {
            // Windows consoles need escape-sequence support switched on; other terminals already have it
            if (Environment.OSVersion.Platform == PlatformID.Win32NT)
            {
                uint mode;
                IntPtr outHandle = GetStdHandle(-11);
                if (GetConsoleMode(outHandle, out mode)) SetConsoleMode(outHandle, mode | 0x0004);
            }
 
            string[] colorCodes = new string[anim.Palette.Length];
            for (int i = 0; i < colorCodes.Length; i++)
            {
                int c = anim.Palette[i];
                colorCodes[i] = Esc + "[38;2;" + ((c >> 16) & 255) + ";" + ((c >> 8) & 255) + ";" + (c & 255) + "m";
            }
 
            int frameMs = fps > 0 ? 1000 / fps : anim.FrameMs;
            Stream stdout = Console.OpenStandardOutput();
            StringBuilder sb = new StringBuilder();
            int w = -1, h = -1;
            char[] chars = null;
            int[] colors = null;
 
            bool oldCtrlC = false;
            try { oldCtrlC = Console.TreatControlCAsInput; Console.TreatControlCAsInput = true; } catch (IOException) { }
            Write(stdout, Esc + "[?1049h" + Esc + "[?25l"); // alternate screen, hidden cursor
            Stopwatch clock = Stopwatch.StartNew();
            try
            {
                while (seconds <= 0 || clock.Elapsed.TotalSeconds < seconds)
                {
                    if (KeyPressed()) break;
                    double t = clock.Elapsed.TotalSeconds;
 
                    int cw = Math.Max(10, Console.WindowWidth), ch = Math.Max(6, Console.WindowHeight);
                    if (cw != w || ch != h)
                    {
                        w = cw; h = ch;
                        chars = new char[w * h];
                        colors = new int[w * h];
                        anim.Resize(w, h);
                        Write(stdout, Esc + "[2J");
                    }
 
                    anim.Frame(t, chars, colors);
                    sb.Length = 0;
                    string current = null;
                    for (int y = 0; y < h; y++)
                    {
                        sb.Append(Esc).Append('[').Append(y + 1).Append(";1H");
                        for (int x = 0; x < w; x++)
                        {
                            int k = y * w + x;
                            if (colors[k] < 0) { sb.Append(' '); continue; }
                            string col = colorCodes[colors[k]];
                            if (!ReferenceEquals(col, current)) { sb.Append(col); current = col; }
                            sb.Append(chars[k]);
                        }
                    }
                    Write(stdout, sb.ToString());
 
                    int wait = (int)(frameMs - (clock.Elapsed.TotalSeconds - t) * 1000);
                    if (wait > 0) Thread.Sleep(wait);
                }
            }
            finally
            {
                Write(stdout, Esc + "[0m" + Esc + "[?25h" + Esc + "[?1049l");
                try { Console.TreatControlCAsInput = oldCtrlC; } catch (IOException) { }
            }
        }
 
        static bool KeyPressed()
        {
            try
            {
                if (!Console.KeyAvailable) return false;
                Console.ReadKey(true);
                return true;
            }
            catch (InvalidOperationException) { return false; }
        }
 
        static void Write(Stream stream, string text)
        {
            byte[] bytes = Encoding.ASCII.GetBytes(text);
            stream.Write(bytes, 0, bytes.Length);
            stream.Flush();
        }
    }
}