Editor/QueuePanel.cs
using System;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;

namespace HumanoidMocapX.EditorTools;

/// <summary>The queue popup: each job with its place in line, progress and time estimates, cancel
/// for waiting jobs and open for finished ones, and an add button for several videos at once.</summary>
sealed class QueuePopup : PopupWidget
{
    const string VideoFilter = "Video files (*.mp4 *.mov *.m4v *.avi *.mkv *.webm)";
    readonly QueueTracker tracker;
    readonly SectionLine title;
    readonly Widget list;
    readonly Label problem;

    internal QueuePopup(Widget parent, QueueTracker tracker) : base(parent)
    {
        this.tracker = tracker;
        FixedWidth = 380; MaximumHeight = 460;
        Layout = Layout.Column(); Layout.Margin = 12; Layout.Spacing = 8;
        var header = Layout.AddRow(); header.Spacing = 6;
        title = header.Add(new SectionLine(this, "Queue"), 1);
        header.Add(Ui.Icon(this, "playlist_add", () => AddVideos(tracker), "Add videos to the queue", Ui.RowHeight));
        problem = Layout.Add(Ui.Error(this));
        var scroll = Layout.Add(new ScrollArea(this), 1);
        list = new Widget(scroll) { Layout = Layout.Column() };
        list.Layout.Spacing = 2;
        scroll.Canvas = list;
        tracker.Changed += Rebuild;
        Rebuild();
    }

    /// <summary>Opens the file picker and queues every selected video.</summary>
    internal static void AddVideos(QueueTracker tracker)
    {
        var dialog = new FileDialog(null) { Title = "Add videos to the queue" };
        dialog.SetFindExistingFiles(); dialog.SetModeOpen(); dialog.SetNameFilter(VideoFilter);
        if (dialog.Execute()) tracker.Add(dialog.SelectedFiles);
    }

    void Rebuild()
    {
        if (!this.IsValid()) return;
        title.Count = tracker.Active > 0 ? tracker.Active.ToString() : null;
        Ui.Show(problem, tracker.Problem);
        list.Layout.Clear(true);
        if (!tracker.Ready) { list.Layout.Add(Empty("lock", "Sign in to see your queue.")); return; }
        if (tracker.Uploading is { } uploading) list.Layout.Add(new JobRow(list, Path.GetFileName(uploading), "Uploading…", Ui.Accent));
        foreach (var job in tracker.Jobs.Reverse()) list.Layout.Add(Row(job));
        var slots = tracker.Limits?.MaximumActiveJobs ?? 1;
        foreach (var video in tracker.Waiting)
            list.Layout.Add(new JobRow(list, Path.GetFileName(video), $"Waits for a free slot · {slots} at a time", Theme.TextLight))
                .Action("close", "Remove", () => tracker.Remove(video));
        if (tracker.Jobs.Length == 0 && tracker.Waiting.Count == 0 && tracker.Uploading is null)
            list.Layout.Add(Empty("movie", "Nothing queued. Drop a video in the workspace, or add several here."));
        else if (tracker.Limits?.MaximumClipSeconds is { } clip)
            list.Layout.Add(Ui.Muted(new Label($"Added videos are cut to the first {HostedCapture.Duration((int)clip)}.", list), small: true));
        AdjustSize();
    }

    JobRow Row(HostedJob job)
    {
        var (color, line) = job.State switch
        {
            "queued" => (Theme.Yellow, Place(job)),
            "processing" => (Ui.Accent, job.Progress is { } done ? $"{Math.Round(done * 100)}%" + Left(job) : "Starting" + Left(job)),
            "completed" => (Ui.Accent, job.ExpiresAt is { } expires ? "Ready · deleted in " + HostedCapture.Duration((int)(expires - DateTimeOffset.UtcNow).TotalSeconds) : "Ready"),
            "failed" => (Theme.Red, job.Message),
            "cancelled" => (Theme.TextLight, "Cancelled"),
            _ => (Theme.TextLight, job.Message)
        };
        var row = new JobRow(list, job.Name ?? "Untitled video", line, color, job.Priority);
        if (job.State == "processing") row.Progress((float)(job.Progress ?? 0), job.Message);
        if (job.State == "completed") row.Action("file_open", "Open in the workspace", () => _ = tracker.OpenAsync(job));
        if (job.State != "processing")
            row.Action(QueueTracker.Finished(job) ? "delete" : "close", QueueTracker.Finished(job) ? "Remove" : "Cancel", () => _ = tracker.CancelAsync(job));
        else row.ToolTip = "A job that is processing cannot be cancelled. Its result is deleted 24 hours after it finishes.";
        return row;
    }

    static string Place(HostedJob job)
        => (job.QueuePosition is { } position ? $"#{position} in line" : "Waiting")
            + (job.SecondsUntilStart is { } start ? " · starts in " + HostedCapture.Duration(start) : "") + Left(job);

    static string Left(HostedJob job) => job.SecondsRemaining is { } left ? " · about " + HostedCapture.Duration(left) + " left" : "";

    Widget Empty(string icon, string text)
    {
        var empty = new Widget(list) { Layout = Layout.Column() };
        empty.Layout.Margin = new Sandbox.UI.Margin(0, 16, 0, 16); empty.Layout.Spacing = 6;
        var iconRow = empty.Layout.AddRow(); iconRow.AddStretchCell();
        iconRow.Add(new IconCell(empty, icon, Theme.TextLight.WithAlpha(.5f), 28)); iconRow.AddStretchCell();
        empty.Layout.Add(Ui.Muted(new Label(text, empty) { WordWrap = true, Alignment = TextFlag.Center }));
        return empty;
    }

    public override void OnDestroyed()
    {
        tracker.Changed -= Rebuild;
        base.OnDestroyed();
    }

    /// <summary>One job: a status dot, its name and pills, a status line, and icon actions that
    /// appear on the right.</summary>
    sealed class JobRow : Widget
    {
        readonly Color color;
        readonly Layout text, actions;

        internal JobRow(Widget parent, string name, string status, Color color, bool priority = false) : base(parent)
        {
            this.color = color; MouseTracking = true;
            Layout = Layout.Row(); Layout.Margin = new Sandbox.UI.Margin(20, 6, 4, 6); Layout.Spacing = 6;
            text = Layout.AddColumn(1); text.Spacing = 2;
            var top = text.AddRow(); top.Spacing = 6;
            top.Add(Ui.Bold(new Label(name.Length > 34 ? name[..33] + "…" : name, this) { ToolTip = name }));
            if (priority) top.Add(new Pill(this, "Priority", Theme.Yellow));
            top.AddStretchCell();
            text.Add(Ui.Muted(new Label(status, this) { WordWrap = true }, small: true));
            actions = Layout.AddRow(); actions.Spacing = 4;
        }

        internal JobRow Action(string icon, string tooltip, Action clicked)
        {
            actions.Add(Ui.Icon(this, icon, clicked, tooltip, Ui.RowHeight));
            return this;
        }

        internal void Progress(float value, string message)
        {
            text.Add(new UiProgress(this) { Value = value });
            text.Add(Ui.Muted(new Label(message, this) { WordWrap = true }, small: true));
        }

        protected override void OnMouseEnter() { base.OnMouseEnter(); Update(); }
        protected override void OnMouseLeave() { base.OnMouseLeave(); Update(); }

        protected override void OnPaint()
        {
            Paint.Antialiasing = true; Paint.ClearPen();
            if (Paint.HasMouseOver) { Paint.SetBrush(Color.White.WithAlpha(.04f)); Paint.DrawRect(LocalRect, Ui.Radius); }
            Paint.SetBrush(color); Paint.DrawRect(new Rect(7, 11, 7, 7), 3.5f);
        }
    }
}

/// <summary>A tinted material icon in a square cell.</summary>
sealed class IconCell : Widget
{
    readonly string icon;
    readonly Color color;

    internal IconCell(Widget parent, string icon, Color color, float size = 20) : base(parent)
    {
        this.icon = icon; this.color = color; FixedSize = size;
    }

    protected override void OnPaint()
    {
        Paint.SetPen(color); Paint.DrawIcon(LocalRect, icon, Width - 4);
    }
}