Files
OpenRA/OpenRA.Mods.Common/Widgets/Logic/Settings/HotkeysSettingsLogic.cs
RoosterDragon b7e0ed9b87 Improve lookups of nodes by key in MiniYaml.
When handling the Nodes collection in MiniYaml, individual nodes are located via one of two methods:

// Lookup a single key with linear search.
var node = yaml.Nodes.FirstOrDefault(n => n.Key == "SomeKey");

// Convert to dictionary, expecting many key lookups.
var dict = nodes.ToDictionary();

// Lookup a single key in the dictionary.
var node = dict["SomeKey"];

To simplify lookup of individual keys via linear search, provide helper methods NodeWithKeyOrDefault and NodeWithKey. These helpers do the equivalent of Single{OrDefault} searches. Whilst this requires checking the whole list, it provides a useful correctness check. Two duplicated keys in TS yaml are fixed as a result. We can also optimize the helpers to not use LINQ, avoiding allocation of the delegate to search for a key.

Adjust existing code to use either lnear searches or dictionary lookups based on whether it will be resolving many keys. Resolving few keys can be done with linear searches to avoid building a dictionary. Resolving many keys should be done with a dictionary to avoid quaradtic runtime from repeated linear searches.
2023-09-23 14:31:04 +02:00

362 lines
12 KiB
C#

#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;
using System.Collections.Generic;
using System.Linq;
using OpenRA.Primitives;
using OpenRA.Widgets;
namespace OpenRA.Mods.Common.Widgets.Logic
{
public class HotkeysSettingsLogic : ChromeLogic
{
[TranslationReference("key")]
const string OriginalNotice = "label-original-notice";
[TranslationReference("key", "context")]
const string DuplicateNotice = "label-duplicate-notice";
readonly ModData modData;
readonly Dictionary<string, MiniYaml> logicArgs;
ScrollPanelWidget hotkeyList;
ButtonWidget selectedHotkeyButton;
HotkeyEntryWidget hotkeyEntryWidget;
HotkeyDefinition duplicateHotkeyDefinition, selectedHotkeyDefinition;
int validHotkeyEntryWidth;
int invalidHotkeyEntryWidth;
bool isHotkeyValid;
bool isHotkeyDefault;
string currentContext = "Any";
readonly HashSet<string> contexts = new() { "Any" };
readonly Dictionary<string, HashSet<string>> hotkeyGroups = new();
TextFieldWidget filterInput;
Widget headerTemplate;
Widget template;
Widget emptyListMessage;
Widget remapDialog;
static HotkeysSettingsLogic() { }
[ObjectCreator.UseCtor]
public HotkeysSettingsLogic(Action<string, string, Func<Widget, Func<bool>>, Func<Widget, Action>> registerPanel, string panelID, string label, ModData modData, Dictionary<string, MiniYaml> logicArgs)
{
this.modData = modData;
this.logicArgs = logicArgs;
registerPanel(panelID, label, InitPanel, ResetPanel);
}
void BindHotkeyPref(HotkeyDefinition hd, Widget template)
{
var key = template.Clone();
key.Id = hd.Name;
key.IsVisible = () => true;
key.Get<LabelWidget>("FUNCTION").GetText = () => hd.Description + ":";
var remapButton = key.Get<ButtonWidget>("HOTKEY");
WidgetUtils.TruncateButtonToTooltip(remapButton, modData.Hotkeys[hd.Name].GetValue().DisplayString());
remapButton.IsHighlighted = () => selectedHotkeyDefinition == hd;
var hotkeyValidColor = ChromeMetrics.Get<Color>("HotkeyColor");
var hotkeyInvalidColor = ChromeMetrics.Get<Color>("HotkeyColorInvalid");
remapButton.GetColor = () => hd.HasDuplicates ? hotkeyInvalidColor : hotkeyValidColor;
if (selectedHotkeyDefinition == hd)
{
selectedHotkeyButton = remapButton;
hotkeyEntryWidget.Key = modData.Hotkeys[hd.Name].GetValue();
ValidateHotkey();
}
remapButton.OnClick = () =>
{
selectedHotkeyDefinition = hd;
selectedHotkeyButton = remapButton;
hotkeyEntryWidget.Key = modData.Hotkeys[hd.Name].GetValue();
ValidateHotkey();
if (hd.Readonly)
hotkeyEntryWidget.YieldKeyboardFocus();
else
hotkeyEntryWidget.TakeKeyboardFocus();
};
hotkeyList.AddChild(key);
}
Func<bool> InitPanel(Widget panel)
{
hotkeyList = panel.Get<ScrollPanelWidget>("HOTKEY_LIST");
hotkeyList.Layout = new GridLayout(hotkeyList);
headerTemplate = hotkeyList.Get("HEADER");
template = hotkeyList.Get("TEMPLATE");
emptyListMessage = panel.Get("HOTKEY_EMPTY_LIST");
remapDialog = panel.Get("HOTKEY_REMAP_DIALOG");
foreach (var hd in modData.Hotkeys.Definitions)
contexts.UnionWith(hd.Contexts);
filterInput = panel.Get<TextFieldWidget>("FILTER_INPUT");
filterInput.OnTextEdited = () => InitHotkeyList();
filterInput.OnEscKey = _ =>
{
if (string.IsNullOrEmpty(filterInput.Text))
filterInput.YieldKeyboardFocus();
else
{
filterInput.Text = "";
filterInput.OnTextEdited();
}
return true;
};
var contextDropdown = panel.GetOrNull<DropDownButtonWidget>("CONTEXT_DROPDOWN");
if (contextDropdown != null)
{
contextDropdown.OnMouseDown = _ => ShowContextDropdown(contextDropdown);
var contextName = new CachedTransform<string, string>(GetContextDisplayName);
contextDropdown.GetText = () => contextName.Update(currentContext);
}
if (logicArgs.TryGetValue("HotkeyGroups", out var hotkeyGroupsYaml))
{
foreach (var hg in hotkeyGroupsYaml.Nodes)
{
var typesNode = hg.Value.NodeWithKeyOrDefault("Types");
if (typesNode != null)
hotkeyGroups.Add(hg.Key, FieldLoader.GetValue<HashSet<string>>("Types", typesNode.Value.Value));
}
InitHotkeyRemapDialog(panel);
InitHotkeyList();
}
return () =>
{
hotkeyEntryWidget.Key =
selectedHotkeyDefinition != null ?
modData.Hotkeys[selectedHotkeyDefinition.Name].GetValue() :
Hotkey.Invalid;
hotkeyEntryWidget.ForceYieldKeyboardFocus();
return false;
};
}
Action ResetPanel(Widget panel)
{
return () =>
{
foreach (var hd in modData.Hotkeys.Definitions)
{
modData.Hotkeys.Set(hd.Name, hd.Default);
var hotkeyButton = panel.GetOrNull(hd.Name)?.Get<ButtonWidget>("HOTKEY");
if (hotkeyButton != null)
WidgetUtils.TruncateButtonToTooltip(hotkeyButton, hd.Default.DisplayString());
}
};
}
void InitHotkeyList()
{
hotkeyList.RemoveChildren();
selectedHotkeyDefinition = null;
foreach (var hg in hotkeyGroups)
{
var typesInGroup = hg.Value;
var keysInGroup = modData.Hotkeys.Definitions
.Where(hd => IsHotkeyVisibleInFilter(hd) && hd.Types.Overlaps(typesInGroup))
.ToList();
if (keysInGroup.Count == 0)
continue;
var header = headerTemplate.Clone();
header.Get<LabelWidget>("LABEL").GetText = () => hg.Key;
hotkeyList.AddChild(header);
var added = new HashSet<HotkeyDefinition>();
foreach (var type in typesInGroup)
{
foreach (var hd in keysInGroup.Where(k => k.Types.Contains(type)))
{
if (added.Add(hd))
{
selectedHotkeyDefinition ??= hd;
BindHotkeyPref(hd, template);
}
}
}
}
emptyListMessage.Visible = selectedHotkeyDefinition == null;
remapDialog.Visible = selectedHotkeyDefinition != null;
hotkeyList.ScrollToTop();
}
void InitHotkeyRemapDialog(Widget panel)
{
var label = panel.Get<LabelWidget>("HOTKEY_LABEL");
var labelText = new CachedTransform<HotkeyDefinition, string>(hd => hd?.Description + ":");
label.IsVisible = () => selectedHotkeyDefinition != null;
label.GetText = () => labelText.Update(selectedHotkeyDefinition);
var duplicateNotice = panel.Get<LabelWidget>("DUPLICATE_NOTICE");
duplicateNotice.TextColor = ChromeMetrics.Get<Color>("NoticeErrorColor");
duplicateNotice.IsVisible = () => !isHotkeyValid;
var duplicateNoticeText = new CachedTransform<HotkeyDefinition, string>(hd =>
hd != null ?
TranslationProvider.GetString(DuplicateNotice, Translation.Arguments("key", hd.Description,
"context", hd.Contexts.First(c => selectedHotkeyDefinition.Contexts.Contains(c)))) :
"");
duplicateNotice.GetText = () => duplicateNoticeText.Update(duplicateHotkeyDefinition);
var originalNotice = panel.Get<LabelWidget>("ORIGINAL_NOTICE");
originalNotice.TextColor = ChromeMetrics.Get<Color>("NoticeInfoColor");
originalNotice.IsVisible = () => isHotkeyValid && !isHotkeyDefault;
var originalNoticeText = new CachedTransform<HotkeyDefinition, string>(hd =>
TranslationProvider.GetString(OriginalNotice, Translation.Arguments("key", hd?.Default.DisplayString())));
originalNotice.GetText = () => originalNoticeText.Update(selectedHotkeyDefinition);
var readonlyNotice = panel.Get<LabelWidget>("READONLY_NOTICE");
readonlyNotice.TextColor = ChromeMetrics.Get<Color>("NoticeInfoColor");
readonlyNotice.IsVisible = () => selectedHotkeyDefinition.Readonly;
var resetButton = panel.Get<ButtonWidget>("RESET_HOTKEY_BUTTON");
resetButton.IsDisabled = () => isHotkeyDefault || selectedHotkeyDefinition.Readonly;
resetButton.OnClick = ResetHotkey;
var clearButton = panel.Get<ButtonWidget>("CLEAR_HOTKEY_BUTTON");
clearButton.IsDisabled = () => selectedHotkeyDefinition.Readonly || !hotkeyEntryWidget.Key.IsValid();
clearButton.OnClick = ClearHotkey;
var overrideButton = panel.Get<ButtonWidget>("OVERRIDE_HOTKEY_BUTTON");
overrideButton.IsDisabled = () => isHotkeyValid;
overrideButton.IsVisible = () => !isHotkeyValid && !duplicateHotkeyDefinition.Readonly;
overrideButton.OnClick = OverrideHotkey;
hotkeyEntryWidget = panel.Get<HotkeyEntryWidget>("HOTKEY_ENTRY");
hotkeyEntryWidget.IsValid = () => isHotkeyValid;
hotkeyEntryWidget.OnLoseFocus = ValidateHotkey;
hotkeyEntryWidget.OnEscKey = _ =>
hotkeyEntryWidget.Key = modData.Hotkeys[selectedHotkeyDefinition.Name].GetValue();
hotkeyEntryWidget.IsDisabled = () => selectedHotkeyDefinition.Readonly;
validHotkeyEntryWidth = hotkeyEntryWidget.Bounds.Width;
invalidHotkeyEntryWidth = validHotkeyEntryWidth - (clearButton.Bounds.X - overrideButton.Bounds.X);
}
void ValidateHotkey()
{
if (selectedHotkeyDefinition == null)
return;
duplicateHotkeyDefinition = modData.Hotkeys.GetFirstDuplicate(selectedHotkeyDefinition, hotkeyEntryWidget.Key);
isHotkeyValid = duplicateHotkeyDefinition == null || selectedHotkeyDefinition.Readonly;
isHotkeyDefault = hotkeyEntryWidget.Key == selectedHotkeyDefinition.Default || (!hotkeyEntryWidget.Key.IsValid() && !selectedHotkeyDefinition.Default.IsValid());
if (isHotkeyValid)
{
hotkeyEntryWidget.Bounds.Width = validHotkeyEntryWidth;
SaveHotkey();
}
else
{
hotkeyEntryWidget.Bounds.Width = duplicateHotkeyDefinition.Readonly ? validHotkeyEntryWidth : invalidHotkeyEntryWidth;
hotkeyEntryWidget.TakeKeyboardFocus();
}
}
void SaveHotkey()
{
if (selectedHotkeyDefinition.Readonly)
return;
WidgetUtils.TruncateButtonToTooltip(selectedHotkeyButton, hotkeyEntryWidget.Key.DisplayString());
modData.Hotkeys.Set(selectedHotkeyDefinition.Name, hotkeyEntryWidget.Key);
Game.Settings.Save();
}
void ResetHotkey()
{
hotkeyEntryWidget.Key = selectedHotkeyDefinition.Default;
hotkeyEntryWidget.YieldKeyboardFocus();
}
void ClearHotkey()
{
hotkeyEntryWidget.Key = Hotkey.Invalid;
hotkeyEntryWidget.YieldKeyboardFocus();
}
void OverrideHotkey()
{
var duplicateHotkeyButton = hotkeyList.GetOrNull<ContainerWidget>(duplicateHotkeyDefinition.Name)?.Get<ButtonWidget>("HOTKEY");
if (duplicateHotkeyButton != null)
WidgetUtils.TruncateButtonToTooltip(duplicateHotkeyButton, Hotkey.Invalid.DisplayString());
modData.Hotkeys.Set(duplicateHotkeyDefinition.Name, Hotkey.Invalid);
Game.Settings.Save();
hotkeyEntryWidget.YieldKeyboardFocus();
}
bool IsHotkeyVisibleInFilter(HotkeyDefinition hd)
{
var filter = filterInput.Text;
var isFilteredByName = string.IsNullOrWhiteSpace(filter) ||
hd.Description.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
var isFilteredByContext = currentContext == "Any" || hd.Contexts.Contains(currentContext);
return isFilteredByName && isFilteredByContext;
}
bool ShowContextDropdown(DropDownButtonWidget dropdown)
{
hotkeyEntryWidget.YieldKeyboardFocus();
var contextName = new CachedTransform<string, string>(GetContextDisplayName);
ScrollItemWidget SetupItem(string context, ScrollItemWidget itemTemplate)
{
var item = ScrollItemWidget.Setup(itemTemplate,
() => currentContext == context,
() => { currentContext = context; InitHotkeyList(); });
item.Get<LabelWidget>("LABEL").GetText = () => contextName.Update(context);
return item;
}
dropdown.ShowDropDown("LABEL_DROPDOWN_TEMPLATE", 280, contexts, SetupItem);
return true;
}
static string GetContextDisplayName(string context)
{
if (string.IsNullOrEmpty(context))
return "Any";
return context;
}
}
}