Files

586 lines
24 KiB
C++
Raw Permalink Normal View History

// Fill out your copyright notice in the Description page of Project Settings.
#include "NpcScheduleTimeline.h"
#include "Editor.h"
#include "EngineUtils.h"
#include "Rendering/DrawElements.h"
#include "Styling/CoreStyle.h"
#include "Templates/TypeHash.h"
#include "Widgets/SNullWidget.h"
#include "ProjectEleri/Ai/NpcScheduleAsset.h"
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
#define LOCTEXT_NAMESPACE "NpcScheduleTimeline"
/*-----------------------------------------------------------------------------
How the strip is laid out
-----------------------------------------------------------------------------*/
namespace
{
/** One day, in the minutes of the day that every start in a schedule is measured in. */
constexpr int32 MinutesPerDay = 24 * 60;
/** Where the strip starts and ends inside the widget, so that a slot sitting at midnight is not
* drawn against the panel's own edge and its label has somewhere to start. */
constexpr float StripLeftPadding = 8.0f;
constexpr float StripRightPadding = 8.0f;
/** The ruler row, which holds the hour labels and nothing else. */
constexpr float HourLabelHeight = 12.0f;
/** One row of slots. High enough to read a label in, and no higher, since a day is mostly one row. */
constexpr float LaneHeight = 26.0f;
constexpr float LaneGap = 3.0f;
/** The legend below the day: one line per action, each with the swatch that matches its slot. */
constexpr float LegendTopGap = 8.0f;
constexpr float LegendRowHeight = 14.0f;
constexpr float LegendSwatchWidth = 8.0f;
constexpr float LegendSwatchGap = 6.0f;
/** Below that, and under the day when there is no legend to draw. */
constexpr float BottomPadding = 2.0f;
/** What the strip asks for in a panel that has not decided how wide it should be yet. The width it
* actually gets is whatever the panel gives it, so this is only ever a starting point. */
constexpr float PreferredWidth = 520.0f;
/** A slot narrower than this is drawn as a bare block: no label fits in it, and a label spilling out
* of its own slot would read as belonging to the slot beside it. */
constexpr float MinimumLabelledSlotWidth = 68.0f;
/** A slot's own label: how far it sits from the slot's edges, and how tall the line it is drawn on is,
* which is what centres it in a lane. */
constexpr float SlotLabelInset = 6.0f;
constexpr float SlotLabelHeight = 12.0f;
/** Every this many hours the ruler carries a label. Written out rather than derived so that the
* labels line up with the times an author thinks in. */
constexpr int32 LabelledHourInterval = 3;
/** How long an anchor's name is trusted for before it is read out of the level again. The names are
* the one thing being drawn that can go stale without the schedule changing - an anchor renamed or
* deleted while this tab sat open - and re-reading them is a walk of the level, so it is done on a
* timer rather than every frame. */
constexpr double AnchorLabelRefreshPeriod = 2.0;
/** Colour of the trough the lanes sit in, of the hour marks, and of the text that is not a label of
* anything: the same three the rest of the editor's dark panels read against. */
const FLinearColor TroughColor(0.0f, 0.0f, 0.0f, 0.55f);
const FLinearColor LaneColor(1.0f, 1.0f, 1.0f, 0.03f);
const FLinearColor HourMarkColor(1.0f, 1.0f, 1.0f, 0.07f);
const FLinearColor LabelledHourMarkColor(1.0f, 1.0f, 1.0f, 0.16f);
const FLinearColor HourLabelColor(0.62f, 0.62f, 0.62f);
const FLinearColor LegendTextColor(0.80f, 0.80f, 0.80f);
const FLinearColor QuietTextColor(0.55f, 0.55f, 0.55f);
/** A slot's label is drawn dark on the slot's own colour, which is the only way one label style stays
* legible on six different backgrounds without a second colour per activity to go with them. */
const FLinearColor SlotLabelColor(0.06f, 0.06f, 0.06f, 0.90f);
/** The marker where a slot that runs past midnight carries on at the other end of the strip. Drawn on
* both halves, so that either end says which window it belongs to. */
const FLinearColor WrapMarkerColor(1.0f, 1.0f, 1.0f, 0.85f);
constexpr float WrapMarkerWidth = 3.0f;
/*-----------------------------------------------------------------------------
Reading the schedule and the level
-----------------------------------------------------------------------------*/
/** The id a schedule would name this actor by. Implementing the interface is the whole of the "can
* this actor be an anchor" rule, because the interface is what carries the id a schedule stores.
*
* Written out rather than shared with the two other copies of it, exactly as ScheduleAssetRecheck
* says of its own: it is one line of casting, and the smaller fault next to a details panel that
* answers about one action at a time having to grow a second responsibility for a view of a day. */
FGuid GetAnchorId(AActor* Actor)
{
if (Actor == nullptr)
{
return FGuid();
}
const IActorPersistentIdInterface* Identified = Cast<IActorPersistentIdInterface>(Actor);
return Identified != nullptr ? Identified->GetPersistentId() : FGuid();
}
/** Every anchor the open level can answer for, by the id a schedule names it with.
*
* One walk for the whole strip rather than a walk per action, which is the rule the recheck sweep
* follows and for the same reason: the answer cannot change under the walk. The first actor to claim
* an id keeps it, which is also what a running game arrives at, both sides walking the level and
* taking what they meet first. */
void CollectAnchorNames(TMap<FGuid, FString>& OutAnchorNames)
{
UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr;
if (World == nullptr)
{
return;
}
for (TActorIterator<AActor> It(World); It; ++It)
{
AActor* Actor = *It;
const FGuid AnchorId = GetAnchorId(Actor);
if (!AnchorId.IsValid() || OutAnchorNames.Contains(AnchorId))
{
continue;
}
OutAnchorNames.Add(AnchorId, Actor->GetActorNameOrLabel());
}
}
/** What an activity is called, which is the name the enum itself carries rather than a table here: a
* behaviour added as an enum value is then named in the strip without anyone remembering this file
* exists. */
FText GetActivityLabel(ENpcActivity Activity)
{
const UEnum* Enum = StaticEnum<ENpcActivity>();
return Enum != nullptr ? Enum->GetDisplayNameTextByValue(static_cast<int64>(Activity)) : FText::GetEmpty();
}
/** The colour that activity is drawn in. Handed out per activity rather than hashed, because these
* are the ones that exist and they are worth telling apart at a glance: sleep dark, work warm, and
* the two quiet ones grey. */
FLinearColor GetActivityColor(ENpcActivity Activity)
{
switch (Activity)
{
case ENpcActivity::Idle: return FLinearColor(0.42f, 0.44f, 0.48f);
case ENpcActivity::Sleep: return FLinearColor(0.29f, 0.36f, 0.72f);
case ENpcActivity::Sit: return FLinearColor(0.22f, 0.55f, 0.60f);
case ENpcActivity::Stand: return FLinearColor(0.34f, 0.62f, 0.35f);
case ENpcActivity::Work: return FLinearColor(0.78f, 0.55f, 0.20f);
case ENpcActivity::Roam: return FLinearColor(0.66f, 0.35f, 0.55f);
default: break;
}
// A behaviour added later gets a colour of its own instead of the grey of an unknown: the value is
// spread over the colour wheel by the golden angle, which lands the next few far from each other
// and from the six above without anyone having to come back here to choose it.
return FLinearColor::MakeFromHSV8(static_cast<uint8>(static_cast<uint8>(Activity) * 137), 140, 190);
}
/** "22:00" for a minute of the day. Culture invariant, because a clock is digits and not prose. */
FText MakeClockText(int32 MinutesOfDay)
{
const int32 Wrapped = ((MinutesOfDay % MinutesPerDay) + MinutesPerDay) % MinutesPerDay;
return FText::AsCultureInvariant(FString::Printf(TEXT("%02d:%02d"), Wrapped / 60, Wrapped % 60));
}
/** "22:00 - 06:00" for a window, which is the whole of what a slot has to say about when it is. */
FText MakeWindowText(int32 StartMinutes, int32 EndMinutes)
{
return FText::AsCultureInvariant(FString::Printf(
TEXT("%s - %s"), *MakeClockText(StartMinutes).ToString(), *MakeClockText(EndMinutes).ToString()));
}
/** What an action's anchor is called on the legend line: the name of the actor the id resolves to in
* this level, or what it means when it resolves to nothing. An id no actor claims is a real state -
* furniture that was deleted, or furniture in a cell that is not loaded - and not one to hide, so it
* is named with the id it is, which is also the only way to tell two unresolved anchors apart. */
FText MakeAnchorText(const FGuid& AnchorId, const TMap<FGuid, FString>& AnchorNames)
{
if (!AnchorId.IsValid())
{
return LOCTEXT("NoAnchor", "no anchor");
}
if (const FString* Name = AnchorNames.Find(AnchorId))
{
return FText::FromString(*Name);
}
return FText::Format(
LOCTEXT("AnchorNotInLevel", "an anchor no actor in this level claims ({0})"),
FText::AsCultureInvariant(AnchorId.ToString(EGuidFormats::Digits)));
}
/** "Sleep 22:00 - 06:00 Bed_01" for the legend, which is where an action is named in full. */
FText MakeLegendLine(const FText& ActivityText, const FText& WindowText, const FText& AnchorText)
{
return FText::Format(LOCTEXT("LegendLine", "{0} {1} {2}"), ActivityText, WindowText, AnchorText);
}
}
/*-----------------------------------------------------------------------------
The widget
-----------------------------------------------------------------------------*/
void SNpcScheduleTimeline::Construct(const FArguments& InArgs)
{
Schedule = InArgs._Schedule;
Font = FCoreStyle::GetDefaultFontStyle(TEXT("Regular"), 8);
// The strip paints itself and holds no widget of its own, and it says so with an empty child slot
// rather than by leaving the slot unset: the slot is what a compound widget arranges, and this is
// the one child it has to be given to arrange.
ChildSlot
[
SNullWidget::NullWidget
];
// The ruler is the same eight labels whatever is being drawn, so it is made once here rather than
// rebuilt along with the schedules that share it.
for (int32 Hour = 0; Hour < 24; Hour += LabelledHourInterval)
{
HourLabels.Add(FText::AsCultureInvariant(FString::Printf(TEXT("%02d:00"), Hour)));
}
// Built here rather than left to the first tick, so that the first paint has a day to draw rather
// than an empty one. The time it is built at is 0 because there is no clock here yet, which is
// earlier than any anchor's name is trusted for: the first tick reads the level once more and
// leaves Construct with nothing to decide about when a name goes stale.
Rebuild(ComputeSignature(), 0.0);
}
void SNpcScheduleTimeline::Tick(const FGeometry& AllottedGeometry, const double InCurrentTime, const float InDeltaTime)
{
const uint32 Signature = ComputeSignature();
const bool bScheduleChanged = Signature != CachedSignature;
const bool bAnchorNamesStale = InCurrentTime - LastBuildTime >= AnchorLabelRefreshPeriod;
if (!bScheduleChanged && !bAnchorNamesStale)
{
return;
}
Rebuild(Signature, InCurrentTime);
// What has been painted was built from the schedule as it was, so the strip has to be painted
// again. An anchor renamed out from under an open tab is the only reason this happens without the
// asset changing, which is a repaint of one row every couple of seconds at worst: cheaper than
// keeping a subscription to every way an actor can be renamed, deleted or replaced by another.
Invalidate(EInvalidateWidgetReason::Paint);
}
uint32 SNpcScheduleTimeline::ComputeSignature() const
{
const UNpcScheduleAsset* Asset = Schedule.Get();
if (Asset == nullptr)
{
return 0;
}
// Every value the strip draws is in here, so that anything which would change the day, the
// windows or the names on them comes out as a different signature. The anchor's recorded transform
// is deliberately not: the strip never draws where an anchor stands, so a recheck that refreshed
// one has nothing here to say.
uint32 Signature = PointerHash(Asset);
for (const FScheduledAction& Action : Asset->ScheduledActions)
{
Signature = HashCombine(Signature,
GetTypeHash(Action.StartTime.GetMinutesOfDay()),
GetTypeHash(static_cast<int32>(Action.Activity)),
GetTypeHash(Action.UsableObjectGuid));
}
return Signature;
}
void SNpcScheduleTimeline::Rebuild(uint32 InSignature, double InCurrentTime)
{
CachedSignature = InSignature;
LastBuildTime = InCurrentTime;
Slots.Reset();
LaneCount = 0;
const UNpcScheduleAsset* Asset = Schedule.Get();
if (Asset == nullptr || Asset->ScheduledActions.Num() == 0)
{
return;
}
// The day is read out of the starts and out of nothing else, because a start is all an action
// holds: sorted, so that the next entry is the next start in the day, and kept in the schedule's
// own order where two starts are equal, so that the lanes a tied pair lands on do not swap places
// between one rebuild and the next.
struct FStart
{
int32 Minutes = 0;
int32 ActionIndex = 0;
};
TArray<FStart> Starts;
Starts.Reserve(Asset->ScheduledActions.Num());
for (int32 ActionIndex = 0; ActionIndex < Asset->ScheduledActions.Num(); ++ActionIndex)
{
Starts.Add(FStart{ Asset->ScheduledActions[ActionIndex].StartTime.GetMinutesOfDay(), ActionIndex });
}
Starts.Sort([](const FStart& A, const FStart& B)
{
return A.Minutes != B.Minutes ? A.Minutes < B.Minutes : A.ActionIndex < B.ActionIndex;
});
// The anchors are named with one walk of the open level rather than one per action, and the walk is
// skipped entirely - which is most of the time - when no action in this schedule names one.
TMap<FGuid, FString> AnchorNames;
const bool bAnchorNamed = Asset->ScheduledActions.ContainsByPredicate([](const FScheduledAction& Action)
{
return Action.UsableObjectGuid.IsValid();
});
if (bAnchorNamed)
{
CollectAnchorNames(AnchorNames);
}
for (int32 SortedIndex = 0; SortedIndex < Starts.Num(); ++SortedIndex)
{
const FStart Start = Starts[SortedIndex];
const FScheduledAction& Action = Asset->ScheduledActions[Start.ActionIndex];
// The next start that is a different one, looked for rather than read off the next entry,
// because two actions authored at the same minute claim one window between them and are told
// apart by their lane instead of by a window of their own.
int32 NextMinutes = INDEX_NONE;
for (int32 LaterIndex = SortedIndex + 1; LaterIndex < Starts.Num(); ++LaterIndex)
{
if (Starts[LaterIndex].Minutes != Start.Minutes)
{
NextMinutes = Starts[LaterIndex].Minutes;
break;
}
}
// No later start at all means the day wraps round and the earliest start closes this window,
// which is the 06:00 that closes a 22:00-06:00 night. When that earliest start is this one's
// own minute then every action in the schedule was authored at one minute, and there is no
// window to be had between them: the whole day is theirs, which is how a schedule whose start
// and end are the same is read, see FScheduledAction.
bool bWholeDay = false;
if (NextMinutes == INDEX_NONE)
{
NextMinutes = Starts[0].Minutes;
bWholeDay = NextMinutes == Start.Minutes;
if (!bWholeDay)
{
NextMinutes += MinutesPerDay;
}
}
// The lane is however many actions before this one in the day claim the same window. Windows
// worked out this way never overlap unless they are the same window, so one lane per earlier
// claim is the whole of the separation there is to make.
int32 LaneIndex = 0;
for (int32 EarlierIndex = 0; EarlierIndex < SortedIndex; ++EarlierIndex)
{
if (Starts[EarlierIndex].Minutes == Start.Minutes)
{
++LaneIndex;
}
}
LaneCount = FMath::Max(LaneCount, LaneIndex + 1);
const FText ActivityText = GetActivityLabel(Action.Activity);
const FText WindowText = bWholeDay
? LOCTEXT("WholeDayWindow", "all day")
: MakeWindowText(Start.Minutes, NextMinutes);
const FText AnchorText = MakeAnchorText(Action.UsableObjectGuid, AnchorNames);
FSlotView& Slot = Slots.AddDefaulted_GetRef();
// A whole day is drawn from midnight rather than from the minute it was authored at: the strip
// is the day, and an action that covers it covers all of it.
Slot.StartMinutes = bWholeDay ? 0 : Start.Minutes;
Slot.EndMinutes = bWholeDay ? MinutesPerDay : NextMinutes;
Slot.LaneIndex = LaneIndex;
Slot.Color = GetActivityColor(Action.Activity);
Slot.BlockLabel = FText::Format(LOCTEXT("SlotLabel", "{0} {1}"), ActivityText, WindowText);
Slot.LegendLabel = MakeLegendLine(ActivityText, WindowText, AnchorText);
}
}
FVector2D SNpcScheduleTimeline::ComputeDesiredSize(float LayoutScaleMultiplier) const
{
const int32 Lanes = FMath::Max(1, LaneCount);
const float DayHeight = Lanes * LaneHeight + (Lanes - 1) * LaneGap;
const float LegendHeight = Slots.Num() > 0 ? LegendTopGap + Slots.Num() * LegendRowHeight : 0.0f;
return FVector2D(PreferredWidth, HourLabelHeight + DayHeight + LegendHeight + BottomPadding);
}
int32 SNpcScheduleTimeline::OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
// The width is read here and nowhere else. Everything below is placed against whatever the panel
// gave the row this frame, which is the whole of how the strip follows the panel being resized
// without anybody having to lay it out again.
const FVector2f LocalSize = AllottedGeometry.GetLocalSize();
const float StripLeft = StripLeftPadding;
const float StripWidth = FMath::Max(1.0f, LocalSize.X - StripLeftPadding - StripRightPadding);
const int32 Lanes = FMath::Max(1, LaneCount);
const float DayTop = HourLabelHeight;
const float DayBottom = DayTop + Lanes * LaneHeight + (Lanes - 1) * LaneGap;
const float LegendTop = DayBottom + LegendTopGap;
const FSlateBrush* const WhiteBrush = FCoreStyle::Get().GetBrush(TEXT("WhiteBrush"));
const ESlateDrawEffect DrawEffects = bParentEnabled
? ESlateDrawEffect::None
: ESlateDrawEffect::DisabledEffect;
/** A minute of the day as a place along the strip. */
const auto XOfMinute = [StripLeft, StripWidth](int32 MinutesOfDay)
{
return StripLeft + (StripWidth * static_cast<float>(MinutesOfDay)) / static_cast<float>(MinutesPerDay);
};
/** The top of a lane: the day's own rows, stacked under the ruler. */
const auto LaneTop = [DayTop](int32 LaneIndex)
{
return DayTop + LaneIndex * (LaneHeight + LaneGap);
};
const auto DrawRect = [&](float X, float Y, float RectWidth, float RectHeight, const FLinearColor& Color, int32 Layer)
{
if (RectWidth <= 0.0f || RectHeight <= 0.0f)
{
return;
}
FSlateDrawElement::MakeBox(OutDrawElements, Layer,
AllottedGeometry.ToPaintGeometry(FVector2f(RectWidth, RectHeight), FSlateLayoutTransform(FVector2f(X, Y))),
WhiteBrush, DrawEffects, Color);
};
const auto DrawText = [&](float X, float Y, float TextWidth, float TextHeight, const FText& Text, const FLinearColor& Color, int32 Layer)
{
if (TextWidth <= 0.0f)
{
return;
}
FSlateDrawElement::MakeText(OutDrawElements, Layer,
AllottedGeometry.ToPaintGeometry(FVector2f(TextWidth, TextHeight), FSlateLayoutTransform(FVector2f(X, Y))),
Text, Font, DrawEffects, Color);
};
/* The trough, the lanes in it, and the hour marks over both, so that an hour reads as a line
across a whole day rather than as a mark inside one lane. The marks are the ruler's and the slots
are drawn over them: an hour inside a slot is drawn dark on that slot's own colour, see below. */
DrawRect(StripLeft, DayTop, StripWidth, DayBottom - DayTop, TroughColor, LayerId);
for (int32 LaneIndex = 0; LaneIndex < Lanes; ++LaneIndex)
{
DrawRect(StripLeft, LaneTop(LaneIndex), StripWidth, LaneHeight, LaneColor, LayerId);
}
for (int32 Hour = 0; Hour <= 24; ++Hour)
{
const float HourX = XOfMinute(Hour * 60);
const bool bLabelled = Hour % LabelledHourInterval == 0;
DrawRect(HourX, DayTop, 1.0f, DayBottom - DayTop,
bLabelled ? LabelledHourMarkColor : HourMarkColor, LayerId + 1);
if (bLabelled && Hour < 24)
{
// Drawn to the right of its own mark rather than centred on it, so that none of the labels
// is ever cut in half by the edge of the strip.
DrawText(HourX + 3.0f, 0.0f, StripWidth - (HourX + 3.0f - StripLeft), HourLabelHeight,
HourLabels[Hour / LabelledHourInterval], HourLabelColor, LayerId + 1);
}
}
/* One half of a slot. The day ends at midnight, so a window that runs past it is drawn as two
halves with a marker on the edges where they would meet, and either half names the window it is
part of: the half with room to say when the whole of it is says it, and is the same answer at
both ends of the strip. */
const auto DrawSlotHalf = [&](float X, float RectWidth, float Y, const FSlotView& Slot, float MarkerX)
{
DrawRect(X, Y, RectWidth, LaneHeight, Slot.Color, LayerId + 2);
// A brighter edge along the top, so that two slots of the same colour meeting at an hour are
// still two slots.
FLinearColor TopColor = Slot.Color * 1.35f;
TopColor.A = 1.0f;
DrawRect(X, Y, RectWidth, 2.0f, TopColor, LayerId + 3);
if (MarkerX >= 0.0f)
{
DrawRect(MarkerX, Y, WrapMarkerWidth, LaneHeight, WrapMarkerColor, LayerId + 3);
}
if (RectWidth < MinimumLabelledSlotWidth)
{
// Nothing is legible in a slot this narrow, and a label spilling out of its own slot would
// read as belonging to the one beside it. The legend below names it in full instead.
return;
}
// Clipped to the slot it names, for the same reason: a slot that cannot hold its whole label
// shows as much of it as it can and not a letter of the next slot's.
const FGeometry HalfGeometry = AllottedGeometry.MakeChild(
FVector2f(RectWidth, LaneHeight), FSlateLayoutTransform(FVector2f(X, Y)));
const FGeometry LabelGeometry = HalfGeometry.MakeChild(
FVector2f(FMath::Max(0.0f, RectWidth - SlotLabelInset * 2.0f), SlotLabelHeight),
FSlateLayoutTransform(FVector2f(SlotLabelInset, (LaneHeight - SlotLabelHeight) * 0.5f)));
OutDrawElements.PushClip(FSlateClippingZone(HalfGeometry));
FSlateDrawElement::MakeText(OutDrawElements, LayerId + 4, LabelGeometry.ToPaintGeometry(),
Slot.BlockLabel, Font, DrawEffects, SlotLabelColor);
OutDrawElements.PopClip();
};
for (const FSlotView& Slot : Slots)
{
const float Y = LaneTop(Slot.LaneIndex);
// The part of the slot that fits inside the day, which is all of it unless the window wraps.
const int32 FirstEndMinutes = FMath::Min(Slot.EndMinutes, MinutesPerDay);
const float FirstX = XOfMinute(Slot.StartMinutes);
const float FirstWidth = XOfMinute(FirstEndMinutes) - FirstX;
const bool bWraps = Slot.EndMinutes > MinutesPerDay;
DrawSlotHalf(FirstX, FirstWidth, Y, Slot,
bWraps ? XOfMinute(MinutesPerDay) - WrapMarkerWidth : -1.0f);
if (bWraps)
{
// The rest of the window, at the start of the same day: a 22:00-06:00 night is a slot from
// 22:00 to midnight here, and from midnight to 06:00 at the left edge of the strip.
DrawSlotHalf(StripLeft, XOfMinute(Slot.EndMinutes - MinutesPerDay) - StripLeft, Y, Slot, StripLeft);
}
}
if (Slots.Num() == 0)
{
// Said in the trough rather than left blank, because a day with nothing in it is either a
// schedule nobody has written yet or an asset that is no longer there, and the two read the
// same from here without something to tell them apart.
DrawText(StripLeft + SlotLabelInset, DayTop + (LaneHeight - SlotLabelHeight) * 0.5f,
StripWidth - SlotLabelInset * 2.0f, SlotLabelHeight,
Schedule.IsValid()
? LOCTEXT("EmptySchedule", "No actions in this schedule, so the day is empty.")
: LOCTEXT("NoSchedule", "No schedule is being drawn."),
QuietTextColor, LayerId + 1);
}
else
{
// The legend, in the order the day reads rather than the order the array holds, so that
// walking it left to right walks the slots left to right as well.
int32 RowIndex = 0;
for (const FSlotView& Slot : Slots)
{
const float RowY = LegendTop + RowIndex * LegendRowHeight;
DrawRect(StripLeft, RowY + (LegendRowHeight - LegendSwatchWidth) * 0.5f,
LegendSwatchWidth, LegendSwatchWidth, Slot.Color, LayerId + 5);
DrawText(StripLeft + LegendSwatchWidth + LegendSwatchGap, RowY,
FMath::Max(0.0f, StripWidth - LegendSwatchWidth - LegendSwatchGap), SlotLabelHeight,
Slot.LegendLabel, LegendTextColor, LayerId + 5);
++RowIndex;
}
}
return LayerId + 6;
}
#undef LOCTEXT_NAMESPACE