#nullable enable using System; using System.Collections.Generic; using System.Linq; using Barotrauma.Extensions; using Microsoft.Xna.Framework; namespace Barotrauma { struct ContextMenuOption { public LocalizedString Label; public Action OnSelected; public ContextMenuOption[]? SubOptions; public bool IsEnabled; public LocalizedString Tooltip; public ContextMenuOption(string label, bool isEnabled, Action onSelected) : this(TextManager.Get(label).Fallback(label), isEnabled, onSelected) { } public ContextMenuOption(Identifier labelTag, bool isEnabled, Action onSelected) : this(TextManager.Get(labelTag), isEnabled, onSelected) { } // Creates a regular context menu public ContextMenuOption(LocalizedString label, bool isEnabled, Action onSelected) { Label = label; OnSelected = onSelected; IsEnabled = isEnabled; SubOptions = null; Tooltip = string.Empty; } // Creates a option with a sub context menu public ContextMenuOption(string label, bool isEnabled, params ContextMenuOption[] options): this(label, isEnabled, () => { }) { SubOptions = options; } } internal class GUIContextMenu : GUIComponent { public static GUIContextMenu? CurrentContextMenu; private readonly Dictionary Options = new Dictionary(); private GUIContextMenu? SubMenu; public readonly GUITextBlock? HeaderLabel; public GUITextBlock? ParentOption; /// /// Creates a context menu. This constructor does not make the context menu active. /// Use to make right click context menus. /// /// Position at which to create the context menu /// Header text /// Background style /// list of context menu options public GUIContextMenu(Vector2? position, LocalizedString header, string style, params ContextMenuOption[] options) : base(style, new RectTransform(Point.Zero, GUI.Canvas)) { Vector2 pos = position ?? PlayerInput.MousePosition; GUIFont headerFont = GUIStyle.SubHeadingFont; GUIFont font = GUIStyle.SmallFont; // font the context menu options use Vector4 padding = new Vector4(4), headerPadding = new Vector4(8); int horizontalPadding = (int)(padding.X + padding.Z), verticalPadding = (int)(padding.Y + padding.W); bool hasHeader = !header.IsNullOrWhiteSpace(); //---------------------------------------------------------------------------------- // Estimate the size of the context menu //---------------------------------------------------------------------------------- Dictionary optionsAndSizes = new Dictionary(); // estimate how big the context menu needs to be Point estimatedSize = new Point(horizontalPadding, verticalPadding); if (hasHeader) { InflateSize(ref estimatedSize, header, headerFont!); } foreach (ContextMenuOption option in options) { Vector2 optionSize = InflateSize(ref estimatedSize, option.Label, font!); optionsAndSizes.Add(option, optionSize); } // it's better to overestimate the size since it's going to be cropped anyways estimatedSize = estimatedSize.Multiply(1.2f); RectTransform.NonScaledSize = estimatedSize; RectTransform.AbsoluteOffset = pos.ToPoint(); //---------------------------------------------------------------------------------- // Construct the GUI elements //---------------------------------------------------------------------------------- GUILayoutGroup background = new GUILayoutGroup(new RectTransform(Vector2.One, RectTransform, Anchor.Center)) { Stretch = true }; Point listSize = estimatedSize; if (hasHeader) { Point sz = Point.Zero; InflateSize(ref sz, header, headerFont!); listSize.Y -= sz.Y; HeaderLabel = new GUITextBlock(new RectTransform(sz, background.RectTransform), header, font: headerFont) { Padding = headerPadding }; } GUIListBox optionList = new GUIListBox(new RectTransform(listSize, background.RectTransform), style: null) { AutoHideScrollBar = false, ScrollBarVisible = false, Padding = hasHeader ? new Vector4(4, 0, 4, 4) : padding, PlaySoundOnSelect = true }; foreach (var (option, size) in optionsAndSizes) { GUITextBlock optionElement = new GUITextBlock(new RectTransform(size.ToPoint(), optionList.Content.RectTransform), option.Label, font: font) { UserData = option, Enabled = option.IsEnabled }; Options.Add(option, optionElement); if (!option.Tooltip.IsNullOrWhiteSpace() && optionElement.Enabled) { optionElement.ToolTip = option.Tooltip; } //option doesn't do anything, make it a label if (option.OnSelected == null) { optionElement.TextAlignment = Alignment.BottomLeft; optionElement.TextColor = optionElement.DisabledTextColor = GUIStyle.Green; } else if (!option.IsEnabled) { optionElement.TextColor *= 0.5f; } } //---------------------------------------------------------------------------------- // Positioning and cropping the context menu //---------------------------------------------------------------------------------- List children = optionList.Content.Children.ToList(); // Resize all children to the size of their text foreach (GUITextBlock block in children.Where(c => c is GUITextBlock).Cast()) { bool isLabel = block.UserData is ContextMenuOption option && option.OnSelected == null; block.RectTransform.NonScaledSize = new Point( (int)(block.TextSize.X + (block.Padding.X + block.Padding.Z)), (int)Math.Max(block.TextSize.Y * 1.2f, 18 * GUI.Scale)); } int largestWidth = children.Max(c => c.Rect.Width + horizontalPadding); // if the header is bigger than any of the options then overwrite if (HeaderLabel != null) { RectTransform headerTransform = HeaderLabel.RectTransform; headerTransform.MinSize = new Point((int)(HeaderLabel.TextSize.X + (headerPadding.X + headerPadding.Z)), headerTransform.NonScaledSize.Y); if (largestWidth < headerTransform.MinSize.X) { largestWidth = headerTransform.MinSize.X; } } // resize all children to the size of the longest element foreach (GUIComponent c in children) { c.RectTransform.MinSize = new Point(largestWidth, c.Rect.Height); } // the cropped size of the option list Point newSize = new Point(largestWidth, children.Sum(c => c.Rect.Height) + verticalPadding); // resize the menu itself taking into account the option menus relative Y size RectTransform.NonScaledSize = new Point(newSize.X, (int)(newSize.Y / optionList.RectTransform.RelativeSize.Y)); optionList.RectTransform.NonScaledSize = newSize; // move the context menu if it would go outside of screen if (RectTransform.Rect.Bottom > GameMain.GraphicsHeight) { Rectangle rect = RectTransform.Rect; RectTransform.AbsoluteOffset = new Point(rect.X, rect.Y - rect.Height); } if (RectTransform.Rect.Right > GameMain.GraphicsWidth) { Rectangle rect = RectTransform.Rect; RectTransform.AbsoluteOffset = new Point(rect.X - rect.Width, rect.Y); } background.Recalculate(); optionList.OnSelected = OnSelected; } public static GUIContextMenu CreateContextMenu(params ContextMenuOption[] options) => CreateContextMenu(PlayerInput.MousePosition, string.Empty, null, options); public static GUIContextMenu CreateContextMenu(Vector2? pos, LocalizedString header, Color? headerColor, params ContextMenuOption[] options) { GUIContextMenu menu = new GUIContextMenu(pos,header, "GUIToolTip", options); if (headerColor != null) { menu.HeaderLabel?.OverrideTextColor(headerColor.Value); } CurrentContextMenu = menu; return menu; } private bool OnSelected(GUIComponent _, object data) { if (data is ContextMenuOption option && option.IsEnabled) { CurrentContextMenu = null; option.OnSelected(); return true; } return false; } /// /// Inflates a point by the size of the text /// /// Pint to resize /// String whose size to inflate by /// What font to use /// The size of the text private Vector2 InflateSize(ref Point size, LocalizedString label, ScalableFont font) { Vector2 textSize = font.MeasureString(label); size.X = Math.Max((int)Math.Ceiling(textSize.X), size.X); size.Y += (int)Math.Ceiling(textSize.Y); return textSize; } protected override void Update(float deltaTime) { base.Update(deltaTime); // keep the parent highlighted if (ParentOption != null) { ParentOption.State = ComponentState.Hover; } if (SubMenu != null && !SubMenu.IsMouseOver()) { SubMenu = null; return; } foreach (var (option, textBlock) in Options) { // Create a new sub context menu if hovering over an option with sub options if (GUI.MouseOn != textBlock) { continue; } if (option.IsEnabled && option.SubOptions is { } subOptions && subOptions.Any()) { Vector2 subMenuPos = new Vector2(textBlock.MouseRect.Right + 4, textBlock.MouseRect.Y); SubMenu = new GUIContextMenu(subMenuPos, "", "GUIToolTip", subOptions) { ParentOption = textBlock }; } } } /// /// Checks if the mouse cursor is over this context menu or any of its sub menus /// /// private bool IsMouseOver() { Rectangle expandedRect = Rect; expandedRect.Inflate(20, 20); bool isMouseOn = expandedRect.Contains(PlayerInput.MousePosition); if (ParentOption != null) { isMouseOn |= GUI.MouseOn == ParentOption; } // Recursively check sub context menus if (!isMouseOn && SubMenu != null) { isMouseOn = SubMenu.IsMouseOver(); } return isMouseOn; } public override void AddToGUIUpdateList(bool ignoreChildren = false, int order = 0) { base.AddToGUIUpdateList(ignoreChildren, order); SubMenu?.AddToGUIUpdateList(order: 2); } public static void AddActiveToGUIUpdateList() { if (CurrentContextMenu != null && !CurrentContextMenu.IsMouseOver()) { CurrentContextMenu = null; } CurrentContextMenu?.AddToGUIUpdateList(order: 2); } } }