OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / Controls / TransformBrowser.cs

269 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.Linq;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;

using IFSEngine.Model;

namespace IFSRenderer.AvaloniaShell.Controls;

/// <summary>
/// The searchable list of transform plugins, ported from the left column of
/// <c>WpfDisplay/Views/EditorWindow.xaml</c>.
/// </summary>
/// <remarks>
/// <para>
/// Clicking a transform ADDS an iterator using it — it does not retarget the selected one.
/// That is the canonical behaviour and the reason the original has no "change transform"
/// control anywhere.
/// </para>
/// <para>
/// The filter reproduces <c>IFSViewModel.FilterTransforms</c>, which is a ranking rather than
/// a predicate: names that START with the term come first, then names that CONTAIN it, then
/// matches on TAGS, de-duplicated in that order.
/// </para>
/// </remarks>
public sealed class TransformBrowser : UserControl
{
    private readonly TextBox _filter = new()
    {
        Watermark = "Filter by name, tags",
        Margin = new Thickness(0, 0, 0, 4),
    };
    private readonly StackPanel _list = new();

    private IReadOnlyList<TransformPlugin> _all = [];

    /// <summary>Raised when a transform is picked, to add an iterator using it.</summary>
    public event Action<TransformPlugin> TransformActivated;

    /// <summary>Raised by the 🔄 button.</summary>
    public event Action ReloadRequested;

    /// <summary>Raised by the 📂 button.</summary>
    public event Action OpenDirectoryRequested;

    /// <summary>Raised by a transform's ✏️ Edit source menu item.</summary>
    public event Action<TransformPlugin> EditSourceRequested;

    public TransformBrowser()
    {
        // Width comes from the grid column it sits in, so it stays resizable.

        var title = new TextBlock
        {
            Text = "Transforms",
            VerticalAlignment = VerticalAlignment.Center,
        };

        var reload = ToolButton("🔄", "Reload plugins (Ctrl+R)", () => ReloadRequested?.Invoke());
        var folder = ToolButton("📂", "Open plugin directory", () => OpenDirectoryRequested?.Invoke());

        var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") };
        header.Children.Add(title);
        var tools = new StackPanel { Orientation = Orientation.Horizontal, Children = { reload, folder } };
        Grid.SetColumn(tools, 1);
        header.Children.Add(tools);
        DockPanel.SetDock(header, Dock.Top);

        _filter.TextChanged += (_, _) => Rebuild();
        DockPanel.SetDock(_filter, Dock.Top);

        Content = new DockPanel
        {
            Margin = new Thickness(4, 0, 0, 0),
            Children =
            {
                header,
                _filter,
                new ScrollViewer
                {
                    HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
                    Content = _list,
                },
            },
        };
    }

    private static Button ToolButton(string glyph, string tip, Action onClick)
    {
        var b = new Button
        {
            Content = glyph,
            Classes = { "Flat" },
            Background = Brushes.Transparent,
            BorderThickness = new Thickness(0),
            Padding = new Thickness(4, 0),
            MinWidth = 0,
            FontSize = 13,
        };
        ToolTip.SetTip(b, tip);
        b.Click += (_, _) => onClick();
        return b;
    }

    public void SetTransforms(IReadOnlyList<TransformPlugin> transforms)
    {
        _all = transforms ?? [];
        Rebuild();
    }

    /// <summary>IFSViewModel.FilterTransforms — a ranked search, not a boolean filter.</summary>
    private IEnumerable<TransformPlugin> Filtered()
    {
        string term = _filter.Text;
        if (string.IsNullOrEmpty(term))
            return _all;

        var startsWith = _all.Where(t => t.Name.StartsWith(term, StringComparison.InvariantCultureIgnoreCase));
        var contains = _all.Where(t => t.Name.Contains(term, StringComparison.InvariantCultureIgnoreCase));
        var tagged = _all.Where(t => t.Tags.Any(tag => tag.Contains(term, StringComparison.InvariantCultureIgnoreCase)));
        return startsWith.Concat(contains).Concat(tagged).Distinct();
    }

    private void Rebuild()
    {
        _list.Children.Clear();
        foreach (TransformPlugin plugin in Filtered())
            _list.Children.Add(Row(plugin));
    }

    /// <summary>
    /// One transform: a flat full-width button showing the centred name, with a ⋮ that appears
    /// on hover and opens the plugin's details.
    /// </summary>
    private Control Row(TransformPlugin plugin)
    {
        var name = new TextBlock
        {
            Text = plugin.Name,
            HorizontalAlignment = HorizontalAlignment.Center,
            VerticalAlignment = VerticalAlignment.Center,
            Padding = new Thickness(24, 4),
        };

        var more = new Button
        {
            Content = "⋮",
            Foreground = Brushes.Gray,
            Background = Brushes.Transparent,
            BorderThickness = new Thickness(0),
            Padding = new Thickness(4, 0),
            MinWidth = 0,
            HorizontalAlignment = HorizontalAlignment.Right,
            VerticalAlignment = VerticalAlignment.Center,
            IsVisible = false,
            Flyout = DetailsFlyout(plugin),
        };

        var content = new Grid();
        content.Children.Add(name);
        content.Children.Add(more);

        var button = new Button
        {
            Content = content,
            Classes = { "Flat" },
            Background = Brushes.Transparent,
            BorderThickness = new Thickness(0),
            CornerRadius = new CornerRadius(0),
            Padding = new Thickness(0, 4),
            HorizontalAlignment = HorizontalAlignment.Stretch,
            HorizontalContentAlignment = HorizontalAlignment.Stretch,
        };
        button.Click += (_, _) => TransformActivated?.Invoke(plugin);

        // The ⋮ only shows on hover, as in the original.
        button.PointerEntered += (_, _) => more.IsVisible = true;
        button.PointerExited += (_, _) => more.IsVisible = false;

        return button;
    }

    /// <summary>
    /// The plugin's details: name, tag chips, description, include-uses, a reference link and
    /// Edit source — the same content as the WPF context menu.
    /// </summary>
    private Flyout DetailsFlyout(TransformPlugin plugin)
    {
        var stack = new StackPanel { MaxWidth = 260, Spacing = 4 };

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

        if (plugin.Tags is { Count: > 0 })
        {
            var chips = new WrapPanel { Orientation = Orientation.Horizontal };
            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(Bold("Description"));
        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(Bold("Include uses"));
        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),
                Padding = new Thickness(0, 4),
                HorizontalAlignment = HorizontalAlignment.Left,
            };
            ToolTip.SetTip(info, plugin.ReferenceUrl.ToString());
            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),
            Padding = new Thickness(0, 4),
            HorizontalAlignment = HorizontalAlignment.Left,
        };
        edit.Click += (_, _) => EditSourceRequested?.Invoke(plugin);
        stack.Children.Add(edit);

        return new Flyout { Content = stack };
    }

    private static TextBlock Bold(string text) =>
        new() { Text = text, FontWeight = FontWeight.Bold, Margin = new Thickness(0, 6, 0, 0) };
}