diff --git a/Content.Client/Commands/GroupingContextMenuCommand.cs b/Content.Client/Commands/GroupingContextMenuCommand.cs new file mode 100644 index 0000000000..8489543a37 --- /dev/null +++ b/Content.Client/Commands/GroupingContextMenuCommand.cs @@ -0,0 +1,45 @@ +#nullable enable +using Content.Client.GameObjects.EntitySystems; +using Content.Shared; +using Robust.Shared.Configuration; +using Robust.Shared.Console; +using Robust.Shared.IoC; +using ContextMenuView = Content.Client.UserInterface.ContextMenu.ContextMenuView; + +namespace Content.Client.Commands +{ + public class GroupingContextMenuCommand : IConsoleCommand + { + public string Command => "contextmenug"; + + public string Description => "Sets the contextmenu-groupingtype."; + + public string Help => ($"Usage: contextmenug <0:{ContextMenuView.GroupingTypesCount}>"); + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length != 1) + { + shell.WriteLine(Help); + return; + } + + if (!int.TryParse(args[0], out var id)) + { + shell.WriteLine($"{args[0]} is not a valid integer."); + return; + } + + if (id < 0 ||id > ContextMenuView.GroupingTypesCount - 1) + { + shell.WriteLine($"{args[0]} is not a valid integer."); + return; + } + + var configurationManager = IoCManager.Resolve(); + var cvar = CCVars.ContextMenuGroupingType; + + configurationManager.SetCVar(cvar, id); + shell.WriteLine($"Context Menu Grouping set to type: {configurationManager.GetCVar(cvar)}"); + } + } +} diff --git a/Content.Client/GameObjects/EntitySystems/ContextMenuPresenter.cs b/Content.Client/GameObjects/EntitySystems/ContextMenuPresenter.cs new file mode 100644 index 0000000000..4b342fd812 --- /dev/null +++ b/Content.Client/GameObjects/EntitySystems/ContextMenuPresenter.cs @@ -0,0 +1,336 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading; +using Content.Client.State; +using Content.Client.UserInterface; +using Content.Client.UserInterface.ContextMenu; +using Content.Client.Utility; +using Content.Shared; +using Content.Shared.GameObjects.Verbs; +using Content.Shared.Input; +using Robust.Client.GameObjects; +using Robust.Client.Input; +using Robust.Client.Player; +using Robust.Client.State; +using Robust.Client.UserInterface; +using Robust.Shared.Configuration; +using Robust.Shared.Containers; +using Robust.Shared.GameObjects; +using Robust.Shared.Input; +using Robust.Shared.Input.Binding; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Timing; +using Timer = Robust.Shared.Timing.Timer; +namespace Content.Client.GameObjects.EntitySystems +{ + public class ContextMenuPresenter : IDisposable + { + [Dependency] private readonly IEntitySystemManager _systemManager = default!; + [Dependency] private readonly IItemSlotManager _itemSlotManager = default!; + [Dependency] private readonly IEntityManager _entityManager = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly IStateManager _stateManager = default!; + [Dependency] private readonly IInputManager _inputManager = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + + private readonly IContextMenuView _contextMenuView; + private readonly VerbSystem _verbSystem; + + private bool _playerCanSeeThroughContainers; + + private MapCoordinates _mapCoordinates; + private CancellationTokenSource? _cancellationTokenSource; + + public ContextMenuPresenter(VerbSystem verbSystem) + { + IoCManager.InjectDependencies(this); + + _verbSystem = verbSystem; + _verbSystem.ToggleContextMenu += SystemOnToggleContextMenu; + _verbSystem.ToggleContainerVisibility += SystemOnToggleContainerVisibility; + + _contextMenuView = new ContextMenuView(); + _contextMenuView.OnKeyBindDownSingle += OnKeyBindDownSingle; + _contextMenuView.OnMouseEnteredSingle += OnMouseEnteredSingle; + _contextMenuView.OnMouseExitedSingle += OnMouseExitedSingle; + _contextMenuView.OnMouseHoveringSingle += OnMouseHoveringSingle; + + _contextMenuView.OnKeyBindDownStack += OnKeyBindDownStack; + _contextMenuView.OnMouseEnteredStack += OnMouseEnteredStack; + + _contextMenuView.OnExitedTree += OnExitedTree; + _contextMenuView.OnCloseRootMenu += OnCloseRootMenu; + _contextMenuView.OnCloseChildMenu += OnCloseChildMenu; + + _cfg.OnValueChanged(CCVars.ContextMenuGroupingType, _contextMenuView.OnGroupingContextMenuChanged, true); + } + + #region View Events + private void OnCloseChildMenu(object? sender, int depth) + { + _contextMenuView.CloseContextPopups(depth); + } + + private void OnCloseRootMenu(object? sender, EventArgs e) + { + _contextMenuView.CloseContextPopups(); + } + + private void OnExitedTree(object? sender, ContextMenuElement e) + { + _contextMenuView.UpdateParents(e); + } + + private void OnMouseEnteredStack(object? sender, StackContextElement e) + { + var realGlobalPosition = e.GlobalPosition; + + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource = new(); + + Timer.Spawn(e.HoverDelay, () => + { + _verbSystem.CloseGroupMenu(); + + if (_contextMenuView.Menus.Count == 0) + { + return; + } + + OnCloseChildMenu(sender, e.ParentMenu?.Depth ?? 0); + + var filteredEntities = e.ContextEntities.Where(entity => !entity.Deleted); + if (filteredEntities.Any()) + { + _contextMenuView.AddChildMenu(filteredEntities, realGlobalPosition, e); + } + }, _cancellationTokenSource.Token); + } + + private void OnKeyBindDownStack(object? sender, (GUIBoundKeyEventArgs, StackContextElement) e) + { + var (args, stack) = e; + var firstEntity = stack.ContextEntities.FirstOrDefault(ent => !ent.Deleted); + + if (firstEntity == null) return; + + if (args.Function == EngineKeyFunctions.Use || args.Function == ContentKeyFunctions.TryPullObject || args.Function == ContentKeyFunctions.MovePulledObject) + { + var inputSys = _systemManager.GetEntitySystem(); + + var func = args.Function; + var funcId = _inputManager.NetworkBindMap.KeyFunctionID(func); + + var message = new FullInputCmdMessage(_gameTiming.CurTick, _gameTiming.TickFraction, funcId, + BoundKeyState.Down, firstEntity.Transform.Coordinates, args.PointerLocation, firstEntity.Uid); + + var session = _playerManager.LocalPlayer?.Session; + if (session != null) + { + inputSys.HandleInputCommand(session, func, message); + } + CloseAllMenus(); + return; + } + + if (_itemSlotManager.OnButtonPressed(args, firstEntity)) + { + CloseAllMenus(); + } + } + + private void OnMouseHoveringSingle(object? sender, SingleContextElement e) + { + if (!e.DrawOutline) return; + + var localPlayer = _playerManager.LocalPlayer; + if (localPlayer?.ControlledEntity != null) + { + var inRange = + localPlayer.InRangeUnobstructed(e.ContextEntity, ignoreInsideBlocker: true); + e.OutlineComponent?.UpdateInRange(inRange); + } + } + + private void OnMouseEnteredSingle(object? sender, SingleContextElement e) + { + _cancellationTokenSource?.Cancel(); + + var entity = e.ContextEntity; + _verbSystem.CloseGroupMenu(); + + OnCloseChildMenu(sender, e.ParentMenu?.Depth ?? 0); + + if (entity.Deleted) return; + + var localPlayer = _playerManager.LocalPlayer; + if (localPlayer?.ControlledEntity == null) return; + + e.OutlineComponent?.OnMouseEnter(localPlayer.InRangeUnobstructed(entity, ignoreInsideBlocker: true)); + if (e.SpriteComp != null) + { + e.SpriteComp.DrawDepth = (int) Shared.GameObjects.DrawDepth.HighlightedItems; + } + e.DrawOutline = true; + } + + private void OnMouseExitedSingle(object? sender, SingleContextElement e) + { + if (!e.ContextEntity.Deleted) + { + if (e.SpriteComp != null) + { + e.SpriteComp.DrawDepth = e.OriginalDrawDepth; + } + e.OutlineComponent?.OnMouseLeave(); + } + e.DrawOutline = false; + } + + private void OnKeyBindDownSingle(object? sender, (GUIBoundKeyEventArgs, SingleContextElement) valueTuple) + { + var (args, single) = valueTuple; + var entity = single.ContextEntity; + if (args.Function == ContentKeyFunctions.OpenContextMenu) + { + _verbSystem.OnContextButtonPressed(entity); + return; + } + + if (args.Function == ContentKeyFunctions.ExamineEntity) + { + _systemManager.GetEntitySystem().DoExamine(entity); + return; + } + + if (args.Function == EngineKeyFunctions.Use || args.Function == ContentKeyFunctions.Point || + args.Function == ContentKeyFunctions.TryPullObject || args.Function == ContentKeyFunctions.MovePulledObject) + { + var inputSys = _systemManager.GetEntitySystem(); + + var func = args.Function; + var funcId = _inputManager.NetworkBindMap.KeyFunctionID(func); + + var message = new FullInputCmdMessage(_gameTiming.CurTick, _gameTiming.TickFraction, funcId, + BoundKeyState.Down, entity.Transform.Coordinates, args.PointerLocation, entity.Uid); + + var session = _playerManager.LocalPlayer?.Session; + if (session != null) + { + inputSys.HandleInputCommand(session, func, message); + } + + CloseAllMenus(); + return; + } + + if (_itemSlotManager.OnButtonPressed(args, single.ContextEntity)) + { + CloseAllMenus(); + } + } + #endregion + + #region Model Updates + private void SystemOnToggleContainerVisibility(object? sender, bool args) + { + _playerCanSeeThroughContainers = args; + } + + private void SystemOnToggleContextMenu(object? sender, PointerInputCmdHandler.PointerInputCmdArgs args) + { + if (_stateManager.CurrentState is not GameScreenBase) + { + return; + } + + var playerEntity = _playerManager.LocalPlayer?.ControlledEntity; + if (playerEntity == null) + { + return; + } + + _mapCoordinates = args.Coordinates.ToMap(_entityManager); + if (!_verbSystem.TryGetContextEntities(playerEntity, _mapCoordinates, out var entities)) + { + return; + } + + entities = entities.Where(CanSeeOnContextMenu).ToList(); + if (entities.Count > 0) + { + _contextMenuView.AddRootMenu(entities); + } + } + + public void HandleMoveEvent(MoveEvent ev) + { + if (_contextMenuView.Elements.Count == 0) return; + var entity = ev.Sender; + if (_contextMenuView.Elements.ContainsKey(entity)) + { + if (!entity.Transform.MapPosition.InRange(_mapCoordinates, 1.0f)) + { + _contextMenuView.RemoveEntity(entity); + } + } + } + + public void Update() + { + if (_contextMenuView.Elements.Count == 0) return; + + foreach (var entity in _contextMenuView.Elements.Keys.ToList()) + { + if (entity.Deleted || !_playerCanSeeThroughContainers && entity.IsInContainer()) + { + _contextMenuView.RemoveEntity(entity); + } + } + } + #endregion + + private bool CanSeeOnContextMenu(IEntity entity) + { + if (!entity.TryGetComponent(out ISpriteComponent? spriteComponent) || !spriteComponent.Visible) + { + return false; + } + + if (entity.GetAllComponents().Any(s => !s.ShowContextMenu(entity))) + { + return false; + } + + return _playerCanSeeThroughContainers || !entity.TryGetContainer(out var container) || container.ShowContents; + } + + private void CloseAllMenus() + { + _contextMenuView.CloseContextPopups(); + _verbSystem.CloseGroupMenu(); + _verbSystem.CloseVerbMenu(); + } + + public void Dispose() + { + _verbSystem.ToggleContextMenu -= SystemOnToggleContextMenu; + _verbSystem.ToggleContainerVisibility -= SystemOnToggleContainerVisibility; + + _contextMenuView.OnKeyBindDownSingle -= OnKeyBindDownSingle; + _contextMenuView.OnMouseEnteredSingle -= OnMouseEnteredSingle; + _contextMenuView.OnMouseExitedSingle -= OnMouseExitedSingle; + _contextMenuView.OnMouseHoveringSingle -= OnMouseHoveringSingle; + + _contextMenuView.OnKeyBindDownStack -= OnKeyBindDownStack; + _contextMenuView.OnMouseEnteredStack -= OnMouseEnteredStack; + + _contextMenuView.OnExitedTree -= OnExitedTree; + _contextMenuView.OnCloseRootMenu -= OnCloseRootMenu; + _contextMenuView.OnCloseChildMenu -= OnCloseChildMenu; + } + } +} diff --git a/Content.Client/GameObjects/EntitySystems/VerbSystem.cs b/Content.Client/GameObjects/EntitySystems/VerbSystem.cs index 9dbd60b6fe..b72d51d936 100644 --- a/Content.Client/GameObjects/EntitySystems/VerbSystem.cs +++ b/Content.Client/GameObjects/EntitySystems/VerbSystem.cs @@ -1,34 +1,27 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Reflection; using System.Threading; -using Content.Client.State; -using Content.Client.UserInterface; using Content.Client.Utility; using Content.Shared.GameObjects.EntitySystemMessages; using Content.Shared.GameObjects.Verbs; using Content.Shared.GameTicking; using Content.Shared.Input; using JetBrains.Annotations; -using Robust.Client.GameObjects; using Robust.Client.Graphics; -using Robust.Client.Input; using Robust.Client.Player; using Robust.Client.ResourceManagement; -using Robust.Client.State; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.Utility; -using Robust.Shared.Containers; using Robust.Shared.GameObjects; using Robust.Shared.Input; using Robust.Shared.Input.Binding; using Robust.Shared.IoC; +using Robust.Shared.Localization; using Robust.Shared.Log; using Robust.Shared.Map; using Robust.Shared.Maths; -using Robust.Shared.Timing; using Robust.Shared.Utility; using Timer = Robust.Shared.Timing.Timer; @@ -37,52 +30,67 @@ namespace Content.Client.GameObjects.EntitySystems [UsedImplicitly] public sealed class VerbSystem : SharedVerbSystem, IResettingEntitySystem { - [Dependency] private readonly IStateManager _stateManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly IInputManager _inputManager = default!; - [Dependency] private readonly IItemSlotManager _itemSlotManager = default!; - [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; - private EntityList _currentEntityList; + private ContextMenuPresenter _contextMenuPresenter; + public event EventHandler ToggleContextMenu; + public event EventHandler ToggleContainerVisibility; + private VerbPopup _currentVerbListRoot; private VerbPopup _currentGroupList; - private EntityUid _currentEntity; - private bool IsAnyContextMenuOpen => _currentEntityList != null || _currentVerbListRoot != null; - - private bool _playerCanSeeThroughContainers; - + // TODO: Move presenter out of the system + // TODO: Separate the rest of the UI from the logic public override void Initialize() { base.Initialize(); + IoCManager.InjectDependencies(this); SubscribeNetworkEvent(FillEntityPopup); SubscribeNetworkEvent(HandleContainerVisibilityMessage); - IoCManager.InjectDependencies(this); + _contextMenuPresenter = new ContextMenuPresenter(this); + SubscribeLocalEvent(_contextMenuPresenter.HandleMoveEvent); CommandBinds.Builder .Bind(ContentKeyFunctions.OpenContextMenu, - new PointerInputCmdHandler(OnOpenContextMenu)) + new PointerInputCmdHandler(HandleOpenContextMenu)) .Register(); } public override void Shutdown() { + UnsubscribeLocalEvent(); + _contextMenuPresenter?.Dispose(); + CommandBinds.Unregister(); base.Shutdown(); } public void Reset() { - _playerCanSeeThroughContainers = false; + ToggleContainerVisibility?.Invoke(this, false); } + private bool HandleOpenContextMenu(in PointerInputCmdHandler.PointerInputCmdArgs args) + { + if (args.State == BoundKeyState.Down) + { + ToggleContextMenu?.Invoke(this, args); + } + return true; + } private void HandleContainerVisibilityMessage(PlayerContainerVisibilityMessage ev) { - _playerCanSeeThroughContainers = ev.CanSeeThrough; + ToggleContainerVisibility?.Invoke(this, ev.CanSeeThrough); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + _contextMenuPresenter?.Update(); } public void OpenContextMenu(IEntity entity, ScreenCoordinates screenCoordinates) @@ -99,7 +107,7 @@ namespace Content.Client.GameObjects.EntitySystems if (!entity.Uid.IsClientSide()) { - _currentVerbListRoot.List.AddChild(new Label {Text = "Waiting on Server..."}); + _currentVerbListRoot.List.AddChild(new Label { Text = Loc.GetString("Waiting on Server...") }); RaiseNetworkEvent(new VerbSystemMessages.RequestVerbsMessage(_currentEntity)); } @@ -107,84 +115,7 @@ namespace Content.Client.GameObjects.EntitySystems _currentVerbListRoot.Open(box); } - public bool CanSeeOnContextMenu(IEntity entity) - { - if (!entity.TryGetComponent(out SpriteComponent sprite) || !sprite.Visible) - { - return false; - } - - if (entity.GetAllComponents().Any(s => !s.ShowContextMenu(entity))) - { - return false; - } - - if (!_playerCanSeeThroughContainers && - entity.TryGetContainer(out var container) && - !container.ShowContents) - { - return false; - } - - return true; - } - - private bool OnOpenContextMenu(in PointerInputCmdHandler.PointerInputCmdArgs args) - { - if (IsAnyContextMenuOpen) - { - CloseAllMenus(); - return true; - } - - if (_stateManager.CurrentState is not GameScreenBase) - { - return false; - } - - var mapCoordinates = args.Coordinates.ToMap(EntityManager); - var playerEntity = _playerManager.LocalPlayer?.ControlledEntity; - - if (playerEntity == null || !TryGetContextEntities(playerEntity, mapCoordinates, out var entities)) - { - return false; - } - - _currentEntityList = new EntityList(); - _currentEntityList.OnPopupHide += CloseAllMenus; - var first = true; - foreach (var entity in entities) - { - if (!CanSeeOnContextMenu(entity)) - { - continue; - } - - if (!first) - { - _currentEntityList.List.AddChild(new PanelContainer - { - MinSize = (0, 2), - PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#333")} - }); - } - - var debugEnabled = _userInterfaceManager.DebugMonitors.Visible; - _currentEntityList.List.AddChild(new EntityButton(this, entity, debugEnabled)); - first = false; - } - - _userInterfaceManager.ModalRoot.AddChild(_currentEntityList); - - _currentEntityList.List.Measure(Vector2.Infinity); - var size = _currentEntityList.List.DesiredSize; - var box = UIBox2.FromDimensions(_userInterfaceManager.MousePositionScaled, size); - _currentEntityList.Open(box); - - return true; - } - - private void OnContextButtonPressed(IEntity entity) + public void OnContextButtonPressed(IEntity entity) { OpenContextMenu(entity, new ScreenCoordinates(_userInterfaceManager.MousePositionScaled)); } @@ -286,7 +217,7 @@ namespace Content.Client.GameObjects.EntitySystems vBox.AddChild(new PanelContainer { MinSize = (0, 2), - PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#333")} + PanelOverride = new StyleBoxFlat { BackgroundColor = Color.FromHex("#333") } }); } @@ -308,7 +239,7 @@ namespace Content.Client.GameObjects.EntitySystems vBox.AddChild(new PanelContainer { MinSize = (0, 2), - PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#333")} + PanelOverride = new StyleBoxFlat { BackgroundColor = Color.FromHex("#333") } }); } @@ -321,7 +252,7 @@ namespace Content.Client.GameObjects.EntitySystems else { var panel = new PanelContainer(); - panel.AddChild(new Label {Text = "No verbs!"}); + panel.AddChild(new Label { Text = Loc.GetString("No verbs!") }); vBox.AddChild(panel); } } @@ -330,7 +261,7 @@ namespace Content.Client.GameObjects.EntitySystems { var button = new VerbButton { - Text = data.Text, + Text = Loc.GetString(data.Text), Disabled = data.Disabled }; @@ -364,31 +295,25 @@ namespace Content.Client.GameObjects.EntitySystems return new VerbGroupButton(this, verbButtons, icon) { - Text = text, + Text = Loc.GetString(text), }; } - private void CloseVerbMenu() + public void CloseVerbMenu() { _currentVerbListRoot?.Dispose(); _currentVerbListRoot = null; _currentEntity = EntityUid.Invalid; } - private void CloseEntityList() - { - _currentEntityList?.Dispose(); - _currentEntityList = null; - } - private void CloseAllMenus() { CloseVerbMenu(); - CloseEntityList(); + // CloseContextPopups(); CloseGroupMenu(); } - private void CloseGroupMenu() + public void CloseGroupMenu() { _currentGroupList?.Dispose(); _currentGroupList = null; @@ -399,20 +324,6 @@ namespace Content.Client.GameObjects.EntitySystems return _playerManager.LocalPlayer.ControlledEntity; } - private sealed class EntityList : Popup - { - public VBoxContainer List { get; } - - public EntityList() - { - AddChild(new PanelContainer - { - Children = {(List = new VBoxContainer())}, - PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#111E")} - }); - } - } - private sealed class VerbPopup : Popup { public VBoxContainer List { get; } @@ -427,102 +338,6 @@ namespace Content.Client.GameObjects.EntitySystems } } - private sealed class EntityButton : Control - { - private readonly VerbSystem _master; - private readonly IEntity _entity; - - public EntityButton(VerbSystem master, IEntity entity, bool showUid) - { - _master = master; - _entity = entity; - - MouseFilter = MouseFilterMode.Stop; - - var control = new HBoxContainer {SeparationOverride = 6}; - if (entity.TryGetComponent(out ISpriteComponent sprite)) - { - control.AddChild(new SpriteView {Sprite = sprite}); - } - - var text = entity.Name; - if (showUid) - { - text = $"{text} ({entity.Uid})"; - } - - control.AddChild(new Label - { - Margin = new Thickness(4, 0), - Text = text - }); - - AddChild(control); - } - - protected override void KeyBindDown(GUIBoundKeyEventArgs args) - { - base.KeyBindDown(args); - - if (args.Function == ContentKeyFunctions.OpenContextMenu) - { - _master.OnContextButtonPressed(_entity); - return; - } - - if (args.Function == EngineKeyFunctions.Use || - args.Function == ContentKeyFunctions.Point || - args.Function == ContentKeyFunctions.TryPullObject || - args.Function == ContentKeyFunctions.MovePulledObject) - { - // TODO: Remove an entity from the menu when it is deleted - if (_entity.Deleted) - { - _master.CloseAllMenus(); - return; - } - - var inputSys = _master.EntitySystemManager.GetEntitySystem(); - - var func = args.Function; - var funcId = _master._inputManager.NetworkBindMap.KeyFunctionID(args.Function); - - var message = new FullInputCmdMessage(_master._gameTiming.CurTick, _master._gameTiming.TickFraction, - funcId, BoundKeyState.Down, - _entity.Transform.Coordinates, - args.PointerLocation, _entity.Uid); - - // client side command handlers will always be sent the local player session. - var session = _master._playerManager.LocalPlayer.Session; - inputSys.HandleInputCommand(session, func, message); - - _master.CloseAllMenus(); - return; - } - - if (args.Function == ContentKeyFunctions.ExamineEntity) - { - Get().DoExamine(_entity); - return; - } - - if (_master._itemSlotManager.OnButtonPressed(args, _entity)) - { - _master.CloseAllMenus(); - } - } - - protected override void Draw(DrawingHandleScreen handle) - { - base.Draw(handle); - - if (UserInterfaceManager.CurrentlyHovered == this) - { - handle.DrawRect(PixelSizeBox, Color.DarkSlateGray); - } - } - } - private sealed class VerbButton : BaseButton { private readonly Label _label; @@ -611,7 +426,7 @@ namespace Content.Client.GameObjects.EntitySystems (_label = new Label { - HorizontalExpand = true + SizeFlagsHorizontal = SizeFlags.FillExpand }), // Padding diff --git a/Content.Client/UserInterface/ContextMenu/ContextMenuElement.cs b/Content.Client/UserInterface/ContextMenu/ContextMenuElement.cs new file mode 100644 index 0000000000..75a77e7242 --- /dev/null +++ b/Content.Client/UserInterface/ContextMenu/ContextMenuElement.cs @@ -0,0 +1,233 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Content.Client.GameObjects.Components; +using Content.Client.UserInterface.Stylesheets; +using Content.Client.Utility; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Maths; +using Vector2 = Robust.Shared.Maths.Vector2; + +namespace Content.Client.UserInterface.ContextMenu +{ + public abstract class ContextMenuElement : Control + { + private static readonly Color HoverColor = Color.DarkSlateGray; + protected internal readonly ContextMenuPopup? ParentMenu; + + protected ContextMenuElement(ContextMenuPopup? parentMenu) + { + ParentMenu = parentMenu; + MouseFilter = MouseFilterMode.Stop; + } + protected override void Draw(DrawingHandleScreen handle) + { + base.Draw(handle); + + if (UserInterfaceManager.CurrentlyHovered == this) + { + handle.DrawRect(PixelSizeBox, HoverColor); + } + } + } + + public sealed class SingleContextElement : ContextMenuElement + { + public event Action? OnMouseHovering; + public event Action? OnExitedTree; + + public IEntity ContextEntity{ get; } + public readonly StackContextElement? Pre; + + public ISpriteComponent? SpriteComp { get; } + public InteractionOutlineComponent? OutlineComponent { get; } + public int OriginalDrawDepth { get; } + public bool DrawOutline { get; set; } + + public SingleContextElement(IEntity entity, StackContextElement? pre, ContextMenuPopup? parentMenu) : base(parentMenu) + { + Pre = pre; + ContextEntity = entity; + if (ContextEntity.TryGetComponent(out ISpriteComponent? sprite)) + { + SpriteComp = sprite; + OriginalDrawDepth = SpriteComp.DrawDepth; + } + OutlineComponent = ContextEntity.GetComponentOrNull(); + + AddChild( + new HBoxContainer + { + SeparationOverride = 6, + Children = + { + new LayoutContainer + { + Children = { new SpriteView { Sprite = SpriteComp } } + }, + new Label + { + Text = Loc.GetString(UserInterfaceManager.DebugMonitors.Visible ? $"{ContextEntity.Name} ({ContextEntity.Uid})" : ContextEntity.Name) + } + } + } + ); + } + + protected override void Draw(DrawingHandleScreen handle) + { + base.Draw(handle); + if (UserInterfaceManager.CurrentlyHovered == this) + { + OnMouseHovering?.Invoke(); + } + } + + protected override void ExitedTree() + { + OnExitedTree?.Invoke(); + base.ExitedTree(); + } + } + + public sealed class StackContextElement : ContextMenuElement + { + public event Action? OnExitedTree; + public readonly TimeSpan HoverDelay = TimeSpan.FromSeconds(0.2); + + public HashSet ContextEntities { get; } + public readonly StackContextElement? Pre; + + private readonly SpriteView _spriteView; + private readonly Label _label; + + public int EntitiesCount => ContextEntities.Count; + + public StackContextElement(IEnumerable entities, StackContextElement? pre, ContextMenuPopup? parentMenu) + : base(parentMenu) + { + Pre = pre; + ContextEntities = new(entities); + _spriteView = new SpriteView + { + Sprite = ContextEntities.First().GetComponent() + }; + _label = new Label + { + Text = Loc.GetString(ContextEntities.Count.ToString()), + StyleClasses = { StyleNano.StyleClassContextMenuCount } + }; + + LayoutContainer.SetAnchorPreset(_label, LayoutContainer.LayoutPreset.BottomRight); + LayoutContainer.SetGrowHorizontal(_label, LayoutContainer.GrowDirection.Begin); + LayoutContainer.SetGrowVertical(_label, LayoutContainer.GrowDirection.Begin); + + AddChild( + new HBoxContainer() + { + SeparationOverride = 6, + Children = + { + new LayoutContainer { Children = { _spriteView, _label } }, + new HBoxContainer() + { + SeparationOverride = 6, + Children = + { + new Label + { + Text = Loc.GetString(ContextEntities.First().Name) + }, + new TextureRect + { + Texture = IoCManager.Resolve().GetTexture("/Textures/Interface/VerbIcons/group.svg.96dpi.png"), + Stretch = TextureRect.StretchMode.KeepCentered, + } + }, + } + }, + } + ); + } + + protected override void ExitedTree() + { + OnExitedTree?.Invoke(); + base.ExitedTree(); + } + + public void RemoveEntity(IEntity entity) + { + ContextEntities.Remove(entity); + + _label.Text = Loc.GetString(ContextEntities.Count.ToString()); + _spriteView.Sprite = ContextEntities.FirstOrDefault(e => !e.Deleted)?.GetComponent(); + } + } + + public sealed class ContextMenuPopup : Popup + { + private static readonly Color DefaultColor = Color.FromHex("#1116"); + private static readonly Color MarginColor = Color.FromHex("#222E"); + private const int MaxItemsBeforeScroll = 10; + + public VBoxContainer List { get; } + public int Depth { get; } + + public ContextMenuPopup(int depth = 0) + { + Depth = depth; + AddChild(new ScrollContainer + { + HScrollEnabled = false, + Children = { new PanelContainer + { + Children = { (List = new VBoxContainer()) }, + PanelOverride = new StyleBoxFlat { BackgroundColor = MarginColor } + }} + }); + } + + public void AddToMenu(ContextMenuElement element) + { + List.AddChild(new PanelContainer + { + Children = { element }, + Margin = new Thickness(0,0,0, 2), + PanelOverride = new StyleBoxFlat {BackgroundColor = DefaultColor} + }); + } + + public void RemoveFromMenu(ContextMenuElement element) + { + List.RemoveChild(element.Parent!); + InvalidateMeasure(); + } + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + if (List.ChildCount == 0) + { + return Vector2.Zero; + } + + List.Measure(availableSize); + var listSize = List.DesiredSize; + + if (List.ChildCount < MaxItemsBeforeScroll) + { + return listSize; + } + listSize.Y = MaxItemsBeforeScroll * 32 + MaxItemsBeforeScroll * 2; + return listSize; + } + } +} diff --git a/Content.Client/UserInterface/ContextMenu/ContextMenuView.cs b/Content.Client/UserInterface/ContextMenu/ContextMenuView.cs new file mode 100644 index 0000000000..3d7bacd94e --- /dev/null +++ b/Content.Client/UserInterface/ContextMenu/ContextMenuView.cs @@ -0,0 +1,265 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using Robust.Client.UserInterface; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Maths; + +namespace Content.Client.UserInterface.ContextMenu +{ + public interface IContextMenuView : IDisposable + { + Dictionary Elements { get; set; } + Stack Menus { get; } + event EventHandler<(GUIBoundKeyEventArgs, SingleContextElement)>? OnKeyBindDownSingle; + event EventHandler? OnMouseEnteredSingle; + event EventHandler? OnMouseExitedSingle; + event EventHandler? OnMouseHoveringSingle; + + event EventHandler<(GUIBoundKeyEventArgs, StackContextElement)>? OnKeyBindDownStack; + event EventHandler? OnMouseEnteredStack; + + event EventHandler? OnExitedTree; + + event EventHandler? OnCloseRootMenu; + event EventHandler? OnCloseChildMenu; + + void UpdateParents(ContextMenuElement element); + void RemoveEntity(IEntity element); + void AddRootMenu(List entities); + void AddChildMenu(IEnumerable entities, Vector2 position, StackContextElement? stack); + void CloseContextPopups(int depth); + void CloseContextPopups(); + + void OnGroupingContextMenuChanged(int obj); + } + + public partial class ContextMenuView : IContextMenuView + { + [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; + + public Stack Menus { get; } + public Dictionary Elements { get; set; } + + public event EventHandler<(GUIBoundKeyEventArgs, SingleContextElement)>? OnKeyBindDownSingle; + public event EventHandler? OnMouseEnteredSingle; + public event EventHandler? OnMouseExitedSingle; + public event EventHandler? OnMouseHoveringSingle; + + public event EventHandler<(GUIBoundKeyEventArgs, StackContextElement)>? OnKeyBindDownStack; + public event EventHandler? OnMouseEnteredStack; + + public event EventHandler? OnExitedTree; + + public event EventHandler? OnCloseRootMenu; + public event EventHandler? OnCloseChildMenu; + + public ContextMenuView() + { + IoCManager.InjectDependencies(this); + Menus = new Stack(); + Elements = new Dictionary(); + } + + public void AddRootMenu(List entities) + { + Elements = new Dictionary(entities.Count); + + var rootContextMenu = new ContextMenuPopup(); + rootContextMenu.OnPopupHide += () => OnCloseRootMenu?.Invoke(this, EventArgs.Empty); + Menus.Push(rootContextMenu); + + var entitySpriteStates = GroupEntities(entities); + var orderedStates = entitySpriteStates.ToList(); + orderedStates.Sort((x, y) => string.CompareOrdinal(x.First().Prototype!.Name, y.First().Prototype!.Name)); + AddToUI(orderedStates); + + _userInterfaceManager.ModalRoot.AddChild(rootContextMenu); + var size = rootContextMenu.List.CombinedMinimumSize; + var box = UIBox2.FromDimensions(_userInterfaceManager.MousePositionScaled, size); + rootContextMenu.Open(box); + } + public void AddChildMenu(IEnumerable entities, Vector2 position, StackContextElement? stack) + { + if (stack == null) return; + var newDepth = stack.ParentMenu?.Depth + 1 ?? 1; + var childContextMenu = new ContextMenuPopup(newDepth); + Menus.Push(childContextMenu); + + var orderedStates = GroupEntities(entities, newDepth); + AddToUI(orderedStates, stack); + + _userInterfaceManager.ModalRoot.AddChild(childContextMenu); + var size = childContextMenu.List.CombinedMinimumSize; + childContextMenu.Open(UIBox2.FromDimensions(position + (stack.Width, 0), size)); + } + + private void AddToUI(List> entities, StackContextElement? stack = null) + { + if (entities.Count == 1) + { + foreach (var entity in entities[0]) + { + AddSingleContextElement(entity, stack); + } + } + else + { + foreach (var entity in entities) + { + if (entity.Count == 1) + { + AddSingleContextElement(entity[0], stack); + } + else + { + AddStackContextElement(entity, stack); + } + } + } + } + private void AddSingleContextElement(IEntity entity, StackContextElement? pre) + { + if (Menus.TryPeek(out var menu)) + { + var single = new SingleContextElement(entity, pre, menu); + + single.OnKeyBindDown += args => OnKeyBindDownSingle?.Invoke(this, (args, single)); + single.OnMouseEntered += _ => OnMouseEnteredSingle?.Invoke(this, single); + single.OnMouseExited += _ => OnMouseExitedSingle?.Invoke(this, single); + single.OnMouseHovering += () => OnMouseHoveringSingle?.Invoke(this, single); + single.OnExitedTree += () => OnExitedTree?.Invoke(this, single); + + UpdateElements(entity, single); + menu.AddToMenu(single); + } + } + private void AddStackContextElement(IEnumerable entities, StackContextElement? pre) + { + if (Menus.TryPeek(out var menu)) + { + var stack = new StackContextElement(entities, pre, menu); + + stack.OnKeyBindDown += args => OnKeyBindDownStack?.Invoke(this, (args, stack)); + stack.OnMouseEntered += _ => OnMouseEnteredStack?.Invoke(this, stack); + stack.OnExitedTree += () => OnExitedTree?.Invoke(this, stack); + + foreach (var entity in entities) + { + UpdateElements(entity, stack); + } + menu.AddToMenu(stack); + } + } + private void UpdateElements(IEntity entity, ContextMenuElement element) + { + if (Elements.ContainsKey(entity)) + { + Elements[entity] = element; + } + else + { + Elements.Add(entity, element); + } + } + + private void RemoveFromUI(ContextMenuElement element) + { + var menu = element.ParentMenu; + if (menu != null) + { + menu.RemoveFromMenu(element); + if (menu.List.ChildCount == 0) + { + OnCloseChildMenu?.Invoke(this, menu.Depth - 1); + } + } + } + public void RemoveEntity(IEntity entity) + { + var element = Elements[entity]; + switch (element) + { + case SingleContextElement singleContextElement: + RemoveFromUI(singleContextElement); + UpdateBranch(entity, singleContextElement.Pre); + break; + case StackContextElement stackContextElement: + stackContextElement.RemoveEntity(entity); + if (stackContextElement.EntitiesCount == 0) + { + RemoveFromUI(stackContextElement); + } + UpdateBranch(entity, stackContextElement.Pre); + break; + default: + throw new ArgumentOutOfRangeException(nameof(element)); + } + Elements.Remove(entity); + } + private void UpdateBranch(IEntity entity, StackContextElement? stack) + { + while (stack != null) + { + stack.RemoveEntity(entity); + if (stack.EntitiesCount == 0) + { + RemoveFromUI(stack); + } + + stack = stack.Pre; + } + } + + public void UpdateParents(ContextMenuElement element) + { + switch (element) + { + case SingleContextElement singleContextElement: + if (singleContextElement.Pre != null) + { + Elements[singleContextElement.ContextEntity] = singleContextElement.Pre; + } + + break; + case StackContextElement stackContextElement: + if (stackContextElement.Pre != null) + { + foreach (var entity in stackContextElement.ContextEntities) + { + Elements[entity] = stackContextElement.Pre; + } + } + + break; + default: + throw new ArgumentOutOfRangeException(nameof(element)); + } + } + + public void CloseContextPopups() + { + while (Menus.Count > 0) + { + Menus.Pop().Dispose(); + } + + Elements.Clear(); + } + public void CloseContextPopups(int depth) + { + while (Menus.Count > 0 && Menus.Peek().Depth > depth) + { + Menus.Pop().Dispose(); + } + } + + public void Dispose() + { + CloseContextPopups(); + } + } +} diff --git a/Content.Client/UserInterface/ContextMenu/ContextMenuViewGrouping.cs b/Content.Client/UserInterface/ContextMenu/ContextMenuViewGrouping.cs new file mode 100644 index 0000000000..1a2c2add44 --- /dev/null +++ b/Content.Client/UserInterface/ContextMenu/ContextMenuViewGrouping.cs @@ -0,0 +1,118 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Robust.Client.GameObjects; +using Robust.Shared.GameObjects; + +namespace Content.Client.UserInterface.ContextMenu +{ + public partial class ContextMenuView + { + public const int GroupingTypesCount = 2; + private int GroupingContextMenuType { get; set; } + public void OnGroupingContextMenuChanged(int obj) + { + CloseContextPopups(); + GroupingContextMenuType = obj; + } + + private List> GroupEntities(IEnumerable entities, int depth = 0) + { + if (GroupingContextMenuType == 0) + { + var newEntities = entities.GroupBy(e => e, new PrototypeContextMenuComparer()).ToList(); + return newEntities.Select(grp => grp.ToList()).ToList(); + } + else + { + var newEntities = entities.GroupBy(e => e, new PrototypeAndStatesContextMenuComparer(depth)).ToList(); + return newEntities.Select(grp => grp.ToList()).ToList(); + } + } + + private sealed class PrototypeAndStatesContextMenuComparer : IEqualityComparer + { + private static readonly List> EqualsList = new() + { + (a, b) => a.Prototype!.ID == b.Prototype!.ID, + (a, b) => + { + var xStates = a.GetComponent().AllLayers.Where(e => e.Visible).Select(s => s.RsiState.Name); + var yStates = b.GetComponent().AllLayers.Where(e => e.Visible).Select(s => s.RsiState.Name); + + return xStates.OrderBy(t => t).SequenceEqual(yStates.OrderBy(t => t)); + }, + }; + private static readonly List> GetHashCodeList = new() + { + e => EqualityComparer.Default.GetHashCode(e.Prototype!.ID), + e => + { + var hash = 0; + foreach (var element in e.GetComponent().AllLayers.Where(obj => obj.Visible).Select(s => s.RsiState.Name)) + { + hash ^= EqualityComparer.Default.GetHashCode(element!); + } + return hash; + }, + }; + + private static int Count => EqualsList.Count - 1; + + private readonly int _depth; + public PrototypeAndStatesContextMenuComparer(int step = 0) + { + _depth = step > Count ? Count : step; + } + + public bool Equals(IEntity? x, IEntity? y) + { + if (x == null) + { + return y == null; + } + + return y != null && EqualsList[_depth](x, y); + } + + public int GetHashCode(IEntity e) + { + return GetHashCodeList[_depth](e); + } + } + + private sealed class PrototypeContextMenuComparer : IEqualityComparer + { + public bool Equals(IEntity? x, IEntity? y) + { + if (x == null) + { + return y == null; + } + if (y != null) + { + if (x.Prototype?.ID == y.Prototype?.ID) + { + var xStates = x.GetComponent().AllLayers.Where(e => e.Visible).Select(s => s.RsiState.Name); + var yStates = y.GetComponent().AllLayers.Where(e => e.Visible).Select(s => s.RsiState.Name); + + return xStates.OrderBy(t => t).SequenceEqual(yStates.OrderBy(t => t)); + } + } + return false; + } + + public int GetHashCode(IEntity e) + { + var hash = EqualityComparer.Default.GetHashCode(e.Prototype?.ID!); + foreach (var element in e.GetComponent().AllLayers.Where(obj => obj.Visible).Select(s => s.RsiState.Name)) + { + hash ^= EqualityComparer.Default.GetHashCode(element!); + } + + return hash; + } + } + } +} diff --git a/Content.Client/UserInterface/Stylesheets/StyleNano.cs b/Content.Client/UserInterface/Stylesheets/StyleNano.cs index 83186507b7..efaac6f9d3 100644 --- a/Content.Client/UserInterface/Stylesheets/StyleNano.cs +++ b/Content.Client/UserInterface/Stylesheets/StyleNano.cs @@ -1,4 +1,5 @@ -using System.Linq; +#nullable enable +using System.Linq; using Content.Client.GameObjects.EntitySystems; using Content.Client.UserInterface.Controls; using Content.Client.Utility; @@ -30,6 +31,7 @@ namespace Content.Client.UserInterface.Stylesheets public const string StyleClassHotbarSlotNumber = "hotbarSlotNumber"; public const string StyleClassActionSearchBox = "actionSearchBox"; public const string StyleClassActionMenuItemRevoked = "actionMenuItemRevoked"; + public const string StyleClassContextMenuCount = "contextMenuCount"; public const string StyleClassSliderRed = "Red"; public const string StyleClassSliderGreen = "Green"; @@ -635,6 +637,13 @@ namespace Content.Client.UserInterface.Stylesheets new StyleProperty("font", notoSans15) }), + // small number for the context menu + new StyleRule(new SelectorElement(typeof(Label), new[] {StyleClassContextMenuCount}, null, null), new[] + { + new StyleProperty("font", notoSans10), + new StyleProperty(Label.StylePropertyAlignMode, Label.AlignMode.Right), + }), + // hotbar slot new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassHotbarSlotNumber}, null, null), new[] { diff --git a/Content.Shared/CCVars.cs b/Content.Shared/CCVars.cs index 816d91f022..8d23542ca8 100644 --- a/Content.Shared/CCVars.cs +++ b/Content.Shared/CCVars.cs @@ -1,3 +1,4 @@ + using Robust.Shared; using Robust.Shared.Configuration; @@ -269,5 +270,10 @@ namespace Content.Shared public static readonly CVarDef AdminOocEnabled = CVarDef.Create("ooc.enabled_admin", true, CVar.NOTIFY); + + /* + * Context Menu Grouping Types + */ + public static readonly CVarDef ContextMenuGroupingType = CVarDef.Create("context_menu", 0, CVar.CLIENTONLY); } } diff --git a/Content.Shared/GameObjects/DrawDepth.cs b/Content.Shared/GameObjects/DrawDepth.cs index eea05d7d94..22771f551f 100644 --- a/Content.Shared/GameObjects/DrawDepth.cs +++ b/Content.Shared/GameObjects/DrawDepth.cs @@ -22,8 +22,9 @@ namespace Content.Shared.GameObjects Objects = DrawDepthTag.Default, Items = DrawDepthTag.Default + 1, Mobs = DrawDepthTag.Default + 2, - Effects = DrawDepthTag.Default + 3, - Ghosts = DrawDepthTag.Default + 4, - Overlays = DrawDepthTag.Default + 5, + HighlightedItems = DrawDepthTag.Default + 3, + Effects = DrawDepthTag.Default + 4, + Ghosts = DrawDepthTag.Default + 5, + Overlays = DrawDepthTag.Default + 6, } } diff --git a/Content.Shared/GameObjects/Verbs/SharedVerbSystem.cs b/Content.Shared/GameObjects/Verbs/SharedVerbSystem.cs index 1adc5d79cb..d20cbf987d 100644 --- a/Content.Shared/GameObjects/Verbs/SharedVerbSystem.cs +++ b/Content.Shared/GameObjects/Verbs/SharedVerbSystem.cs @@ -20,7 +20,7 @@ namespace Content.Shared.GameObjects.Verbs /// /// Whether we should slightly extend out the ignored range for the ray predicated /// - protected bool TryGetContextEntities(IEntity player, MapCoordinates targetPos, [NotNullWhen(true)] out List? contextEntities, bool buffer = false) + public bool TryGetContextEntities(IEntity player, MapCoordinates targetPos, [NotNullWhen(true)] out List? contextEntities, bool buffer = false) { contextEntities = null; var length = buffer ? 1.0f: 0.5f;