CatStation/Content.Client/Guidebook/Controls/GuidebookWindow.xaml.cs
pathetic meowmeow dc10275428
Add table of contents to the guidebook (#35316)
* Add table of contents to the guidebook

* It takes way too much reading space adding another panel, so lets do
this instead

* unused

---------

Co-authored-by: AJCM-git <ajcm_mail@disroot.org>
2026-07-07 02:52:58 +00:00

369 lines
12 KiB
C#

using System.Linq;
using Content.Client.Guidebook.RichText;
using Content.Client.UserInterface.ControlExtensions;
using Content.Client.UserInterface.Controls;
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;
using Robust.Shared.Prototypes;
namespace Content.Client.Guidebook.Controls;
[GenerateTypedNameReferences]
public sealed partial class GuidebookWindow : FancyWindow, ILinkClickHandler, IAnchorClickHandler
{
[Dependency] private DocumentParsingManager _parsingMan = default!;
[Dependency] private IResourceManager _resourceManager = default!;
private Dictionary<ProtoId<GuideEntryPrototype>, GuideEntry> _entries = [];
private readonly ISawmill _sawmill;
public ProtoId<GuideEntryPrototype>? Selected { get; private set; }
public GuidebookWindow()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_sawmill = Logger.GetSawmill("guidebook");
Tree.OnSelectedItemChanged += OnSelectionChanged;
TableOfContents.OnSelectedItemChanged += OnTableOfContentsSelectionChanged;
SearchBar.OnTextChanged += _ =>
{
HandleFilter();
};
}
public void HandleClick(string link)
{
if (!_entries.TryGetValue(link, out var entry))
return;
if (Tree.TryGetIndexFromMetadata(entry, out var index))
{
Tree.ExpandParentEntries(index.Value);
Tree.SetSelectedIndex(index);
}
else
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)
{
// do nothing if the guide is the same as the currently selected one
if (entry.Id == Selected)
return;
ShowGuide(entry);
var isRulesEntry = entry.RuleEntry;
ReturnContainer.Visible = isRulesEntry;
HomeButton.OnPressed += _ => ShowGuide(entry);
}
else
ClearSelectedGuide();
}
private void OnTableOfContentsSelectionChanged(TreeItem? item)
{
if (item is null || item.Metadata is not Label entry)
return;
UserInterfaceManager.DeferAction(() =>
{
if (entry.GetControlScrollPosition() is not { } position)
return;
Scroll.HScrollTarget = position.X;
Scroll.VScrollTarget = position.Y;
});
}
public void ClearSelectedGuide()
{
Placeholder.Visible = true;
EntryContainer.Visible = false;
SearchContainer.Visible = false;
EntryContainer.RemoveAllChildren();
Selected = null;
}
private void ShowGuide(GuideEntry entry)
{
Scroll.SetScrollValue(default);
Placeholder.Visible = false;
EntryContainer.Visible = true;
SearchBar.Text = "";
EntryContainer.RemoveAllChildren();
using var file = _resourceManager.ContentFileReadText(entry.Text);
SearchContainer.Visible = entry.FilterEnabled;
if (!_parsingMan.TryAddMarkup(EntryContainer, file.ReadToEnd()))
{
// The guidebook will automatically display the in-guidebook error if it fails
_sawmill.Error($"Failed to parse contents of guide document {entry.Id}.");
}
Selected = entry.Id;
var (linkableControls, linkControls) = GetLinkableControlsAndLinks(EntryContainer);
HashSet<IPrototype> availablePrototypeLinks = [];
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();
}
RepopulateTableOfContents();
}
private int? HeadingDepth(Label control)
{
if (control.StyleClasses.Contains("LabelHeadingBigger"))
return 1;
else if (control.StyleClasses.Contains("LabelHeading"))
return 2;
else if (control.StyleClasses.Contains("LabelKeyText"))
return 3;
return null;
}
private void RepopulateTableOfContents()
{
TableOfContents.Clear();
var firstEntry = TableOfContents.AddItem(null);
firstEntry.Label.Text = Loc.GetString("guidebook-toc-header");
var labels = EntryContainer.GetControlOfType<Label>(true);
var stack = new Stack<(TreeItem Item, int Depth)>();
foreach (var label in labels)
{
if (HeadingDepth(label) is not { } depth)
continue;
while (stack.TryPeek(out var previous) && previous.Depth >= depth)
{
stack.Pop();
}
var item = stack.TryPeek(out var parent)
? TableOfContents.AddItem(parent.Item)
: TableOfContents.AddItem(firstEntry);
item.Label.Text = label.Text;
item.Metadata = label;
stack.Push((item, depth));
}
// Expand all entries, but collapse the first one
TableOfContents.SetAllExpanded(true);
firstEntry.SetExpanded(false);
}
/// <summary>
/// Updates the guides used in the window.
/// Returns whether the guides changed.
/// </summary>
public bool UpdateGuides(
Dictionary<ProtoId<GuideEntryPrototype>, GuideEntry> entries,
List<ProtoId<GuideEntryPrototype>>? rootEntries = null,
ProtoId<GuideEntryPrototype>? forceRoot = null,
ProtoId<GuideEntryPrototype>? selected = null)
{
// check if old and new entries are equal
var sameAsLastUpdate = entries.Count != _entries.Count || !entries.All(_entries.Contains);
if (sameAsLastUpdate)
{
_entries = entries;
RepopulateTree(rootEntries, forceRoot);
Split.State = SplitContainer.SplitState.Auto;
if (entries.Count == 1)
{
TreeBox.Visible = false;
Split.ResizeMode = SplitContainer.SplitResizeMode.NotResizable;
selected = entries.Keys.First();
}
else
{
TreeBox.Visible = true;
Split.ResizeMode = SplitContainer.SplitResizeMode.RespectChildrenMinSize;
}
}
if (selected == null)
ClearSelectedGuide();
else
{
var item = Tree.Items.FirstOrDefault(x => x.Metadata is GuideEntry entry && entry.Id == selected);
Tree.SetSelectedIndex(item?.Index);
}
return sameAsLastUpdate;
}
private IEnumerable<GuideEntry> GetSortedEntries(List<ProtoId<GuideEntryPrototype>>? rootEntries)
{
if (rootEntries == null)
{
HashSet<ProtoId<GuideEntryPrototype>> entries = [.. _entries.Keys];
foreach (var entry in _entries.Values)
{
entries.ExceptWith(entry.Children);
}
rootEntries = [.. entries];
}
// Only roots need to be sorted.
// As defined in the SS14 Dev Wiki, children are already sorted based on their child field order within their parent's prototype definition.
// Roots are sorted by priority. If there is no defined priority for a root then it is by definition sorted undefined.
return rootEntries
.Select(rootEntryId => _entries[rootEntryId])
.OrderBy(rootEntry => rootEntry.Priority)
.ThenBy(rootEntry => Loc.GetString(rootEntry.Name));
}
private void RepopulateTree(List<ProtoId<GuideEntryPrototype>>? roots = null,
ProtoId<GuideEntryPrototype>? forcedRoot = null)
{
Tree.Clear();
HashSet<ProtoId<GuideEntryPrototype>> addedEntries = [];
var parent = forcedRoot == null ? null : AddEntry(forcedRoot.Value, null, addedEntries);
foreach (var entry in GetSortedEntries(roots))
{
AddEntry(entry.Id, parent, addedEntries);
}
Tree.SetAllExpanded(true);
}
private TreeItem? AddEntry(ProtoId<GuideEntryPrototype> id,
TreeItem? parent,
HashSet<ProtoId<GuideEntryPrototype>> addedEntries)
{
if (!_entries.TryGetValue(id, out var entry))
return null;
if (!addedEntries.Add(id))
{
// TODO GUIDEBOOK Maybe allow duplicate entries?
// E.g., for adding medicine under both chemicals & the chemist job
_sawmill.Error($"Adding duplicate guide entry: {id}");
return null;
}
var rulesProto = UserInterfaceManager.GetUIController<InfoUIController>().GetCoreRuleEntry();
if (entry.RuleEntry && entry.Id != rulesProto.Id)
return null;
var item = Tree.AddItem(parent);
item.Metadata = entry;
var name = Loc.GetString(entry.Name);
item.Label.Text = name;
foreach (var child in entry.Children)
{
AddEntry(child, item, addedEntries);
}
return item;
}
private void HandleFilter()
{
if (Tree.SelectedItem != null && Tree.SelectedItem.Metadata is GuideEntry entry && entry.FilterEnabled)
{
var foundElements = EntryContainer.GetSearchableControls();
foreach (var element in foundElements)
{
element.SetHiddenState(true, SearchBar.Text.Trim());
}
}
}
private static (List<IPrototypeRepresentationControl>, List<IPrototypeLinkControl>) GetLinkableControlsAndLinks(Control parent)
{
List<IPrototypeRepresentationControl> linkableList = [];
List<IPrototypeLinkControl> linkList = [];
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);
}
}