OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / MainWindow.axaml.cs

3410 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Threading;

using IFSEngine.Animation;
using IFSEngine.Generation;
using IFSEngine.Model;
using IFSEngine.Rendering;
using IFSEngine.Serialization;

using IFSRenderer.AvaloniaShell.Controls;
using IFSRenderer.AvaloniaShell.Rendering;
using IFSRenderer.Shared;

namespace IFSRenderer.AvaloniaShell;

public partial class MainWindow : Window
{
    private readonly IRenderQueue _service;
    private readonly IFSHistoryTracker _history = new();
    private readonly AppSettings _settings = AppSettings.Load();
    private WriteableBitmap _bitmap;
    private bool _framePending;

    /// <summary>Set while pushing engine state INTO the controls, so their change handlers no-op.</summary>
    private bool _syncingUi;

    private Point _lastPointer;
    private PointerUpdateKind _dragButton = PointerUpdateKind.Other;
    private bool _dragging;

    /// <summary>Whether this camera drag has already recorded its undo state.</summary>
    private bool _cameraDragSnapshotted;

    private Iterator _selectedIterator;

    /// <summary>Keys currently held, driving the fly navigation tick.</summary>
    private readonly HashSet<Key> _keysDown = [];
    private bool _renderingPaused;
    private bool _confirmedExit;

    /// <summary>Path of the open document, or null if it has never been saved.</summary>
    private string _editedFilePath;

    /// <summary>The window as the application runs it, on a real OpenGL render thread.</summary>
    public MainWindow() : this(new RenderService()) { }

    /// <summary>
    /// The window against a supplied renderer.
    /// </summary>
    /// <remarks>
    /// Tests pass a queue that runs its actions immediately and drives no GPU, which is what
    /// lets the add/remove, camera and document paths be exercised headlessly. Nothing else
    /// about the window changes — it is the same visual tree and the same wiring.
    /// </remarks>
    public MainWindow(IRenderQueue service)
    {
        _service = service;
        InitializeComponent();
        Opened += OnOpened;
        // NOT disposed here. Closing fires on every close ATTEMPT, and the confirm-exit handler
        // cancels the first one — so this tore the renderer down while the app kept running, and
        // then ran a second time when the user confirmed, adding to a disposed queue and aborting
        // the process. The renderer is disposed once, after the exit is actually confirmed.
        WireUi();
    }

    // ------------------------------------------------------------------ startup

    private async void OnOpened(object sender, EventArgs e)
    {
        string library = AssetPaths.Library;
        string transforms = AssetPaths.Transforms;
        if (!AssetPaths.Found)
        {
            OverlayText.Text = "Could not find the plugin library.\n" + AssetPaths.Describe();
            return;
        }

        _service.FrameReady += OnFrameReady;
        // A failed dispatch pauses the loop; mirror that into the UI so the View menu offers
        // "Resume rendering" instead of the app just appearing to freeze.
        _service.Failed += ex => Dispatcher.UIThread.Post(() =>
        {
            if (_service.LastError is null)
                return;
            _renderingPaused = true;
            MenuToggleRender.Header = "Resume _rendering";
        });

        try
        {
            OverlayText.Text = "Compiling shaders…";
            await _service.StartAsync(library, transforms);
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine(ex);
            OverlayText.Text = "Failed to start renderer:\n" + (ex.InnerException ?? ex).Message;
            return;
        }

        GpuText.Text = _service.GpuName;
        ProjectionCombo.ItemsSource = Enum.GetValues<ProjectionType>();
        ShadingCombo.ItemsSource = Enum.GetValues<ShadingMode>();

        string presetFile = Path.Combine(library, "ResolutionPresets.json");
        if (File.Exists(presetFile))
        {
            var parsed = Newtonsoft.Json.JsonConvert
                .DeserializeObject<Dictionary<string, int[]>>(await File.ReadAllTextAsync(presetFile));
            _resolutionPresets = parsed?.ToList() ?? [];
            ResolutionPresets.ItemsSource = _resolutionPresets.Select(kv => $"{kv.Key}  ({kv.Value[0]}x{kv.Value[1]})").ToList();
        }
        TransformCombo.ItemsSource = _service.Transforms.Select(t => t.Name).ToList();
        TransformBrowserView.SetTransforms(_service.Transforms);
        PostFxAddCombo.ItemsSource = _service.Effects.Select(t => t.Name).ToList();
        BuildTemplatesMenu(library);
        RebuildRecentMenu();

        string template = Path.Combine(library, "Templates", "nebula_sample.ifsjson");
        if (File.Exists(template))
            LoadFractal(await IfsNodesSerializer.LoadJsonFileAsync(template, _service.Transforms, _service.Effects, true), template);
        else
            GenerateRandom();

        OverlayText.IsVisible = false;
        _syncingUi = true;
        try { Sensitivity.Value = 1.0; }
        finally { _syncingUi = false; }
        MenuToggleHints.Header = "Hide control _hints";
        MenuPanelIterators.Header = "✓ _Tone mapping panel";
        MenuPanelProperties.Header = "✓ _Properties panel";
        MenuPanelGraph.Header = "✓ Node _graph panel";
        ApplySettings();
        StartNavigationTick();
        StartStatusTicker();

        if (_settings.ShowWelcomeOnStartup)
        {
            var templateFiles = Directory.Exists(Path.Combine(library, "Templates"))
                ? Directory.GetFiles(Path.Combine(library, "Templates"), "*.ifsjson").OrderBy(f => f).ToList()
                : [];
            string picked = await Dialogs.ShowWelcome(this, _settings, templateFiles,
                GenerateRandom, RenderFilePreviewAsync);

            if (picked == Dialogs.OpenFileSentinel)
                await OpenFractal();
            else if (picked is not null)
                await OpenPath(picked, addToRecents: false);
        }
    }

    /// <summary>
    /// Renders a 100x100 preview of a fractal file, for the welcome screen's template and
    /// recent-file tiles. Returns null if the file will not load.
    /// </summary>
    private async Task<Avalonia.Media.Imaging.Bitmap> RenderFilePreviewAsync(string path)
    {
        const int size = 100, level = 7;
        try
        {
            IFS fractal = await IfsNodesSerializer.LoadJsonFileAsync(
                path, _service.Transforms, _service.Effects, true);
            byte[] bgra = await _service.RenderThumbnailAsync(fractal, size, size, level);

            var bitmap = new WriteableBitmap(new PixelSize(size, size),
                new Avalonia.Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
            using (ILockedFramebuffer fb = bitmap.Lock())
            {
                int rowBytes = size * 4;
                for (int y = 0; y < size; y++)
                {
                    // GL reads bottom-up.
                    Marshal.Copy(bgra, (size - 1 - y) * rowBytes, fb.Address + y * fb.RowBytes, rowBytes);
                }
            }
            return bitmap;
        }
        catch
        {
            return null;
        }
    }

    private void StartStatusTicker()
    {
        var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
        timer.Tick += (_, _) =>
        {
            IFS f = _service.Current;
            if (f is null)
                return;
            // A stalled renderer keeps saying so — the progress line would otherwise paint
            // over the one message explaining why nothing is being drawn.
            if (_service.LastError is Exception failure)
            {
                StatusText.Text = "Rendering stopped: " + failure.Message
                    + "   ·   View ▸ Resume rendering to retry";
                return;
            }
            StatusText.Text =
                $"{f.Title}   ·   level {_service.IterationLevel}/{f.TargetIterationLevel:F0}   ·   " +
                $"{_service.TotalIterations:N0} iterations   ·   {_service.FrameWidth}x{_service.FrameHeight} preview   ·   " +
                $"{_service.Fps:F0} fps";
            RefreshCameraFields();
            PerfSpeed.Text = _service.IterationSpeed > 1e6
                ? $"{_service.IterationSpeed / 1e6:F1}M/s"
                : $"{_service.IterationSpeed / 1e3:F0}K/s";
            PerfInvocations.Text = _service.InvocationIters.ToString("N0") + " iterations";
            PerfFps.Text = $"{_service.Fps:F0} fps";
            PerfTotal.Text = _service.TotalIterations.ToString("N0");

            // MainViewModel.cs:60-69 - progress is the iteration count as a fraction of the
            // target level's, not the level as a fraction of the target level.
            int level = _service.IterationLevel;
            double target = f.TargetIterationLevel;
            ProgressText.Text = $"Progress: {level} / {target:F0}";
            double denominator = Math.Pow(2, target) - 1;
            double reached = denominator > 0 && _service.FrameWidth > 0 && _service.FrameHeight > 0
                ? _service.TotalIterations / (double)(_service.FrameWidth * _service.FrameHeight) / denominator
                : 0;
            IterationProgress.Value = Math.Clamp(reached * 100.0, 0, 100);
            if (!IsTypingTarget() && WorkgroupBox.Value != _service.WorkgroupCount && _service.WorkgroupCount > 0)
            {
                _syncingUi = true;
                try { WorkgroupBox.Value = _service.WorkgroupCount; }
                finally { _syncingUi = false; }
            }
        };
        timer.Start();
    }

    // ------------------------------------------------------------------- frames

    private void OnFrameReady()
    {
        // Coalesce: never queue more than one pending UI update.
        if (_framePending)
            return;
        _framePending = true;
        Dispatcher.UIThread.Post(PresentFrame, DispatcherPriority.Render);
    }

    private void PresentFrame()
    {
        _framePending = false;
        lock (_service.FrameLock)
        {
            int w = _service.FrameWidth, h = _service.FrameHeight;
            byte[] src = _service.FrameBuffer;
            if (w <= 0 || h <= 0 || src.Length < w * h * 4)
                return;

            if (_bitmap is null || _bitmap.PixelSize.Width != w || _bitmap.PixelSize.Height != h)
            {
                _bitmap = new WriteableBitmap(new PixelSize(w, h), new Avalonia.Vector(96, 96),
                    PixelFormat.Bgra8888, AlphaFormat.Opaque);
                Viewport.Source = _bitmap;
            }

            using ILockedFramebuffer fb = _bitmap.Lock();
            int rowBytes = w * 4;
            for (int y = 0; y < h; y++)
            {
                // OpenGL reads bottom-up; the bitmap is top-down.
                int srcOffset = (h - 1 - y) * rowBytes;
                IntPtr dst = fb.Address + y * fb.RowBytes;
                Marshal.Copy(src, srcOffset, dst, rowBytes);
            }
        }
        Viewport.InvalidateVisual();
    }

    // -------------------------------------------------------------------- state

    private void LoadFractal(IFS fractal, string path = null)
    {
        // Replacing the open document is an undoable edit, and the snapshot belongs HERE rather
        // than at the call sites — Workspace.LoadParams does exactly this (Workspace.cs:152-154),
        // which is why the canonical's callers never snapshot themselves. Without it New, Random,
        // Open, Templates and Recents were all unrecoverable: Ctrl+Z had nothing to return to,
        // which is what made undo look broken.
        //
        // At startup Current is still null and Snapshot() guards on that, so the first load
        // records nothing, as it should.
        Snapshot();

        // Workspace.LoadParamsFileAsync takes an isTemplate flag and passes null as the edited
        // path for one, "so it cannot be saved over". Templates ship inside the install tree,
        // which is a read-only squashfs mount in an AppImage — writing back to one threw out of
        // an async void handler and took the process down. Ctrl+S on a template falls through
        // to Save as instead, exactly as on Windows.
        _editedFilePath = path is not null && IsShippedTemplate(path) ? null : path;
        Title = "IFSRenderer — " + (path is null ? fractal.Title : Path.GetFileName(path));
        var size = ViewportPixelSize();
        _service.LoadFractal(fractal, size.Width, size.Height);
        if (FinalRenderMode.IsChecked != true)
            _service.SetViewport(size.Width, size.Height, fillViewport: true);
        PushStateToUi(fractal);
        NodeEditorView.SetFractal(fractal);
        AnimationView.SetFractal(fractal);
        RebuildIteratorList(fractal);
        RebuildPostFxList(fractal);
        _connection = null;
        ConnectionName.Text = "(none — click an arrow)";
        ConnectionWeight.IsEnabled = false;
        OverlayText.IsVisible = false;
    }

    private PixelSize ViewportPixelSize()
    {
        double scale = RenderScaling;
        int w = (int)(ViewportHost.Bounds.Width * scale);
        int h = (int)(ViewportHost.Bounds.Height * scale);
        return new PixelSize(Math.Max(w, 16), Math.Max(h, 16));
    }

    private void PushStateToUi(IFS f)
    {
        _syncingUi = true;
        try
        {
            TitleBox.Text = f.Title ?? "";
            FovSlider.Value = f.Camera.FieldOfView;
            FocusSlider.Value = f.Camera.FocusDistance;
            ApertureSlider.Value = f.Camera.Aperture;
            DofSlider.Value = f.Camera.DepthOfField;
            ProjectionCombo.SelectedItem = f.Camera.Projection;
            // No clamping: these parameters are unbounded above in the original.
            BrightnessSlider.Value = f.Brightness;
            GammaSlider.Value = f.Gamma;
            GammaThresholdSlider.Value = f.GammaThreshold;
            VibrancySlider.Value = f.Vibrancy;
            QualitySlider.Value = f.TargetIterationLevel;
            ExportWidth.Value = f.ImageResolution.Width;
            ExportHeight.Value = f.ImageResolution.Height;
            WarmupSlider.Value = f.Warmup;
            // The canonical UI shows syntropy, the inverse of entropy.
            SyntropySlider.Value = f.Entropy > 0 ? 1.0 / f.Entropy : 0;
            FogSlider.Value = f.FogEffect;
            BgR.Value = f.BackgroundColor.R;
            BgG.Value = f.BackgroundColor.G;
            BgB.Value = f.BackgroundColor.B;
        }
        finally { _syncingUi = false; }
        RecomputeAspect();
        ShowBackgroundColour();
        ShowPalette(f.Palette);
        ShowAuthors(f);
    }

    /// <summary>
    /// Rebinds the iterator list, keeping the same row selected.
    /// </summary>
    /// <remarks>
    /// Selection is preserved BY INDEX, because undo and redo hand back a freshly deserialized
    /// fractal whose Iterator instances are new objects — matching by reference would always
    /// miss. Snapping back to the first iterator made undo look broken: the panel would show a
    /// different iterator's values, which is indistinguishable from the values not reverting.
    /// </remarks>
    /// <summary>
    /// Lists the fractal's authors as links, as the title box's popup does.
    /// </summary>
    /// <remarks>
    /// TitleBox.xaml:32-41 renders each author as a Hyperlink to their site. Author.Unknown
    /// carries "-" as its link, so that one is shown as plain text rather than a dead link.
    /// </remarks>
    private void ShowAuthors(IFS f)
    {
        AuthorsList.Children.Clear();
        var authors = f?.Authors?.ToList() ?? [];
        if (authors.Count == 0)
        {
            AuthorsList.Children.Add(new TextBlock
            {
                Text = "(none recorded)",
                Foreground = Brushes.Gray,
                FontSize = 12,
                Margin = new Thickness(8, 2),
            });
            return;
        }

        foreach (Author author in authors)
        {
            bool hasLink = !string.IsNullOrWhiteSpace(author.Link) && author.Link != "-";
            if (!hasLink)
            {
                AuthorsList.Children.Add(new TextBlock
                {
                    Text = author.Name,
                    Margin = new Thickness(8, 2),
                });
                continue;
            }

            var link = new Button
            {
                Classes = { "Flat" },
                Content = author.Name,
                Background = Brushes.Transparent,
                BorderThickness = new Thickness(0),
                Padding = new Thickness(8, 2),
                HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
                Foreground = new SolidColorBrush(Color.Parse("#8ab4f8")),
            };
            ToolTip.SetTip(link, author.Link);
            string target = author.Link;
            link.Click += (_, _) => Dialogs.OpenUrl(target);
            AuthorsList.Children.Add(link);
        }
    }

    private void RebuildIteratorList(IFS f)
    {
        int previous = IteratorList.SelectedIndex;
        _syncingUi = true;
        try
        {
            var items = f.Iterators.ToList();
            IteratorList.ItemsSource = items.Select(Describe).ToList();
            _iterators = items;
            IteratorList.SelectedIndex = previous >= 0 && previous < items.Count
                ? previous
                : items.Count > 0 ? 0 : -1;
        }
        finally { _syncingUi = false; }

        int index = IteratorList.SelectedIndex;
        Iterator selected = index >= 0 && index < _iterators.Count ? _iterators[index] : null;
        SelectIterator(selected);
        NodeEditorView.Select(selected);
    }

    private List<Iterator> _iterators = [];

    private static string Describe(Iterator it) =>
        (string.IsNullOrWhiteSpace(it.Name) ? it.Transform?.Name ?? "iterator" : it.Name)
        + $"   ({it.BaseWeight:F2})";

    private void SelectIterator(Iterator it)
    {
        _selectedIterator = it;
        _syncingUi = true;
        try
        {
            IteratorName.Text = it is null ? "(none)" : (it.Transform?.Name ?? "iterator");
            if (it is not null)
            {
                ItWeight.Value = it.BaseWeight;
                ItOpacity.Value = it.Opacity;
                ItColorIndex.Value = it.ColorIndex;
                ItColorSpeed.Value = it.ColorSpeed;
                ItStartWeight.Value = it.StartWeight;
                ItMix.Value = it.Mix;
                ItAdd.Value = it.Add;
                ShadingCombo.SelectedItem = it.ShadingMode;
                IteratorNameBox.Text = it.Name ?? string.Empty;
                TransformCombo.SelectedIndex = it.Transform is null
                    ? -1
                    : _service.Transforms.ToList().FindIndex(t => t == it.Transform);
            }
            else
            {
                TransformCombo.SelectedIndex = -1;
                ShadingCombo.SelectedIndex = -1;
                IteratorNameBox.Text = string.Empty;
            }
        }
        finally { _syncingUi = false; }

        PaletteBar.ColorIndex = it?.ColorIndex ?? 0;

        WireIteratorSliders(it);

        if (it is null)
            IteratorParams.Children.Clear();
        else
            BuildParamSliders(IteratorParams, it.RealParams, it.Vec3Params,
                () => _service.Post(r => r.InvalidateParamsBuffer()),
                it.Transform?.RealParams, it.Transform?.Vec3Params, $"[{it.Id}]");
    }

    // ----------------------------------------------------------------- ui wiring

    private void WireUi()
    {
        MenuOpen.Click += async (_, _) => await OpenFractal();
        MenuSave.Click += async (_, _) => await SaveFractal();
        MenuSaveAs.Click += async (_, _) => await SaveFractalAs();
        MenuNew.Click += (_, _) => NewFractal();
        MenuExportPng.Click += async (_, _) => await Export(".png");
        MenuExportExr.Click += async (_, _) => await Export(".exr");
        MenuExit.Click += (_, _) => Close();

        Closing += async (_, e) =>
        {
            if (_confirmedExit)
                return;
            e.Cancel = true;
            Dialogs.ExitChoice choice = await Dialogs.ConfirmExit(this);
            if (choice == Dialogs.ExitChoice.Stay)
                return;

            if (choice == Dialogs.ExitChoice.Save)
            {
                await SaveFractal();
                // A cancelled file picker, or a save that failed, leaves the document unwritten —
                // quitting anyway would discard exactly what the user just asked to keep.
                if (_editedFilePath is null)
                    return;
            }

            _confirmedExit = true;
            _service.Dispose();
            Close();
        };
        MenuRandom.Click += (_, _) => GenerateRandom();
        MenuResetCamera.Click += (_, _) => ResetCamera();

        // Camera
        ProjectionCombo.SelectionChanged += (_, _) =>
        {
            if (_syncingUi || ProjectionCombo.SelectedItem is not ProjectionType p)
                return;
            Snapshot();
            CameraChange(c => c.Projection = p);

            // Some projections are only correct at a particular FOV and aspect, so the canonical
            // sets both when one is chosen (CameraSettingsViewModel.cs:221-231).
            IFS f = _service.Current;
            if (f is null)
                return;
            if (p is ProjectionType.Equirectangular)
                ApplyProjectionFraming(f, 90, f.ImageResolution.Width, f.ImageResolution.Width / 2);
            else if (p is ProjectionType.Fisheye)
                ApplyProjectionFraming(f, 90, f.ImageResolution.Width, f.ImageResolution.Width);
        };

        // Tone mapping - consumed as uniforms at RenderImage time, so no buffer reset
        // (resetting would throw away the accumulated histogram).
        WireKeyframing();
        WireToneMapping();
        WireQualitySliders();

        // Warmup, entropy and the spatial filter feed the settings struct that is
        // uploaded every dispatch, so they take effect immediately - but they change
        // how samples are generated, so the accumulated histogram must be discarded
        // rather than mixed.

        // Density estimation is a post-pass over the finished histogram, so these
        // only affect the next RenderImage - no reset.
        EnableDe.IsCheckedChanged += (_, _) =>
        {
            if (_syncingUi) return;
            bool on = EnableDe.IsChecked == true;
            _service.Post(r => r.EnableDE = on);
        };

        // Final render mode renders the histogram 1:1 instead of scaling it to the
        // viewport - the same switch the WPF quality panel makes.
        FinalRenderMode.IsCheckedChanged += (_, _) =>
        {
            if (_syncingUi) return;
            bool final = FinalRenderMode.IsChecked == true;
            var size = ViewportPixelSize();
            if (final)
            {
                IFS f = _service.Current;
                int w = (int)ExportWidth.Value, h = (int)ExportHeight.Value;
                _service.Post(r =>
                {
                    if (f is null)
                        return;
                    f.ImageResolution = new System.Drawing.Size(w, h);
                    r.LoadParams(f);
                    r.SetHistogramScale(1.0);
                });
            }
            else
            {
                _service.SetViewport(size.Width, size.Height, fillViewport: true);
            }
        };

        ResolutionPresets.SelectionChanged += (_, _) =>
        {
            if (_syncingUi || ResolutionPresets.SelectedIndex < 0)
                return;
            if (ResolutionPresets.SelectedIndex >= _resolutionPresets.Count)
                return;
            int[] dims = _resolutionPresets[ResolutionPresets.SelectedIndex].Value;
            _syncingUi = true;
            try
            {
                ExportWidth.Value = dims[0];
                ExportHeight.Value = dims[1];
            }
            finally { _syncingUi = false; }
            ApplyExportResolution();
        };

        ExportWidth.ValueChanged += (_, _) =>
        {
            if (_syncingUi) return;
            if (LinkAspect.IsChecked == true && _aspect > 0)
            {
                _syncingUi = true;
                try { ExportHeight.Value = (int)Math.Round((double)(ExportWidth.Value ?? 0) / _aspect); }
                finally { _syncingUi = false; }
            }
            ApplyExportResolution();
        };
        ExportHeight.ValueChanged += (_, _) =>
        {
            if (_syncingUi) return;
            if (LinkAspect.IsChecked == true && _aspect > 0)
            {
                _syncingUi = true;
                try { ExportWidth.Value = (int)Math.Round((double)(ExportHeight.Value ?? 0) * _aspect); }
                finally { _syncingUi = false; }
            }
            ApplyExportResolution();
        };
        LinkAspect.IsCheckedChanged += (_, _) => RecomputeAspect();

        // Background colour is a RenderImage uniform, so it needs no invalidation -
        // it simply appears on the next presented frame.
        WireBackgroundChannel(BgR, "Red");
        WireBackgroundChannel(BgG, "Green");
        WireBackgroundChannel(BgB, "Blue");
        BgHex.TextChanged += (_, _) => ApplyBackgroundHex();
        BgHex.LostFocus += (_, _) => ShowBackgroundColour();

        // Fog is consumed while points accumulate, so changing it invalidates the histogram.

        BtnLoadPalette.Click += async (_, _) => await LoadPaletteFile();
        BtnPickPalette.Click += async (_, _) => await PickPalette();

        PaletteBar.DragStarted += Snapshot;
        PaletteBar.ColorIndexChanged += v =>
        {
            if (_selectedIterator is null)
                return;
            IteratorChange(i => i.ColorIndex = v);
            _syncingUi = true;
            try { ItColorIndex.Value = v; }
            finally { _syncingUi = false; }
        };
        // Palette choice is a visual decision, so it happens in the gradient picker.


        // Iterator params - these live in GPU buffers, so they must invalidate.

        ShadingCombo.SelectionChanged += (_, _) =>
        {
            if (_syncingUi || _selectedIterator is null || ShadingCombo.SelectedItem is not ShadingMode mode)
                return;
            IteratorChange(i => i.ShadingMode = mode);
        };

        // Renaming only changes what the list and the node show - no GPU state.
        IteratorNameBox.TextChanged += (_, _) =>
        {
            if (_syncingUi || _selectedIterator is null)
                return;
            string name = string.IsNullOrWhiteSpace(IteratorNameBox.Text) ? null : IteratorNameBox.Text;
            Iterator it = _selectedIterator;
            _service.Post(_ => it.Name = name);
            RefreshIteratorLabel(it);
            NodeEditorView.InvalidateVisual();
        };

        IteratorList.SelectionChanged += (_, _) =>
        {
            if (_syncingUi)
                return;
            int i = IteratorList.SelectedIndex;
            Iterator picked = i >= 0 && i < _iterators.Count ? _iterators[i] : null;
            SelectIterator(picked);
            NodeEditorView.Select(picked);
        };
        // AddIteratorWith snapshots; doing it here too would record the same state twice.
        BtnAddIterator.Click += (_, _) => AddIterator();
        BtnRemoveIterator.Click += (_, _) => RemoveSelected();
        BtnRemoveSelected.Click += (_, _) => RemoveSelected();
        BtnDuplicate.Click += (_, _) => { Snapshot(); DuplicateIterator(splitWeights: false); };
        BtnSplit.Click += (_, _) => { Snapshot(); DuplicateIterator(splitWeights: true); };

        // Both flips are a 0 <-> 1 toggle, matching IteratorViewModel.
        BtnFlipOpacity.Click += (_, _) =>
        {
            if (_selectedIterator is null) return;
            Snapshot();
            IteratorChange(i => i.Opacity = i.Opacity > 0.0 ? 0.0 : 1.0);
            SelectIterator(_selectedIterator);
            NodeEditorView.InvalidateVisual();
        };
        BtnFlipWeight.Click += (_, _) =>
        {
            if (_selectedIterator is null) return;
            Snapshot();
            IteratorChange(i => i.BaseWeight = i.BaseWeight > 0.0 ? 0.0 : 1.0);
            SelectIterator(_selectedIterator);
            NodeEditorView.InvalidateVisual();
        };
        MenuAutoLayout.Click += (_, _) => { Snapshot(); NodeEditorView.ResetView(); };

        // Transform browser - the canonical left column of the Editor window.
        TransformBrowserView.TransformActivated += AddIteratorWith;
        TransformBrowserView.ReloadRequested += async () => await ReloadPlugins();
        TransformBrowserView.OpenDirectoryRequested += () => OpenDirectory(AssetPaths.Transforms);
        TransformBrowserView.EditSourceRequested += p => OpenPluginSource(p?.FilePath, "transform");

        MenuToggleRender.Click += (_, _) => ToggleRendering();
        BtnToggleRender.Click += (_, _) => ToggleRendering();

        MenuGenerator.Click += async (_, _) => await ShowGenerator();

        WorkgroupBox.ValueChanged += async (_, _) =>
        {
            if (_syncingUi) return;
            await _service.SetWorkgroupCountAsync((int)(WorkgroupBox.Value ?? 256));
        };

        BtnEditTransform.Click += (_, _) =>
            OpenPluginSource(_selectedIterator?.Transform?.FilePath, "transform");
        BtnEditEffect.Click += (_, _) =>
            OpenPluginSource(_selectedLayer?.Effect?.FilePath, "effect");
        BtnReloadPlugins.Click += async (_, _) => await ReloadPlugins();

        // ---- animation ----
        AnimationView.ParamsProvider = BuildAnimatableParams;
        AnimationView.SnapshotRequested = Snapshot;
        AnimationView.AudioFilePicker = PickAudioFile;
        AnimationView.ChannelCsvPicker = PickChannelCsv;
        AnimationView.ImportFailed += message => StatusText.Text = message;
        AnimationView.TimeChanged += t =>
        {
            IFS f = _service.Current;
            if (f is null || f.Dopesheet.Channels.Count == 0)
                return;
            // The engine writes the evaluated values straight back onto the fractal.
            _service.Post(r =>
            {
                f.EvaluateAt(TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(t)));
                r.InvalidateParamsBuffer();
            });
            // Moving the playhead changes both "is there a key here" and "has the value
            // drifted", so every diamond has to be repainted.
            ValueSlider.NotifyAnimationStateChanged();
        };
        // Raised by every keyframe edit - a value, an interpolation type, a handle, a drag.
        //
        // This used to push the panel state and stop there, so an edit did NOT reach the
        // viewport: the fractal kept whatever values the last EvaluateAt had written onto it,
        // and TimeChanged above is the only other thing that calls one. The edit appeared the
        // moment the playhead moved, which is exactly how it was reported - "if you edit a
        // keyframe there it won't update the viewport until you change the frame".
        AnimationView.Changed += () =>
        {
            IFS f = _service.Current;
            if (f is null)
                return;

            if (f.Dopesheet.Channels.Count == 0)
            {
                PushStateToUi(f);
                ValueSlider.NotifyAnimationStateChanged();
                return;
            }

            PostThenRefresh(
                r =>
                {
                    f.EvaluateAt(TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(AnimationView.CurrentTime)));
                    r.InvalidateParamsBuffer();
                },
                () =>
                {
                    // Ordered AFTER the evaluation, so the sliders show the values that were
                    // just written rather than the ones being replaced.
                    PushStateToUi(f);
                    ValueSlider.NotifyAnimationStateChanged();
                });
        };
        AnimationView.ExportRequested += async () => await ExportClip();

        // Toolbar quick commands and the right-hand help menu.
        MenuSaveQuick.Click += async (_, _) => await SaveFractal();
        MenuExportFrames.Click += async (_, _) => await ExportClip();
        MenuExportVideo.Click += async (_, _) => await ExportVideo();
        BtnCancelExport.Click += (_, _) =>
        {
            _exportCancelled = true;
            OverlayText.Text = "Cancelling…";
        };
        MenuExportClipboard.Click += async (_, _) => await ExportToClipboard();
        MenuTutorials.Click += (_, _) => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/wiki");
        MenuIssues.Click += (_, _) => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/issues");
        MenuCheckUpdates.Click += (_, _) =>
            Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/releases");

        // The title box edits the fractal's title in place, as TitleBox.xaml does.
        TitleBox.TextChanged += (_, _) =>
        {
            IFS f = _service.Current;
            if (_syncingUi || f is null)
                return;
            string title = TitleBox.Text ?? "";
            _service.Post(_ => f.Title = title);
            Title = "IFSRenderer — " + (_editedFilePath is null
                ? title
                : Path.GetFileName(_editedFilePath));
        };

        MenuSettings.Click += async (_, _) => await Dialogs.ShowSettings(this, _settings, ApplySettings);
        MenuAbout.Click += async (_, _) => await Dialogs.ShowAbout(this, _service.GpuName);
        MenuWiki.Click += (_, _) => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/wiki");
        MenuForum.Click += (_, _) => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/discussions");
        MenuReportBug.Click += (_, _) => Dialogs.OpenUrl(
            "https://github.com/bezo97/IFSRenderer/issues/new?assignees=&labels=&template=bug_report.md");

        // Panel visibility, in place of AvalonDock's floating/dockable panes.
        MenuPanelIterators.Click += (_, _) =>
            TogglePanel(LeftPanel, MenuPanelIterators, "_Tone mapping panel", 0, LeftSplitter, 200);
        MenuPanelProperties.Click += (_, _) =>
            TogglePanel(PropertiesPanel, MenuPanelProperties, "_Properties panel", 4, RightSplitter, 250);
        MenuPanelGraph.Click += (_, _) =>
        {
            bool show = !GraphPanel.IsVisible;
            GraphPanel.IsVisible = show;
            GraphSplitter.IsVisible = show;
            ViewportSplit.RowDefinitions[2].Height = show ? new GridLength(300) : new GridLength(0);
            ViewportSplit.RowDefinitions[1].Height = show ? new GridLength(4) : new GridLength(0);
            MenuPanelGraph.Header = (show ? "✓ " : "") + "Node _graph panel";
        };
        MenuToggleHints.Click += (_, _) =>
        {
            HintText.IsVisible = !HintText.IsVisible;
            MenuToggleHints.Header = HintText.IsVisible ? "Hide control _hints" : "Show control _hints";
        };

        WireShortcuts();
        WireCameraSliders();
        BtnAnimateCamera.Click += (_, _) => AnimateCamera();

        // Navigation pad - orbit around the focus point, or dolly along the view axis.
        NavLeft.Click += (_, _) => Orbit(-1, 0);
        NavRight.Click += (_, _) => Orbit(1, 0);
        NavUp.Click += (_, _) => Orbit(0, -1);
        NavDown.Click += (_, _) => Orbit(0, 1);
        NavIn.Click += (_, _) => CameraChange(c => c.Translate(new Vector3(0, 0, 0.25f)));
        NavOut.Click += (_, _) => CameraChange(c => c.Translate(new Vector3(0, 0, -0.25f)));
        NavHome.Click += (_, _) => ResetCamera();

        void Orbit(float dx, float dy)
        {
            CameraChange(c =>
            {
                float speed = (float)c.FieldOfView / 180.0f * (float)Sensitivity.Value;
                float y = InvertY.IsChecked == true ? -dy : dy;
                Vector3 pivot = c.Position + c.ForwardDirection * (float)c.FocusDistance;
                c.OrbitAround(pivot, new Vector3(dx * speed * 0.15f, y * speed * 0.15f, 0));
            });
        }

        MenuUndo.Click += (_, _) => Undo();
        MenuRedo.Click += (_, _) => Redo();
        MenuCopyParams.Click += async (_, _) => await CopyParamsToClipboard();
        MenuPasteParams.Click += async (_, _) => await PasteParamsFromClipboard();

        AddHandler(DragDrop.DragOverEvent, (_, e) =>
        {
            e.DragEffects = e.Data.Contains(DataFormats.Files) ? DragDropEffects.Copy : DragDropEffects.None;
        });
        AddHandler(DragDrop.DropEvent, async (_, e) =>
        {
            var file = e.Data.GetFiles()?.FirstOrDefault();
            if (file?.Path?.LocalPath is string dropped)
                await OpenPath(dropped);
        });

        // Node graph <-> list/panel selection stay in sync.
        NodeEditorView.IteratorSelected += it =>
        {
            SelectIterator(it);
            int idx = _iterators.IndexOf(it);
            if (idx >= 0)
            {
                _syncingUi = true;
                try { IteratorList.SelectedIndex = idx; }
                finally { _syncingUi = false; }
            }
        };
        // Xaos weights live in the iterator GPU buffer, so a graph edit must invalidate.
        NodeEditorView.GraphChanged += () => _service.Post(r => r.InvalidateParamsBuffer());
        NodeEditorView.SnapshotRequested = Snapshot;
        NodeEditorView.ConnectionSelected += OnConnectionSelected;

        // The node's own hover controls, mirroring Node.xaml's floating action buttons.
        // Each selects the node first, so the action and the panel agree on the target.
        NodeEditorView.FlipOpacityRequested += it =>
        {
            SelectFromGraph(it);
            Snapshot();
            IteratorChange(i => i.Opacity = i.Opacity > 0.0 ? 0.0 : 1.0);
            SelectIterator(it);
            NodeEditorView.InvalidateVisual();
        };
        NodeEditorView.FlipWeightRequested += it =>
        {
            SelectFromGraph(it);
            Snapshot();
            IteratorChange(i => i.BaseWeight = i.BaseWeight > 0.0 ? 0.0 : 1.0);
            SelectIterator(it);
            NodeEditorView.InvalidateVisual();
        };
        NodeEditorView.DuplicateRequested += it =>
        {
            SelectFromGraph(it);
            Snapshot();
            DuplicateIterator(splitWeights: false);
        };
        NodeEditorView.SplitRequested += it =>
        {
            SelectFromGraph(it);
            Snapshot();
            DuplicateIterator(splitWeights: true);
        };

        // Transform picker
        TransformCombo.SelectionChanged += (_, _) =>
        {
            if (_syncingUi || _selectedIterator is null)
                return;
            if (TransformCombo.SelectedIndex < 0 || TransformCombo.SelectedIndex >= _service.Transforms.Count)
                return;
            Snapshot();
            TransformPlugin plugin = _service.Transforms[TransformCombo.SelectedIndex];
            Iterator it = _selectedIterator;
            if (plugin == it.Transform)
                return;

            // SetTransform keeps parameter values whose names still exist and adds
            // defaults for new ones, so the iterator buffer must be rebuilt.
            _service.Post(r =>
            {
                it.SetTransform(plugin);
                r.InvalidateParamsBuffer();
            });
            IteratorName.Text = plugin.Name;
            BuildParamSliders(IteratorParams, it.RealParams, it.Vec3Params,
                () => _service.Post(r => r.InvalidateParamsBuffer()),
                it.Transform?.RealParams, it.Transform?.Vec3Params, $"[{it.Id}]");
            RefreshIteratorLabel(it);
            NodeEditorView.InvalidateVisual();
        };

        ConnectionWeight.SliderSettings = new ValueSliderSettings
        {
            Label = "Weight",
            ToolTip = "The weight of the connection controls the transition probability between "
                    + "the two iterators. 0 weight means no connection.",
            DefaultValue = 1.0,
            MinValue = 0,
            Increment = 0.01,
            ValueWillChange = Snapshot,
            ValueChanged = v =>
            {
                if (_connection is null)
                    return;
                (Iterator from, Iterator to) = _connection.Value;
                _service.Post(r =>
                {
                    from.WeightTo[to] = v;
                    r.InvalidateParamsBuffer();
                });
                NodeEditorView.InvalidateVisual();
            },
        };

        // Post-processing. Each layer owns its own controls now, so the panel only wires the
        // add-layer combo and the two tool buttons - selecting the combo adds immediately, as
        // PostFxPanel.xaml does with its SelectionChanged trigger.
        PostFxAddCombo.SelectionChanged += (_, _) =>
        {
            if (_syncingUi || PostFxAddCombo.SelectedIndex < 0)
                return;
            AddPostFx();
        };
        BtnReloadEffects.Click += async (_, _) => await ReloadPlugins();
        BtnOpenEffectDir.Click += (_, _) => OpenDirectory(AssetPaths.Library);

        // Viewport interaction
        ViewportHost.PointerPressed += OnPointerPressed;
        ViewportHost.PointerMoved += OnPointerMoved;
        ViewportHost.PointerReleased += (_, _) => _dragging = false;
        ViewportHost.PointerWheelChanged += OnPointerWheel;
        ViewportHost.PropertyChanged += (_, e) =>
        {
            if (e.Property == BoundsProperty && _service.Current is not null)
            {
                var s = ViewportPixelSize();
                _service.SetViewport(s.Width, s.Height, fillViewport: FinalRenderMode.IsChecked != true);
            }
        };
    }

    /// <summary>
    /// Configures the tone-mapping ValueSliders from the canonical settings in
    /// <c>WpfDisplay/ViewModels/ToneMappingViewModel.cs</c> — same labels, same defaults,
    /// same increments, and the same absence of an upper bound. The previous Fluent sliders
    /// invented maxima (10 / 6 / 1 / 3) that the original never had, so values above them
    /// were unreachable.
    /// </summary>
    /// <summary>
    /// Connects the keyframe diamond on every ValueSlider to the dopesheet.
    /// </summary>
    /// <remarks>
    /// Ported rule-for-rule from <c>WpfDisplay/ViewModels/AnimationViewModel.cs</c>:
    /// <list type="bullet">
    /// <item><c>GetChannelCurrentState</c> — null when no channel exists, otherwise whether a
    /// keyframe sits exactly at the current time (<c>kf.t == currentTimeSeconds</c>).</item>
    /// <item><c>IsChannelBeingModified</c> — the live value has drifted from what the channel
    /// evaluates to, by more than 0.001. This is what turns the diamond orange.</item>
    /// <item><c>AnimateValue</c> — snapshot for undo FIRST, then add or update the channel.</item>
    /// </list>
    /// </remarks>
    private void WireKeyframing()
    {
        ValueSlider.ChannelStateProvider = path =>
        {
            IFS f = _service.Current;
            if (f is null || !f.Dopesheet.Channels.TryGetValue(path, out Channel channel))
                return null;
            double now = AnimationView.CurrentTime;
            return channel.Keyframes.Any(kf => kf.t == now);
        };

        ValueSlider.ChannelModifiedProvider = (path, value) =>
        {
            IFS f = _service.Current;
            if (f is null || !f.Dopesheet.Channels.TryGetValue(path, out Channel channel))
                return false;
            return Math.Abs(channel.EvaluateAt(AnimationView.CurrentTime) - value) > 0.001;
        };

        ValueSlider.AnimateRequested = (label, path, value) =>
        {
            IFS f = _service.Current;
            if (f is null)
                return;
            Snapshot();
            f.Dopesheet.AddOrUpdateChannel(label, path,
                TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(AnimationView.CurrentTime)), value);
            AnimationView.ReloadChannels();
            ValueSlider.NotifyAnimationStateChanged();
        };
    }

    private void WireToneMapping()
    {
        BrightnessSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "💡 Brightness",
            DefaultValue = IFS.Default.Brightness,
            MinValue = 0,
            Increment = 0.05,
            AnimationPath = nameof(IFS.Brightness),
            ValueWillChange = Snapshot,
            ValueChanged = v => TonemapChange(f => f.Brightness = v),
        };
        GammaSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "◑ 1/Gamma",
            DefaultValue = IFS.Default.Gamma,
            MinValue = 0,
            Increment = 0.005,
            AnimationPath = nameof(IFS.Gamma),
            ValueWillChange = Snapshot,
            ValueChanged = v => TonemapChange(f => f.Gamma = v),
        };
        GammaThresholdSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "◓ Gamma threshold",
            DefaultValue = IFS.Default.GammaThreshold,
            MinValue = 0,
            Increment = 0.0001,
            AnimationPath = nameof(IFS.GammaThreshold),
            ValueWillChange = Snapshot,
            ValueChanged = v => TonemapChange(f => f.GammaThreshold = v),
        };
        VibrancySlider.SliderSettings = new ValueSliderSettings
        {
            Label = "🌷 Vibrancy",
            DefaultValue = IFS.Default.Vibrancy,
            Increment = 0.005,
            AnimationPath = nameof(IFS.Vibrancy),
            ValueWillChange = Snapshot,
            ValueChanged = v => TonemapChange(f => f.Vibrancy = v),
        };
    }

    /// <summary>
    /// Camera sliders, from <c>WpfDisplay/ViewModels/CameraSettingsViewModel.cs</c>.
    /// </summary>
    /// <remarks>
    /// Position, orientation and tilt use <c>IsLabelShown = false</c>: the group heading names
    /// them, so the keyframe diamond sits inline at the right of the -/value/+ row. Focus
    /// distance and depth of field set <c>MarkAreaInFocus</c> while dragging, which makes the
    /// renderer draw the focal plane — the reason those two have dragging callbacks at all.
    /// </remarks>
    private void WireCameraSliders()
    {
        FovSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "🔬 Field of View",
            DefaultValue = 60,
            MinValue = 1,
            MaxValue = 179,
            Increment = 0.1,
            AnimationPath = "Camera.FieldOfView",
            ValueWillChange = Snapshot,
            ValueChanged = v => CameraChange(c => c.FieldOfView = v),
        };
        ApertureSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "✨ Aperture",
            DefaultValue = 0,
            MinValue = 0,
            Increment = 0.0001,
            AnimationPath = "Camera.Aperture",
            ValueWillChange = Snapshot,
            ValueChanged = v => CameraChange(c => c.Aperture = v),
        };
        FocusSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "📏 Focus Distance",
            DefaultValue = 10,
            Increment = 0.01,
            AnimationPath = "Camera.FocusDistance",
            ValueWillChange = Snapshot,
            ValueChanged = v => CameraChange(c => c.FocusDistance = v),
            ValueDraggingStarted = () => _service.Post(r => r.MarkAreaInFocus = true),
            ValueDraggingCompleted = _ => _service.Post(r =>
            {
                r.MarkAreaInFocus = false;
                r.InvalidateHistogramBuffer();
            }),
        };
        DofSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "🔾 Depth of Field",
            ToolTip = "a.k.a. Range of focus",
            DefaultValue = 0,
            MinValue = 0,
            Increment = 0.01,
            AnimationPath = "Camera.DepthOfField",
            ValueWillChange = Snapshot,
            ValueChanged = v => CameraChange(c => c.DepthOfField = v),
            ValueDraggingStarted = () => _service.Post(r => r.MarkAreaInFocus = true),
            ValueDraggingCompleted = _ => _service.Post(r =>
            {
                r.MarkAreaInFocus = false;
                r.InvalidateHistogramBuffer();
            }),
        };

        // Each axis writes ONLY its own component, exactly as the matching property setter in
        // CameraSettingsViewModel does — and, as there, the orientation quaternion is NOT
        // re-normalised. Writing all four at once and normalising (what this used to do) made
        // the orientation sliders inert: normalising projects the value straight back onto the
        // unit sphere, so any purely radial change is cancelled outright. From the default
        // pose (0,0,0,1) the W slider is exactly that, and the 500 ms camera refresh then wrote
        // the normalised value back over the slider, so nothing moved and nothing stuck.
        Axis(PosX, "Camera-X", "Camera position on X axis.", "Camera.Position.X", 0.01,
            (c, v) => c.Position = new Vector3((float)v, c.Position.Y, c.Position.Z));
        Axis(PosY, "Camera-Y", "Camera position on Y axis.", "Camera.Position.Y", 0.01,
            (c, v) => c.Position = new Vector3(c.Position.X, (float)v, c.Position.Z));
        Axis(PosZ, "Camera-Z", "Camera position on Z axis.", "Camera.Position.Z", 0.01,
            (c, v) => c.Position = new Vector3(c.Position.X, c.Position.Y, (float)v));
        Axis(OriX, "Orientation-X", "Camera rotation around X axis.", "Camera.Orientation.X", 0.001,
            (c, v) => c.Orientation = new Quaternion((float)v, c.Orientation.Y, c.Orientation.Z, c.Orientation.W));
        Axis(OriY, "Orientation-Y", "Camera rotation around Y axis.", "Camera.Orientation.Y", 0.001,
            (c, v) => c.Orientation = new Quaternion(c.Orientation.X, (float)v, c.Orientation.Z, c.Orientation.W));
        Axis(OriZ, "Orientation-Z", "Camera rotation around Z axis.", "Camera.Orientation.Z", 0.001,
            (c, v) => c.Orientation = new Quaternion(c.Orientation.X, c.Orientation.Y, (float)v, c.Orientation.W));
        Axis(OriW, "Orientation-W", "Camera rotation around W axis.", "Camera.Orientation.W", 0.001,
            (c, v) => c.Orientation = new Quaternion(c.Orientation.X, c.Orientation.Y, c.Orientation.Z, (float)v));
        Axis(TiltX, "Tilt-X", "Projection tilt on X axis.", "Camera.ProjectionTilt.X", 1,
            (c, v) => c.ProjectionTilt = new Vector3((float)v, c.ProjectionTilt.Y, c.ProjectionTilt.Z));
        Axis(TiltY, "Tilt-Y", "Projection tilt on Y axis.", "Camera.ProjectionTilt.Y", 1,
            (c, v) => c.ProjectionTilt = new Vector3(c.ProjectionTilt.X, (float)v, c.ProjectionTilt.Z));
        Axis(TiltZ, "Tilt-Z", "Projection tilt on Z axis.", "Camera.ProjectionTilt.Z", 1,
            (c, v) => c.ProjectionTilt = new Vector3(c.ProjectionTilt.X, c.ProjectionTilt.Y, (float)v));

        void Axis(ValueSlider slider, string label, string tip, string path, double increment,
            Action<Camera, double> apply) =>
            slider.SliderSettings = new ValueSliderSettings
            {
                Label = label,
                IsLabelShown = false,
                ToolTip = tip,
                DefaultValue = 0,
                Increment = increment,
                AnimationPath = path,
                ValueWillChange = Snapshot,
                ValueChanged = v => CameraChange(c => apply(c, v)),
            };

        Sensitivity.SliderSettings = new ValueSliderSettings
        {
            Label = "Sensitivity",
            ToolTip = "Fine-tune the sensitivity of camera movement controls. This applies to "
                    + "mouse, keyboard and gamepad thumbsticks. Default value is 1.",
            DefaultValue = 1,
            MinValue = 0.1,
            MaxValue = 10.0,
            Increment = 0.1,
        };
    }

    /// <summary>One menu accelerator: the keystroke, what it does, and whether it beats a text box.</summary>
    private readonly record struct Shortcut(KeyGesture Gesture, Action Invoke, bool BeatsTextInput);

    private readonly List<Shortcut> _shortcuts = [];

    /// <summary>The accelerators actually bound, for the test that keeps the menus honest.</summary>
    internal IReadOnlyList<KeyGesture> BoundGestures =>
        _shortcuts.Select(s => s.Gesture).ToList();

    /// <summary>
    /// Binds the accelerators the File and Export menus advertise.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <c>MenuItem.InputGesture</c> only DRAWS the accelerator text — it binds nothing. Every
    /// shortcut this app displayed was therefore decorative: fourteen of them, none listening.
    /// Space and Delete worked only because they had their own explicit key handler.
    /// </para>
    /// <para>
    /// The set matches <c>WpfDisplay/Views/MainWindow.xaml</c>, which declares it in two
    /// places: explicit <c>InputBindings</c> for the file and export gestures, and
    /// <c>CommandBindings</c> on <c>ApplicationCommands.Undo/Redo/Copy/Paste</c>, which carry
    /// WPF's own default gestures — Ctrl+Z, Ctrl+Y, Ctrl+C, Ctrl+V. Those four are easy to
    /// miss when reading the XAML, because the keystroke never appears in it.
    /// </para>
    /// <para>
    /// One deviation: the canonical halts rendering with the PAUSE key. Space is kept because
    /// this port has always used it and the menu advertises it, with Pause added alongside.
    /// </para>
    /// <para>
    /// <see cref="Shortcut.BeatsTextInput"/> is false for the accelerators that collide with
    /// ordinary text editing — Ctrl+C, Ctrl+V and Ctrl+A — so typing in the title box still
    /// copies, pastes and selects text rather than exporting a clipboard image.
    /// </para>
    /// </remarks>
    private void WireShortcuts()
    {
        void Bind(string gesture, Action invoke, bool beatsTextInput = true) =>
            _shortcuts.Add(new Shortcut(KeyGesture.Parse(gesture), invoke, beatsTextInput));

        Bind("Ctrl+N", NewFractal);
        Bind("Ctrl+O", () => _ = OpenFractal());
        Bind("Ctrl+S", () => _ = SaveFractal());
        Bind("Ctrl+Shift+S", () => _ = SaveFractalAs());
        Bind("Ctrl+R", GenerateRandom);
        Bind("Ctrl+Shift+C", () => _ = ExportToClipboard(), beatsTextInput: false);
        Bind("Ctrl+Shift+E", () => _ = Export(".exr"));
        Bind("Ctrl+E", () => _ = Export(".png"));
        Bind("Ctrl+A", () => _ = ExportClip(), beatsTextInput: false);
        Bind("Ctrl+Shift+A", () => _ = ExportVideo(), beatsTextInput: false);
        Bind("Ctrl+C", () => _ = CopyParamsToClipboard(), beatsTextInput: false);
        Bind("Ctrl+V", () => _ = PasteParamsFromClipboard(), beatsTextInput: false);
        Bind("F1", () => Dialogs.OpenUrl("https://github.com/bezo97/IFSRenderer/wiki"));
        Bind("Pause", ToggleRendering);

        // ApplicationCommands.Undo / .Redo in the canonical; these are their WPF gestures.
        Bind("Ctrl+Z", Undo);
        Bind("Ctrl+Y", Redo);

        // Tunnel, so an accelerator is seen before a focused control swallows the key; the
        // BeatsTextInput flag is what hands the ordinary editing keys back to a text box.
        AddHandler(KeyDownEvent, (_, e) =>
        {
            if (e.Handled)
                return;
            bool typing = IsTypingTarget();
            foreach (Shortcut shortcut in _shortcuts)
            {
                if (!shortcut.Gesture.Matches(e))
                    continue;
                if (typing && !shortcut.BeatsTextInput)
                    return;
                shortcut.Invoke();
                e.Handled = true;
                return;
            }
        }, RoutingStrategies.Tunnel);
    }

    /// <summary>
    /// Restores every camera setting, as <c>CameraSettingsViewModel.ResetCamera</c> does by
    /// replacing the whole <see cref="Camera"/> (CameraSettingsViewModel.cs:293-300).
    /// </summary>
    /// <remarks>
    /// Resetting only orientation and position, as this used to, left a wrecked field of view,
    /// aperture, focus distance, depth of field, projection and tilt in place — so "reset" did
    /// not recover from the thing people most often need to recover from.
    /// </remarks>
    private void ResetCamera()
    {
        IFS f = _service.Current;
        if (f is null)
            return;
        Snapshot();
        var fresh = new Camera();
        CameraChange(c =>
        {
            c.Position = fresh.Position;
            c.Orientation = fresh.Orientation;
            c.FieldOfView = fresh.FieldOfView;
            c.Aperture = fresh.Aperture;
            c.FocusDistance = fresh.FocusDistance;
            c.DepthOfField = fresh.DepthOfField;
            c.Projection = fresh.Projection;
            c.ProjectionTilt = fresh.ProjectionTilt;
        });
        PushStateToUi(f);
    }

    /// <summary>Sets the field of view and image aspect a projection requires.</summary>
    private void ApplyProjectionFraming(IFS f, double fieldOfView, int width, int height)
    {
        _service.Post(r =>
        {
            f.Camera.FieldOfView = fieldOfView;
            f.ImageResolution = new System.Drawing.Size(width, Math.Max(height, 1));
            r.LoadParams(f);
            r.InvalidateHistogramBuffer();
        });
        _syncingUi = true;
        try
        {
            FovSlider.Value = fieldOfView;
            ExportWidth.Value = width;
            ExportHeight.Value = Math.Max(height, 1);
        }
        finally { _syncingUi = false; }
        RecomputeAspect();
    }

    /// <summary>Keys all seven camera channels at once, as the canonical Animate Camera does.</summary>
    private void AnimateCamera()
    {
        IFS f = _service.Current;
        if (f is null)
            return;
        Snapshot();
        foreach (ValueSlider s in new[] { PosX, PosY, PosZ, OriX, OriY, OriZ, OriW })
            f.Dopesheet.AddOrUpdateChannel(s.Label, s.AnimationPath,
                TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(AnimationView.CurrentTime)), s.Value);
        AnimationView.ReloadChannels();
        ValueSlider.NotifyAnimationStateChanged();
    }

    /// <summary>
    /// Quality and density-estimation sliders, from <c>QualitySettingsViewModel.cs</c>.
    /// </summary>
    private void WireQualitySliders()
    {
        QualitySlider.SliderSettings = new ValueSliderSettings
        {
            Label = "🎌 Target Iteration Level",
            ToolTip = "The image is considered finished when the rendering progress reaches this "
                    + "level. It is recommended to animate this value when rendering animations, "
                    + "since certain frames may require a lot more iterations than others.",
            AnimationPath = "TargetIterationLevel",
            DefaultValue = IFS.Default.TargetIterationLevel,
            MinValue = 1,
            MaxValue = 50,
            Increment = 1,
            ValueWillChange = Snapshot,
            ValueChanged = v => TonemapChange(f => f.TargetIterationLevel = v),
        };
        // Syntropy is the inverse of entropy: the tendency to iterate deeper before the state
        // resets. The engine stores entropy, the UI shows syntropy — as on Windows.
        SyntropySlider.SliderSettings = new ValueSliderSettings
        {
            Label = "🧵 Syntropy",
            ToolTip = "Syntropy represents the tendency of the system to move towards more "
                    + "structure. High syntropy lets the system iterate deeper before the state "
                    + "resets. Syntropy is the inverse of entropy, which is the chance to reset "
                    + "state in each iteration.",
            DefaultValue = (int)(1.0 / IFS.Default.Entropy),
            MinValue = 10,
            MaxValue = 1000000,
            Increment = 10,
            ValueWillChange = Snapshot,
            ValueChanged = v => QualityChange(f => f.Entropy = 1.0 / v, null),
        };
        WarmupSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "🌡 Warmup",
            ToolTip = "A.k.a. 'fuse count', the number of iterations before plotting starts.",
            DefaultValue = IFS.Default.Warmup,
            MinValue = 0,
            Increment = 10,
            ValueWillChange = Snapshot,
            ValueChanged = v => QualityChange(f => f.Warmup = (int)v, null),
        };
        FilterRadiusSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "Filter Radius",
            DefaultValue = 0,
            MinValue = 0,
            MaxValue = 3,
            Increment = 1,
            ValueChanged = v => QualityChange(null, r => r.MaxFilterRadius = (int)v),
        };

        DeRadiusSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "Radius",
            DefaultValue = 0,
            MinValue = 0,
            MaxValue = 20,
            Increment = 1,
            ValueChanged = v => _service.Post(r => r.DEMaxRadius = (int)v),
        };
        DePowerSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "Power",
            DefaultValue = 0.4,
            MinValue = 0,
            MaxValue = 1,
            Increment = 0.01,
            ValueChanged = v => _service.Post(r => r.DEPower = v),
        };
        DeThresholdSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "Threshold",
            DefaultValue = 0,
            MinValue = 0,
            MaxValue = 1,
            Increment = 0.01,
            ValueChanged = v => _service.Post(r => r.DEThreshold = v),
        };

        FogSlider.SliderSettings = new ValueSliderSettings
        {
            Label = "🌫 Fog effect",
            // No maximum: IFSViewModel.cs:107-116 declares MinValue = 0 and nothing above, so
            // capping it at 1 put values the original can reach out of reach. Every other
            // maximum in this app matches the canonical; this was the last invented one.
            ToolTip = "Fades parts that are out of focus.",
            DefaultValue = IFS.Default.FogEffect,
            MinValue = 0,
            Increment = 0.01,
            AnimationPath = "FogEffect",
            ValueWillChange = Snapshot,
            ValueChanged = v => QualityChange(f => f.FogEffect = v, null),
        };
    }

    /// <summary>
    /// Per-iterator sliders, from <c>IteratorViewModel.cs</c>. Re-applied on every selection
    /// because each iterator has its own animation channel paths — <c>[id].PropertyName</c>.
    /// </summary>
    private void WireIteratorSliders(Iterator it)
    {
        string Path(string property) => it is null ? null : $"[{it.Id}].{property}";

        ItStartWeight.SliderSettings = new ValueSliderSettings
        {
            Label = "Start Weight",
            ToolTip = "Controls the probability that the iteration starts in this state.",
            DefaultValue = 1.0,
            MinValue = 0,
            Increment = 0.01,
            AnimationPath = Path(nameof(Iterator.StartWeight)),
            ValueWillChange = Snapshot,
            ValueChanged = v => IteratorChange(i => i.StartWeight = v),
        };
        ItWeight.SliderSettings = new ValueSliderSettings
        {
            Label = "Base Weight",
            ToolTip = "Multiplies incoming connection weights for ease of use. 0 base weight means "
                    + "this iterator does not take part in the iteration process, because the IFS "
                    + "never transitions here.",
            DefaultValue = 1.0,
            MinValue = 0,
            Increment = 0.01,
            AnimationPath = Path(nameof(Iterator.BaseWeight)),
            ValueWillChange = Snapshot,
            ValueChanged = v => IteratorChange(i => i.BaseWeight = v),
        };
        ItOpacity.SliderSettings = new ValueSliderSettings
        {
            Label = "Opacity",
            ToolTip = "Setting this to 0 means this iterator will not draw anything.",
            DefaultValue = 1.0,
            MinValue = -1,
            MaxValue = 1,
            Increment = 0.01,
            AnimationPath = Path(nameof(Iterator.Opacity)),
            ValueWillChange = Snapshot,
            ValueChanged = v => IteratorChange(i => i.Opacity = v),
        };
        ItColorIndex.SliderSettings = new ValueSliderSettings
        {
            Label = "Color Index",
            ToolTip = "Indexes to the color on the palette. 0 -> Left most color, 1 -> Right most color.",
            DefaultValue = 0.0,
            MinValue = 0,
            MaxValue = 1,
            Increment = 0.01,
            AnimationPath = Path(nameof(Iterator.ColorIndex)),
            ValueWillChange = Snapshot,
            ValueChanged = v =>
            {
                IteratorChange(i => i.ColorIndex = v);
                PaletteBar.ColorIndex = v;
            },
        };
        ItColorSpeed.SliderSettings = new ValueSliderSettings
        {
            Label = "Color Speed",
            ToolTip = "The Color Speed controls how fast the color state of the IFS progresses "
                    + "towards this iterator's Color Index. 0 -> no effect on colors. 1 -> Draw "
                    + "with the selected color index immediately.",
            DefaultValue = 0.0,
            Increment = 0.01,
            AnimationPath = Path(nameof(Iterator.ColorSpeed)),
            ValueWillChange = Snapshot,
            ValueChanged = v => IteratorChange(i => i.ColorSpeed = v),
        };
        ItMix.SliderSettings = new ValueSliderSettings
        {
            Label = "Mix",
            ToolTip = "Linearly interpolate between the states before/after the transform. "
                    + "0 -> The transform has no effect on the position. 1 -> Default.",
            DefaultValue = 1.0,
            Increment = 0.001,
            AnimationPath = Path(nameof(Iterator.Mix)),
            ValueWillChange = Snapshot,
            ValueChanged = v => IteratorChange(i => i.Mix = v),
        };
        ItAdd.SliderSettings = new ValueSliderSettings
        {
            Label = "Add",
            ToolTip = "Add up the positions before/after the transform.",
            DefaultValue = 0.0,
            Increment = 0.001,
            AnimationPath = Path(nameof(Iterator.Add)),
            ValueWillChange = Snapshot,
            ValueChanged = v => IteratorChange(i => i.Add = v),
        };
    }

    /// <summary>One 0-255 channel of the background colour, inside the swatch's picker.</summary>
    /// <remarks>
    /// A whole increment makes ValueSlider format the value as N0, which is what a colour
    /// channel wants, and - unlike the stock Slider this replaced - it can be typed exactly.
    /// </remarks>
    private void WireBackgroundChannel(ValueSlider slider, string label) =>
        slider.SliderSettings = new ValueSliderSettings
        {
            Label = label,
            DefaultValue = 0,
            MinValue = 0,
            MaxValue = 255,
            Increment = 1,
            ValueWillChange = Snapshot,
            ValueChanged = _ => ApplyBackgroundColour(),
        };

    /// <summary>Applies a typed hex colour, ignoring anything not yet a complete value.</summary>
    private void ApplyBackgroundHex()
    {
        if (_syncingUi)
            return;
        string text = (BgHex.Text ?? string.Empty).Trim();
        if (!text.StartsWith('#'))
            text = "#" + text;
        // Partial input is normal while typing; only act on something parseable.
        if (!Color.TryParse(text, out Color parsed))
            return;

        _syncingUi = true;
        try
        {
            BgR.Value = parsed.R;
            BgG.Value = parsed.G;
            BgB.Value = parsed.B;
        }
        finally { _syncingUi = false; }
        ApplyBackgroundColour(updateHex: false);
    }

    // ------------------------------------------------------------------ actions

    private void CameraChange(Action<Camera> change)
    {
        IFS f = _service.Current;
        if (f is null)
            return;
        _service.Post(r =>
        {
            change(f.Camera);
            r.InvalidateHistogramBuffer();
        });
    }

    /// <summary>
    /// Applies a structural change to the fractal on the GL thread, then rebuilds the UI from
    /// it — in that order.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <see cref="RenderService.Post"/> only QUEUES work; it returns before the engine has run
    /// it. Rebuilding the node graph or the effect stack on the line after a Post therefore
    /// read the fractal as it was BEFORE the edit, so every add/remove appeared to take effect
    /// one action late — and the rebuild enumerated <c>Iterators</c> / <c>PostEffects</c> on
    /// the UI thread while the GL thread was mutating the very same list, which could throw
    /// mid-render and leave the renderer permanently paused.
    /// </para>
    /// <para>
    /// Posting the refresh back from inside the queued action fixes both: the rebuild is
    /// ordered after the mutation, and it never runs concurrently with it.
    /// </para>
    /// </remarks>
    private void PostThenRefresh(Action<IRenderer> mutate, Action refresh)
    {
        _service.Post(r =>
        {
            mutate(r);
            Dispatcher.UIThread.Post(refresh);
        });
    }

    private void TonemapChange(Action<IFS> change)
    {
        IFS f = _service.Current;
        if (f is null)
            return;
        _service.Post(_ => change(f));
    }

    private void IteratorChange(Action<Iterator> change)
    {
        Iterator it = _selectedIterator;
        if (it is null)
            return;
        _service.Post(r =>
        {
            change(it);
            r.InvalidateParamsBuffer();
        });
        int idx = _iterators.IndexOf(it);
        if (idx >= 0 && IteratorList.ItemsSource is List<string> labels)
        {
            labels[idx] = Describe(it);
            _syncingUi = true;
            try
            {
                IteratorList.ItemsSource = null;
                IteratorList.ItemsSource = labels;
                IteratorList.SelectedIndex = idx;
            }
            finally { _syncingUi = false; }
        }
    }

    private void AddIterator()
    {
        if (_service.Transforms.Count == 0)
            return;
        AddIteratorWith(_service.Transforms[Random.Shared.Next(_service.Transforms.Count)]);
    }

    /// <summary>
    /// Adds an iterator using a chosen transform, as IFSViewModel.AddIterator does: the new
    /// iterator is connected to and from the current selection with weight 1, and then becomes
    /// the selection.
    /// </summary>
    private void AddIteratorWith(TransformPlugin plugin)
    {
        IFS f = _service.Current;
        if (f is null || plugin is null)
            return;

        Snapshot();
        Iterator selected = _selectedIterator;
        var created = new Iterator(plugin);
        PostThenRefresh(r =>
        {
            f.AddIterator(created, false);
            if (selected is not null)
            {
                selected.WeightTo[created] = 1.0;
                created.WeightTo[selected] = 1.0;
            }
            r.LoadParams(f);
            r.SetHistogramScaleToDisplay();
        },
        () =>
        {
            NodeEditorView.SetFractal(f);
            RebuildIteratorList(f);

            int index = _iterators.IndexOf(created);
            if (index < 0)
                return;
            _syncingUi = true;
            try { IteratorList.SelectedIndex = index; }
            finally { _syncingUi = false; }
            SelectIterator(created);
            NodeEditorView.Select(created);
        });
    }

    /// <summary>
    /// Duplicates the selected iterator. <paramref name="splitWeights"/> false is the
    /// classic flame "duplicate" (both connected, weights untouched); true halves the
    /// base weights and leaves the pair unconnected, preserving the overall look.
    /// </summary>
    private void DuplicateIterator(bool splitWeights)
    {
        IFS f = _service.Current;
        Iterator original = _selectedIterator;
        if (f is null || original is null)
            return;

        // Place the copy beside its source rather than letting auto-layout fling it away.
        NodePosition? from = original.GetPosition();

        PostThenRefresh(r =>
        {
            Iterator dupe = f.DuplicateIterator(original, splitWeights);
            if (from is not null)
                dupe.SetPosition(new NodePosition(from.Value.X + 28, from.Value.Y + 34));
            r.LoadParams(f);
        },
        () =>
        {
            NodeEditorView.SetFractal(f);
            RebuildIteratorList(f);
        });
    }

    /// <summary>
    /// Records the current state for undo. Called before discrete edits (add, remove,
    /// duplicate, flip, transform swap, palette change) - deliberately NOT on every slider
    /// tick, which would bury the history in noise.
    /// </summary>
    private void Snapshot()
    {
        if (_service.Current is not null)
            _history.TakeSnapshot(_service.Current);
        RefreshHistoryCommands();
    }

    /// <summary>
    /// Greys the undo/redo items when there is nothing to go back or forward to.
    /// </summary>
    /// <remarks>
    /// Parity with <c>IFSViewModel.UndoCommand</c>/<c>RedoCommand</c>, whose
    /// <c>CanExecute</c> is <c>IsHistoryUndoable</c>/<c>IsHistoryRedoable</c>
    /// (IFSViewModel.cs:403-409). Also the honest signal: a fresh document has no history, so
    /// Ctrl+Z genuinely has nothing to do, and the menu should say so rather than look inert.
    /// </remarks>
    private void RefreshHistoryCommands()
    {
        MenuUndo.IsEnabled = _history.IsHistoryUndoable;
        MenuRedo.IsEnabled = _history.IsHistoryRedoable;
    }

    private void Undo()
    {
        if (!_history.IsHistoryUndoable || _service.Current is null)
            return;
        IFS restored = _history.Undo(_service.Current, _service.Transforms, _service.Effects);
        ReloadAfterHistory(restored, "Undo");
        RefreshHistoryCommands();
    }

    private void Redo()
    {
        if (!_history.IsHistoryRedoable || _service.Current is null)
            return;
        IFS restored = _history.Redo(_service.Current, _service.Transforms, _service.Effects);
        ReloadAfterHistory(restored, "Redo");
        RefreshHistoryCommands();
    }

    /// <summary>
    /// Undo/redo hand back a freshly deserialized fractal, so the whole UI must rebind to it.
    /// Node positions come along because the tracker serializes through IfsNodesSerializer.
    /// </summary>
    private void ReloadAfterHistory(IFS restored, string what)
    {
        var size = ViewportPixelSize();
        _service.LoadFractal(restored, size.Width, size.Height);
        PushStateToUi(restored);
        NodeEditorView.SetFractal(restored);
        AnimationView.SetFractal(restored);
        RebuildIteratorList(restored);
        RebuildPostFxList(restored);
        StatusText.Text = what;
    }

    /// <summary>
    /// The canonical RemoveSelected: a selected CONNECTION is disconnected (its weight set to
    /// 0); otherwise the selected iterator is removed.
    /// </summary>
    private void RemoveSelected()
    {
        if (_connection is { } link)
        {
            Snapshot();
            (Iterator from, Iterator to) = link;
            IFS current = _service.Current;
            PostThenRefresh(r =>
            {
                from.WeightTo[to] = 0.0;
                r.InvalidateParamsBuffer();
            },
            () =>
            {
                _connection = null;
                ConnectionName.Text = "(none — click an arrow)";
                ConnectionWeight.IsEnabled = false;
                NodeEditorView.SetFractal(current);
            });
            return;
        }

        Snapshot();
        RemoveIterator();
    }

    private void RemoveIterator()
    {
        IFS f = _service.Current;
        Iterator it = _selectedIterator;
        if (f is null || it is null || f.Iterators.Count <= 1)
            return;
        PostThenRefresh(r =>
        {
            f.RemoveIterator(it);
            r.LoadParams(f);
            r.SetHistogramScaleToDisplay();
        },
        () =>
        {
            NodeEditorView.SetFractal(f);
            RebuildIteratorList(f);
        });
    }

    private void GenerateRandom()
    {
        var generator = new Generator(_service.Transforms);
        IFS fractal = generator.GenerateOne(new GeneratorOptions());
        fractal.Title = "Random fractal";
        LoadFractal(fractal);
    }

    // -------------------------------------------------------------- interaction

    private void OnPointerPressed(object sender, PointerPressedEventArgs e)
    {
        // NOT snapshotted here: TakeSnapshot clears the redo stack, so recording on a bare
        // press meant any click in the viewport destroyed redo. The snapshot happens on the
        // first actual movement instead - the canonical's InteractionStarted semantics.
        _cameraDragSnapshotted = false;

        // MainViewModel.cs:345-350 hides the hints on the first camera interaction - it is a
        // first-run prompt, not a permanent overlay. Ours sat there until toggled off by hand.
        if (HintText.IsVisible)
        {
            HintText.IsVisible = false;
            MenuToggleHints.Header = "Show control _hints";
        }
        var props = e.GetCurrentPoint(ViewportHost).Properties;
        _dragButton = props.PointerUpdateKind;
        _lastPointer = e.GetPosition(ViewportHost);
        _dragging = true;
        e.Pointer.Capture(ViewportHost);
    }

    private void OnPointerMoved(object sender, PointerEventArgs e)
    {
        if (!_dragging || _service.Current is null)
            return;

        Point pos = e.GetPosition(ViewportHost);
        var delta = new Vector2((float)(pos.X - _lastPointer.X), (float)(pos.Y - _lastPointer.Y));
        _lastPointer = pos;
        if (delta.LengthSquared() <= 0)
            return;

        // One snapshot per drag, taken once the camera is actually about to move.
        if (!_cameraDragSnapshotted)
        {
            _cameraDragSnapshotted = true;
            Snapshot();
        }

        Camera cam = _service.Current.Camera;
        // Rotation speed scales with field of view, as in the WPF display.
        float speed = (float)cam.FieldOfView / 180.0f;
        // Both settings apply to mouse look in the canonical; neither did here.
        float sensitivity = (float)Sensitivity.Value;
        float yaw = delta.X * speed;
        float pitch = delta.Y * speed * (InvertY.IsChecked == true ? -1f : 1f);

        switch (_dragButton)
        {
            case PointerUpdateKind.LeftButtonPressed:
                CameraChange(c => c.Rotate(new Vector3(yaw, pitch, 0) * 0.01f * sensitivity));
                break;

            case PointerUpdateKind.MiddleButtonPressed:
                CameraChange(c =>
                {
                    Vector3 pivot = c.Position + c.ForwardDirection * (float)c.FocusDistance;
                    c.OrbitAround(pivot, new Vector3(yaw, pitch, 0) * 0.01f * sensitivity);
                });
                break;

            case PointerUpdateKind.RightButtonPressed:
            {
                var center = new Vector2((float)ViewportHost.Bounds.Width / 2, (float)ViewportHost.Bounds.Height / 2);
                var lastOffset = new Vector2((float)(pos.X - delta.X), (float)(pos.Y - delta.Y)) - center;
                var offset = new Vector2((float)pos.X, (float)pos.Y) - center;
                if (lastOffset.Length() > 0.001f && offset.Length() > 0.001f)
                {
                    Vector2 dir = Vector2.Normalize(lastOffset);
                    float roll = dir.X * delta.Y - dir.Y * delta.X;
                    float fovScale = lastOffset.Length() / offset.Length();
                    CameraChange(c =>
                    {
                        c.FieldOfView = Math.Clamp(c.FieldOfView * fovScale, 1, 179);
                        c.Rotate(new Vector3(0, 0, roll * 0.01f));
                    });
                    _syncingUi = true;
                    try { FovSlider.Value = Math.Clamp(cam.FieldOfView, 1, 179); }
                    finally { _syncingUi = false; }
                }
                break;
            }
        }
    }

    private void OnPointerWheel(object sender, PointerWheelEventArgs e)
    {
        if (_service.Current is null)
            return;
        double factor = 1 + e.Delta.Y * 0.1;
        CameraChange(c => c.FocusDistance = Math.Max(0.01, c.FocusDistance * factor));
        _syncingUi = true;
        try
        {
            FocusSlider.Value = _service.Current.Camera.FocusDistance;
        }
        finally { _syncingUi = false; }
    }

    // ---------------------------------------------------------------- file i/o

    private async Task OpenFractal()
    {
        var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
        {
            Title = "Open fractal",
            AllowMultiple = false,
            FileTypeFilter =
            [
                new FilePickerFileType("Fractals and palettes")
                    { Patterns = ["*.ifsjson", "*.png", "*.gradient", "*.ugr"] },
            ],
        });
        if (files.Count == 0)
            return;

        await OpenPath(files[0].Path.LocalPath);
    }

    /// <summary>
    /// True for a file that ships with the application — a template under <c>Library/</c>.
    /// Those live in the install tree, which is read-only in a packaged build.
    /// </summary>
    internal static bool IsShippedTemplate(string path)
    {
        try
        {
            string library = Path.GetFullPath(AssetPaths.Library);
            return Path.GetFullPath(path).StartsWith(
                library + Path.DirectorySeparatorChar, StringComparison.Ordinal);
        }
        catch
        {
            // A malformed path is not a template; let the normal save path report on it.
            return false;
        }
    }

    /// <summary>Ctrl+S: writes back to the open file, or falls through to Save as.</summary>
    private async Task SaveFractal()
    {
        if (_service.Current is null)
            return;
        if (_editedFilePath is null)
        {
            await SaveFractalAs();
            return;
        }
        // Saving is I/O and can fail for reasons the app does not control — a read-only
        // location, a full disk, a path that vanished. This runs from an async void click
        // handler, where an escaping exception is fatal, so it is reported, never thrown.
        try
        {
            await IfsNodesSerializer.SaveJsonFileAsync(_service.Current, _editedFilePath);
            StatusText.Text = "Saved " + _editedFilePath;
        }
        catch (Exception ex)
        {
            StatusText.Text = $"Could not save {_editedFilePath}: {ex.Message} — try Save as…";
        }
    }

    private async Task SaveFractalAs()
    {
        if (_service.Current is null)
            return;
        var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
        {
            Title = "Save fractal",
            DefaultExtension = "ifsjson",
            SuggestedFileName = _service.Current.Title,
            FileTypeChoices = [new FilePickerFileType("IFS fractal") { Patterns = ["*.ifsjson"] }],
        });
        if (file is null)
            return;

        string path = file.Path.LocalPath;
        if (!path.EndsWith(".ifsjson", StringComparison.OrdinalIgnoreCase))
            path += ".ifsjson";

        try
        {
            await IfsNodesSerializer.SaveJsonFileAsync(_service.Current, path);
        }
        catch (Exception ex)
        {
            StatusText.Text = $"Could not save {path}: {ex.Message}";
            return;
        }
        _editedFilePath = path;
        _settings.AddRecent(path);
        RebuildRecentMenu();
        Title = "IFSRenderer — " + Path.GetFileName(path);
        StatusText.Text = "Saved " + path;
    }

    /// <summary>
    /// Blank parameters. A random palette by default so it isn't a white screen, or the plain
    /// default palette when Settings > User settings > White blank params is set — the same
    /// choice Workspace.LoadBlankParams makes.
    /// </summary>
    private void NewFractal()
    {
        var blank = new IFS();
        if (!_settings.UseWhiteForBlankParams)
            blank.Palette = IqPaletteGenerator.Generate(
                new Vector4(0.6f, 0.6f, 0.6f, 1f),
                new Vector4(0.5f, 0.5f, 0.5f, 1f),
                new Vector4(0.5f, 0.5f, 0.5f, 1f),
                new Vector4((float)Random.Shared.NextDouble(), (float)Random.Shared.NextDouble(),
                    (float)Random.Shared.NextDouble(), 1f),
                IFSEngine.Model.InterpolationMode.Mixbox,
                10);
        LoadFractal(blank);
        StatusText.Text = "Blank parameters loaded";
    }

    private async Task Export(string extension)
    {
        if (_service.Current is null)
            return;

        var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
        {
            Title = "Export image",
            DefaultExtension = extension.TrimStart('.'),
            SuggestedFileName = _service.Current.Title,
            FileTypeChoices = [new FilePickerFileType(extension.ToUpperInvariant())
                { Patterns = ["*" + extension] }],
        });
        if (file is null)
            return;

        string path = file.Path.LocalPath;
        if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
            path += extension;

        int w = (int)(ExportWidth.Value ?? 1920);
        int h = (int)(ExportHeight.Value ?? 1080);
        int level = (int)QualitySlider.Value;

        OverlayText.IsVisible = true;
        OverlayText.Text = $"Rendering {w}x{h} to level {level}…";
        try
        {
            await _service.ExportAsync(path, w, h, level,
                lvl => Dispatcher.UIThread.Post(() => OverlayText.Text = $"Rendering {w}x{h} … level {lvl}/{level}"),
                new ExportMetadata
                {
                    SaveMetadata = _settings.SaveMetadata,
                    IncludeParams = _settings.SaveMetadata && _settings.IncludeParamsInMetadata,
                    Author = _settings.AuthorName,
                    CreatedUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ",
                        System.Globalization.CultureInfo.InvariantCulture),
                });
            StatusText.Text = "Exported " + path;
        }
        catch (Exception ex)
        {
            StatusText.Text = "Export failed: " + ex.Message;
        }
        finally
        {
            OverlayText.IsVisible = false;
        }
    }

    /// <summary>Palettes parsed from the most recently opened .gradient / .ugr file.</summary>
    private List<ColorPalette> _loadedPalettes = [];

    private void ApplyBackgroundColour(bool updateHex = true)
    {
        IFS f = _service.Current;
        if (f is null)
            return;
        int r = (int)BgR.Value, g = (int)BgG.Value, b = (int)BgB.Value;
        _service.Post(_ => f.BackgroundColor = System.Drawing.Color.FromArgb(255, r, g, b));
        BackgroundSwatch.Background = new SolidColorBrush(Color.FromRgb((byte)r, (byte)g, (byte)b));
        if (updateHex)
            SyncHexBox((byte)r, (byte)g, (byte)b);
    }

    /// <summary>Repaints the swatch and hex box from the open fractal.</summary>
    private void ShowBackgroundColour()
    {
        IFS f = _service.Current;
        if (f is null)
            return;
        System.Drawing.Color c = f.BackgroundColor;
        BackgroundSwatch.Background = new SolidColorBrush(Color.FromRgb(c.R, c.G, c.B));
        SyncHexBox(c.R, c.G, c.B);
    }

    private void SyncHexBox(byte r, byte g, byte b)
    {
        _syncingUi = true;
        try { BgHex.Text = $"#{r:X2}{g:X2}{b:X2}"; }
        finally { _syncingUi = false; }
    }

    /// <summary>
    /// A .gradient / .ugr file holds MANY palettes, so parsing it fills a picker
    /// rather than applying one directly - the same shape as the WPF palette dialog.
    /// </summary>
    private async Task LoadPaletteFile()
    {
        var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
        {
            Title = "Load palette file",
            AllowMultiple = false,
            FileTypeFilter = [new FilePickerFileType("Flame palettes") { Patterns = ["*.gradient", "*.ugr"] }],
        });
        if (files.Count == 0)
            return;

        string path = files[0].Path.LocalPath;
        try
        {
            _loadedPalettes = (await FlameFormatImporter.ImportFromFileAsync(path)).Palettes;
        }
        catch (Exception ex)
        {
            StatusText.Text = "Could not read palette file: " + ex.Message;
            return;
        }

        if (_loadedPalettes.Count == 0)
        {
            StatusText.Text = "No palettes found in " + Path.GetFileName(path);
            return;
        }

        BtnPickPalette.IsVisible = _loadedPalettes.Count > 1;
        ApplyPalette(_loadedPalettes[0]);
        StatusText.Text = $"{_loadedPalettes.Count} palette(s) loaded from {Path.GetFileName(path)}";
        if (_loadedPalettes.Count > 1)
            await PickPalette();
    }

    /// <summary>Offers every palette in the loaded file as a gradient, and applies the pick.</summary>
    private async Task PickPalette()
    {
        if (_loadedPalettes.Count == 0)
            return;
        int index = await Dialogs.ShowPalettePicker(this, _loadedPalettes);
        if (index < 0 || index >= _loadedPalettes.Count)
            return;
        Snapshot();
        ApplyPalette(_loadedPalettes[index]);
    }

    private void ApplyPalette(ColorPalette palette)
    {
        IFS f = _service.Current;
        if (f is null || palette is null)
            return;
        // The gradient lives in its own GPU buffer now, so the params buffer is not enough.
        _service.Post(r =>
        {
            f.Palette = palette;
            r.InvalidatePaletteBuffer();
            r.InvalidateParamsBuffer();
        });
        ShowPalette(palette);
        NodeEditorView.InvalidateVisual();
    }

    /// <summary>Shows the palette on the gradient bar and names it.</summary>
    private void ShowPalette(ColorPalette palette)
    {
        // ColorPalette holds KEY colours - stops - rather than FlamePalette's 400 baked samples,
        // so the count means something different and is worth saying plainly.
        PaletteName.Text = palette is null || palette.KeyColors.Count == 0
            ? "(none)"
            : $"{palette.Name}  ·  {palette.KeyColors.Count} stops";

        PaletteBar.Palette = palette;
        PaletteBar.ColorIndex = _selectedIterator?.ColorIndex ?? 0;

        if (palette is null || palette.KeyColors.Count == 0)
            return;

    }

    /// <summary>Resolution presets loaded from the shipped Library/ResolutionPresets.json.</summary>
    private List<KeyValuePair<string, int[]>> _resolutionPresets = [];

    /// <summary>Width divided by height, captured when the aspect lock is engaged.</summary>
    private double _aspect;

    private void RecomputeAspect()
    {
        double w = (double)(ExportWidth.Value ?? 0), h = (double)(ExportHeight.Value ?? 0);
        _aspect = h > 0 ? w / h : 0;
    }

    /// <summary>
    /// Applies the export resolution to the fractal. In final-render mode the histogram
    /// follows it 1:1; otherwise the preview stays scaled to the viewport.
    /// </summary>
    private void ApplyExportResolution()
    {
        IFS f = _service.Current;
        if (f is null)
            return;
        int w = (int)(ExportWidth.Value ?? 1920), h = (int)(ExportHeight.Value ?? 1080);
        if (w <= 0 || h <= 0)
            return;

        bool final = FinalRenderMode.IsChecked == true;
        var size = ViewportPixelSize();
        _service.Post(r =>
        {
            if (final)
            {
                f.ImageResolution = new System.Drawing.Size(w, h);
                r.LoadParams(f);
                r.SetHistogramScale(1.0);
            }
        });
        if (!final)
            _service.SetViewport(size.Width, size.Height, fillViewport: true);
    }

    /// <summary>
    /// Applies a change that alters how samples are produced, discarding the
    /// accumulated histogram so old and new samples are never mixed.
    /// </summary>
    private void QualityChange(Action<IFS> onFractal, Action<IRenderer> onRenderer)
    {
        IFS f = _service.Current;
        if (f is null)
            return;
        _service.Post(r =>
        {
            onFractal?.Invoke(f);
            onRenderer?.Invoke(r);
            r.InvalidateHistogramBuffer();
        });
    }

    // ------------------------------------------------------- connections / params

    private (Iterator From, Iterator To)? _connection;
    private EffectLayer _selectedLayer;
    private List<EffectLayer> _layers = [];

    /// <summary>Makes a graph-side selection the panel's selection, without re-entering the graph.</summary>
    private void SelectFromGraph(Iterator it)
    {
        SelectIterator(it);
        int idx = _iterators.IndexOf(it);
        if (idx < 0)
            return;
        _syncingUi = true;
        try { IteratorList.SelectedIndex = idx; }
        finally { _syncingUi = false; }
    }

    private void OnConnectionSelected(Iterator from, Iterator to)
    {
        _connection = (from, to);
        _syncingUi = true;
        try
        {
            string a = from.Transform?.Name ?? "iterator";
            string b = to.Transform?.Name ?? "iterator";
            ConnectionName.Text = from == to ? $"{a} → itself" : $"{a} → {b}";
            ConnectionWeight.IsEnabled = true;
            // Makes the keyframe diamond live for this connection: xaos weights are animatable
            // in the canonical (ConnectionViewModel.cs:55) and were not here, so the diamond sat
            // permanently dead.
            ConnectionWeight.AnimationPath = $"[{from.Id}].[{to.Id}]";
            ConnectionWeight.Value = from.WeightTo.TryGetValue(to, out double w) ? w : 0;
        }
        finally { _syncingUi = false; }
    }

    /// <summary>
    /// Builds a ValueSlider per plugin parameter, using the canonical settings from
    /// <c>RealParamViewModel</c> / <c>Vec3ParamViewModel</c>: the plugin's own default, an
    /// increment of 0.001, and no bounds.
    /// </summary>
    /// <param name="pathPrefix">
    /// Animation channel prefix, e.g. <c>[iteratorId]</c>. Null for effect layers, which have
    /// no path this build can resolve, so their params are simply not keyable.
    /// </param>
    private void BuildParamSliders(StackPanel host, Dictionary<string, double> reals,
        Dictionary<string, Vector3> vec3s, Action onChanged,
        IReadOnlyDictionary<string, double> realDefaults = null,
        IReadOnlyDictionary<string, Vector3> vec3Defaults = null,
        string pathPrefix = null)
    {
        host.Children.Clear();
        BuildRealParamSliders(host, reals, onChanged, realDefaults, pathPrefix);
        BuildVec3ParamSliders(host, vec3s, onChanged, vec3Defaults, pathPrefix);
    }

    private void BuildRealParamSliders(StackPanel host, Dictionary<string, double> parameters,
        Action onChanged, IReadOnlyDictionary<string, double> defaults, string pathPrefix)
    {
        if (parameters is null || parameters.Count == 0)
            return;

        foreach (string key in parameters.Keys.ToList())
        {
            string captured = key;
            var slider = new ValueSlider
            {
                SliderSettings = new ValueSliderSettings
                {
                    Label = key,
                    DefaultValue = defaults is not null && defaults.TryGetValue(key, out double d) ? d : 0,
                    Increment = 0.001,
                    AnimationPath = pathPrefix is null ? null : $"{pathPrefix}.RealParams.[{key}]",
                    ValueWillChange = Snapshot,
                    ValueChanged = v =>
                    {
                        // Mutate on the GL thread - the render loop reads these every frame.
                        _service.Post(_ => parameters[captured] = v);
                        onChanged();
                    },
                },
                Value = parameters[key],
            };
            host.Children.Add(slider);
        }
    }

    /// <summary>One labelled group of three unlabelled axis sliders, as Vec3Control does.</summary>
    private void BuildVec3ParamSliders(StackPanel host, Dictionary<string, Vector3> parameters,
        Action onChanged, IReadOnlyDictionary<string, Vector3> defaults, string pathPrefix)
    {
        if (parameters is null || parameters.Count == 0)
            return;

        foreach (string key in parameters.Keys.ToList())
        {
            string captured = key;
            Vector3 fallback = defaults is not null && defaults.TryGetValue(key, out Vector3 d)
                ? d : default;

            host.Children.Add(new TextBlock
            {
                Text = key,
                FontSize = 13,
                Foreground = Brushes.Silver,
                Margin = new Thickness(0, 6, 0, 0),
            });

            Axis("X", parameters[key].X, fallback.X, v => Update(c => c with { X = (float)v }));
            Axis("Y", parameters[key].Y, fallback.Y, v => Update(c => c with { Y = (float)v }));
            Axis("Z", parameters[key].Z, fallback.Z, v => Update(c => c with { Z = (float)v }));

            void Update(Func<Vector3, Vector3> change)
            {
                _service.Post(_ => parameters[captured] = change(parameters[captured]));
                onChanged();
            }

            void Axis(string axis, float value, float fallbackValue, Action<double> apply) =>
                host.Children.Add(new ValueSlider
                {
                    SliderSettings = new ValueSliderSettings
                    {
                        Label = $"{key}-{axis}",
                        IsLabelShown = false,
                        DefaultValue = fallbackValue,
                        Increment = 0.001,
                        AnimationPath = pathPrefix is null
                            ? null : $"{pathPrefix}.Vec3Params.[{key}].{axis}",
                        ValueWillChange = Snapshot,
                        ValueChanged = apply,
                    },
                    Value = value,
                });
        }
    }

    private void RefreshIteratorLabel(Iterator it)
    {
        int idx = _iterators.IndexOf(it);
        if (idx < 0 || IteratorList.ItemsSource is not List<string> labels)
            return;
        labels[idx] = Describe(it);
        _syncingUi = true;
        try
        {
            IteratorList.ItemsSource = null;
            IteratorList.ItemsSource = labels;
            IteratorList.SelectedIndex = idx;
        }
        finally { _syncingUi = false; }
    }

    // ------------------------------------------------------------ post-processing

    /// <summary>
    /// Rebuilds the post-effect stack as one Expander per layer, matching PostFxPanel.xaml:
    /// each carries its own enable checkbox, its name, hover-revealed info / ▲ / ▼ / ✕ buttons,
    /// and its parameters inside.
    /// </summary>
    private void RebuildPostFxList(IFS f)
    {
        _layers = f.PostEffects.ToList();
        PostFxLayers.Children.Clear();
        PostFxEmpty.IsVisible = _layers.Count == 0;

        foreach (EffectLayer layer in _layers)
            PostFxLayers.Children.Add(BuildLayerCard(layer));
    }

    private Control BuildLayerCard(EffectLayer layer)
    {
        var enabled = new CheckBox
        {
            IsChecked = layer.Enabled,
            VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
            Margin = new Thickness(0, 0, 4, 0),
        };
        enabled.IsCheckedChanged += (_, _) =>
        {
            if (_syncingUi)
                return;
            Snapshot();
            bool on = enabled.IsChecked == true;
            _service.Post(_ => layer.Enabled = on);
        };

        var name = new TextBlock
        {
            Text = layer.Effect?.Name ?? "effect",
            VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
        };
        Grid.SetColumn(name, 1);

        var tools = new StackPanel
        {
            Orientation = Avalonia.Layout.Orientation.Horizontal,
            IsVisible = false,
        };
        Grid.SetColumn(tools, 2);

        tools.Children.Add(LayerButton("ℹ️", "Effect information", null, layer));
        tools.Children.Add(LayerButton("▲", "Move up", () => MovePostFx(layer, -1), null));
        tools.Children.Add(LayerButton("▼", "Move down", () => MovePostFx(layer, +1), null));
        Button remove = LayerButton("✕", "Remove layer", () => RemovePostFx(layer), null);
        remove.Foreground = Brushes.Red;
        tools.Children.Add(remove);

        var header = new Grid
        {
            ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
            MinHeight = 32,
        };
        header.Children.Add(enabled);
        header.Children.Add(name);
        header.Children.Add(tools);

        var body = new StackPanel();
        // Effect params are plain uniforms set each frame, so no buffer invalidation.
        BuildParamSliders(body, layer.RealParams, layer.Vec3Params, () => { },
            layer.Effect?.RealParams, layer.Effect?.Vec3Params);

        var expander = new Expander
        {
            Header = header,
            Content = body,
            Margin = new Thickness(0, 2),
            HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch,
        };
        // The header buttons only appear while the layer is hovered, as in the original.
        expander.PointerEntered += (_, _) => tools.IsVisible = true;
        expander.PointerExited += (_, _) => tools.IsVisible = false;
        return expander;
    }

    private Button LayerButton(string glyph, string tip, Action onClick, EffectLayer infoFor)
    {
        var b = new Button
        {
            Content = glyph,
            Classes = { "Flat" },
            Background = Brushes.Transparent,
            BorderThickness = new Thickness(0),
            Padding = new Thickness(3, 0),
            MinWidth = 0,
            FontSize = 12,
            VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
        };
        ToolTip.SetTip(b, tip);

        if (infoFor is not null)
            b.Flyout = EffectDetails(infoFor);
        else
            b.Click += (_, _) => onClick();
        return b;
    }

    /// <summary>Name, tag chips, description, include-uses, reference link and Edit source.</summary>
    private Flyout EffectDetails(EffectLayer layer)
    {
        var stack = new StackPanel { MaxWidth = 300, Spacing = 4 };
        EffectPlugin plugin = layer.Effect;

        stack.Children.Add(new TextBlock
        {
            Text = plugin?.Name ?? "effect",
            FontSize = 18,
            FontWeight = FontWeight.Bold,
            TextWrapping = TextWrapping.Wrap,
        });

        if (plugin?.Tags is { Count: > 0 })
        {
            var chips = new WrapPanel();
            foreach (string tag in plugin.Tags)
                chips.Children.Add(new Border
                {
                    Background = new SolidColorBrush(Color.Parse("#5a5a5a")),
                    CornerRadius = new CornerRadius(9),
                    Padding = new Thickness(8, 2),
                    Margin = new Thickness(0, 2, 4, 2),
                    Child = new TextBlock { Text = tag, FontSize = 11 },
                });
            stack.Children.Add(chips);
        }

        stack.Children.Add(new TextBlock { Text = "Description", FontWeight = FontWeight.Bold, Margin = new Thickness(0, 6, 0, 0) });
        stack.Children.Add(new TextBlock
        {
            Text = string.IsNullOrWhiteSpace(plugin?.Description) ? "—" : plugin.Description,
            TextWrapping = TextWrapping.Wrap,
            Margin = new Thickness(8, 0, 0, 0),
        });
        stack.Children.Add(new TextBlock { Text = "Include uses", FontWeight = FontWeight.Bold, Margin = new Thickness(0, 6, 0, 0) });
        stack.Children.Add(new TextBlock
        {
            Text = plugin?.IncludeUses is { Count: > 0 } ? string.Join(", ", plugin.IncludeUses) : "None",
            TextWrapping = TextWrapping.Wrap,
            Margin = new Thickness(8, 0, 0, 0),
        });

        if (plugin?.ReferenceUrl is not null)
        {
            var info = new Button
            {
                Content = "ℹ️ Information…",
                Classes = { "Flat" },
                Background = Brushes.Transparent,
                BorderThickness = new Thickness(0),
                HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
            };
            info.Click += (_, _) => Dialogs.OpenUrl(plugin.ReferenceUrl.ToString());
            stack.Children.Add(info);
        }

        var edit = new Button
        {
            Content = "✏️ Edit source",
            Classes = { "Flat" },
            Background = Brushes.Transparent,
            BorderThickness = new Thickness(0),
            HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
        };
        edit.Click += (_, _) => OpenPluginSource(plugin?.FilePath, "effect");
        stack.Children.Add(edit);

        return new Flyout { Content = stack };
    }

    /// <summary>Adds the effect chosen in the combo, which then resets to the placeholder.</summary>
    private void AddPostFx()
    {
        IFS f = _service.Current;
        int i = PostFxAddCombo.SelectedIndex;
        if (f is null || i < 0 || i >= _service.Effects.Count)
            return;

        Snapshot();
        var layer = new EffectLayer(_service.Effects[i]);
        PostThenRefresh(_ => f.AddPostEffectLayer(layer), () =>
        {
            RebuildPostFxList(f);
            _syncingUi = true;
            try { PostFxAddCombo.SelectedIndex = -1; }
            finally { _syncingUi = false; }
        });
    }

    private void RemovePostFx(EffectLayer layer)
    {
        IFS f = _service.Current;
        if (f is null || layer is null)
            return;
        Snapshot();
        PostThenRefresh(_ => f.RemovePostEffectLayer(layer), () => RebuildPostFxList(f));
    }

    private void MovePostFx(EffectLayer layer, int delta)
    {
        IFS f = _service.Current;
        if (f is null || layer is null)
            return;
        int target = _layers.IndexOf(layer) + delta;
        if (target < 0 || target >= _layers.Count)
            return;

        Snapshot();
        PostThenRefresh(_ => f.MovePostEffectLayer(layer, target), () => RebuildPostFxList(f));
    }

    private async Task CopyParamsToClipboard()
    {
        if (_service.Current is null || Clipboard is null)
            return;
        await Clipboard.SetTextAsync(IfsNodesSerializer.SerializeJsonString(_service.Current));
        StatusText.Text = "Parameters copied to clipboard";
    }

    private async Task PasteParamsFromClipboard()
    {
        if (Clipboard is null)
            return;
        string text = await Clipboard.GetTextAsync();
        if (string.IsNullOrWhiteSpace(text))
            return;
        try
        {
            IFS pasted = IfsNodesSerializer.DeserializeJsonString(text, _service.Transforms, _service.Effects, true);
            LoadFractal(pasted);
            StatusText.Text = "Parameters pasted from clipboard";
        }
        catch (Exception ex)
        {
            StatusText.Text = "Clipboard does not contain valid parameters: " + ex.Message;
        }
    }

    private void BuildTemplatesMenu(string library)
    {
        string dir = Path.Combine(library, "Templates");
        if (!Directory.Exists(dir))
            return;
        var items = new List<MenuItem>();
        foreach (string path in Directory.GetFiles(dir, "*.ifsjson").OrderBy(f => f))
        {
            string captured = path;
            var item = new MenuItem { Header = Path.GetFileNameWithoutExtension(path).Replace('_', ' ') };
            item.Click += async (_, _) => await OpenPath(captured, addToRecents: false);
            items.Add(item);
        }
        MenuTemplates.ItemsSource = items;
    }

    private void RebuildRecentMenu()
    {
        var items = new List<MenuItem>();
        foreach (string path in _settings.RecentFiles)
        {
            string captured = path;
            var item = new MenuItem { Header = Path.GetFileName(path), };
            ToolTip.SetTip(item, path);
            item.Click += async (_, _) => await OpenPath(captured);
            items.Add(item);
        }
        MenuRecent.ItemsSource = items;
        MenuRecent.IsEnabled = items.Count > 0;
    }

    /// <summary>
    /// Opens a document. Accepts .ifsjson, a .png exported with parameters embedded,
    /// or a .gradient/.ugr palette file.
    /// </summary>
    private async Task OpenPath(string path, bool addToRecents = true)
    {
        try
        {
            if (path.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
            {
                if (!PngParams.TryExtractParams(path, _service.Transforms, _service.Effects, out IFS fromImage))
                {
                    StatusText.Text = "That PNG has no embedded parameters";
                    return;
                }
                LoadFractal(fromImage);
                StatusText.Text = "Parameters recovered from " + Path.GetFileName(path);
                return;
            }

            if (path.EndsWith(".gradient", StringComparison.OrdinalIgnoreCase) ||
                path.EndsWith(".ugr", StringComparison.OrdinalIgnoreCase))
            {
                _loadedPalettes = (await FlameFormatImporter.ImportFromFileAsync(path)).Palettes;
                if (_loadedPalettes.Count == 0)
                {
                    StatusText.Text = "No palettes found in " + Path.GetFileName(path);
                    return;
                }
                BtnPickPalette.IsVisible = _loadedPalettes.Count > 1;
                Snapshot();
                ApplyPalette(_loadedPalettes[0]);
                StatusText.Text = $"{_loadedPalettes.Count} palette(s) loaded";
                return;
            }

            IFS fractal = await IfsNodesSerializer.LoadJsonFileAsync(path, _service.Transforms, _service.Effects, true);
            LoadFractal(fractal, path);
            if (addToRecents)
            {
                _settings.AddRecent(path);
                RebuildRecentMenu();
            }
        }
        catch (Exception ex)
        {
            StatusText.Text = "Could not open: " + ex.Message;
        }
    }

    /// <summary>
    /// Continuous keyboard fly navigation, on the same 16 ms tick and with the same
    /// mapping the WPF display used: WASD/QE translate, IJKL/UO rotate. Rotation speed
    /// scales with field of view so a narrow lens moves proportionally slower.
    /// </summary>
    private void StartNavigationTick()
    {
        AddHandler(KeyDownEvent, (_, e) =>
        {
            if (IsTypingTarget())
                return;
            // Fly navigation ignores Ctrl-modified keys. Without this, an accelerator we do not
            // bind - Ctrl+W, Ctrl+D - reached the camera instead of doing nothing. Canonical
            // avoids the clash by scoping fly nav to the viewport element; with the Editor
            // folded into this window there is no such scope, so Ctrl is simply not a nav
            // modifier here. Shift (slow) still is.
            if (e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Alt))
                return;
            _keysDown.Add(e.Key);
            if (e.Key == Key.Space)
            {
                ToggleRendering();
                e.Handled = true;
            }
            else if (e.Key == Key.Delete)
            {
                // EditorWindow.xaml binds DELETE to RemoveSelectedCommand.
                RemoveSelected();
                e.Handled = true;
            }
        }, RoutingStrategies.Tunnel);
        AddHandler(KeyUpEvent, (_, e) => _keysDown.Remove(e.Key), RoutingStrategies.Tunnel);
        LostFocus += (_, _) => _keysDown.Clear();

        var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
        timer.Tick += (_, _) =>
        {
            if (_keysDown.Count == 0 || _service.Current is null || _renderingPaused)
                return;

            var translate = new Vector3(
                Held(Key.D) - Held(Key.A),
                Held(Key.E) - Held(Key.Q),
                Held(Key.W) - Held(Key.S)) * 0.005f;
            var rotate = new Vector3(
                Held(Key.L) - Held(Key.J),
                Held(Key.K) - Held(Key.I),
                Held(Key.U) - Held(Key.O)) * 0.03f;

            if (translate.LengthSquared() <= 0 && rotate.LengthSquared() <= 0)
                return;

            float sensitivity = (float)Sensitivity.Value;
            // Shift slows the fly, as in InteractiveDisplay.xaml.cs:122-131.
            if (_keysDown.Contains(Key.LeftShift) || _keysDown.Contains(Key.RightShift))
                sensitivity *= 0.05f;
            if (InvertY.IsChecked == true)
                rotate.Y = -rotate.Y;

            CameraChange(c =>
            {
                float rotateSpeed = (float)c.FieldOfView / 180.0f;
                var scaled = new Vector3(rotate.X * rotateSpeed, rotate.Y * rotateSpeed, rotate.Z);
                // Translation is proportional to how far away the subject is, so the fly does not
                // rocket past it up close (InteractiveDisplay.xaml.cs:169-170).
                float reach = Math.Abs((float)c.FocusDistance) * 2.0f;
                if (translate.LengthSquared() > 0)
                    c.Translate(translate * sensitivity * reach);
                if (scaled.LengthSquared() > 0)
                    c.Rotate(scaled * sensitivity);
            });
        };
        timer.Start();

        float Held(Key k) => _keysDown.Contains(k) ? 1f : 0f;
    }

    /// <summary>Keyboard navigation must not fire while the user is typing in a field.</summary>
    private bool IsTypingTarget() =>
        FocusManager?.GetFocusedElement() is TextBox or NumericUpDown;

    private void ToggleRendering()
    {
        _renderingPaused = !_renderingPaused;
        _service.Paused = _renderingPaused;
        MenuToggleRender.Header = _renderingPaused ? "Resume _rendering" : "Pause _rendering";
        // MainViewModel.IsRenderingIcon (:64): play glyph when paused, pause bars when running.
        BtnToggleRender.Content = _renderingPaused ? "▶" : "||";
        StatusText.Text = _renderingPaused ? "Rendering paused" : "Rendering resumed";
    }

    /// <summary>Pushes camera values into the numeric fields, unless one is being typed into.</summary>
    private void RefreshCameraFields()
    {
        IFS f = _service.Current;
        if (f is null || IsTypingTarget())
            return;
        _syncingUi = true;
        try
        {
            PosX.Value = f.Camera.Position.X;
            PosY.Value = f.Camera.Position.Y;
            PosZ.Value = f.Camera.Position.Z;
            OriX.Value = f.Camera.Orientation.X;
            OriY.Value = f.Camera.Orientation.Y;
            OriZ.Value = f.Camera.Orientation.Z;
            OriW.Value = f.Camera.Orientation.W;
            TiltX.Value = f.Camera.ProjectionTilt.X;
            TiltY.Value = f.Camera.ProjectionTilt.Y;
            TiltZ.Value = f.Camera.ProjectionTilt.Z;
        }
        finally { _syncingUi = false; }
    }

    /// <summary>
    /// Shows or hides a pane. The pane lives in a Grid column, so its column and the splitter
    /// beside it have to collapse with it - hiding the Border alone would leave a gap.
    /// </summary>
    private void TogglePanel(Control panel, MenuItem item, string label,
        int column, Control splitter, double width)
    {
        bool show = !panel.IsVisible;
        panel.IsVisible = show;
        splitter.IsVisible = show;
        PaneGrid.ColumnDefinitions[column].Width = show ? new GridLength(width) : new GridLength(0);
        PaneGrid.ColumnDefinitions[column == 0 ? 1 : 3].Width = show ? new GridLength(4) : new GridLength(0);
        item.Header = (show ? "✓ " : "") + label;
    }

    /// <summary>Pushes stored preferences into the live UI and renderer.</summary>
    private void ApplySettings()
    {
        _syncingUi = true;
        try
        {
            InvertY.IsChecked = _settings.InvertAxisY;
            Sensitivity.Value = Math.Clamp(_settings.Sensitivity, 0.1, 10.0);
        }
        finally { _syncingUi = false; }

        int framerate = _settings.TargetFramerate;
        int workgroups = _settings.WorkgroupCount;
        bool perceptual = _settings.PerceptuallyUniformUpdates;
        _service.Post(r => r.EnablePerceptualUpdates = perceptual);
        _service.Post(r =>
        {
            r.TargetFramerate = framerate;
            if (r.WorkgroupCount != workgroups)
                r.SetWorkgroupCount(workgroups);
        });
    }

    /// <summary>
    /// Everything the dopesheet can key, as engine reflection paths. The iterator and
    /// effect entries use the same <c>Node[id]</c> / <c>PostFx[id]</c> prefixes and the
    /// <c>RealParams.[name]</c> indexer form the WPF app writes.
    /// </summary>
    private IReadOnlyList<AnimatableParam> BuildAnimatableParams()
    {
        IFS f = _service.Current;
        var list = new List<AnimatableParam>();
        if (f is null)
            return list;

        foreach (string name in new[] { "Brightness", "Gamma", "GammaThreshold", "Vibrancy", "FogEffect", "Entropy" })
            list.Add(new AnimatableParam(name, name));

        foreach (string name in new[] { "FieldOfView", "FocusDistance", "Aperture", "DepthOfField" })
            list.Add(new AnimatableParam("Camera " + name, "Camera." + name));
        foreach (string axis in new[] { "X", "Y", "Z" })
            list.Add(new AnimatableParam($"Camera position {axis}", $"Camera.Position.{axis}"));

        foreach (Iterator it in f.Iterators)
        {
            string label = string.IsNullOrWhiteSpace(it.Name) ? it.Transform?.Name ?? "iterator" : it.Name;
            string prefix = $"Node[{it.Id}]";
            foreach (string member in new[] { "BaseWeight", "Opacity", "ColorIndex", "ColorSpeed", "Mix", "Add", "StartWeight" })
                list.Add(new AnimatableParam($"{label} · {member}", $"{prefix}.{member}"));
            foreach (string key in it.RealParams.Keys)
                list.Add(new AnimatableParam($"{label} · {key}", $"{prefix}.RealParams.[{key}]"));
            foreach (string key in it.Vec3Params.Keys)
                foreach (string axis in new[] { "X", "Y", "Z" })
                    list.Add(new AnimatableParam($"{label} · {key}.{axis}", $"{prefix}.Vec3Params.[{key}].{axis}"));
        }

        foreach (EffectLayer layer in f.PostEffects)
        {
            string prefix = $"PostFx[{layer.Id}]";
            foreach (string key in layer.RealParams.Keys)
                list.Add(new AnimatableParam($"{layer.Effect?.Name} · {key}", $"{prefix}.RealParams.[{key}]"));
        }

        return list;
    }

    /// <summary>Picks a CSV of animation channels — columns are channel paths, rows are frames.</summary>
    private async Task<string> PickChannelCsv()
    {
        var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
        {
            Title = "Import animation channels",
            AllowMultiple = false,
            FileTypeFilter = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }],
        });
        return files.Count == 0 ? null : files[0].Path.LocalPath;
    }

    private async Task<string> PickAudioFile()
    {
        var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
        {
            Title = "Load audio",
            AllowMultiple = false,
            FileTypeFilter = [new FilePickerFileType("Audio") { Patterns = ["*.wav", "*.flac", "*.mp3", "*.ogg", "*.m4a"] }],
        });
        return files.Count == 0 ? null : files[0].Path.LocalPath;
    }

    /// <summary>
    /// Renders the clip to a PNG sequence, then offers to encode it with ffmpeg.
    /// The frames are kept either way - encoding is the optional step.
    /// </summary>
    /// <summary>Set by the overlay's Cancel button; polled by the frame-export loop.</summary>
    private bool _exportCancelled;

    private async Task ExportClip()
    {
        IFS f = _service.Current;
        if (f is null)
            return;

        var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
        {
            Title = "Choose an output folder for the frame sequence",
            AllowMultiple = false,
        });
        if (folders.Count == 0)
            return;

        string dir = folders[0].Path.LocalPath;
        int w = (int)(ExportWidth.Value ?? 1920), h = (int)(ExportHeight.Value ?? 1080);
        int level = (int)QualitySlider.Value;

        OverlayText.IsVisible = true;
        OverlayText.Text = "Rendering clip…";
        _exportCancelled = false;
        BtnCancelExport.IsVisible = true;
        bool exr = _settings.IsRawFrameExportEnabled;

        // Video export streams frames into ffmpeg and never touches the disk. Raw exr has no
        // streaming form, so it always writes a sequence; so does an unticked video export,
        // which is the way to keep frames for re-encoding later.
        bool pipeToVideo = _settings.IsExportVideoFileEnabled && !exr;
        FfmpegFramePipe pipe = null;

        try
        {
            if (pipeToVideo)
            {
                string video = Path.Combine(dir,
                    "animation" + FfmpegFramePipe.ExtensionFor(_settings.FfmpegArgs));
                pipe = new FfmpegFramePipe(_settings.FfmpegPath, _settings.FfmpegArgs, video,
                                           AnimationView.AudioPath, f.Dopesheet.Length.TotalSeconds);

                // Before frame 0: a bad ffmpeg path must not cost an entire render.
                pipe.Start(w, h, Math.Max(f.Dopesheet.Fps, 1));
                OverlayText.Text = "Encoding clip…";
            }

            await _service.ExportFramesAsync(dir, w, h, level,
                (frame, total) => Dispatcher.UIThread.Post(() =>
                    OverlayText.Text = $"Rendering frame {frame}/{total}  ({w}x{h}, level {level})"),
                () => _exportCancelled,
                rawExr: exr,
                frameSink: pipe is null ? null : (bgra, _, _) => pipe.Write(bgra));

            if (pipeToVideo)
            {
                // Closed rather than killed even when cancelled, so ffmpeg writes the moov atom
                // and a cancelled export still leaves something playable.
                OverlayText.Text = "Finishing video…";
                string failure = pipe.Finish();
                StatusText.Text = failure is not null
                    ? "Video encoding failed: " + failure
                    : _exportCancelled
                        ? $"Cancelled — wrote the first {pipe.FramesWritten} frames to {pipe.OutputPath}"
                        : $"Wrote {pipe.OutputPath}";
                return;
            }

            if (_exportCancelled)
            {
                StatusText.Text = $"Frame render cancelled — frames written so far are in {dir}";
                return;
            }

            StatusText.Text = exr && _settings.IsExportVideoFileEnabled
                ? $"Rendered exr frames to {dir} — video encoding needs png frames"
                : $"Rendered frames to {dir}";
        }
        catch (Exception ex)
        {
            StatusText.Text = "Clip render failed: " + ex.Message;
        }
        finally
        {
            pipe?.Dispose();
            OverlayText.IsVisible = false;
            BtnCancelExport.IsVisible = false;
        }
    }

    /// <summary>
    /// Renders the clip straight to a video file.
    /// </summary>
    /// <remarks>
    /// Distinct from <see cref="ExportClip"/>, which is the frame-sequence export and only
    /// produces a video as a side effect of a Settings checkbox. Video had no menu item at all,
    /// so the capability was unreachable without knowing to tick a box two dialogs away.
    ///
    /// Two routes, chosen by the dialog: pipe straight into ffmpeg (nothing touches the disk),
    /// or render the frames and encode from them afterwards, which is what the canonical does
    /// and the only way to end up with both.
    /// </remarks>
    private async Task ExportVideo()
    {
        IFS f = _service.Current;
        if (f is null)
            return;

        int w = (int)(ExportWidth.Value ?? 1920), h = (int)(ExportHeight.Value ?? 1080);
        int level = (int)QualitySlider.Value;
        int fps = Math.Max(f.Dopesheet.Fps, 1);
        bool exr = _settings.IsRawFrameExportEnabled;

        Dialogs.VideoExportChoice choice =
            await Dialogs.ShowVideoExport(this, _settings, f.Title, w, h, exr,
                                          AnimationView.AudioPath);
        if (choice is null)
            return;

        string dir = Path.GetDirectoryName(choice.OutputPath);
        if (string.IsNullOrWhiteSpace(dir))
        {
            StatusText.Text = "Video export needs a full output path.";
            return;
        }
        Directory.CreateDirectory(dir);

        OverlayText.IsVisible = true;
        OverlayText.Text = "Rendering clip…";
        _exportCancelled = false;
        BtnCancelExport.IsVisible = true;
        FfmpegFramePipe pipe = null;

        try
        {
            if (!choice.KeepFrames)
            {
                pipe = new FfmpegFramePipe(_settings.FfmpegPath, choice.Args, choice.OutputPath,
                                           AnimationView.AudioPath, f.Dopesheet.Length.TotalSeconds);
                // Before frame 0: a bad ffmpeg path must not cost an entire render.
                pipe.Start(w, h, fps);
                OverlayText.Text = "Encoding clip…";
            }

            await _service.ExportFramesAsync(dir, w, h, level,
                (frame, total) => Dispatcher.UIThread.Post(() =>
                    OverlayText.Text = $"Rendering frame {frame}/{total}  ({w}x{h}, level {level})"),
                () => _exportCancelled,
                rawExr: false,   // the dialog refuses to export video while exr is selected
                frameSink: pipe is null ? null : (bgra, _, _) => pipe.Write(bgra));

            if (pipe is not null)
            {
                OverlayText.Text = "Finishing video…";
                string failure = pipe.Finish();
                StatusText.Text = failure is not null
                    ? "Video encoding failed: " + failure
                    : _exportCancelled
                        ? $"Cancelled — wrote the first {pipe.FramesWritten} frames to {pipe.OutputPath}"
                        : $"Wrote {pipe.OutputPath}";
                return;
            }

            if (_exportCancelled)
            {
                StatusText.Text = $"Cancelled — frames written so far are in {dir}";
                return;
            }

            OverlayText.Text = "Encoding video…";
            (bool ok, string message) = await EncodeWithFfmpeg(dir, fps, choice.OutputPath);
            StatusText.Text = ok
                ? $"Wrote {choice.OutputPath} (frames kept in {dir})"
                : "Video encoding failed: " + message;
        }
        catch (Exception ex)
        {
            StatusText.Text = "Video export failed: " + ex.Message;
        }
        finally
        {
            pipe?.Dispose();
            OverlayText.IsVisible = false;
            BtnCancelExport.IsVisible = false;
        }
    }

    private async Task<(bool, string)> EncodeWithFfmpeg(string dir, int fps, string outputPath)
    {
        string exe = string.IsNullOrWhiteSpace(_settings.FfmpegPath) ? "ffmpeg" : _settings.FfmpegPath;
        var psi = new System.Diagnostics.ProcessStartInfo(exe)
        {
            UseShellExecute = false,
            RedirectStandardError = true,
            WorkingDirectory = dir,
        };
        // IFSRenderer supplies input, output and fps; everything else comes from the
        // Settings > Animations args box (or a preset), as in the canonical app.
        foreach (string arg in new[] { "-y", "-framerate", fps.ToString(), "-i", "frame_%05d.png" })
            psi.ArgumentList.Add(arg);

        string extra = _settings.FfmpegArgs;
        if (string.IsNullOrWhiteSpace(extra))
            extra = "-c:v libx264 -pix_fmt yuv420p -crf 18";
        foreach (string arg in SplitArgs(extra))
            psi.ArgumentList.Add(arg);

        psi.ArgumentList.Add(outputPath);

        try
        {
            using var process = System.Diagnostics.Process.Start(psi);
            if (process is null)
                return (false, "Frames rendered, but ffmpeg could not be started.");
            string stderr = await process.StandardError.ReadToEndAsync();
            await process.WaitForExitAsync();
            return process.ExitCode == 0
                ? (true, null)
                : (false, "Frames rendered; ffmpeg failed: " + stderr.Split('\n').LastOrDefault(l => l.Length > 0));
        }
        catch (Exception ex)
        {
            return (false, "Frames rendered, but ffmpeg is unavailable: " + ex.Message);
        }
    }

    /// <summary>
    /// Renders at the export resolution and puts the resulting PNG on the clipboard as a file,
    /// which is how a Linux desktop carries an image between applications.
    /// </summary>
    private async Task ExportToClipboard()
    {
        if (_service.Current is null || Clipboard is null)
            return;

        string path = Path.Combine(Path.GetTempPath(),
            $"ifsrenderer-{_service.Current.Title}.png".Replace(Path.DirectorySeparatorChar, '-'));
        int w = (int)ExportWidth.Value, h = (int)ExportHeight.Value;
        int level = (int)QualitySlider.Value;

        OverlayText.IsVisible = true;
        OverlayText.Text = $"Rendering {w}x{h} for the clipboard…";
        try
        {
            await _service.ExportAsync(path, w, h, level, null, new ExportMetadata
            {
                SaveMetadata = _settings.SaveMetadata,
                IncludeParams = _settings.SaveMetadata && _settings.IncludeParamsInMetadata,
                Author = _settings.AuthorName,
            });

            var data = new DataObject();
            data.Set(DataFormats.Files, new[] { new System.IO.FileInfo(path).FullName });
            await Clipboard.SetDataObjectAsync(data);
            StatusText.Text = "Rendered image copied to the clipboard";
        }
        catch (Exception ex)
        {
            StatusText.Text = "Clipboard export failed: " + ex.Message;
        }
        finally
        {
            OverlayText.IsVisible = false;
        }
    }

    /// <summary>Splits an ffmpeg argument string, honouring quoted runs.</summary>
    private static IEnumerable<string> SplitArgs(string text)
    {
        var current = new System.Text.StringBuilder();
        char quote = '\0';
        foreach (char c in text)
        {
            if (quote != '\0')
            {
                if (c == quote)
                    quote = '\0';
                else
                    current.Append(c);
            }
            else if (c is '"' or '\'')
            {
                quote = c;
            }
            else if (char.IsWhiteSpace(c))
            {
                if (current.Length > 0)
                {
                    yield return current.ToString();
                    current.Clear();
                }
            }
            else
            {
                current.Append(c);
            }
        }
        if (current.Length > 0)
            yield return current.ToString();
    }

    /// <summary>
    /// Opens the generator. It stays open while you work, sending pinned candidates to the
    /// editor as you click them — the canonical SendToMain behaviour, rather than a dialog
    /// that closes on a pick.
    /// </summary>
    private async Task ShowGenerator()
    {
        var window = new GeneratorWindow(_service)
        {
            CurrentFractal = () => _service.Current,
        };
        window.SendToMain += fractal =>
        {
            LoadFractal(fractal);
            StatusText.Text = "Loaded a generated fractal";
        };
        await window.ShowDialog(this);
    }

    /// <summary>Opens a folder in the desktop's file manager (the 📂 plugin-directory button).</summary>
    private void OpenDirectory(string path)
    {
        if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
        {
            StatusText.Text = "Plugin directory not found";
            return;
        }
        try
        {
            System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
            {
                UseShellExecute = true,
            });
        }
        catch (Exception ex)
        {
            StatusText.Text = "Could not open the folder: " + ex.Message;
        }
    }

    /// <summary>Hands the plugin file to the desktop's default editor.</summary>
    private void OpenPluginSource(string path, string what)
    {
        if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
        {
            StatusText.Text = $"No {what} source file to open";
            return;
        }
        Dialogs.OpenUrl(path);
        StatusText.Text = "Opened " + Path.GetFileName(path) + " — use Reload plugins after editing";
    }

    private async Task ReloadPlugins()
    {
        try
        {
            await _service.ReloadPluginsAsync(AssetPaths.Library, AssetPaths.Transforms);

            TransformCombo.ItemsSource = _service.Transforms.Select(t => t.Name).ToList();
            PostFxAddCombo.ItemsSource = _service.Effects.Select(t => t.Name).ToList();
            if (_service.Current is not null)
            {
                RebuildIteratorList(_service.Current);
                RebuildPostFxList(_service.Current);
                NodeEditorView.InvalidateVisual();
                AnimationView.RefreshParams();
            }
            StatusText.Text = $"Reloaded {_service.Transforms.Count} transforms and {_service.Effects.Count} effects";
        }
        catch (Exception ex)
        {
            StatusText.Text = "Plugin reload failed: " + ex.Message;
        }
    }

}