Files
2026-07-13 13:13:17 +08:00

345 lines
13 KiB
C#

using ImGuiNET;
using T3.Core.DataTypes.Vector;
using T3.Core.Utils;
using T3.Editor.Gui.Input;
using T3.Editor.Gui.Styling;
using T3.Editor.Gui.Styling.Markdown;
using T3.Editor.UiModel;
using T3.Editor.UiModel.ProjectHandling;
using T3.Editor.UiModel.Selection;
using SkillTraining = T3.Editor.Skills.Training.SkillTraining;
namespace T3.Editor.Gui.MagGraph.Interaction;
/// <summary>
/// Displays and handles the navigation through tour points
/// </summary>
internal static class TourInteraction
{
internal static void Draw(ProjectView projectView)
{
if (projectView == null || projectView.GraphView.Destroyed)
return;
if (projectView.CompositionInstance == null)
return;
_symbolTourProgress ??= new Dictionary<Guid, int>();
var compositionUi = projectView.CompositionInstance.Symbol.GetSymbolUi();
if (compositionUi.TourPoints.Count == 0)
return;
UpdateStyles();
if (ImGui.IsWindowAppearing())
{
_lastClickTime = ImGui.GetTime();
}
SkillTraining.DrawLevelHeader();
FormInputs.AddVerticalSpace();
var cursorPos = ImGui.GetCursorScreenPos();
ImGui.Indent(40 * T3Ui.UiScaleFactor - 5);
if (!_symbolTourProgress.TryGetValue(compositionUi.Symbol.Id, out var progressIndex))
{
progressIndex = 0;
}
var completed = progressIndex < 0 || progressIndex >= compositionUi.TourPoints.Count;
if (completed)
{
if (CustomComponents.DrawCtaButton("Restart Tour", Icon.None, UiColors.TextMuted, Color.Transparent, UiColors.ForegroundFull.Fade(0.2f)))
SetProgressIndex(compositionUi, 0);
}
else
{
var dl = ImGui.GetWindowDrawList();
var activeTourPoint = compositionUi.TourPoints[progressIndex];
if (DrawProgressDots(dl, compositionUi, cursorPos, progressIndex, out var hoveredIndex))
{
if (hoveredIndex >= 0)
SetProgressIndex(compositionUi, hoveredIndex);
}
RenderTourPointDetails(compositionUi, progressIndex, hoveredIndex);
DrawGraphIndicator(dl, activeTourPoint, projectView, compositionUi);
}
}
private static void RenderTourPointDetails(SymbolUi compositionUi, int progressIndex, int hoveredIndex)
{
if (compositionUi.TourPoints.Count == 0)
return;
var isDotHovered = hoveredIndex >= 0 && hoveredIndex != progressIndex;
var activeIndex = (isDotHovered ? hoveredIndex : progressIndex).Clamp(0, compositionUi.TourPoints.Count - 1);
var isLast = activeIndex == compositionUi.TourPoints.Count - 1;
var activeTourPoint = compositionUi.TourPoints[activeIndex];
var style = _windowStyles[int.Min(_windowStyles.Length - 1, (int)activeTourPoint.Style)];
ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 10);
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(10, 6) * T3Ui.UiScaleFactor);
ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, 1);
ImGui.PushStyleColor(ImGuiCol.ChildBg, style.WindowBackground.Rgba);
ImGui.PushStyleColor(ImGuiCol.Border, style.WindowBorder.Rgba);
if (ImGui.BeginChild("Task", new Vector2(500 * T3Ui.UiScaleFactor, _lastContentHeight), ImGuiChildFlags.Borders | ImGuiChildFlags.AlwaysUseWindowPadding,
ImGuiWindowFlags.NoScrollbar))
{
// Draw task
ImGui.SameLine(0, 4);
var typeWriterProgress = (TimeSinceInteraction * 200f / (activeTourPoint.Description.Length + 1)).Clamp(0, 1);
if (!string.IsNullOrEmpty(activeTourPoint.Description))
{
// Use the original description string once typing is done so
// MarkdownView's reference-equality cache stays warm. During
// typing we allocate a sliced snapshot per frame — brief and
// bounded by the description length.
var textToRender = typeWriterProgress >= 1f
? activeTourPoint.Description
: StringUtils.SliceToProgress(activeTourPoint.Description,
typeWriterProgress).ToString();
EnsureMarkdownView();
_markdown.Draw(textToRender);
}
if (typeWriterProgress >= 1 && !isDotHovered)
{
FormInputs.AddVerticalSpace();
if (CustomComponents.DrawCtaButton(style.ButtonLabel,
isLast ? Icon.None : Icon.ArrowRight,
textColor: style.ButtonLabelColor,
bgColor: style.ButtonBackground,
borderColor: style.ButtonBorder))
{
SetProgressIndex(compositionUi, activeIndex + 1);
}
}
FormInputs.AddVerticalSpace();
_lastContentHeight = ImGui.GetCursorPosY();
}
ImGui.EndChild();
ImGui.PopStyleVar(3);
ImGui.PopStyleColor(2);
}
private static bool DrawProgressDots(ImDrawListPtr dl, SymbolUi compositionUi, Vector2 pos, int activeIndex, out int hoveredIndex)
{
var clicked = false;
hoveredIndex = -1;
var tourPointsCount = compositionUi.TourPoints.Count;
var windowsHovered = ImGui.IsWindowHovered();
var mousePos = ImGui.GetMousePos();
var h = ImGui.GetFrameHeight();
pos += new Vector2(0.9f, 0.3f) * h;
var size = MathF.Floor(14 * T3Ui.UiScaleFactor);
var spacing = new Vector2(0, size);
for (var dotIndex = 0; dotIndex < tourPointsCount; dotIndex++)
{
var dotPos = pos + spacing * dotIndex;
var isHovered = hoveredIndex == -1 && windowsHovered && Vector2.Distance(mousePos, dotPos) < size / 2;
if (isHovered)
{
dl.AddCircle(dotPos, size * 0.6f, UiColors.ForegroundFull.Fade(0.5f));
if (ImGui.IsMouseClicked(ImGuiMouseButton.Left))
{
clicked = true;
}
hoveredIndex = dotIndex;
}
var isCurrent = dotIndex == activeIndex;
var radius = 3 + (isCurrent ? 2 + 5 * AttentionScaleFactor : 0);
var color = isCurrent ? UiColors.BackgroundActive : UiColors.ForegroundFull.Fade(0.3f);
dl.AddCircleFilled(dotPos, radius, color, 16);
}
return clicked;
}
private static void DrawGraphIndicator(ImDrawListPtr dl, TourPoint activeTourPoint, ProjectView projectView,
SymbolUi compositionUi)
{
if (activeTourPoint.Style == TourPoint.Styles.Info)
return;
if (!compositionUi.ChildUis.TryGetValue(activeTourPoint.ChildId, out var child))
return;
if (_lastCompositionId != compositionUi.Symbol.Id)
{
_lastCompositionId = compositionUi.Symbol.Id;
_dampedCanvasPos = child.PosOnCanvas;
}
else
{
_dampedCanvasPos = Vector2.Lerp(_dampedCanvasPos, child.PosOnCanvas, 0.1f);
}
var posOnScreen = projectView.GraphView.Canvas.TransformPosition(_dampedCanvasPos);
var fadeCount = 4;
var t = ImGui.GetTime();
var dotRadius = 40 + (100 / (TimeSinceInteraction * 0.5f + 1));
for (int fadeIndex = 0; fadeIndex < fadeCount; fadeIndex++)
{
var xx = (float)((t * 0.1f + fadeIndex / (float)fadeCount) % 1);
xx = MathF.Pow(1 - xx, 2.5f);
dl.AddCircleFilled(posOnScreen, (1 - xx) * dotRadius, UiColors.BackgroundActive.Fade(0.3f * xx));
}
}
private sealed record Style(
Color WindowBorder,
Color WindowBackground,
Color ButtonBorder,
Color ButtonBackground,
Color ButtonLabelColor,
string ButtonLabel);
private static void UpdateStyles()
{
// if (!FrameStats.Last.UiColorsChanged && _windowStyles != null)
// return;
_windowStyles ??= new Style[Enum.GetNames<TourPoint.Styles>().Length];
var solidBackground = Color.Mix(UiColors.WindowBackground, UiColors.BackgroundFull, 0.6f);
_windowStyles[(int)TourPoint.Styles.Info]
= new Style(UiColors.BackgroundActive,
solidBackground,
Color.Transparent,
UiColors.BackgroundActive,
UiColors.ForegroundFull,
"Continue Reading..."
);
_windowStyles[(int)TourPoint.Styles.InfoFor]
= new Style(UiColors.ForegroundFull.Fade(0.5f),
solidBackground,
Color.Transparent,
UiColors.BackgroundActive,
UiColors.ForegroundFull,
"Got it"
);
// Something close to background color, but opaque enough to ensure readability...
var dimmedBackground = Color.Mix(UiColors.WindowBackground, UiColors.BackgroundFull, 0.6f).Fade(0.8f);
_windowStyles[(int)TourPoint.Styles.CallToAction]
= new Style(UiColors.BackgroundFull.Fade(0.6f),
dimmedBackground,
UiColors.ForegroundFull.Fade(0.4f),
Color.Transparent,
UiColors.ForegroundFull,
"Done"
);
_windowStyles[(int)TourPoint.Styles.Conclusion]
= new Style(UiColors.BackgroundFull.Fade(0.6f),
dimmedBackground,
UiColors.ForegroundFull.Fade(0.4f),
Color.Transparent,
UiColors.ForegroundFull,
"Okay"
);
_windowStyles[(int)TourPoint.Styles.Tip]
= new Style(UiColors.BackgroundFull.Fade(0.6f),
dimmedBackground,
UiColors.ForegroundFull.Fade(0.4f),
Color.Transparent,
UiColors.ForegroundFull,
"Got it"
);
}
private static void EnsureMarkdownView()
{
if (_markdown != null)
return;
_markdown = new MarkdownView(new MarkdownView.Options
{
BodyFont = Fonts.FontLarge,
// Keep op links at body size — the default FontBold
// would render them visibly smaller than FontLarge.
OpRefFont = Fonts.FontLarge,
// TourPoint descriptions historically treat each \n as a
// hard line break (used as paragraph separators by the
// legacy paragraph-renderer); preserve that here.
HardLineBreaks = true,
WrapWidthPx = 470 * T3Ui.UiScaleFactor,
});
}
private static void SetProgressIndex(SymbolUi symbolUi, int index)
{
_symbolTourProgress[symbolUi.Symbol.Id] = index;
_lastClickTime = ImGui.GetTime();
}
private static float _lastContentHeight;
private static Style[] _windowStyles;
private static Vector2 _dampedCanvasPos;
private static Guid _lastCompositionId;
private static double _lastClickTime;
private static float TimeSinceInteraction => (float)(ImGui.GetTime() - _lastClickTime).ClampMin(0);
private static float AttentionScaleFactor => (1 / (TimeSinceInteraction * 3f + 1)).Clamp(0, 1);
public static void SetProgressIndex(Guid compositionId, int index)
{
_symbolTourProgress[compositionId] = index;
}
/// <summary>
/// True while the user is reading an introductory tour point (Info / InfoFor) on this
/// composition. Used by SkillQuest feedback to suppress hint icons while the tour owns
/// the user's attention. Call-to-Action, Conclusion, and Tip points expect the user to
/// engage with the graph, so they don't block hints.
/// </summary>
public static bool IsTourBlockingHints(SymbolUi compositionUi)
{
if (compositionUi.TourPoints.Count == 0)
return false;
if (_symbolTourProgress == null)
return true;
if (!_symbolTourProgress.TryGetValue(compositionUi.Symbol.Id, out var progressIndex))
return true;
if (progressIndex < 0 || progressIndex >= compositionUi.TourPoints.Count)
return false;
var style = compositionUi.TourPoints[progressIndex].Style;
return style == TourPoint.Styles.Info || style == TourPoint.Styles.InfoFor;
}
// -1 means completed or hidden
private static Dictionary<Guid, int> _symbolTourProgress = new();
private static MarkdownView _markdown;
}