From 4a04f8df83fe936bf5e937b51be2e688a3c2b017 Mon Sep 17 00:00:00 2001 From: xan2622 Date: Thu, 12 Feb 2026 22:28:44 +0100 Subject: [PATCH] Add command history to single-player and multiplayer The last 50 valid commands starting with / are saved to memory. Regular chat messages are not saved to prevent spam. The command history persists across single-player and multiplayer games. Press arrow up/down to show the previously typed commands within the current OpenRA session. --- .../Widgets/Logic/CommandHistory.cs | 89 +++++++++++++++++++ .../Widgets/Logic/Ingame/IngameChatLogic.cs | 40 +++++++++ OpenRA.Mods.Common/Widgets/TextFieldWidget.cs | 12 +++ 3 files changed, 141 insertions(+) create mode 100644 OpenRA.Mods.Common/Widgets/Logic/CommandHistory.cs diff --git a/OpenRA.Mods.Common/Widgets/Logic/CommandHistory.cs b/OpenRA.Mods.Common/Widgets/Logic/CommandHistory.cs new file mode 100644 index 0000000000..a888db6304 --- /dev/null +++ b/OpenRA.Mods.Common/Widgets/Logic/CommandHistory.cs @@ -0,0 +1,89 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System.Collections.Generic; + +namespace OpenRA.Mods.Common.Widgets.Logic +{ + public sealed class CommandHistory + { + const int MaxHistorySize = 50; + static CommandHistory instance; + static readonly object LockObject = new(); + readonly List history = []; + int currentIndex = -1; + + CommandHistory() { } + + public static CommandHistory Instance + { + get + { + if (instance == null) + { + lock (LockObject) + { + instance ??= new CommandHistory(); + } + } + + return instance; + } + } + + public void AddCommand(string command) + { + if (string.IsNullOrWhiteSpace(command) || !command.StartsWith('/')) + return; + + var trimmedCommand = command.Trim(); + history.RemoveAll(c => c == trimmedCommand); + history.Insert(0, trimmedCommand); + if (history.Count > MaxHistorySize) + history.RemoveAt(history.Count - 1); + + currentIndex = -1; + } + + public string GetPrevious() + { + if (history.Count == 0) + return null; + + if (currentIndex < history.Count - 1) + currentIndex++; + + return history[currentIndex]; + } + + public string GetNext() + { + if (history.Count == 0) + return null; + + if (currentIndex > 0) + { + currentIndex--; + return history[currentIndex]; + } + + if (currentIndex == 0) + currentIndex = -1; + + return null; + } + + public void Reset() + { + currentIndex = -1; + } + } +} diff --git a/OpenRA.Mods.Common/Widgets/Logic/Ingame/IngameChatLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Ingame/IngameChatLogic.cs index a4a36cf50c..073f12e8d3 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Ingame/IngameChatLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Ingame/IngameChatLogic.cs @@ -49,6 +49,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic readonly Dictionary templates = []; readonly TabCompletionLogic tabCompletion = new(); + readonly CommandHistory commandHistory = CommandHistory.Instance; readonly string chatLineSound = ChromeMetrics.Get("ChatLineSound"); @@ -159,11 +160,18 @@ namespace OpenRA.Mods.Common.Widgets.Logic { var text = chatText.Text.Trim(); var from = world.IsReplay ? null : orderManager.LocalClient.Name; + var commandName = text[1..].Split(' ')[0].ToLowerInvariant(); + var isValidCommand = !string.IsNullOrEmpty(commandName) && + chatTraits.OfType().Any(trait => trait.Commands.ContainsKey(commandName)); foreach (var trait in chatTraits) trait.OnChat(from, text); + + if (isValidCommand) + commandHistory.AddCommand(text); } } + commandHistory.Reset(); chatText.Text = ""; if (!isMenuChat) CloseChat(); @@ -191,9 +199,40 @@ namespace OpenRA.Mods.Common.Widgets.Logic else chatText.YieldKeyboardFocus(); + commandHistory.Reset(); return true; }; + chatText.OnArrowUp = _ => + { + var previousCommand = commandHistory.GetPrevious(); + if (previousCommand != null) + { + chatText.Text = previousCommand; + chatText.CursorPosition = chatText.Text.Length; + return true; + } + + return false; + }; + + chatText.OnArrowDown = _ => + { + var nextCommand = commandHistory.GetNext(); + if (nextCommand != null) + { + chatText.Text = nextCommand; + chatText.CursorPosition = chatText.Text.Length; + return true; + } + else + { + chatText.Text = ""; + commandHistory.Reset(); + return true; + } + }; + chatAvailableIn = new CachedTransform(x => FluentProvider.GetMessage(ChatAvailability, "seconds", x)); if (!isMenuChat) @@ -265,6 +304,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic public void OpenChat() { chatText.Text = ""; + commandHistory.Reset(); chatChrome.Visible = true; chatScrollPanel.ScrollToBottom(); if (!chatText.IsDisabled()) diff --git a/OpenRA.Mods.Common/Widgets/TextFieldWidget.cs b/OpenRA.Mods.Common/Widgets/TextFieldWidget.cs index acc8572181..94b2797d4c 100644 --- a/OpenRA.Mods.Common/Widgets/TextFieldWidget.cs +++ b/OpenRA.Mods.Common/Widgets/TextFieldWidget.cs @@ -57,6 +57,8 @@ namespace OpenRA.Mods.Common.Widgets public Func OnEnterKey = _ => false; public Func OnTabKey = _ => false; public Func OnEscKey = _ => false; + public Func OnArrowUp = _ => false; + public Func OnArrowDown = _ => false; public Func OnAltKey = () => false; public Action OnLoseFocus = () => { }; public Action OnTextEdited = () => { }; @@ -282,6 +284,16 @@ namespace OpenRA.Mods.Common.Widgets break; + case Keycode.UP: + if (OnArrowUp(e)) + return true; + break; + + case Keycode.DOWN: + if (OnArrowDown(e)) + return true; + break; + case Keycode.HOME: ResetBlinkCycle(); if (e.Modifiers.HasModifier(Modifiers.Shift))