OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / Dialogs.cs

925 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;

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

using IFSEngine.Model;

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

namespace IFSRenderer.AvaloniaShell;

/// <summary>
/// The application's small dialogs. Built in code rather than XAML: they are simple,
/// and it keeps three more generated-field partial classes out of the build.
/// </summary>
internal static class Dialogs
{
    /// <summary>Version reported in About and compared against the latest GitHub release.</summary>
    public const string Version = "1.5.0";

    private const string RepoApi = "https://api.github.com/repos/bezo97/IFSRenderer/releases/latest";

    /// <summary>Returned by the welcome window when the user chose "Load file…".</summary>
    public const string OpenFileSentinel = "\u0000open";

    // MaterialDesignPaper, measured from the canonical tool windows.
    private static readonly IBrush PanelBg = new SolidColorBrush(Color.Parse("#323232"));
    private static readonly IBrush Ink = new SolidColorBrush(Color.Parse("#e0e0e6"));
    private static readonly IBrush Muted = new SolidColorBrush(Color.Parse("#9a9aa6"));
    private static readonly IBrush Accent = new SolidColorBrush(Color.Parse("#8ab4f8"));

    private static TextBlock Label(string text, IBrush brush = null, double size = 12) =>
        new() { Text = text, Foreground = brush ?? Muted, FontSize = size, TextWrapping = TextWrapping.Wrap };

    /// <summary>
    /// A dialog shell. MinWidth/MinHeight are set as well as Width/Height: with CanResize
    /// false, X11 takes the window's size hints from its desired size, which is computed
    /// before the content is attached — leaving the window a few dozen pixels across.
    /// </summary>
    private static Window Shell(string title, double w, double h) => new()
    {
        Title = title,
        Width = w,
        Height = h,
        MinWidth = w,
        MinHeight = h,
        SizeToContent = SizeToContent.Manual,
        Background = PanelBg,
        WindowStartupLocation = WindowStartupLocation.CenterOwner,
        CanResize = false,
    };

    // ---------------------------------------------------------------- settings

    /// <summary>
    /// The settings window, ported from <c>WpfDisplay/Views/SettingsWindow.xaml</c>: a 500x500
    /// tool window whose five sections live on a vertical navigation rail, each row laid out as
    /// label / ⓘ / control, closing with Cancel and Save.
    /// </summary>
    /// <remarks>
    /// Two of these tabs had no counterpart in this port at all — Export options and Animations —
    /// so the settings they carry (metadata, frame format, ffmpeg arguments) were unreachable.
    /// </remarks>
    public static async Task ShowSettings(Window owner, AppSettings settings, Action onApplied)
    {
        var window = Shell("⚙️ Settings", 620, 520);
        window.CanResize = true;
        window.MinWidth = 500;
        window.MinHeight = 500;

        // --- user settings ---
        var author = new TextBox { Text = settings.AuthorName, Watermark = "Provide an author name" };
        var link = new TextBox { Text = settings.AuthorLink, Watermark = "https://example.com" };
        var whiteBlank = new CheckBox { IsChecked = settings.UseWhiteForBlankParams };
        var welcome = new CheckBox { IsChecked = settings.ShowWelcomeOnStartup };
        var notifications = new CheckBox { IsEnabled = false };

        // --- controls ---
        var invertY = new CheckBox { Content = "Enable", IsChecked = settings.InvertAxisY };
        var sensitivity = new ValueSlider
        {
            SliderSettings = new ValueSliderSettings
            {
                Label = "Movement 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,
            },
            Value = settings.Sensitivity,
        };

        // --- export options ---
        var saveMetadata = new CheckBox { IsChecked = settings.SaveMetadata };
        var includeParams = new CheckBox
        {
            IsChecked = settings.IncludeParamsInMetadata,
            IsEnabled = settings.SaveMetadata,
        };
        saveMetadata.IsCheckedChanged += (_, _) => includeParams.IsEnabled = saveMetadata.IsChecked == true;
        var watermark = new CheckBox { IsEnabled = false };

        // --- animations ---
        var frameFormat = new ComboBox
        {
            ItemsSource = new[] { "png (8-bit)", "exr (32-bit)" },
            SelectedIndex = settings.IsRawFrameExportEnabled ? 1 : 0,
            HorizontalAlignment = HorizontalAlignment.Stretch,
        };
        var ffmpeg = new TextBox { Text = settings.FfmpegPath, Watermark = "ffmpeg (leave blank to use PATH)" };
        var exportVideo = new CheckBox
        {
            Content = "Save video file after frames have rendered",
            IsChecked = settings.IsExportVideoFileEnabled,
        };
        var ffmpegArgs = new TextBox
        {
            Text = settings.FfmpegArgs,
            AcceptsReturn = true,
            TextWrapping = TextWrapping.Wrap,
            MinHeight = 90,
        };
        var presets = new ComboBox { PlaceholderText = "▼ Presets", HorizontalAlignment = HorizontalAlignment.Right };
        Dictionary<string, string> presetMap = LoadFfmpegPresets();
        presets.ItemsSource = presetMap.Keys.ToList();
        presets.SelectionChanged += (_, _) =>
        {
            if (presets.SelectedItem is string key && presetMap.TryGetValue(key, out string args))
                ffmpegArgs.Text = args;
        };

        // --- performance ---
        var uniform = new CheckBox { IsChecked = settings.PerceptuallyUniformUpdates };
        var framerate = new ValueSlider
        {
            SliderSettings = new ValueSliderSettings
            {
                Label = "Target Framerate",
                ToolTip = "The display refresh target. Lower values leave more GPU for iterating.",
                DefaultValue = 60,
                MinValue = 10,
                MaxValue = 240,
                Increment = 5,
            },
            Value = settings.TargetFramerate,
        };
        var workgroups = new ValueSlider
        {
            SliderSettings = new ValueSliderSettings
            {
                Label = "Workgroups",
                ToolTip = "How many GPU workgroups each dispatch uses. Higher values do more work "
                        + "per frame at the cost of responsiveness.",
                DefaultValue = 256,
                MinValue = 16,
                MaxValue = 4096,
                Increment = 16,
            },
            Value = settings.WorkgroupCount,
        };

        var tabs = new TabControl
        {
            // MaterialDesignNavigationRailTabControl: the tab strip runs down the left side.
            TabStripPlacement = Dock.Left,
            Items =
            {
                Tab("👤 User settings", "User settings",
                    Row("Author name", "Optionally provide your name to be embedded in params and image metadata.", author),
                    Row("Author link", "Optionally provide a link to be embedded in params and image metadata.", link),
                    Row("White blank params", "Use a simple white palette instead of random colors when starting from scratch.", whiteBlank),
                    Row("Welcome screen", "Hide or show the welcome screen when IFSRenderer starts.", welcome),
                    Row("Notifications", "Display a notification when a render finishes. (coming soon)", notifications)),

                Tab("🕹️ Controls", "Controls",
                    Row("Invert Y axis (pitch)", null, invertY),
                    Row("Movement sensitivity", null, sensitivity)),

                Tab("🖼 Export options", "Export options",
                    Row("Save metadata", "Whether to write metadata in the exported images. This includes title, authors, copyright, date of creation and keywords.", saveMetadata),
                    Row("Include params", "Whether to include fractal params in the image metadata. This makes it possible to drop or paste the image in the editor instead of loading param files.", includeParams),
                    Row("Apply watermark", "Print the author name on exported images. (coming soon)", watermark)),

                Tab("🎥 Animations", "Animation export options",
                    Row("Frame export format", null, frameFormat),
                    Row("ffmpeg", "In order to save video files, install ffmpeg and select the executable.", ffmpeg),
                    Row(null, null, exportVideo),
                    Row("ffmpeg args", "Provide additional ffmpeg settings here. IFSRenderer handles input, output, fps and audio.", presets),
                    Row(null, null, ffmpegArgs)),

                Tab("⏱️ Performance", "Performance settings",
                    Row("Perceptually uniform updates", "Decreases the display framerate to allow more computing power on the iteration loop. Checked by default.", uniform),
                    Row("Target Framerate", null, framerate),
                    Row("Workgroups", null, workgroups)),
            },
        };

        var ok = new Button { Content = "💾 Save", IsDefault = true, MinWidth = 120 };
        var cancel = new Button { Content = "Cancel", IsCancel = true, MinWidth = 120 };

        ok.Click += (_, _) =>
        {
            settings.AuthorName = author.Text ?? "";
            settings.AuthorLink = link.Text ?? "";
            settings.UseWhiteForBlankParams = whiteBlank.IsChecked == true;
            settings.ShowWelcomeOnStartup = welcome.IsChecked == true;

            settings.InvertAxisY = invertY.IsChecked == true;
            settings.Sensitivity = sensitivity.Value;

            settings.SaveMetadata = saveMetadata.IsChecked == true;
            settings.IncludeParamsInMetadata = includeParams.IsChecked == true;

            settings.IsRawFrameExportEnabled = frameFormat.SelectedIndex == 1;
            settings.FfmpegPath = ffmpeg.Text ?? "";
            settings.IsExportVideoFileEnabled = exportVideo.IsChecked == true;
            settings.FfmpegArgs = ffmpegArgs.Text ?? "";

            settings.PerceptuallyUniformUpdates = uniform.IsChecked == true;
            settings.TargetFramerate = (int)framerate.Value;
            settings.WorkgroupCount = (int)workgroups.Value;

            settings.Save();
            onApplied?.Invoke();
            window.Close();
        };
        cancel.Click += (_, _) => window.Close();

        var buttons = new StackPanel
        {
            Orientation = Orientation.Horizontal,
            Spacing = 8,
            HorizontalAlignment = HorizontalAlignment.Right,
            Margin = new Thickness(0, 10, 0, 0),
            Children = { cancel, ok },
        };
        DockPanel.SetDock(buttons, Dock.Bottom);

        window.Content = new DockPanel
        {
            Margin = new Thickness(10),
            Children = { buttons, tabs },
        };

        await window.ShowDialog(owner);
    }

    /// <summary>One tab of the rail: a centred headline over label / ⓘ / control rows.</summary>
    private static TabItem Tab(string header, string headline, params Control[] rows)
    {
        var stack = new StackPanel { Margin = new Thickness(12, 4, 4, 4), Spacing = 2 };
        stack.Children.Add(new TextBlock
        {
            Text = headline,
            FontSize = 20,
            FontWeight = FontWeight.SemiBold,
            Foreground = Ink,
            TextAlignment = TextAlignment.Center,
            Margin = new Thickness(0, 0, 0, 10),
        });
        foreach (Control row in rows)
            stack.Children.Add(row);

        return new TabItem
        {
            Header = header,
            MinWidth = 150,
            Content = new ScrollViewer { Content = stack },
        };
    }

    /// <summary>
    /// A settings row: label in a 0.75* column, an ⓘ carrying the help text, and the control in
    /// a 1* column — the three-column grid the canonical window uses on every tab.
    /// </summary>
    private static Control Row(string label, string help, Control control)
    {
        var grid = new Grid
        {
            ColumnDefinitions = new ColumnDefinitions("0.75*,Auto,1*"),
            MinHeight = 44,
            Margin = new Thickness(0, 2),
        };

        if (label is not null)
        {
            var text = new TextBlock
            {
                Text = label,
                Foreground = Ink,
                TextWrapping = TextWrapping.Wrap,
                VerticalAlignment = VerticalAlignment.Center,
            };
            grid.Children.Add(text);
        }

        if (help is not null)
        {
            var info = new TextBlock
            {
                Text = "ⓘ",
                Foreground = Muted,
                Margin = new Thickness(6, 0),
                VerticalAlignment = VerticalAlignment.Center,
            };
            ToolTip.SetTip(info, help);
            Grid.SetColumn(info, 1);
            grid.Children.Add(info);
        }

        control.VerticalAlignment = VerticalAlignment.Center;
        Grid.SetColumn(control, 2);
        // A row with no label spans the full width, for the wide fields.
        if (label is null)
        {
            Grid.SetColumn(control, 0);
            Grid.SetColumnSpan(control, 3);
        }
        grid.Children.Add(control);
        return grid;
    }

    /// <summary>The shipped Library/FfmpegPresets.json, the same file the canonical reads.</summary>
    private static Dictionary<string, string> LoadFfmpegPresets()
    {
        try
        {
            string path = System.IO.Path.Combine(AssetPaths.Library, "FfmpegPresets.json");
            if (!System.IO.File.Exists(path))
                return [];
            return Newtonsoft.Json.JsonConvert
                .DeserializeObject<Dictionary<string, string>>(System.IO.File.ReadAllText(path)) ?? [];
        }
        catch
        {
            return [];
        }
    }

    // ------------------------------------------------------------------- about

    public static async Task ShowAbout(Window owner, string gpu)
    {
        var window = Shell("About IFSRenderer", 440, 340);
        var status = Label("");

        var check = new Button { Content = "Check for updates" };
        check.Click += async (_, _) =>
        {
            status.Text = "Checking…";
            string latest = await FetchLatestVersion();
            status.Text = latest is null
                ? "Could not reach GitHub."
                : latest.TrimStart('v') == Version
                    ? $"You are up to date ({Version})."
                    : $"Version {latest} is available (you have {Version}).";
        };

        window.Content = new StackPanel
        {
            Margin = new Thickness(20),
            Spacing = 8,
            Children =
            {
                new TextBlock { Text = "IFSRenderer", Foreground = Ink, FontSize = 20, FontWeight = FontWeight.SemiBold },
                Label($"Linux port · engine version {Version}", Muted),
                Label("3D IFS fractal renderer and editor by Dócs Zoltán and contributors, "
                    + "licensed GPLv3. This build runs the original IFSEngine unmodified with "
                    + "an Avalonia user interface.", Ink),
                Label($"Renderer: {gpu}", Muted),
                Label("github.com/bezo97/IFSRenderer  ·  ifsrenderer.z97.io", Accent),
                check,
                status,
            },
        };

        await window.ShowDialog(owner);
    }

    private static async Task<string> FetchLatestVersion()
    {
        try
        {
            using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
            http.DefaultRequestHeaders.Add("User-Agent", "IFSRenderer-Linux");
            string json = await http.GetStringAsync(RepoApi);
            var tag = Newtonsoft.Json.Linq.JObject.Parse(json)["tag_name"];
            return tag?.ToString();
        }
        catch
        {
            return null;
        }
    }

    // ----------------------------------------------------------------- welcome

    /// <summary>
    /// The startup chooser, ported from <c>WpfDisplay/Views/WelcomeWindow.xaml</c>: a 300px
    /// featured-artwork header carrying the app name and version, a workflow card, and
    /// expanders for Getting Started, Templates and Recent Files.
    /// </summary>
    /// <param name="thumbnail">
    /// Renders a preview of a fractal file. Supplied by the host because it needs the GL
    /// thread; thumbnails fill in progressively so the window opens immediately.
    /// </param>
    /// <returns>The path to open, or null to start empty.</returns>
    public static async Task<string> ShowWelcome(Window owner, AppSettings settings,
        IReadOnlyList<string> templates, Action generateRandom,
        Func<string, Task<Bitmap>> thumbnail = null)
    {
        var window = Shell("Welcome", 600, 650);
        window.CanResize = true;
        window.MinWidth = 600;
        window.MinHeight = 650;
        string chosen = null;

        // ---- artwork header ----
        var header = new Grid { Height = 300 };
        string artwork = Path.Combine(AppContext.BaseDirectory, "Assets", "featured_artwork.jpg");
        if (File.Exists(artwork))
            header.Children.Add(new Image
            {
                Source = new Bitmap(artwork),
                Stretch = Stretch.UniformToFill,
            });

        header.Children.Add(Shadowed(new TextBlock
        {
            Text = "IFSRenderer",
            FontSize = 48,
            Foreground = Brushes.White,
            HorizontalAlignment = HorizontalAlignment.Left,
            VerticalAlignment = VerticalAlignment.Bottom,
            Margin = new Thickness(8),
        }));

        var version = Link($"v{Version}", "https://github.com/bezo97/IFSRenderer/releases");
        version.HorizontalAlignment = HorizontalAlignment.Right;
        version.VerticalAlignment = VerticalAlignment.Top;
        version.Margin = new Thickness(8);
        header.Children.Add(Shadowed(version));

        var credit = Link("Artwork ↗", "https://art.z97.io/");
        credit.HorizontalAlignment = HorizontalAlignment.Right;
        credit.VerticalAlignment = VerticalAlignment.Bottom;
        credit.Margin = new Thickness(8);
        header.Children.Add(Shadowed(credit));

        // ---- workflow card ----
        void Pick(string path)
        {
            chosen = path;
            window.Close();
        }

        var scratch = Link("Start from scratch", null);
        scratch.Click += (_, _) => Pick(null);
        var loadFile = Link("Load file…", null);
        loadFile.Click += (_, _) => { chosen = OpenFileSentinel; window.Close(); };
        var randoms = Link("Browse randoms…", null);
        randoms.Click += (_, _) => { generateRandom?.Invoke(); window.Close(); };

        var workflow = new Border
        {
            Background = new SolidColorBrush(Color.Parse("#454545")),
            CornerRadius = new CornerRadius(4),
            Padding = new Thickness(4),
            Margin = new Thickness(2, 4),
            MinWidth = 200,
            Child = new StackPanel
            {
                Children =
                {
                    new TextBlock { Text = "Choose a workflow", Foreground = Ink, Margin = new Thickness(4) },
                    new StackPanel
                    {
                        Margin = new Thickness(8, 0),
                        Children = { scratch, loadFile, randoms },
                    },
                },
            },
        };

        // ---- expanders ----
        var gettingStarted = new StackPanel
        {
            Margin = new Thickness(8),
            Children =
            {
                Link("Getting Started Guide ↗", "https://github.com/bezo97/IFSRenderer/wiki/Getting-Started-Guide"),
                Link("Wiki ↗", "https://github.com/bezo97/IFSRenderer/wiki"),
            },
        };

        var templatePanel = new WrapPanel();
        var recentPanel = new WrapPanel();

        var right = new StackPanel
        {
            Margin = new Thickness(2, 4),
            Children =
            {
                new Expander { Header = "Getting Started", Content = gettingStarted, IsExpanded = true },
                new Expander { Header = "Templates", Content = new ScrollViewer { Content = templatePanel, MaxHeight = 240 } },
                new Expander { Header = "Recent Files", Content = new ScrollViewer { Content = recentPanel, MaxHeight = 240 } },
            },
        };

        var middle = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") };
        middle.Children.Add(workflow);
        Grid.SetColumn(right, 1);
        middle.Children.Add(right);

        // ---- footer ----
        var showOnStartup = new CheckBox
        {
            Content = "Show on startup",
            IsChecked = settings.ShowWelcomeOnStartup,
            HorizontalAlignment = HorizontalAlignment.Right,
        };
        showOnStartup.IsCheckedChanged += (_, _) =>
        {
            settings.ShowWelcomeOnStartup = showOnStartup.IsChecked == true;
            settings.Save();
        };

        var cont = new Button
        {
            Content = "Continue",
            IsDefault = true,
            HorizontalAlignment = HorizontalAlignment.Center,
        };
        cont.Click += (_, _) => window.Close();

        var footer = new Grid { Margin = new Thickness(4) };
        footer.Children.Add(cont);
        footer.Children.Add(showOnStartup);

        var root = new Grid { RowDefinitions = new RowDefinitions("300,*,Auto") };
        root.Children.Add(header);
        Grid.SetRow(middle, 1);
        root.Children.Add(middle);
        Grid.SetRow(footer, 2);
        root.Children.Add(footer);
        window.Content = root;

        // Thumbnails arrive after the window is up, so opening is never blocked on rendering.
        window.Opened += async (_, _) =>
        {
            await FillThumbnails(templatePanel, templates, thumbnail, Pick);
            await FillThumbnails(recentPanel, settings.RecentFiles.ToList(), thumbnail, Pick);
        };

        await window.ShowDialog(owner);
        return chosen;
    }

    /// <summary>Fills a panel with 100x100 preview buttons, one file at a time.</summary>
    private static async Task FillThumbnails(Panel host, IReadOnlyList<string> paths,
        Func<string, Task<Bitmap>> thumbnail, Action<string> onPick)
    {
        foreach (string path in paths)
        {
            string captured = path;
            var button = new Button
            {
                Width = 100,
                Height = 100,
                Margin = new Thickness(4),
                Padding = new Thickness(0),
                Content = new TextBlock
                {
                    Text = Path.GetFileNameWithoutExtension(path),
                    TextWrapping = TextWrapping.Wrap,
                    FontSize = 10,
                    TextAlignment = TextAlignment.Center,
                },
            };
            ToolTip.SetTip(button, path);
            button.Click += (_, _) => onPick(captured);
            host.Children.Add(button);

            if (thumbnail is null)
                continue;
            try
            {
                Bitmap image = await thumbnail(captured);
                if (image is not null)
                    button.Content = new Image { Source = image, Stretch = Stretch.UniformToFill };
            }
            catch
            {
                // A template that will not render keeps its name; nothing else to do.
            }
        }
    }

    /// <summary>A text hyperlink; a null url means the caller wires Click itself.</summary>
    private static Button Link(string text, string url)
    {
        var b = new Button
        {
            Content = text,
            Foreground = Accent,
            Background = Brushes.Transparent,
            BorderThickness = new Thickness(0),
            Padding = new Thickness(0, 4),
            HorizontalAlignment = HorizontalAlignment.Left,
            Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
        };
        if (url is not null)
            b.Click += (_, _) => OpenUrl(url);
        return b;
    }

    /// <summary>The drop shadow the header text carries over the artwork.</summary>
    private static Control Shadowed(Control control)
    {
        control.Effect = new DropShadowEffect { BlurRadius = 6, OffsetX = 1, OffsetY = 2, Opacity = 0.9 };
        return control;
    }

    // ------------------------------------------------------------ confirm exit

    /// <summary>What the user chose at the quit prompt.</summary>
    public enum ExitChoice
    {
        /// <summary>Cancel the close and go back to the app.</summary>
        Stay,
        /// <summary>Save first, then close.</summary>
        Save,
        /// <summary>Close, discarding unsaved work.</summary>
        Quit,
    }

    /// <summary>
    /// The quit prompt, offering to rescue unsaved work.
    /// </summary>
    /// <remarks>
    /// ExitDialogWindow.xaml:26-27 offers <c>_Save...</c> and <c>_Quit anyway</c>. This offered
    /// only Quit or Stay — so the one dialog whose entire purpose is saving your work could not
    /// save it, and warned about losing changes while giving no way to prevent it.
    /// </remarks>
    public static async Task<ExitChoice> ConfirmExit(Window owner)
    {
        var window = Shell("Quit IFSRenderer", 420, 180);
        ExitChoice choice = ExitChoice.Stay;

        var save = new Button { Content = "💾 Save…", IsDefault = true };
        save.Click += (_, _) => { choice = ExitChoice.Save; window.Close(); };
        var quit = new Button { Content = "Quit anyway" };
        quit.Click += (_, _) => { choice = ExitChoice.Quit; window.Close(); };
        var stay = new Button { Content = "Stay", IsCancel = true };
        stay.Click += (_, _) => window.Close();

        window.Content = new StackPanel
        {
            Margin = new Thickness(20),
            Spacing = 14,
            Children =
            {
                Label("Quit IFSRenderer? Any unsaved changes to the current fractal will be lost.", Ink),
                new StackPanel
                {
                    Orientation = Orientation.Horizontal,
                    Spacing = 8,
                    HorizontalAlignment = HorizontalAlignment.Right,
                    Children = { stay, quit, save },
                },
            },
        };

        await window.ShowDialog(owner);
        return choice;
    }

    // ------------------------------------------------------------- video export

    /// <summary>What the user chose in <see cref="ShowVideoExport"/>; null if cancelled.</summary>
    internal sealed record VideoExportChoice(string OutputPath, string Args, bool KeepFrames);

    /// <summary>
    /// The export-video dialog.
    /// </summary>
    /// <remarks>
    /// Video export already worked, but was unreachable: the only entry point was a menu item
    /// labelled "Export animation frames", and whether it produced an mp4 depended on a checkbox
    /// buried in Settings > Animations that defaults to off. A capability nobody can find is
    /// indistinguishable from one that does not exist.
    ///
    /// The encoder options live HERE rather than only in Settings because this is where the
    /// decision is made. Settings keeps the ffmpeg PATH, which is a machine-level fact; the
    /// codec and quality are a per-export choice.
    /// </remarks>
    public static async Task<VideoExportChoice> ShowVideoExport(
        Window owner, AppSettings settings, string suggestedName, int width, int height, bool exr,
        string audioPath = null)
    {
        var window = Shell("Export video", 560, 430);
        VideoExportChoice choice = null;

        string resolved = FfmpegFramePipe.Resolve(settings.FfmpegPath);
        string ext = FfmpegFramePipe.ExtensionFor(settings.FfmpegArgs);
        var output = new TextBox
        {
            Text = System.IO.Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.MyVideos) is { Length: > 0 } v
                    ? v : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                (string.IsNullOrWhiteSpace(suggestedName) ? "animation" : suggestedName) + ext),
        };
        var browse = new Button { Content = "Browse…" };
        browse.Click += async (_, _) =>
        {
            var file = await owner.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
            {
                Title = "Save video as",
                SuggestedFileName = System.IO.Path.GetFileName(output.Text),
            });
            if (file is not null)
                output.Text = file.Path.LocalPath;
        };

        var args = new TextBox
        {
            Text = string.IsNullOrWhiteSpace(settings.FfmpegArgs)
                ? FfmpegFramePipe.DefaultArgs : settings.FfmpegArgs,
            AcceptsReturn = true,
            TextWrapping = TextWrapping.Wrap,
            MinHeight = 70,
        };

        var presets = new ComboBox { PlaceholderText = "▼ Presets", HorizontalAlignment = HorizontalAlignment.Stretch };
        Dictionary<string, string> presetMap = LoadFfmpegPresets();
        presets.ItemsSource = presetMap.Keys.ToList();
        presets.SelectionChanged += (_, _) =>
        {
            if (presets.SelectedItem is string key && presetMap.TryGetValue(key, out string preset))
            {
                args.Text = preset;
                // The container follows the codec, so the extension must follow the preset or
                // a prores stream lands in a .mp4 that will not play.
                string want = FfmpegFramePipe.ExtensionFor(preset);
                output.Text = System.IO.Path.ChangeExtension(output.Text, want);
            }
        };

        var keepFrames = new CheckBox
        {
            Content = "Also keep the rendered frames on disk",
            IsChecked = false,
        };

        // Reported, never gated - see FfmpegFramePipe.Resolve.
        var status = Label(
            resolved is not null
                ? (string.IsNullOrWhiteSpace(settings.FfmpegPath)
                    ? $"Using {resolved} (found on PATH)"
                    : $"Using {resolved} (set in Settings)")
                : string.IsNullOrWhiteSpace(settings.FfmpegPath)
                    ? "ffmpeg was not found on PATH. Install it, or set its location in Settings > Animations."
                    : $"'{settings.FfmpegPath}' does not exist. Fix it in Settings > Animations.",
            resolved is not null ? Muted : Accent);
        status.TextWrapping = TextWrapping.Wrap;

        var export = new Button { Content = "🎬 Export", IsDefault = true, IsEnabled = resolved is not null && !exr };
        export.Click += (_, _) =>
        {
            choice = new VideoExportChoice(output.Text, args.Text, keepFrames.IsChecked == true);
            window.Close();
        };
        var cancel = new Button { Content = "Cancel", IsCancel = true };
        cancel.Click += (_, _) => window.Close();

        var panel = new StackPanel { Margin = new Thickness(20), Spacing = 10 };
        panel.Children.Add(Label($"Resolution {width}x{height}" +
            (width % 2 != 0 || height % 2 != 0 ? " (odd — will be padded to even for the codec)" : ""), Muted));
        if (exr)
        {
            // Stated rather than silently ignored: previously an exr frame format made the
            // video request a no-op and the user got a frame sequence with no explanation.
            panel.Children.Add(Label(
                "Frame format is EXR, which cannot be streamed to an encoder. Switch to PNG in "
                + "Settings > Animations to export video.", Accent));
        }
        // Stated up front: the clip is visible in the scrubber, so silence in the exported file
        // would look like a bug rather than a choice.
        panel.Children.Add(Label(
            string.IsNullOrWhiteSpace(audioPath)
                ? "No audio clip loaded — the video will be silent."
                : $"Audio: {System.IO.Path.GetFileName(audioPath)} (muxed, cut to the clip length)",
            Muted));
        panel.Children.Add(Row("Output file", "The container is chosen by the codec.", output));
        panel.Children.Add(browse);
        panel.Children.Add(Row("Preset", "Presets from Library/FfmpegPresets.json.", presets));
        panel.Children.Add(Row("ffmpeg args", "IFSRenderer supplies input, size, fps and output.", args));
        panel.Children.Add(keepFrames);
        panel.Children.Add(status);
        panel.Children.Add(new StackPanel
        {
            Orientation = Orientation.Horizontal,
            Spacing = 8,
            HorizontalAlignment = HorizontalAlignment.Right,
            Children = { cancel, export },
        });
        window.Content = new ScrollViewer { Content = panel };

        await window.ShowDialog(owner);
        return choice;
    }

    // ------------------------------------------------------------ palette picker

    /// <summary>
    /// Shows every palette in a loaded file as its own gradient, returning the chosen index or
    /// -1 if cancelled.
    /// </summary>
    /// <remarks>
    /// PaletteDialogWindow.xaml is 300x500 and lists each palette as a gradient-filled
    /// PaletteButton with its name over the top. A .ugr commonly holds hundreds of palettes with
    /// meaningless names, so choosing one from a list of strings meant applying each in turn and
    /// looking at the render.
    /// </remarks>
    public static async Task<int> ShowPalettePicker(Window owner, IReadOnlyList<ColorPalette> palettes)
    {
        var window = Shell("Palette Picker", 300, 500);
        int chosen = -1;

        var list = new StackPanel { Spacing = 2 };
        for (int i = 0; i < palettes.Count; i++)
        {
            int index = i;
            ColorPalette palette = palettes[i];

            var label = new TextBlock
            {
                Text = string.IsNullOrWhiteSpace(palette.Name) ? $"Palette {i + 1}" : palette.Name,
                Foreground = Brushes.White,
                FontSize = 12,
                VerticalAlignment = VerticalAlignment.Center,
                HorizontalAlignment = HorizontalAlignment.Center,
                // The gradient underneath can be any colour, so the text needs its own contrast.
                Effect = new DropShadowEffect { BlurRadius = 4, OffsetX = 0, OffsetY = 1, Opacity = 1 },
            };

            var button = new Button
            {
                Classes = { "Flat" },
                Height = 34,
                Padding = new Thickness(0),
                Margin = new Thickness(0, 0, 0, 2),
                HorizontalAlignment = HorizontalAlignment.Stretch,
                HorizontalContentAlignment = HorizontalAlignment.Stretch,
                Background = GradientOf(palette),
                Content = label,
            };
            button.Click += (_, _) => { chosen = index; window.Close(); };
            list.Children.Add(button);
        }

        var cancel = new Button { Content = "Cancel", IsCancel = true, HorizontalAlignment = HorizontalAlignment.Right };
        cancel.Click += (_, _) => window.Close();

        window.Content = new DockPanel
        {
            Margin = new Thickness(10),
            Children =
            {
                new Border { Child = cancel, [DockPanel.DockProperty] = Dock.Bottom, Margin = new Thickness(0, 8, 0, 0) },
                new ScrollViewer { Content = list, VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto },
            },
        };

        await window.ShowDialog(owner);
        return chosen;
    }

    /// <summary>Paints a palette as a left-to-right gradient.</summary>
    public static LinearGradientBrush GradientOf(ColorPalette palette)
    {
        var brush = new LinearGradientBrush
        {
            StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative),
            EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative),
        };
        if (palette is null || palette.KeyColors.Count == 0)
        {
            brush.GradientStops.Add(new GradientStop(Colors.Black, 0));
            brush.GradientStops.Add(new GradientStop(Colors.Black, 1));
            return brush;
        }

        const int stops = 64;
        for (int i = 0; i < stops; i++)
        {
            double t = i / (double)(stops - 1);
            System.Numerics.Vector4 c = palette.SampleGradient(t);
            brush.GradientStops.Add(new GradientStop(
                Color.FromRgb(Channel(c.X), Channel(c.Y), Channel(c.Z)), t));
        }
        return brush;

        static byte Channel(float v) => (byte)Math.Clamp(v * 255f, 0f, 255f);
    }

    public static void OpenUrl(string url)
    {
        try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
        catch { /* no browser configured; nothing useful to do */ }
    }
}