OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / GeneratorWindow.cs

430 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;

using IFSEngine.Generation;
using IFSEngine.Model;
using IFSRenderer.AvaloniaShell.Rendering;

using IFSRenderer.AvaloniaShell.Controls;

namespace IFSRenderer.AvaloniaShell;

/// <summary>
/// Breeds random fractals. Candidates are pinned to keep them across batches, and a pinned
/// candidate can be sent to the editor without closing the window.
/// </summary>
/// <remarks>
/// <para>
/// A port of <c>WpfDisplay/Views/GeneratorWindow.xaml</c>: a 250px column of pinned fractals on
/// the left, mutation options above the results on the right, all three on md:Cards, with
/// 200x200 thumbnails.
/// </para>
/// <para>
/// The gesture model is the original's and differs from this port's first version: clicking a
/// GENERATED tile pins it, clicking a PINNED tile sends it to the editor, and the bin button on
/// a pinned tile unpins it. The window stays open throughout.
/// </para>
/// <para>
/// Thumbnails render on the shared GL thread one at a time and the open fractal is restored
/// afterwards, so a batch briefly pauses the main preview rather than needing a second renderer.
/// </para>
/// </remarks>
internal sealed class GeneratorWindow : Window
{
    private const int ThumbSize = 200, ThumbQuality = 9;

    private static readonly IBrush CardBrush = new SolidColorBrush(Color.Parse("#454545"));
    private static readonly IBrush Paper = new SolidColorBrush(Color.Parse("#323232"));

    private readonly IRenderQueue _service;
    private readonly GeneratorOptions _options = new();

    private readonly WrapPanel _generated = new() { Orientation = Orientation.Horizontal };
    private readonly WrapPanel _pinned = new() { Orientation = Orientation.Horizontal };
    private readonly TextBlock _status = new()
    {
        Foreground = new SolidColorBrush(Color.Parse("#ddffffff")),
        VerticalAlignment = VerticalAlignment.Center,
        Margin = new Thickness(8, 4, 0, 0),
    };

    private readonly ValueSlider _chance = new();
    private readonly ValueSlider _strength = new();
    private readonly ValueSlider _batch = new();
    private readonly Button _newBatch = new();

    private readonly List<IFS> _pinnedList = [];

    private bool _busy;
    private int _stamp;

    /// <summary>Raised when a pinned candidate should be loaded into the editor.</summary>
    public event Action<IFS> SendToMain;

    /// <summary>Supplies the fractal currently open in the editor, for "Pin Main".</summary>
    public Func<IFS> CurrentFractal { get; set; }

    public GeneratorWindow(IRenderQueue service)
    {
        _service = service;
        Title = "Generator";
        Width = 1280;
        Height = 720;
        MinWidth = 900;
        MinHeight = 560;
        // NOT maximized here: setting WindowState before the window is mapped leaves it
        // sized to its menu bar under X11, so the body never gets laid out. It is applied
        // in Opened instead, once there is a real window to maximize.
        Background = Paper;
        FontSize = 13;
        WindowStartupLocation = WindowStartupLocation.CenterOwner;

        var pinMain = new MenuItem { Header = "📌 Pin Main" };
        ToolTip.SetTip(pinMain, "Pin the fractal that is loaded in the main window. (Ctrl+M)");
        pinMain.Click += async (_, _) =>
        {
            IFS current = CurrentFractal?.Invoke();
            if (current is not null)
                await PinFractal(current);
        };
        var menu = new Menu { ItemsSource = new[] { pinMain } };
        DockPanel.SetDock(menu, Dock.Top);

        var body = new Grid
        {
            ColumnDefinitions = new ColumnDefinitions("250,*"),
            MinHeight = 400,
        };

        Border pinnedCard = Card(Titled("📌 Pinned fractals", _pinned));
        Grid.SetColumn(pinnedCard, 0);
        body.Children.Add(pinnedCard);

        var right = new Grid { RowDefinitions = new RowDefinitions("Auto,*") };
        Border optionsCard = Card(BuildOptions());
        Grid.SetRow(optionsCard, 0);
        right.Children.Add(optionsCard);

        Border resultsCard = Card(Titled("🎰 Generated fractals", _generated));
        Grid.SetRow(resultsCard, 1);
        right.Children.Add(resultsCard);

        Grid.SetColumn(right, 1);
        body.Children.Add(right);

        var root = new DockPanel();
        root.Children.Add(menu);
        root.Children.Add(body);
        Content = root;

        Opened += async (_, _) =>
        {
            WindowState = WindowState.Maximized;
            await GenerateBatch();
        };
    }

    // ------------------------------------------------------------------ chrome

    /// <summary>An md:Card: the measured #454545 surface, Margin 8 Padding 8.</summary>
    private static Border Card(Control content) => new()
    {
        Background = CardBrush,
        CornerRadius = new CornerRadius(4),
        Margin = new Thickness(8),
        Padding = new Thickness(8),
        BoxShadow = BoxShadows.Parse("0 1 3 0 #55000000, 0 4 10 0 #33000000"),
        Child = content,
    };

    /// <summary>A heading over a scrolling thumbnail area.</summary>
    private static DockPanel Titled(string heading, Control content)
    {
        var label = new TextBlock { Text = heading, Margin = new Thickness(0, 0, 0, 6) };
        DockPanel.SetDock(label, Dock.Top);
        return new DockPanel
        {
            Children = { label, new ScrollViewer { Content = content } },
        };
    }

    private static CheckBox Check(string label, bool value, Action<bool> set, bool enabled = true)
    {
        var c = new CheckBox
        {
            Content = label,
            IsChecked = value,
            IsEnabled = enabled,
            Margin = new Thickness(4),
        };
        c.IsCheckedChanged += (_, _) => set(c.IsChecked == true);
        return c;
    }

    private Control BuildOptions()
    {
        // Slider settings are GeneratorViewModel's, verbatim.
        _chance.SliderSettings = new ValueSliderSettings
        {
            Label = "Mutation chance",
            DefaultValue = 0.5,
            MinValue = 0,
            MaxValue = 1,
            Increment = 0.01,
            ValueChanged = v => _options.MutationChance = v,
        };
        _chance.Value = _options.MutationChance;

        _strength.SliderSettings = new ValueSliderSettings
        {
            Label = "Mutation strength",
            DefaultValue = 1.0,
            MinValue = 0,
            Increment = 0.1,
            ValueChanged = v => _options.MutationStrength = v,
        };
        _strength.Value = _options.MutationStrength;

        _batch.SliderSettings = new ValueSliderSettings
        {
            Label = "Batch size",
            ToolTip = "The number of fractals to generate.",
            DefaultValue = 30,
            MinValue = 1,
            MaxValue = 50,
            Increment = 5,
            ValueChanged = v => _options.BatchSize = (int)v,
        };
        _batch.Value = _options.BatchSize;
        _batch.Width = 110;

        _newBatch.Content = "🎲 New batch";
        _newBatch.Padding = new Thickness(30, 0);
        _newBatch.VerticalAlignment = VerticalAlignment.Bottom;
        _newBatch.Margin = new Thickness(8, 0);
        _newBatch.Click += async (_, _) => await GenerateBatch();

        var columns = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,Auto,Auto,*,Auto") };

        var col0 = new StackPanel
        {
            Margin = new Thickness(8),
            Children =
            {
                Check("Iterators", _options.MutateIterators, v => _options.MutateIterators = v),
                Check("Connections", _options.MutateConnections, v => _options.MutateConnections = v),
                Check("Weights", _options.MutateConnectionWeights, v => _options.MutateConnectionWeights = v),
            },
        };

        // GeneratorOptions.cs:19 replaced the boolean with a PaletteInterpolationMode; Mixbox is
        // still the default, and the checkbox now selects between it and plain linear RGB.
        CheckBox mixbox = Check("Pigment-based mixing",
            _options.PaletteInterpolationMode == IFSEngine.Model.InterpolationMode.Mixbox,
            v => _options.PaletteInterpolationMode = v
                ? IFSEngine.Model.InterpolationMode.Mixbox
                : IFSEngine.Model.InterpolationMode.LinearRGB);
        ToolTip.SetTip(mixbox, "Use Mixbox algorithm to mix colors similar to mixing paint.");

        var col1 = new StackPanel
        {
            Margin = new Thickness(8),
            Children =
            {
                Check("Palette", _options.MutatePalette, v => _options.MutatePalette = v),
                Check("Coloring", _options.MutateColoring, v => _options.MutateColoring = v),
                // md:ColorZone in the original: a tinted strip, not an interactive control.
                new Border
                {
                    Background = new SolidColorBrush(Color.Parse("#5a5a5a")),
                    Padding = new Thickness(6, 3),
                    Margin = new Thickness(4),
                    CornerRadius = new CornerRadius(2),
                    Child = new TextBlock { Text = "Dominant Color" },
                },
                mixbox,
            },
        };

        // Transform selection is present but disabled in the original too - GeneratorOptions
        // carries a "TODO: select transforms" where the field would go.
        var col2 = new StackPanel
        {
            Margin = new Thickness(8),
            Children =
            {
                Check("Transforms", false, _ => { }, enabled: false),
                new ListBox { IsEnabled = false, MaxHeight = 60, ItemsSource = new[] { "Transforms" } },
                Check("Parameters", _options.MutateParameters, v => _options.MutateParameters = v),
            },
        };

        var col4 = new StackPanel
        {
            Margin = new Thickness(8),
            Width = 190,
            Children = { _chance, _strength },
        };

        Grid.SetColumn(col0, 0);
        Grid.SetColumn(col1, 1);
        Grid.SetColumn(col2, 2);
        Grid.SetColumn(col4, 4);
        columns.Children.Add(col0);
        columns.Children.Add(col1);
        columns.Children.Add(col2);
        columns.Children.Add(col4);

        var batchRow = new StackPanel
        {
            Orientation = Orientation.Horizontal,
            HorizontalAlignment = HorizontalAlignment.Center,
            Children = { _batch, _newBatch },
        };

        var heading = new TextBlock { Text = "🔮 Mutation options", Margin = new Thickness(0, 0, 0, 6) };
        return new StackPanel { Children = { heading, columns, batchRow, _status } };
    }

    // -------------------------------------------------------------- generation

    private async Task GenerateBatch()
    {
        if (_busy)
            return;
        _busy = true;
        // The original swaps the button's content for an hourglass while it works.
        _newBatch.Content = "⏳";
        try
        {
            _generated.Children.Clear();

            var generator = new Generator(_service.Transforms);
            int count = Math.Max(_options.BatchSize, 1);
            for (int i = 0; i < count; i++)
            {
                _status.Text = $"Rendering candidate {i + 1}/{count}…";
                IFS fractal = generator.GenerateOne(_options);
                fractal.Title = $"Generated {++_stamp}";

                byte[] bgra = await _service.RenderThumbnailAsync(fractal, ThumbSize, ThumbSize, ThumbQuality);
                _generated.Children.Add(Tile(ToBitmap(bgra), fractal, pinned: false));
            }
            _status.Text = $"{count} candidates";
        }
        catch (Exception ex)
        {
            _status.Text = "Generation failed: " + ex.Message;
        }
        finally
        {
            _newBatch.Content = "🎲 New batch";
            _busy = false;
        }
    }

    /// <summary>
    /// Pins a candidate. The already-rendered bitmap is reused when there is one: re-rendering
    /// produced a visibly DIFFERENT image, because each render accumulates its own samples, so
    /// a pinned tile no longer matched the tile that was clicked.
    /// </summary>
    private async Task PinFractal(IFS fractal, WriteableBitmap existing = null)
    {
        if (_pinnedList.Contains(fractal))
            return;

        WriteableBitmap bitmap = existing;
        if (bitmap is null)
        {
            byte[] bgra = await _service.RenderThumbnailAsync(fractal, ThumbSize, ThumbSize, ThumbQuality);
            bitmap = ToBitmap(bgra);
        }

        _pinnedList.Add(fractal);
        _pinned.Children.Add(Tile(bitmap, fractal, pinned: true));
    }

    /// <summary>Turns the renderer's bottom-up BGRA into a top-down bitmap.</summary>
    private static WriteableBitmap ToBitmap(byte[] bgra)
    {
        var bitmap = new WriteableBitmap(new PixelSize(ThumbSize, ThumbSize),
            new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
        using ILockedFramebuffer fb = bitmap.Lock();
        int rowBytes = ThumbSize * 4;
        for (int y = 0; y < ThumbSize; y++)
        {
            // GL reads bottom-up.
            int srcOffset = (ThumbSize - 1 - y) * rowBytes;
            Marshal.Copy(bgra, srcOffset, fb.Address + y * fb.RowBytes, rowBytes);
        }
        return bitmap;
    }

    /// <summary>
    /// A 200x200 thumbnail. Generated tiles pin on click; pinned tiles send to the editor on
    /// click and carry a small bin button to unpin.
    /// </summary>
    private Border Tile(WriteableBitmap bitmap, IFS fractal, bool pinned)
    {
        var overlay = new Grid { Width = ThumbSize, Height = ThumbSize };
        overlay.Children.Add(new Image { Source = bitmap, Width = ThumbSize, Height = ThumbSize });

        var host = new Border
        {
            Margin = new Thickness(4),
            Child = overlay,
            Cursor = new Cursor(StandardCursorType.Hand),
        };

        if (!pinned)
        {
            ToolTip.SetTip(host, "Pin this fractal");
            host.PointerPressed += async (_, e) =>
            {
                if (e.GetCurrentPoint(host).Properties.IsLeftButtonPressed)
                    await PinFractal(fractal, bitmap);
            };
            return host;
        }

        var bin = new Button
        {
            Content = "🗑",
            Width = 24,
            Height = 24,
            Padding = new Thickness(0),
            FontSize = 11,
            Margin = new Thickness(8),
            HorizontalAlignment = HorizontalAlignment.Left,
            VerticalAlignment = VerticalAlignment.Bottom,
            CornerRadius = new CornerRadius(12),
        };
        ToolTip.SetTip(bin, "Unpin");
        bin.Click += (_, e) =>
        {
            _pinnedList.Remove(fractal);
            _pinned.Children.Remove(host);
            e.Handled = true;
        };
        overlay.Children.Add(bin);

        ToolTip.SetTip(host, "Send to the editor");
        host.PointerPressed += (_, e) =>
        {
            if (e.GetCurrentPoint(host).Properties.IsLeftButtonPressed)
                SendToMain?.Invoke(fractal);
        };
        return host;
    }
}