]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Link to reagent ingredients on the same Guidebook page (#36700)
authorCiarán Walsh <github@ciaranwal.sh>
Fri, 9 May 2025 00:06:26 +0000 (01:06 +0100)
committerGitHub <noreply@github.com>
Fri, 9 May 2025 00:06:26 +0000 (10:06 +1000)
* Add in-page links for guidebook reagent recipes

* Add links to microwave recipes

* This function is too specific to be in Control extensions

* Better naming

* Wrap RichTextLabel instead of subclassing

* "Activate" is ambiguous

Content.Client/Guidebook/Controls/GuideMicrowaveEmbed.xaml.cs
Content.Client/Guidebook/Controls/GuideReagentEmbed.xaml.cs
Content.Client/Guidebook/Controls/GuideReagentReaction.xaml
Content.Client/Guidebook/Controls/GuideReagentReaction.xaml.cs
Content.Client/Guidebook/Controls/GuidebookRichPrototypeLink.cs [new file with mode: 0644]
Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs
Content.Client/Guidebook/Controls/IPrototypeLinkControl.cs [new file with mode: 0644]
Content.Client/Guidebook/Richtext/TextLinkTag.cs
Content.Client/UserInterface/ControlExtensions/ControlExtension.cs

index 1ae09fc8fe58344a842123f8bfcbf78580445b36..da93fb46fd1658e8db27c5714f759d1005505142 100644 (file)
@@ -19,13 +19,15 @@ namespace Content.Client.Guidebook.Controls;
 /// Control for embedding a microwave recipe into a guidebook.
 /// </summary>
 [UsedImplicitly, GenerateTypedNameReferences]
-public sealed partial class GuideMicrowaveEmbed : PanelContainer, IDocumentTag, ISearchableControl
+public sealed partial class GuideMicrowaveEmbed : PanelContainer, IDocumentTag, ISearchableControl, IPrototypeRepresentationControl
 {
     [Dependency] private readonly IPrototypeManager _prototype = default!;
     [Dependency] private readonly ILogManager _logManager = default!;
 
     private ISawmill _sawmill = default!;
 
+    public IPrototype? RepresentedPrototype { get; private set; }
+
     public GuideMicrowaveEmbed()
     {
         RobustXamlLoader.Load(this);
@@ -80,6 +82,8 @@ public sealed partial class GuideMicrowaveEmbed : PanelContainer, IDocumentTag,
     {
         var entity = _prototype.Index<EntityPrototype>(recipe.Result);
 
+        RepresentedPrototype = entity;
+
         IconContainer.AddChild(new GuideEntityEmbed(recipe.Result, false, false));
         ResultName.SetMarkup(entity.Name);
         ResultDescription.SetMarkup(entity.Description);
@@ -99,8 +103,9 @@ public sealed partial class GuideMicrowaveEmbed : PanelContainer, IDocumentTag,
             solidNameMsg.AddMarkupOrThrow(Loc.GetString("guidebook-microwave-solid-name-display", ("ingredient", ingredient.Name)));
             solidNameMsg.Pop();
 
-            var solidNameLabel = new RichTextLabel();
+            var solidNameLabel = new GuidebookRichPrototypeLink();
             solidNameLabel.SetMessage(solidNameMsg);
+            solidNameLabel.LinkedPrototype = ingredient;
 
             IngredientsGrid.AddChild(solidNameLabel);
 
@@ -129,9 +134,10 @@ public sealed partial class GuideMicrowaveEmbed : PanelContainer, IDocumentTag,
             liquidColorMsg.AddMarkupOrThrow(Loc.GetString("guidebook-microwave-reagent-color-display", ("color", reagent.SubstanceColor)));
             liquidColorMsg.Pop();
 
-            var liquidColorLabel = new RichTextLabel();
+            var liquidColorLabel = new GuidebookRichPrototypeLink();
             liquidColorLabel.SetMessage(liquidColorMsg);
             liquidColorLabel.HorizontalAlignment = Control.HAlignment.Center;
+            liquidColorLabel.LinkedPrototype = reagent;
 
             IngredientsGrid.AddChild(liquidColorLabel);
 
index f8d1c7e9720ef7d95e4821aae2b345b30dd7843c..78cd765bdb2d2af4a940bc78b2a9414742072745 100644 (file)
@@ -22,13 +22,15 @@ namespace Content.Client.Guidebook.Controls;
 ///     Control for embedding a reagent into a guidebook.
 /// </summary>
 [UsedImplicitly, GenerateTypedNameReferences]
-public sealed partial class GuideReagentEmbed : BoxContainer, IDocumentTag, ISearchableControl
+public sealed partial class GuideReagentEmbed : BoxContainer, IDocumentTag, ISearchableControl, IPrototypeRepresentationControl
 {
     [Dependency] private readonly IEntitySystemManager _systemManager = default!;
     [Dependency] private readonly IPrototypeManager _prototype = default!;
 
     private readonly ChemistryGuideDataSystem _chemistryGuideData;
 
+    public IPrototype? RepresentedPrototype { get; private set; }
+
     public GuideReagentEmbed()
     {
         RobustXamlLoader.Load(this);
@@ -80,6 +82,8 @@ public sealed partial class GuideReagentEmbed : BoxContainer, IDocumentTag, ISea
 
     private void GenerateControl(ReagentPrototype reagent)
     {
+        RepresentedPrototype = reagent;
+
         NameBackground.PanelOverride = new StyleBoxFlat
         {
             BackgroundColor = reagent.SubstanceColor
index 5b871644ea35ae7f6b241c2c956808de91337281..b84d833628cf613ce8aee9231519c27ab5c39edd 100644 (file)
@@ -4,13 +4,11 @@
               HorizontalExpand="True"
               Margin="0 0 0 5">
     <BoxContainer Orientation="Horizontal">
-        <BoxContainer Name="ReactantsContainer" Orientation="Vertical" HorizontalExpand="True"
+        <BoxContainer Orientation="Vertical" HorizontalExpand="True"
                       VerticalAlignment="Center">
-            <RichTextLabel Name="ReactantsLabel"
-                           HorizontalAlignment="Center"
-                           VerticalAlignment="Center"
-                           Access="Public"
-                           Visible="False" />
+            <BoxContainer Name="ReactantsContainer" HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical">
+                <!-- Reactants will be added as children here -->
+            </BoxContainer>
         </BoxContainer>
         <BoxContainer Orientation="Vertical" HorizontalAlignment="Center" VerticalAlignment="Center">
             <TextureRect TexturePath="/Textures/Interface/Misc/beakerlarge.png"
                            Margin="2 0 0 0" />
         </BoxContainer>
         <BoxContainer Orientation="Vertical" HorizontalExpand="True" VerticalAlignment="Center">
-            <RichTextLabel Name="ProductsLabel"
-                           HorizontalAlignment="Center"
-                           VerticalAlignment="Center"
-                           Access="Public"
-                           Visible="False" />
+            <BoxContainer Name="ProductsContainer" HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical">
+                <!-- Products will be added as children here -->
+            </BoxContainer>
         </BoxContainer>
     </BoxContainer>
     <PanelContainer StyleClasses="LowDivider" Margin="0 5 0 5" />
index 135dc5522acfd9a4c7d04b2d6900d90dae75539a..29ed1244226f62ad627b2d203ff47e80da70adf9 100644 (file)
@@ -34,16 +34,16 @@ public sealed partial class GuideReagentReaction : BoxContainer, ISearchableCont
 
     public GuideReagentReaction(ReactionPrototype prototype, IPrototypeManager protoMan, IEntitySystemManager sysMan) : this(protoMan)
     {
-        var reactantsLabel = ReactantsLabel;
-        SetReagents(prototype.Reactants, ref reactantsLabel, protoMan);
-        var productLabel = ProductsLabel;
+        Container container = ReactantsContainer;
+        SetReagents(prototype.Reactants, ref container, protoMan);
+        Container productContainer = ProductsContainer;
         var products = new Dictionary<string, FixedPoint2>(prototype.Products);
         foreach (var (reagent, reactantProto) in prototype.Reactants)
         {
             if (reactantProto.Catalyst)
                 products.Add(reagent, reactantProto.Amount);
         }
-        SetReagents(products, ref productLabel, protoMan);
+        SetReagents(products, ref productContainer, protoMan, false);
 
         var mixingCategories = new List<MixingCategoryPrototype>();
         if (prototype.MixingCategories != null)
@@ -85,8 +85,8 @@ public sealed partial class GuideReagentReaction : BoxContainer, ISearchableCont
         entContainer.AddChild(nameLabel);
         ReactantsContainer.AddChild(entContainer);
 
-        var productLabel = ProductsLabel;
-        SetReagents(solution.Contents, ref productLabel, protoMan);
+        Container productContainer = ProductsContainer;
+        SetReagents(solution.Contents, ref productContainer, protoMan, false);
         SetMixingCategory(categories, null, sysMan);
     }
 
@@ -95,75 +95,80 @@ public sealed partial class GuideReagentReaction : BoxContainer, ISearchableCont
         IPrototypeManager protoMan,
         IEntitySystemManager sysMan) : this(protoMan)
     {
-        ReactantsLabel.Visible = true;
-        ReactantsLabel.SetMarkup(Loc.GetString("guidebook-reagent-sources-gas-wrapper",
+        var label = new RichTextLabel();
+        label.SetMarkup(Loc.GetString("guidebook-reagent-sources-gas-wrapper",
             ("name", Loc.GetString(prototype.Name).ToLower())));
 
+        ReactantsContainer.Visible = true;
+        ReactantsContainer.AddChild(label);
+
         if (prototype.Reagent != null)
         {
             var quantity = new Dictionary<string, FixedPoint2>
             {
                 { prototype.Reagent, FixedPoint2.New(0.21f) }
             };
-            var productLabel = ProductsLabel;
-            SetReagents(quantity, ref productLabel, protoMan);
+            Container productContainer = ProductsContainer;
+            SetReagents(quantity, ref productContainer, protoMan, false);
         }
         SetMixingCategory(categories, null, sysMan);
     }
 
-    private void SetReagents(List<ReagentQuantity> reagents, ref RichTextLabel label, IPrototypeManager protoMan)
+    private void SetReagents(List<ReagentQuantity> reagents, ref Container container, IPrototypeManager protoMan, bool addLinks = true)
     {
         var amounts = new Dictionary<string, FixedPoint2>();
         foreach (var (reagent, quantity) in reagents)
         {
             amounts.Add(reagent.Prototype, quantity);
         }
-        SetReagents(amounts, ref label, protoMan);
+        SetReagents(amounts, ref container, protoMan, addLinks);
     }
 
     private void SetReagents(
         Dictionary<string, ReactantPrototype> reactants,
-        ref RichTextLabel label,
-        IPrototypeManager protoMan)
+        ref Container container,
+        IPrototypeManager protoMan,
+        bool addLinks = true)
     {
         var amounts = new Dictionary<string, FixedPoint2>();
         foreach (var (reagent, reactantPrototype) in reactants)
         {
             amounts.Add(reagent, reactantPrototype.Amount);
         }
-        SetReagents(amounts, ref label, protoMan);
+        SetReagents(amounts, ref container, protoMan, addLinks);
     }
 
     [PublicAPI]
     private void SetReagents(
         Dictionary<ProtoId<MixingCategoryPrototype>, ReactantPrototype> reactants,
-        ref RichTextLabel label,
-        IPrototypeManager protoMan)
+        ref Container container,
+        IPrototypeManager protoMan,
+        bool addLinks = true)
     {
         var amounts = new Dictionary<string, FixedPoint2>();
         foreach (var (reagent, reactantPrototype) in reactants)
         {
             amounts.Add(reagent, reactantPrototype.Amount);
         }
-        SetReagents(amounts, ref label, protoMan);
+        SetReagents(amounts, ref container, protoMan, addLinks);
     }
 
-    private void SetReagents(Dictionary<string, FixedPoint2> reagents, ref RichTextLabel label, IPrototypeManager protoMan)
+    private void SetReagents(Dictionary<string, FixedPoint2> reagents, ref Container container, IPrototypeManager protoMan, bool addLinks = true)
     {
-        var msg = new FormattedMessage();
-        var reagentCount = reagents.Count;
-        var i = 0;
         foreach (var (product, amount) in reagents.OrderByDescending(p => p.Value))
         {
+            var productProto = protoMan.Index<ReagentPrototype>(product);
+            var msg = new FormattedMessage();
             msg.AddMarkupOrThrow(Loc.GetString("guidebook-reagent-recipes-reagent-display",
-                ("reagent", protoMan.Index<ReagentPrototype>(product).LocalizedName), ("ratio", amount)));
-            i++;
-            if (i < reagentCount)
-                msg.PushNewline();
+                ("reagent", productProto.LocalizedName), ("ratio", amount)));
+
+            var label = new GuidebookRichPrototypeLink();
+            if (addLinks)
+                label.LinkedPrototype = productProto;
+            label.SetMessage(msg);
+            container.AddChild(label);
         }
-        msg.Pop();
-        label.SetMessage(msg);
-        label.Visible = true;
+        container.Visible = true;
     }
 
     private void SetMixingCategory(IReadOnlyList<ProtoId<MixingCategoryPrototype>> mixingCategories, ReactionPrototype? prototype, IEntitySystemManager sysMan)
diff --git a/Content.Client/Guidebook/Controls/GuidebookRichPrototypeLink.cs b/Content.Client/Guidebook/Controls/GuidebookRichPrototypeLink.cs
new file mode 100644 (file)
index 0000000..b54dd8b
--- /dev/null
@@ -0,0 +1,71 @@
+using Content.Client.Guidebook.RichText;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Input;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+using Content.Client.UserInterface.ControlExtensions;
+
+namespace Content.Client.Guidebook.Controls;
+
+/// <summary>
+/// A RichTextLabel which is a link to a specified IPrototype.
+/// The link is activated by the owner if the prototype is represented
+/// somewhere in the same document.
+/// </summary>
+public sealed class GuidebookRichPrototypeLink : Control, IPrototypeLinkControl
+{
+    private bool _linkActive = false;
+    private FormattedMessage? _message;
+    private readonly RichTextLabel _richTextLabel;
+
+    public void EnablePrototypeLink()
+    {
+        if (_message == null)
+            return;
+
+        _linkActive = true;
+
+        DefaultCursorShape = CursorShape.Hand;
+
+        _richTextLabel.SetMessage(_message, null, TextLinkTag.LinkColor);
+    }
+
+    public GuidebookRichPrototypeLink() : base()
+    {
+        MouseFilter = MouseFilterMode.Pass;
+        OnKeyBindDown += HandleClick;
+        _richTextLabel = new RichTextLabel();
+        AddChild(_richTextLabel);
+    }
+
+    public void SetMessage(FormattedMessage message)
+    {
+        _message = message;
+        _richTextLabel.SetMessage(_message);
+    }
+
+    public IPrototype? LinkedPrototype { get; set; }
+
+    private void HandleClick(GUIBoundKeyEventArgs args)
+    {
+        if (!_linkActive)
+            return;
+
+        if (args.Function != EngineKeyFunctions.UIClick)
+            return;
+
+        if (this.TryGetParentHandler<IAnchorClickHandler>(out var handler))
+        {
+            handler.HandleAnchor(this);
+            args.Handle();
+        }
+        else
+            Logger.Warning("Warning! No valid IAnchorClickHandler found.");
+    }
+}
+
+public interface IAnchorClickHandler
+{
+    public void HandleAnchor(IPrototypeLinkControl prototypeLinkControl);
+}
index 5d2d227b3d022697920fee3c770195ff4b26056e..13ee0c87e7df9ebbbe80b286750d6ec3b96ec244 100644 (file)
@@ -1,3 +1,4 @@
+using System.Diagnostics;
 using System.Linq;
 using Content.Client.Guidebook.RichText;
 using Content.Client.UserInterface.ControlExtensions;
@@ -6,6 +7,7 @@ using Content.Client.UserInterface.Controls.FancyTree;
 using Content.Client.UserInterface.Systems.Info;
 using Content.Shared.Guidebook;
 using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
 using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.XAML;
 using Robust.Shared.ContentPack;
@@ -14,7 +16,7 @@ using Robust.Shared.Prototypes;
 namespace Content.Client.Guidebook.Controls;
 
 [GenerateTypedNameReferences]
-public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler
+public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IAnchorClickHandler
 {
     [Dependency] private readonly DocumentParsingManager _parsingMan = default!;
     [Dependency] private readonly IResourceManager _resourceManager = default!;
@@ -53,6 +55,38 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler
             ShowGuide(entry);
     }
 
+    public void HandleAnchor(IPrototypeLinkControl prototypeLinkControl)
+    {
+        var prototype = prototypeLinkControl.LinkedPrototype;
+        if (prototype == null)
+            return;
+
+        var (linkableControls, _) = GetLinkableControlsAndLinks(EntryContainer);
+        foreach (var linkableControl in linkableControls)
+        {
+            if (linkableControl.RepresentedPrototype != prototype)
+                continue;
+
+            if (linkableControl is not Control control)
+                return;
+
+            // Check if the target item is currently filtered out
+            if (!control.Visible)
+                control.Visible = true;
+
+            UserInterfaceManager.DeferAction(() =>
+            {
+                if (control.GetControlScrollPosition() is not {} position)
+                    return;
+
+                Scroll.HScrollTarget = position.X;
+                Scroll.VScrollTarget = position.Y;
+            });
+
+            break;
+        }
+    }
+
     private void OnSelectionChanged(TreeItem? item)
     {
         if (item != null && item.Metadata is GuideEntry entry)
@@ -94,6 +128,23 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler
         }
 
         LastEntry = entry.Id;
+
+        var (linkableControls, linkControls) = GetLinkableControlsAndLinks(EntryContainer);
+
+        HashSet<IPrototype> availablePrototypeLinks = new();
+        foreach (var linkableControl in linkableControls)
+        {
+            var prototype = linkableControl.RepresentedPrototype;
+            if (prototype != null)
+                availablePrototypeLinks.Add(prototype);
+        }
+
+        foreach (var linkControl in linkControls)
+        {
+            var prototype = linkControl.LinkedPrototype;
+            if (prototype != null && availablePrototypeLinks.Contains(prototype))
+                linkControl.EnablePrototypeLink();
+        }
     }
 
     public void UpdateGuides(
@@ -209,4 +260,30 @@ public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler
             }
         }
     }
+
+    private static (List<IPrototypeRepresentationControl>, List<IPrototypeLinkControl>) GetLinkableControlsAndLinks(Control parent)
+    {
+        List<IPrototypeRepresentationControl> linkableList = new();
+        List<IPrototypeLinkControl> linkList = new();
+
+        foreach (var child in parent.Children)
+        {
+            var hasChildren = child.ChildCount > 0;
+
+            if (child is IPrototypeLinkControl linkChild)
+                linkList.Add(linkChild);
+            else if (child is IPrototypeRepresentationControl linkableChild)
+                linkableList.Add(linkableChild);
+
+            if (!hasChildren)
+                continue;
+
+            var (childLinkableList, childLinkList) = GetLinkableControlsAndLinks(child);
+
+            linkableList.AddRange(childLinkableList);
+            linkList.AddRange(childLinkList);
+        }
+
+        return (linkableList, linkList);
+    }
 }
diff --git a/Content.Client/Guidebook/Controls/IPrototypeLinkControl.cs b/Content.Client/Guidebook/Controls/IPrototypeLinkControl.cs
new file mode 100644 (file)
index 0000000..51406e3
--- /dev/null
@@ -0,0 +1,28 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Guidebook.Controls;
+
+/// <summary>
+///    Interface for controls which represent a Prototype
+///    These can be linked to from a IPrototypeLinkControl
+/// </summary>
+public interface IPrototypeRepresentationControl
+{
+    // The prototype that this control represents
+    public IPrototype? RepresentedPrototype { get; }
+}
+
+/// <summary>
+///    Interface for controls which can be clicked to navigate
+///    to a specified prototype representation on the same page.
+/// </summary>
+public interface IPrototypeLinkControl
+{
+    // This control is a link to the specified prototype
+    public IPrototype? LinkedPrototype { get; }
+
+    // Initially the link will not be enabled,
+    // the owner can enable the link once there is a valid target
+    // for the Prototype link.
+    public void EnablePrototypeLink();
+}
index b1e8460bb89a89f1da69c7769a01ce9c965c8f2b..27aaa71939a91acc3067c93bc45ff672fa43825d 100644 (file)
@@ -5,12 +5,15 @@ using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.RichText;
 using Robust.Shared.Input;
 using Robust.Shared.Utility;
+using Content.Client.UserInterface.ControlExtensions;
 
 namespace Content.Client.Guidebook.RichText;
 
 [UsedImplicitly]
 public sealed class TextLinkTag : IMarkupTag
 {
+    public static Color LinkColor => Color.CornflowerBlue;
+
     public string Name => "textlink";
 
     public Control? Control;
@@ -30,7 +33,7 @@ public sealed class TextLinkTag : IMarkupTag
         label.Text = text;
 
         label.MouseFilter = Control.MouseFilterMode.Stop;
-        label.FontColorOverride = Color.CornflowerBlue;
+        label.FontColorOverride = LinkColor;
         label.DefaultCursorShape = Control.CursorShape.Hand;
 
         label.OnMouseEntered += _ => label.FontColorOverride = Color.LightSkyBlue;
@@ -50,17 +53,10 @@ public sealed class TextLinkTag : IMarkupTag
         if (Control == null)
             return;
 
-        var current = Control;
-        while (current != null)
-        {
-            current = current.Parent;
-
-            if (current is not ILinkClickHandler handler)
-                continue;
+        if (Control.TryGetParentHandler<ILinkClickHandler>(out var handler))
             handler.HandleClick(link);
-            return;
-        }
-        Logger.Warning($"Warning! No valid ILinkClickHandler found.");
+        else
+            Logger.Warning("Warning! No valid ILinkClickHandler found.");
     }
 }
 
index c0e4a038a1a1e0687bb0cd66490280877150ec31..a0e5a1063c74352cca1c88d0230ee6731a7b4530 100644 (file)
@@ -1,3 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Numerics;
 using Content.Client.Guidebook.Controls;
 using Robust.Client.UserInterface;
 using Robust.Client.UserInterface.Controls;
@@ -68,6 +70,52 @@ public static class ControlExtension
         return controlList;
     }
 
+    /// <summary>
+    /// Search the control’s tree for a parent node of type T
+    /// E.g. to find the control implementing some event handling interface.
+    /// </summary>
+    public static bool TryGetParentHandler<T>(this Control child, [NotNullWhen(true)] out T? result)
+    {
+        for (var control = child; control is not null; control = control.Parent)
+        {
+            if (control is not T handler)
+                continue;
+
+            result = handler;
+            return true;
+        }
+
+        result = default;
+        return false;
+    }
+
+    /// <summary>
+    /// Find the control’s offset relative to its closest ScrollContainer
+    /// Returns null if the control is not in the tree or not visible.
+    /// </summary>
+    public static Vector2? GetControlScrollPosition(this Control child)
+    {
+        if (!child.VisibleInTree)
+            return null;
+
+        var position = new Vector2();
+        var control = child;
+
+        while (control is not null)
+        {
+            // The scroll container's direct child is re-positioned while scrolling,
+            // so we need to ignore its position.
+            if (control.Parent is ScrollContainer)
+                break;
+
+            position += control.Position;
+
+            control = control.Parent;
+        }
+
+        return position;
+    }
+
     public static bool ChildrenContainText(this Control parent, string search)
     {
         var labels = parent.GetControlOfType<Label>();