550 lines
23 KiB
C#
550 lines
23 KiB
C#
#nullable enable
|
|
using System.Globalization;
|
|
using ImGuiNET;
|
|
using T3.Core.DataTypes.Vector;
|
|
using T3.Core.SystemUi;
|
|
using T3.Core.Utils;
|
|
using T3.Editor.Gui.Input;
|
|
using T3.Editor.Gui.Styling;
|
|
using T3.Editor.Gui.Styling.Markdown;
|
|
|
|
namespace T3.Editor.Gui.Help;
|
|
|
|
/// <summary>
|
|
/// Draws the unified "watch & learn" list below an operator's help body: the hand-authored symbol
|
|
/// <see cref="LinkRow"/>s (wiki pages, example links) and the auto-extracted video
|
|
/// <see cref="HelpIndex.OnlineVideoSegment"/>s, ranked together (links carry a focus-style boost so they lead),
|
|
/// the top few shown with an inline "SHOW ALL N" toggle. Each row opens its target; video rows reveal a
|
|
/// thumbnail tooltip on hover.
|
|
/// </summary>
|
|
internal sealed class VideoResourceList
|
|
{
|
|
/// <summary>A hand-authored symbol link (the old "Links:" section), now ranked into the same list.</summary>
|
|
internal readonly record struct LinkRow(string Title, string Url, string Description, Icon? Icon);
|
|
|
|
/// <param name="mentionKey">The full mention key (<c>op:<fullpath></c> or <c>ui:<id></c>), or null.</param>
|
|
/// <param name="links">Hand-authored symbol links to merge in, ranked above the videos.</param>
|
|
internal void Draw(string? mentionKey, IReadOnlyList<LinkRow> links)
|
|
{
|
|
_tooltipDrawn = false;
|
|
|
|
var rows = BuildRows(mentionKey, links);
|
|
if (rows.Count == 0)
|
|
return;
|
|
|
|
FormInputs.AddVerticalSpace(5);
|
|
|
|
// Heading on the left; the "SHOW ALL N" toggle sits on the same line, right-aligned, small caps, active blue.
|
|
var headerY = ImGui.GetCursorPosY();
|
|
CustomComponents.StylizedText(SectionHeader(rows).ToUpperInvariant(), Fonts.FontSmall, UiColors.TextMuted);
|
|
if (rows.Count > CollapsedCount)
|
|
{
|
|
var afterHeaderY = ImGui.GetCursorPosY();
|
|
ImGui.SetCursorPosY(headerY);
|
|
ImGui.PushFont(Fonts.FontSmall);
|
|
var toggleLabel = _showAll ? "SHOW LESS" : $"SHOW ALL {rows.Count}";
|
|
var toggleWidth = ImGui.CalcTextSize(toggleLabel).X;
|
|
ImGui.SetCursorPosX(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X - toggleWidth);
|
|
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.StatusActivated.Rgba);
|
|
ImGui.PushStyleColor(ImGuiCol.HeaderHovered, UiColors.BackgroundActive.Fade(0.2f).Rgba);
|
|
if (ImGui.Selectable(toggleLabel, false, ImGuiSelectableFlags.None,
|
|
new Vector2(toggleWidth, ImGui.GetTextLineHeight())))
|
|
_showAll = !_showAll;
|
|
ImGui.PopStyleColor(2);
|
|
ImGui.PopFont();
|
|
ImGui.SetCursorPosY(afterHeaderY);
|
|
}
|
|
FormInputs.AddVerticalSpace(3);
|
|
|
|
var rowHeight = RowHeight();
|
|
var shownCount = _showAll ? rows.Count : Math.Min(CollapsedCount, rows.Count);
|
|
for (var i = 0; i < shownCount; i++)
|
|
{
|
|
DrawRow(rows[i], i, rowHeight);
|
|
}
|
|
|
|
FormInputs.AddVerticalSpace(BottomPadding); // breathing room under the last row
|
|
}
|
|
|
|
/// <summary>Resets the expansion state when the help topic changes, so a new operator starts collapsed.</summary>
|
|
internal void Reset() => _showAll = false;
|
|
|
|
/// <summary>
|
|
/// Pixel height the list will occupy for the operator (0 if it has no rows), so the Help window can
|
|
/// dock it as a fixed footer. Capped at half <paramref name="maxHeight"/> — beyond that the footer scrolls.
|
|
/// </summary>
|
|
internal float MeasureHeight(string? mentionKey, IReadOnlyList<LinkRow> links, float maxHeight)
|
|
{
|
|
var rows = BuildRows(mentionKey, links);
|
|
if (rows.Count == 0)
|
|
return 0;
|
|
|
|
ImGui.PushFont(Fonts.FontSmall);
|
|
var smallLine = ImGui.GetTextLineHeightWithSpacing();
|
|
ImGui.PopFont();
|
|
|
|
var rowHeight = RowHeight();
|
|
var spacing = ImGui.GetStyle().ItemSpacing.Y;
|
|
var topPadding = 5;
|
|
var rowCount = _showAll ? rows.Count : Math.Min(CollapsedCount, rows.Count);
|
|
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var height = ImGui.GetStyle().WindowPadding.Y * 2 // the footer child's own top/bottom padding
|
|
+ 3 * scale + smallLine // heading (with inline show-all) + the space under it
|
|
+ rowCount * (rowHeight + spacing)
|
|
+ topPadding
|
|
+ BottomPadding * scale; // breathing room under the last row
|
|
return Math.Min(height, maxHeight * 0.5f);
|
|
}
|
|
|
|
/// <summary>Merges hand-authored links and scored video segments into one list, ranked best-first.</summary>
|
|
private List<Row> BuildRows(string? mentionKey, IReadOnlyList<LinkRow> links)
|
|
{
|
|
var rows = new List<Row>(links.Count + 4);
|
|
|
|
var referencedVideoIds = new HashSet<string>();
|
|
if (!string.IsNullOrEmpty(mentionKey))
|
|
{
|
|
foreach (var scored in GetRanked(mentionKey))
|
|
{
|
|
rows.Add(Row.ForVideo(scored));
|
|
referencedVideoIds.Add(scored.Segment.VideoId);
|
|
}
|
|
}
|
|
|
|
// A hand-authored link that points to a video we already extract is a duplicate — drop it so the
|
|
// (often higher-quality, focus-boosted) extracted reference is the one shown.
|
|
for (var i = 0; i < links.Count; i++)
|
|
{
|
|
if (string.IsNullOrEmpty(links[i].Url))
|
|
continue;
|
|
var youTubeId = ExtractYouTubeId(links[i].Url);
|
|
if (youTubeId != null && referencedVideoIds.Contains(youTubeId))
|
|
continue;
|
|
rows.Add(Row.ForLink(links[i], LinkBaseScore - i * 0.01f));
|
|
}
|
|
|
|
rows.Sort(static (a, b) => b.Score.CompareTo(a.Score));
|
|
return rows;
|
|
}
|
|
|
|
/// <summary>Pulls the 11-char video id out of a youtu.be/… or …v=… URL, or null if it isn't one.</summary>
|
|
private static string? ExtractYouTubeId(string url)
|
|
{
|
|
foreach (var marker in YouTubeMarkers)
|
|
{
|
|
var idx = url.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
|
if (idx < 0)
|
|
continue;
|
|
var start = idx + marker.Length;
|
|
var end = start;
|
|
while (end < url.Length && (char.IsLetterOrDigit(url[end]) || url[end] == '_' || url[end] == '-'))
|
|
end++;
|
|
if (end - start >= 10)
|
|
return url.Substring(start, end - start);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Names the section after what's present — specific when the videos are one type, generic otherwise.</summary>
|
|
private static string SectionHeader(IReadOnlyList<Row> rows)
|
|
{
|
|
bool hasTutorial = false, hasMeetup = false, hasUpdate = false, hasOther = false;
|
|
foreach (var row in rows)
|
|
{
|
|
if (row.IsLink)
|
|
continue;
|
|
HelpIndex.TryGetVideo(row.Segment!.VideoId, out var video);
|
|
switch (video?.Type)
|
|
{
|
|
case "tutorial": hasTutorial = true; break;
|
|
case "meetup": hasMeetup = true; break;
|
|
case "update": hasUpdate = true; break;
|
|
default: hasOther = true; break;
|
|
}
|
|
}
|
|
|
|
var distinctKinds = (hasTutorial ? 1 : 0) + (hasMeetup ? 1 : 0) + (hasUpdate ? 1 : 0) + (hasOther ? 1 : 0);
|
|
if (distinctKinds == 1)
|
|
{
|
|
if (hasTutorial)
|
|
return "Related tutorials";
|
|
if (hasMeetup)
|
|
return "Discussed in meet-ups";
|
|
if (hasUpdate)
|
|
return "Mentioned in release videos";
|
|
}
|
|
|
|
return "Watch & learn";
|
|
}
|
|
|
|
private void DrawRow(Row row, int index, float rowHeight)
|
|
{
|
|
if (row.IsLink)
|
|
DrawLinkRow(row.Link, index, rowHeight);
|
|
else
|
|
DrawVideoRow(row.Segment!, row.PredatesCurrentUi, index, rowHeight);
|
|
}
|
|
|
|
private static void DrawLinkRow(LinkRow link, int index, float rowHeight)
|
|
{
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var width = ImGui.GetContentRegionAvail().X;
|
|
|
|
ImGui.PushID(1000 + index);
|
|
ImGui.InvisibleButton("##link", new Vector2(width, rowHeight));
|
|
ImGui.PopID();
|
|
|
|
var hovered = ImGui.IsItemHovered();
|
|
var clicked = ImGui.IsItemClicked();
|
|
var min = ImGui.GetItemRectMin();
|
|
var max = ImGui.GetItemRectMax();
|
|
var drawList = ImGui.GetWindowDrawList();
|
|
|
|
if (hovered)
|
|
drawList.AddRectFilled(min, max, UiColors.BackgroundActive.Fade(0.2f), 5 * scale);
|
|
|
|
// Links share the video rows' styling so the merged list reads as one consistent list.
|
|
var accentColor = UiColors.StatusActivated.Fade(hovered ? 1 : 0.8f);
|
|
var color = Color.Mix(UiColors.StatusActivated, UiColors.ForegroundFull, 0.3f).Fade(hovered ? 1 : 0.8f);
|
|
|
|
var icon = link.Icon ?? Icon.PlayOutput;
|
|
Icons.GetGlyphDefinition(icon, out _, out var iconSize);
|
|
var iconPos = new Vector2(min.X + 4 * scale, min.Y + (rowHeight - iconSize.Y) * 0.5f);
|
|
Icons.DrawIconAtScreenPosition(icon, iconPos, drawList, accentColor);
|
|
var textX = min.X + 4 * scale + iconSize.X + 6 * scale;
|
|
|
|
var textY = min.Y + (rowHeight - ImGui.GetTextLineHeight()) * 0.5f;
|
|
ImGui.PushFont(Fonts.FontBold);
|
|
drawList.AddText(new Vector2(textX, textY), color, link.Title);
|
|
ImGui.PopFont();
|
|
|
|
if (clicked && !string.IsNullOrEmpty(link.Url))
|
|
CoreUi.Instance.OpenWithDefaultApplication(link.Url);
|
|
|
|
if (hovered && !string.IsNullOrEmpty(link.Description))
|
|
{
|
|
ImGui.BeginTooltip();
|
|
ImGui.PushTextWrapPos(260 * scale);
|
|
ImGui.TextUnformatted(link.Description);
|
|
ImGui.PopTextWrapPos();
|
|
ImGui.EndTooltip();
|
|
}
|
|
}
|
|
|
|
private void DrawVideoRow(HelpIndex.OnlineVideoSegment segment, bool predatesCurrentUi, int index, float rowHeight)
|
|
{
|
|
HelpIndex.TryGetVideo(segment.VideoId, out var video);
|
|
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var width = ImGui.GetContentRegionAvail().X;
|
|
|
|
ImGui.PushID(index);
|
|
ImGui.InvisibleButton("##row", new Vector2(width, rowHeight));
|
|
ImGui.PopID();
|
|
|
|
var hovered = ImGui.IsItemHovered();
|
|
var clicked = ImGui.IsItemClicked();
|
|
var min = ImGui.GetItemRectMin();
|
|
var max = ImGui.GetItemRectMax();
|
|
var drawList = ImGui.GetWindowDrawList();
|
|
|
|
// Hover affordance matches the Asset Library folder rows: a soft rounded fill + white text.
|
|
if (hovered)
|
|
drawList.AddRectFilled(min, max, UiColors.BackgroundActive.Fade(0.2f), 5 * scale);
|
|
|
|
var accentColor = UiColors.StatusActivated.Fade(hovered ? 1:0.8f);
|
|
|
|
var linkColor = Color.Mix(UiColors.StatusActivated, UiColors.ForegroundFull, 0.3f);
|
|
var reactiveLinkColor = linkColor.Fade(hovered ? 1:0.8f);
|
|
|
|
Icons.GetGlyphDefinition(Icon.PlayOutput, out _, out var iconSize);
|
|
var iconPos = new Vector2(min.X + 4 * scale, min.Y + (rowHeight - iconSize.Y) * 0.5f);
|
|
Icons.DrawIconAtScreenPosition(Icon.PlayOutput, iconPos, drawList, accentColor);
|
|
|
|
var textX = min.X + 4 * scale + iconSize.X + 6 * scale;
|
|
|
|
ImGui.PushFont(Fonts.FontBold);
|
|
var typeLabel = TypeLabel(video?.Type);
|
|
var typeWidth = ImGui.CalcTextSize(typeLabel).X;
|
|
var textY = min.Y + (rowHeight - ImGui.GetTextLineHeight()) * 0.5f;
|
|
drawList.AddText(new Vector2(textX, textY), reactiveLinkColor, typeLabel);
|
|
ImGui.PopFont();
|
|
|
|
var annotation = RowAnnotation(segment);
|
|
ImGui.PushFont(Fonts.FontNormal);
|
|
drawList.AddText(new Vector2(textX + typeWidth + 6 * scale, textY), reactiveLinkColor, annotation);
|
|
ImGui.PopFont();
|
|
|
|
if (predatesCurrentUi)
|
|
{
|
|
ImGui.PushFont(Fonts.FontSmall);
|
|
const string cue = "predates current UI";
|
|
var cueSize = ImGui.CalcTextSize(cue);
|
|
drawList.AddText(new Vector2(max.X - cueSize.X - 6 * scale, min.Y + (rowHeight - cueSize.Y) * 0.5f),
|
|
UiColors.TextMuted.Fade(0.6f), cue);
|
|
ImGui.PopFont();
|
|
}
|
|
|
|
if (clicked && !string.IsNullOrEmpty(segment.Url))
|
|
CoreUi.Instance.OpenWithDefaultApplication(segment.Url);
|
|
|
|
// Only one tooltip per frame so rapidly crossed rows can't stack their content into one window.
|
|
if (hovered && !_tooltipDrawn)
|
|
{
|
|
_tooltipDrawn = true;
|
|
DrawRowTooltip(segment, video);
|
|
}
|
|
}
|
|
|
|
private void DrawRowTooltip(HelpIndex.OnlineVideoSegment segment, HelpIndex.VideoInfo? video)
|
|
{
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var textureId = IntPtr.Zero;
|
|
var aspectRatio = 16f / 9f;
|
|
// Prefer the per-reference frame (the exact moment); fall back to the video's cover thumbnail.
|
|
var hasPreview = video != null
|
|
&& (VideoThumbnails.TryGetMoment(video.Id, segment.StartSecond, out textureId, out aspectRatio)
|
|
|| VideoThumbnails.TryGetTexture(video.Id, out textureId, out aspectRatio));
|
|
|
|
var thumbnailWidth = 150 * scale;
|
|
var textWidth = 260 * scale;
|
|
var padding = 10 * scale;
|
|
var tooltipWidth = (hasPreview ? thumbnailWidth + 10 * scale : 0) + textWidth + padding * 2;
|
|
|
|
ImGui.SetNextWindowSizeConstraints(new Vector2(tooltipWidth, 0), new Vector2(tooltipWidth, ImGui.GetMainViewport().WorkSize.Y));
|
|
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(padding, 9 * scale));
|
|
ImGui.BeginTooltip();
|
|
|
|
if (hasPreview)
|
|
{
|
|
ImGui.BeginGroup();
|
|
DrawThumbnail(textureId, aspectRatio, thumbnailWidth, video!);
|
|
ImGui.EndGroup();
|
|
ImGui.SameLine(0, 10 * scale);
|
|
}
|
|
|
|
ImGui.BeginGroup();
|
|
|
|
// e.g. "5MIN ON YOUTUBE / SEP 2024"
|
|
var header = FormatSegmentDuration(segment.DurationSeconds).ToUpperInvariant() + " ON YOUTUBE";
|
|
if (video?.Date != null)
|
|
header += " / " + video.Date.Value.ToString("MMM yyyy", CultureInfo.InvariantCulture).ToUpperInvariant();
|
|
CustomComponents.StylizedText(header, Fonts.FontSmall, UiColors.TextMuted);
|
|
|
|
if (video != null && !string.IsNullOrEmpty(video.Title))
|
|
{
|
|
FormInputs.AddVerticalSpace(2);
|
|
ImGui.PushFont(Fonts.FontBold);
|
|
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.ForegroundFull.Rgba);
|
|
ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + textWidth);
|
|
ImGui.TextWrapped(ClampText(video.Title, MaxTitleChars));
|
|
ImGui.PopTextWrapPos();
|
|
ImGui.PopStyleColor();
|
|
ImGui.PopFont();
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(segment.Note))
|
|
{
|
|
FormInputs.AddVerticalSpace(3);
|
|
// [OpName] refs in the note render as colored labels; they suppress their own tooltip so they don't nest.
|
|
_noteView.Draw(segment.Note,
|
|
onOperatorRef: static op => MarkdownOperatorLinks.HandleOperatorRef(op, suppressTooltip: true),
|
|
operatorColor: MarkdownOperatorLinks.GetOperatorColor,
|
|
wrapWidthPx: textWidth);
|
|
}
|
|
|
|
ImGui.EndGroup();
|
|
ImGui.EndTooltip();
|
|
ImGui.PopStyleVar();
|
|
}
|
|
|
|
private static void DrawThumbnail(IntPtr textureId, float aspectRatio, float width, HelpIndex.VideoInfo video)
|
|
{
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var height = width / (aspectRatio <= 0 ? 16f / 9f : aspectRatio);
|
|
|
|
var min = ImGui.GetCursorScreenPos();
|
|
ImGui.Image(textureId, new Vector2(width, height));
|
|
var max = min + new Vector2(width, height);
|
|
var drawList = ImGui.GetWindowDrawList();
|
|
drawList.AddRect(min, max, UiColors.BackgroundFull.Fade(0.5f));
|
|
|
|
// Play badge.
|
|
var center = (min + max) * 0.5f;
|
|
drawList.AddCircleFilled(center, 15 * scale, UiColors.BackgroundFull.Fade(0.4f));
|
|
var t = 6 * scale;
|
|
drawList.AddTriangleFilled(center + new Vector2(-t * 0.5f, -t),
|
|
center + new Vector2(-t * 0.5f, t),
|
|
center + new Vector2(t, 0),
|
|
UiColors.ForegroundFull);
|
|
|
|
// "Tutorial 5:23" — type + full video length, bottom-left, with a shadow so it reads over any image.
|
|
ImGui.PushFont(Fonts.FontSmall);
|
|
var label = TypeLabel(video.Type) + " " + FormatClock(video.DurationSeconds);
|
|
var labelPos = new Vector2(min.X + 6 * scale, max.Y - ImGui.GetTextLineHeight() - 5 * scale);
|
|
drawList.AddText(labelPos + new Vector2(1, 1) * scale, UiColors.BackgroundFull, label);
|
|
drawList.AddText(labelPos, UiColors.ForegroundFull, label);
|
|
ImGui.PopFont();
|
|
}
|
|
|
|
private IReadOnlyList<ScoredSegment> GetRanked(string mentionKey)
|
|
{
|
|
if (_rankedCache.TryGetValue(mentionKey, out var cached))
|
|
return cached;
|
|
|
|
var segments = HelpIndex.GetSegments(mentionKey);
|
|
var scored = new List<ScoredSegment>(segments.Count);
|
|
var now = DateTime.UtcNow;
|
|
foreach (var segment in segments)
|
|
{
|
|
HelpIndex.TryGetVideo(segment.VideoId, out var video);
|
|
scored.Add(Score(segment, video, now));
|
|
}
|
|
|
|
scored.Sort(static (a, b) => b.Score.CompareTo(a.Score));
|
|
_rankedCache[mentionKey] = scored;
|
|
return scored;
|
|
}
|
|
|
|
private static ScoredSegment Score(HelpIndex.OnlineVideoSegment segment, HelpIndex.VideoInfo? video, DateTime now)
|
|
{
|
|
var depthScore = segment.Depth switch
|
|
{
|
|
"in-depth" => 1.0f,
|
|
"explained" => 0.7f,
|
|
"passing" => 0.4f,
|
|
_ => 0.5f,
|
|
};
|
|
|
|
// Trust order: scripted > answer > discussion > experiment.
|
|
var styleScore = segment.Style switch
|
|
{
|
|
"scripted" => 1.0f,
|
|
"answer" => 0.85f,
|
|
"discussion" => 0.65f,
|
|
"experiment" => 0.5f,
|
|
_ => 0.65f,
|
|
};
|
|
|
|
var confidenceScore = ((segment.Confidence ?? 65) / 100f).Clamp(0.2f, 1f);
|
|
|
|
// What the clip offers — mirrors the index's purpose weighting (Example/Comparison first).
|
|
var purposeScore = segment.Purpose switch
|
|
{
|
|
"Example" => 1.2f,
|
|
"Comparison" => 1.2f,
|
|
"Parameters" => 1.1f,
|
|
"Gotcha" => 1.1f,
|
|
"Tip" => 0.9f,
|
|
_ => 1.0f,
|
|
};
|
|
|
|
// A focus video is the dedicated tutorial for this op — surface it first, matching the index boost.
|
|
var focusBoost = segment.IsFocus ? 5f : 1f;
|
|
|
|
// Operator math is stable, so age decays gently here (the heavier ui:-topic curve is a Phase 2 concern).
|
|
var yearsOld = video?.Date != null ? (float)(now - video.Date.Value).TotalDays / 365.25f : 1f;
|
|
var ageScore = (1f - yearsOld * 0.07f).Clamp(0.45f, 1f);
|
|
|
|
var predatesCurrentUi = yearsOld >= PredatesCurrentUiYears;
|
|
var deEmphasized = segment.Style == "experiment" || (segment.Confidence ?? 65) < 50;
|
|
|
|
return new ScoredSegment(segment,
|
|
depthScore * styleScore * confidenceScore * ageScore * purposeScore * focusBoost,
|
|
predatesCurrentUi,
|
|
deEmphasized);
|
|
}
|
|
|
|
/// <summary>Compact relevancy line for a row, e.g. <c>(5min · In-depth · Example)</c> — duration, depth, purpose.</summary>
|
|
private static string RowAnnotation(HelpIndex.OnlineVideoSegment segment)
|
|
{
|
|
var annotation = "(" + FormatSegmentDuration(segment.DurationSeconds);
|
|
if (!string.IsNullOrEmpty(segment.Depth))
|
|
annotation += " · " + Capitalize(segment.Depth!);
|
|
if (!string.IsNullOrEmpty(segment.Purpose)) // purpose (Example/Gotcha/…) replaces the old style chip
|
|
annotation += " · " + Capitalize(segment.Purpose!);
|
|
return annotation + ")";
|
|
}
|
|
|
|
private static string Capitalize(string text)
|
|
{
|
|
return text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text.Substring(1);
|
|
}
|
|
|
|
private static string ClampText(string text, int maxChars)
|
|
{
|
|
return text.Length <= maxChars ? text : text.Substring(0, maxChars).TrimEnd() + "...";
|
|
}
|
|
|
|
private static float RowHeight()
|
|
{
|
|
ImGui.PushFont(Fonts.FontNormal);
|
|
var height = ImGui.GetTextLineHeight() + 6 * T3Ui.UiScaleFactor;
|
|
ImGui.PopFont();
|
|
return height;
|
|
}
|
|
|
|
private static string TypeLabel(string? type)
|
|
{
|
|
return type switch
|
|
{
|
|
"meetup" => "Meet-up",
|
|
"tutorial" => "Tutorial",
|
|
"update" => "Release overview",
|
|
_ => "Video",
|
|
};
|
|
}
|
|
|
|
private static string FormatSegmentDuration(int seconds)
|
|
{
|
|
if (seconds < 60)
|
|
return $"{seconds}s";
|
|
|
|
return $"{(int)Math.Round(seconds / 60f)}min";
|
|
}
|
|
|
|
private static string FormatClock(int seconds)
|
|
{
|
|
var h = seconds / 3600;
|
|
var m = seconds % 3600 / 60;
|
|
var s = seconds % 60;
|
|
return h > 0 ? $"{h}:{m:00}:{s:00}" : $"{m}:{s:00}";
|
|
}
|
|
|
|
private readonly record struct ScoredSegment(
|
|
HelpIndex.OnlineVideoSegment Segment,
|
|
float Score,
|
|
bool PredatesCurrentUi,
|
|
bool IsDeEmphasized);
|
|
|
|
/// <summary>A unified list entry: either a hand-authored <see cref="LinkRow"/> or a scored video segment.</summary>
|
|
private readonly record struct Row(
|
|
float Score,
|
|
bool IsLink,
|
|
LinkRow Link,
|
|
HelpIndex.OnlineVideoSegment? Segment,
|
|
bool PredatesCurrentUi)
|
|
{
|
|
internal static Row ForLink(LinkRow link, float score) => new(score, true, link, null, false);
|
|
|
|
internal static Row ForVideo(ScoredSegment scored) =>
|
|
new(scored.Score, false, default, scored.Segment, scored.PredatesCurrentUi);
|
|
}
|
|
|
|
private const int CollapsedCount = 3;
|
|
private const float BottomPadding = 8f;
|
|
// Below a focus tutorial's score (~2.3) but above incidental video mentions — curated links sit just under
|
|
// the dedicated tutorial. (Interim: once symbol links are migrated into the index, this special-casing goes away.)
|
|
private const float LinkBaseScore = 2.0f;
|
|
private static readonly string[] YouTubeMarkers = { "youtu.be/", "v=" };
|
|
private const float PredatesCurrentUiYears = 2f;
|
|
private const int MaxTitleChars = 84;
|
|
|
|
private bool _showAll;
|
|
private bool _tooltipDrawn;
|
|
private readonly Dictionary<string, IReadOnlyList<ScoredSegment>> _rankedCache = new();
|
|
private readonly MarkdownView _noteView = new(new MarkdownView.Options());
|
|
}
|