From 4102c9cf7cc2d6eb1882014403317179e549d76f Mon Sep 17 00:00:00 2001 From: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> Date: Mon, 16 Jan 2023 19:45:46 +1300 Subject: [PATCH] Add fancy tree control (#13426) * Add fancy tree control * inject dependencies --- Content.Client/Stylesheets/StyleNano.cs | 34 +++ .../Controls/FancyTree/FancyTree.xaml | 6 + .../Controls/FancyTree/FancyTree.xaml.cs | 282 ++++++++++++++++++ .../Controls/FancyTree/TreeItem.xaml | 14 + .../Controls/FancyTree/TreeItem.xaml.cs | 92 ++++++ .../Controls/FancyTree/TreeLine.cs | 61 ++++ .../Interface/Nano/triangle_right.png | Bin 0 -> 252 bytes .../Interface/Nano/triangle_right.png.yml | 3 + .../Interface/Nano/triangle_right_hollow.svg | 132 ++++++++ .../Nano/triangle_right_hollow.svg.png | Bin 0 -> 209 bytes .../Nano/triangle_right_hollow.svg.png.yml | 2 + 11 files changed, 626 insertions(+) create mode 100644 Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml create mode 100644 Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml.cs create mode 100644 Content.Client/UserInterface/Controls/FancyTree/TreeItem.xaml create mode 100644 Content.Client/UserInterface/Controls/FancyTree/TreeItem.xaml.cs create mode 100644 Content.Client/UserInterface/Controls/FancyTree/TreeLine.cs create mode 100644 Resources/Textures/Interface/Nano/triangle_right.png create mode 100644 Resources/Textures/Interface/Nano/triangle_right.png.yml create mode 100644 Resources/Textures/Interface/Nano/triangle_right_hollow.svg create mode 100644 Resources/Textures/Interface/Nano/triangle_right_hollow.svg.png create mode 100644 Resources/Textures/Interface/Nano/triangle_right_hollow.svg.png.yml diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs index b22f0453d0..62c6bd3e12 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -113,6 +113,11 @@ namespace Content.Client.Stylesheets public static readonly Color ExamineButtonColorContextPressed = Color.LightSlateGray; public static readonly Color ExamineButtonColorContextDisabled = Color.FromHex("#5A5A5A"); + // Fancy Tree elements + public static readonly Color FancyTreeEvenRowColor = Color.FromHex("#25252A"); + public static readonly Color FancyTreeOddRowColor = FancyTreeEvenRowColor * new Color(0.8f, 0.8f, 0.8f); + public static readonly Color FancyTreeSelectedRowColor = new Color(55, 55, 68); + //Used by the APC and SMES menus public const string StyleClassPowerStateNone = "PowerStateNone"; public const string StyleClassPowerStateLow = "PowerStateLow"; @@ -1408,6 +1413,35 @@ namespace Content.Client.Stylesheets .Prop(Label.StylePropertyFont, notoSans10) .Prop(Label.StylePropertyFontColor, Color.FromHex("#333d3b")), + // Fancy Tree + Element().Identifier(TreeItem.StyleIdentifierTreeButton) + .Class(TreeItem.StyleClassEvenRow) + .Prop(ContainerButton.StylePropertyStyleBox, new StyleBoxFlat + { + BackgroundColor = FancyTreeEvenRowColor, + }), + + Element().Identifier(TreeItem.StyleIdentifierTreeButton) + .Class(TreeItem.StyleClassOddRow) + .Prop(ContainerButton.StylePropertyStyleBox, new StyleBoxFlat + { + BackgroundColor = FancyTreeOddRowColor, + }), + + Element().Identifier(TreeItem.StyleIdentifierTreeButton) + .Class(TreeItem.StyleClassSelected) + .Prop(ContainerButton.StylePropertyStyleBox, new StyleBoxFlat + { + BackgroundColor = FancyTreeSelectedRowColor, + }), + + Element().Identifier(TreeItem.StyleIdentifierTreeButton) + .Pseudo(ContainerButton.StylePseudoClassHover) + .Prop(ContainerButton.StylePropertyStyleBox, new StyleBoxFlat + { + BackgroundColor = FancyTreeSelectedRowColor, + }), + }).ToList()); } } diff --git a/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml b/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml new file mode 100644 index 0000000000..7a04771ed9 --- /dev/null +++ b/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml @@ -0,0 +1,6 @@ + + + + + diff --git a/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml.cs b/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml.cs new file mode 100644 index 0000000000..f390dea192 --- /dev/null +++ b/Content.Client/UserInterface/Controls/FancyTree/FancyTree.xaml.cs @@ -0,0 +1,282 @@ +using Content.Client.Resources; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface.Controls; + +/// +/// Functionally similar to , but with collapsible sections, +/// +[GenerateTypedNameReferences] +public sealed partial class FancyTree : Control +{ + [Dependency] private readonly IResourceCache _resCache = default!; + + public const string StylePropertyLineWidth = "LineWidth"; + public const string StylePropertyLineColor = "LineColor"; + public const string StylePropertyIconColor = "IconColor"; + public const string StylePropertyIconExpanded = "IconExpanded"; + public const string StylePropertyIconCollapsed = "IconCollapsed"; + public const string StylePropertyIconNoChildren = "IconNoChildren"; + + public readonly List Items = new(); + + public event Action? OnSelectedItemChanged; + + public int? SelectedIndex { get; private set; } + + private bool _rowStyleUpdateQueued = true; + + /// + /// Whether or not to draw the lines connecting parents & children. + /// + public bool DrawLines = true; + + /// + /// Colour of the lines connecting parents & their child entries. + /// + public Color LineColor = Color.White; + + /// + /// Color used to modulate the icon textures. + /// + public Color IconColor = Color.White; + + /// + /// Width of the lines connecting parents & their child entries. + /// + public int LineWidth = 2; + + // If people ever want to customize this, this should be a style parameter/ + public const int Indentation = 16; + + public const string DefaultIconExpanded = "/Textures/Interface/Nano/inverted_triangle.svg.png"; + public const string DefaultIconCollapsed = "/Textures/Interface/Nano/triangle_right.png"; + public const string DefaultIconNoChildren = "/Textures/Interface/Nano/triangle_right_hollow.svg.png"; + + public Texture? IconExpanded; + public Texture? IconCollapsed; + public Texture? IconNoChildren; + + /// + /// If true, tree entries will hide their icon if the texture is set to null. If the icon is hidden then the + /// text of that entry will no longer be aligned with sibling entries that do have an icon. + /// + public bool HideEmptyIcon + { + get => _hideEmptyIcon; + set => SetHideEmptyIcon(value); + } + private bool _hideEmptyIcon; + + public TreeItem? SelectedItem => SelectedIndex == null ? null : Items[SelectedIndex.Value]; + + /// + /// If true, a collapsed item will automatically expand when first selected. If false, it has to be manually expanded by + /// clicking on it a second time. + /// + public bool AutoExpand = true; + + public FancyTree() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + LoadIcons(); + } + + private void LoadIcons() + { + IconColor = TryGetStyleProperty(StylePropertyIconColor, out Color color) ? color : Color.White; + string? path; + + if (!TryGetStyleProperty(StylePropertyIconExpanded, out IconExpanded)) + IconExpanded = _resCache.GetTexture(DefaultIconExpanded); + + if (!TryGetStyleProperty(StylePropertyIconCollapsed, out IconCollapsed)) + IconCollapsed = _resCache.GetTexture(DefaultIconCollapsed); + + if (!TryGetStyleProperty(StylePropertyIconNoChildren, out IconNoChildren)) + IconNoChildren = _resCache.GetTexture(DefaultIconNoChildren); + + foreach (var item in Body.Children) + { + RecursiveUpdateIcon((TreeItem) item); + } + } + + public TreeItem AddItem(TreeItem? parent = null) + { + if (parent != null) + { + if (parent.Tree != this) + throw new ArgumentException("Parent must be owned by this tree.", nameof(parent)); + + DebugTools.Assert(Items[parent.Index] == parent); + } + + var item = new TreeItem() + { + Tree = this, + Index = Items.Count, + }; + + Items.Add(item); + item.Icon.SetSize = (Indentation, Indentation); + item.Button.OnPressed += (_) => OnPressed(item); + + if (parent == null) + Body.AddChild(item); + else + { + item.Padding.MinWidth = parent.Padding.MinWidth + Indentation; + parent.Body.AddChild(item); + } + + item.UpdateIcon(); + QueueRowStyleUpdate(); + return item; + } + + private void OnPressed(TreeItem item) + { + if (SelectedIndex == item.Index) + { + item.SetExpanded(!item.Expanded); + return; + } + + SetSelectedIndex(item.Index); + } + + public void SetSelectedIndex(int? value) + { + if (value == null || value < 0 || value >= Items.Count) + value = null; + + if (SelectedIndex == value) + return; + + SelectedItem?.SetSelected(false); + SelectedIndex = value; + + var newSelection = SelectedItem; + if (newSelection != null) + { + newSelection.SetSelected(true); + if (AutoExpand && !newSelection.Expanded) + newSelection.SetExpanded(true); + } + + OnSelectedItemChanged?.Invoke(newSelection); + } + + public void SetAllExpanded(bool value) + { + foreach (var item in Body.Children) + { + RecursiveSetExpanded((TreeItem) item, value); + } + } + + public void RecursiveSetExpanded(TreeItem item, bool value) + { + item.SetExpanded(value); + foreach (var child in item.Body.Children) + { + RecursiveSetExpanded((TreeItem) child, value); + } + } + + public void Clear() + { + foreach (var item in Items) + { + item.Dispose(); + } + + Items.Clear(); + Body.Children.Clear(); + SelectedIndex = null; + } + + public void QueueRowStyleUpdate() + { + _rowStyleUpdateQueued = true; + } + + protected override void FrameUpdate(FrameEventArgs args) + { + if (!_rowStyleUpdateQueued) + return; + + _rowStyleUpdateQueued = false; + + int index = 0; + + foreach (var item in Body.Children) + { + RecursivelyUpdateRowStyle((TreeItem) item, ref index); + } + } + + private void RecursivelyUpdateRowStyle(TreeItem item, ref int index) + { + if (int.IsOddInteger(index)) + { + item.Button.RemoveStyleClass(TreeItem.StyleClassEvenRow); + item.Button.AddStyleClass(TreeItem.StyleClassOddRow); + } + else + { + item.Button.AddStyleClass(TreeItem.StyleClassEvenRow); + item.Button.RemoveStyleClass(TreeItem.StyleClassOddRow); + } + + index++; + + if (!item.Expanded) + return; + + foreach (var child in item.Body.Children) + { + RecursivelyUpdateRowStyle((TreeItem) child, ref index); + } + } + + private void SetHideEmptyIcon(bool value) + { + if (value == _hideEmptyIcon) + return; + + _hideEmptyIcon = value; + + foreach (var item in Body.Children) + { + RecursiveUpdateIcon((TreeItem) item); + } + } + + private void RecursiveUpdateIcon(TreeItem item) + { + item.UpdateIcon(); + + foreach (var child in item.Body.Children) + { + RecursiveUpdateIcon((TreeItem) child); + } + } + + protected override void StylePropertiesChanged() + { + LoadIcons(); + LineColor = TryGetStyleProperty(StylePropertyLineColor, out Color color) ? color: Color.White; + LineWidth = TryGetStyleProperty(StylePropertyLineWidth, out int width) ? width : 2; + base.StylePropertiesChanged(); + } +} diff --git a/Content.Client/UserInterface/Controls/FancyTree/TreeItem.xaml b/Content.Client/UserInterface/Controls/FancyTree/TreeItem.xaml new file mode 100644 index 0000000000..5f97492328 --- /dev/null +++ b/Content.Client/UserInterface/Controls/FancyTree/TreeItem.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/Content.Client/UserInterface/Controls/FancyTree/TreeItem.xaml.cs b/Content.Client/UserInterface/Controls/FancyTree/TreeItem.xaml.cs new file mode 100644 index 0000000000..bc2d2d71bd --- /dev/null +++ b/Content.Client/UserInterface/Controls/FancyTree/TreeItem.xaml.cs @@ -0,0 +1,92 @@ +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.UserInterface.Controls; + +/// +/// Element of a +/// +[GenerateTypedNameReferences] +public sealed partial class TreeItem : PanelContainer +{ + public const string StyleClassSelected = "selected"; + public const string StyleIdentifierTreeButton = "tree-button"; + public const string StyleClassEvenRow = "even-row"; + public const string StyleClassOddRow = "odd-row"; + + public object? Metadata; + public int Index; + public FancyTree Tree = default!; + public event Action? OnSelected; + public event Action? OnDeselected; + + public bool Expanded { get; private set; } = false; + + public TreeItem() + { + RobustXamlLoader.Load(this); + Button.StyleIdentifier = StyleIdentifierTreeButton; + Body.OnChildAdded += OnItemAdded; + Body.OnChildRemoved += OnItemRemoved; + } + + private void OnItemRemoved(Control obj) + { + Tree.QueueRowStyleUpdate(); + + if (Body.ChildCount == 0) + { + Body.Visible = false; + UpdateIcon(); + } + } + + private void OnItemAdded(Control obj) + { + Tree.QueueRowStyleUpdate(); + + if (Body.ChildCount == 1) + { + Body.Visible = Expanded && Body.ChildCount != 0; + UpdateIcon(); + } + } + + public void SetExpanded(bool value) + { + if (Expanded == value) + return; + + Expanded = value; + Body.Visible = Expanded && Body.ChildCount > 0; + UpdateIcon(); + Tree.QueueRowStyleUpdate(); + } + + public void SetSelected(bool value) + { + if (value) + { + OnSelected?.Invoke(this); + Button.AddStyleClass(StyleClassSelected); + } + else + { + OnDeselected?.Invoke(this); + Button.RemoveStyleClass(StyleClassSelected); + } + } + + public void UpdateIcon() + { + if (Body.ChildCount == 0) + Icon.Texture = Tree.IconNoChildren; + else + Icon.Texture = Expanded ? Tree.IconExpanded : Tree.IconCollapsed; + + Icon.Modulate = Tree.IconColor; + Icon.Visible = Icon.Texture != null || !Tree.HideEmptyIcon; + } +} diff --git a/Content.Client/UserInterface/Controls/FancyTree/TreeLine.cs b/Content.Client/UserInterface/Controls/FancyTree/TreeLine.cs new file mode 100644 index 0000000000..b981980928 --- /dev/null +++ b/Content.Client/UserInterface/Controls/FancyTree/TreeLine.cs @@ -0,0 +1,61 @@ +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface.Controls; + +/// +/// This is a basic control that draws the lines connecting parents & children in a tree. +/// +/// +/// Ideally this would just be a draw method in , but sadly the draw override gets called BEFORE children are drawn. +/// +public sealed class TreeLine : Control +{ + protected override void Draw(DrawingHandleScreen handle) + { + base.Draw(handle); + + // This is basically just a shitty hack to call Draw() after children get drawn. + if (Parent is not TreeItem parent) + return; + + if (!parent.Expanded || !parent.Tree.DrawLines || parent.Body.ChildCount == 0) + return; + + var width = Math.Max(1, (int) (parent.Tree.LineWidth * UIScale)); + var w1 = width / 2; + var w2 = width - w1; + + var global = parent.GlobalPixelPosition; + + var iconPos = parent.Icon.GlobalPixelPosition - global; + var iconSize = parent.Icon.PixelSize; + var x = iconPos.X + iconSize.X / 2; + DebugTools.Assert(parent.Icon.Visible); + + var buttonPos = parent.Button.GlobalPixelPosition - global; + var buttonSize = parent.Button.PixelSize; + var y1 = buttonPos.Y + buttonSize.Y; + + var lastItem = (TreeItem) parent.Body.GetChild(parent.Body.ChildCount - 1); + + var childPos = lastItem.Button.GlobalPixelPosition - global; + var y2 = childPos.Y + lastItem.Button.PixelSize.Y / 2; + + // Vertical line + var rect = new UIBox2i((x - w1, y1), (x + w2, y2)); + handle.DrawRect(rect, parent.Tree.LineColor); + + // Horizontal lines + var dx = Math.Max(1, (int) (FancyTree.Indentation * UIScale / 2)); + foreach (var child in parent.Body.Children) + { + var item = (TreeItem) child; + var pos = item.Button.GlobalPixelPosition - global; + var y = pos.Y + item.Button.PixelSize.Y / 2; + rect = new UIBox2i((x - w1, y - w1), (x + dx, y + w2)); + handle.DrawRect(rect, parent.Tree.LineColor); + } + } +} diff --git a/Resources/Textures/Interface/Nano/triangle_right.png b/Resources/Textures/Interface/Nano/triangle_right.png new file mode 100644 index 0000000000000000000000000000000000000000..6b06120a510569d2b688102a478bda1f3dee50eb GIT binary patch literal 252 zcmeAS@N?(olHy`uVBq!ia0vp^96-#&!3HGb=lz)rq*#ibJVQ8upoSx*1ITAA4sv&5 zSa(k5B}g*e(btiIVPik{pF~z5pR>RtvY3H^?+6GpPSxg<1`0}+xJHx&=ckpFCl;kL zl$V$5W#(lUCnpx9>g5-u&wghk1ymH{>Ealo5uE(v|MUNU{{R19Z2W-9ffdO3SO4|@ z`Tw^96hj!o7!JlaSTJh!FnDk!NL=;O;R;xj!ot}Qn$)!3Va{Qe#K(*h$C?c`t>2(= m;H8JnJr#j~il$kOj0{Z+R(9zd@vi{d!{F)a=d#Wzp$P!Z_)^vY literal 0 HcmV?d00001 diff --git a/Resources/Textures/Interface/Nano/triangle_right.png.yml b/Resources/Textures/Interface/Nano/triangle_right.png.yml new file mode 100644 index 0000000000..900d002f35 --- /dev/null +++ b/Resources/Textures/Interface/Nano/triangle_right.png.yml @@ -0,0 +1,3 @@ +sample: + filter: true +# for .svg, see inverted_triangle.svg. \ No newline at end of file diff --git a/Resources/Textures/Interface/Nano/triangle_right_hollow.svg b/Resources/Textures/Interface/Nano/triangle_right_hollow.svg new file mode 100644 index 0000000000..3b2ec185bd --- /dev/null +++ b/Resources/Textures/Interface/Nano/triangle_right_hollow.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/Resources/Textures/Interface/Nano/triangle_right_hollow.svg.png b/Resources/Textures/Interface/Nano/triangle_right_hollow.svg.png new file mode 100644 index 0000000000000000000000000000000000000000..d5c93911a54d040a436c8ec9b3f366154e503baf GIT binary patch literal 209 zcmeAS@N?(olHy`uVBq!ia0vp^96-#&!3HGb=lz)rq&N#aB8wRq_zr_G=AuCdx4wypFGLq zMitFZ(<()e#3kA2er}J5K4af~e!~2GweDBJq3@setUGlxWXCO_O$?r{elF{r5}E+> CT}!Y4 literal 0 HcmV?d00001 diff --git a/Resources/Textures/Interface/Nano/triangle_right_hollow.svg.png.yml b/Resources/Textures/Interface/Nano/triangle_right_hollow.svg.png.yml new file mode 100644 index 0000000000..497c6beff9 --- /dev/null +++ b/Resources/Textures/Interface/Nano/triangle_right_hollow.svg.png.yml @@ -0,0 +1,2 @@ +sample: + filter: true \ No newline at end of file