#include "editor_server_settings.h" #include "editor.h" #include #include #include #include #include #include #include #include #include #include #include #include using namespace FontIcons; static const int FONT_SIZE = 12.0f; struct IMapSetting { enum EType { SETTING_INT, SETTING_COMMAND, }; const char *m_pName; const char *m_pHelp; EType m_Type; IMapSetting(const char *pName, const char *pHelp, EType Type) : m_pName(pName), m_pHelp(pHelp), m_Type(Type) {} }; struct SMapSettingInt : public IMapSetting { int m_Default; int m_Min; int m_Max; SMapSettingInt(const char *pName, const char *pHelp, int Default, int Min, int Max) : IMapSetting(pName, pHelp, IMapSetting::SETTING_INT), m_Default(Default), m_Min(Min), m_Max(Max) {} }; struct SMapSettingCommand : public IMapSetting { const char *m_pArgs; SMapSettingCommand(const char *pName, const char *pHelp, const char *pArgs) : IMapSetting(pName, pHelp, IMapSetting::SETTING_COMMAND), m_pArgs(pArgs) {} }; void CEditor::RenderServerSettingsEditor(CUIRect View, bool ShowServerSettingsEditorLast) { static int s_CommandSelectedIndex = -1; static CListBox s_ListBox; s_ListBox.SetActive(!m_MapSettingsCommandContext.m_DropdownContext.m_ListBox.Active() && m_Dialog == DIALOG_NONE && !Ui()->IsPopupOpen()); bool GotSelection = s_ListBox.Active() && s_CommandSelectedIndex >= 0 && (size_t)s_CommandSelectedIndex < m_Map.m_vSettings.size(); const bool CurrentInputValid = m_MapSettingsCommandContext.Valid(); // Use the context to validate the input CUIRect ToolBar, Button, Label, List, DragBar; View.HSplitTop(22.0f, &DragBar, nullptr); DragBar.y -= 2.0f; DragBar.w += 2.0f; DragBar.h += 4.0f; DoEditorDragBar(View, &DragBar, EDragSide::SIDE_TOP, &m_aExtraEditorSplits[EXTRAEDITOR_SERVER_SETTINGS]); View.HSplitTop(20.0f, &ToolBar, &View); View.HSplitTop(2.0f, nullptr, &List); ToolBar.HMargin(2.0f, &ToolBar); // delete button ToolBar.VSplitRight(25.0f, &ToolBar, &Button); ToolBar.VSplitRight(5.0f, &ToolBar, nullptr); static int s_DeleteButton = 0; if(DoButton_FontIcon(&s_DeleteButton, FONT_ICON_TRASH, GotSelection ? 0 : -1, &Button, 0, "[Delete] Delete the selected command from the command list.", IGraphics::CORNER_ALL, 9.0f) == 1 || (GotSelection && CLineInput::GetActiveInput() == nullptr && m_Dialog == DIALOG_NONE && Ui()->ConsumeHotkey(CUi::HOTKEY_DELETE))) { m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::DELETE, &s_CommandSelectedIndex, s_CommandSelectedIndex, m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand)); m_Map.m_vSettings.erase(m_Map.m_vSettings.begin() + s_CommandSelectedIndex); if(s_CommandSelectedIndex >= (int)m_Map.m_vSettings.size()) s_CommandSelectedIndex = m_Map.m_vSettings.size() - 1; if(s_CommandSelectedIndex >= 0) m_SettingsCommandInput.Set(m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand); m_Map.OnModify(); s_ListBox.ScrollToSelected(); } // move down button ToolBar.VSplitRight(25.0f, &ToolBar, &Button); const bool CanMoveDown = GotSelection && s_CommandSelectedIndex < (int)m_Map.m_vSettings.size() - 1; static int s_DownButton = 0; if(DoButton_FontIcon(&s_DownButton, FONT_ICON_SORT_DOWN, CanMoveDown ? 0 : -1, &Button, 0, "[Alt+Down] Move the selected command down.", IGraphics::CORNER_R, 11.0f) == 1 || (CanMoveDown && Input()->AltIsPressed() && Ui()->ConsumeHotkey(CUi::HOTKEY_DOWN))) { m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::MOVE_DOWN, &s_CommandSelectedIndex, s_CommandSelectedIndex)); std::swap(m_Map.m_vSettings[s_CommandSelectedIndex], m_Map.m_vSettings[s_CommandSelectedIndex + 1]); s_CommandSelectedIndex++; m_Map.OnModify(); s_ListBox.ScrollToSelected(); } // move up button ToolBar.VSplitRight(25.0f, &ToolBar, &Button); ToolBar.VSplitRight(5.0f, &ToolBar, nullptr); const bool CanMoveUp = GotSelection && s_CommandSelectedIndex > 0; static int s_UpButton = 0; if(DoButton_FontIcon(&s_UpButton, FONT_ICON_SORT_UP, CanMoveUp ? 0 : -1, &Button, 0, "[Alt+Up] Move the selected command up.", IGraphics::CORNER_L, 11.0f) == 1 || (CanMoveUp && Input()->AltIsPressed() && Ui()->ConsumeHotkey(CUi::HOTKEY_UP))) { m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::MOVE_UP, &s_CommandSelectedIndex, s_CommandSelectedIndex)); std::swap(m_Map.m_vSettings[s_CommandSelectedIndex], m_Map.m_vSettings[s_CommandSelectedIndex - 1]); s_CommandSelectedIndex--; m_Map.OnModify(); s_ListBox.ScrollToSelected(); } // redo button ToolBar.VSplitRight(25.0f, &ToolBar, &Button); static int s_RedoButton = 0; if(DoButton_FontIcon(&s_RedoButton, FONT_ICON_REDO, m_ServerSettingsHistory.CanRedo() ? 0 : -1, &Button, 0, "[Ctrl+Y] Redo command edit", IGraphics::CORNER_R, 11.0f) == 1 || (CanMoveDown && Input()->AltIsPressed() && Ui()->ConsumeHotkey(CUi::HOTKEY_DOWN))) { m_ServerSettingsHistory.Redo(); } // undo button ToolBar.VSplitRight(25.0f, &ToolBar, &Button); ToolBar.VSplitRight(5.0f, &ToolBar, nullptr); static int s_UndoButton = 0; if(DoButton_FontIcon(&s_UndoButton, FONT_ICON_UNDO, m_ServerSettingsHistory.CanUndo() ? 0 : -1, &Button, 0, "[Ctrl+Z] Undo command edit", IGraphics::CORNER_L, 11.0f) == 1 || (CanMoveUp && Input()->AltIsPressed() && Ui()->ConsumeHotkey(CUi::HOTKEY_UP))) { m_ServerSettingsHistory.Undo(); } GotSelection = s_ListBox.Active() && s_CommandSelectedIndex >= 0 && (size_t)s_CommandSelectedIndex < m_Map.m_vSettings.size(); int CollidingCommandIndex = -1; ECollisionCheckResult CheckResult = ECollisionCheckResult::ERROR; if(CurrentInputValid) CollidingCommandIndex = m_MapSettingsCommandContext.CheckCollision(CheckResult); // update button ToolBar.VSplitRight(25.0f, &ToolBar, &Button); const bool CanAdd = CheckResult == ECollisionCheckResult::ADD; const bool CanReplace = CheckResult == ECollisionCheckResult::REPLACE; const bool CanUpdate = GotSelection && CurrentInputValid && str_comp(m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, m_SettingsCommandInput.GetString()) != 0; static int s_UpdateButton = 0; if(DoButton_FontIcon(&s_UpdateButton, FONT_ICON_PENCIL, CanUpdate ? 0 : -1, &Button, 0, "[Alt+Enter] Update the selected command based on the entered value.", IGraphics::CORNER_R, 9.0f) == 1 || (CanUpdate && Input()->AltIsPressed() && m_Dialog == DIALOG_NONE && Ui()->ConsumeHotkey(CUi::HOTKEY_ENTER))) { if(CollidingCommandIndex == -1) { bool Found = false; int i; for(i = 0; i < (int)m_Map.m_vSettings.size(); ++i) { if(i != s_CommandSelectedIndex && !str_comp(m_Map.m_vSettings[i].m_aCommand, m_SettingsCommandInput.GetString())) { Found = true; break; } } if(Found) { m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::DELETE, &s_CommandSelectedIndex, s_CommandSelectedIndex, m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand)); m_Map.m_vSettings.erase(m_Map.m_vSettings.begin() + s_CommandSelectedIndex); s_CommandSelectedIndex = i > s_CommandSelectedIndex ? i - 1 : i; } else { const char *pStr = m_SettingsCommandInput.GetString(); m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::EDIT, &s_CommandSelectedIndex, s_CommandSelectedIndex, m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, pStr)); str_copy(m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, pStr); } } else { if(s_CommandSelectedIndex == CollidingCommandIndex) { // If we are editing the currently collinding line, then we can just call EDIT on it const char *pStr = m_SettingsCommandInput.GetString(); m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::EDIT, &s_CommandSelectedIndex, s_CommandSelectedIndex, m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, pStr)); str_copy(m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, pStr); } else { // If not, then editing the current selected line will result in the deletion of the colliding line, and the editing of the selected line const char *pStr = m_SettingsCommandInput.GetString(); char aBuf[256]; str_format(aBuf, sizeof(aBuf), "Delete command %d; Edit command %d", CollidingCommandIndex, s_CommandSelectedIndex); m_ServerSettingsHistory.BeginBulk(); // Delete the colliding command m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::DELETE, &s_CommandSelectedIndex, CollidingCommandIndex, m_Map.m_vSettings[CollidingCommandIndex].m_aCommand)); m_Map.m_vSettings.erase(m_Map.m_vSettings.begin() + CollidingCommandIndex); // Edit the selected command s_CommandSelectedIndex = s_CommandSelectedIndex > CollidingCommandIndex ? s_CommandSelectedIndex - 1 : s_CommandSelectedIndex; m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::EDIT, &s_CommandSelectedIndex, s_CommandSelectedIndex, m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, pStr)); str_copy(m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, pStr); m_ServerSettingsHistory.EndBulk(aBuf); } } m_Map.OnModify(); s_ListBox.ScrollToSelected(); m_SettingsCommandInput.Clear(); m_MapSettingsCommandContext.Reset(); // Reset context Ui()->SetActiveItem(&m_SettingsCommandInput); } // add button ToolBar.VSplitRight(25.0f, &ToolBar, &Button); ToolBar.VSplitRight(100.0f, &ToolBar, nullptr); static int s_AddButton = 0; if(DoButton_FontIcon(&s_AddButton, CanReplace ? FONT_ICON_ARROWS_ROTATE : FONT_ICON_PLUS, CanAdd || CanReplace ? 0 : -1, &Button, 0, CanReplace ? "[Enter] Replace the corresponding command in the command list." : "[Enter] Add a command to the command list.", IGraphics::CORNER_L) == 1 || ((CanAdd || CanReplace) && !Input()->AltIsPressed() && m_Dialog == DIALOG_NONE && Ui()->ConsumeHotkey(CUi::HOTKEY_ENTER))) { if(CanReplace) { dbg_assert(CollidingCommandIndex != -1, "Could not replace command"); s_CommandSelectedIndex = CollidingCommandIndex; const char *pStr = m_SettingsCommandInput.GetString(); m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::EDIT, &s_CommandSelectedIndex, s_CommandSelectedIndex, m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, pStr)); str_copy(m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand, pStr); } else if(CanAdd) { m_Map.m_vSettings.emplace_back(m_SettingsCommandInput.GetString()); s_CommandSelectedIndex = m_Map.m_vSettings.size() - 1; m_ServerSettingsHistory.RecordAction(std::make_shared(this, CEditorCommandAction::EType::ADD, &s_CommandSelectedIndex, s_CommandSelectedIndex, m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand)); } m_Map.OnModify(); s_ListBox.ScrollToSelected(); m_SettingsCommandInput.Clear(); m_MapSettingsCommandContext.Reset(); // Reset context Ui()->SetActiveItem(&m_SettingsCommandInput); } // command input (use remaining toolbar width) if(!ShowServerSettingsEditorLast) // Just activated Ui()->SetActiveItem(&m_SettingsCommandInput); m_SettingsCommandInput.SetEmptyText("Command"); TextRender()->TextColor(TextRender()->DefaultTextColor()); // command list s_ListBox.DoStart(15.0f, m_Map.m_vSettings.size(), 1, 3, s_CommandSelectedIndex, &List); for(size_t i = 0; i < m_Map.m_vSettings.size(); i++) { const CListboxItem Item = s_ListBox.DoNextItem(&m_Map.m_vSettings[i], s_CommandSelectedIndex >= 0 && (size_t)s_CommandSelectedIndex == i); if(!Item.m_Visible) continue; Item.m_Rect.VMargin(5.0f, &Label); SLabelProperties Props; Props.m_MaxWidth = Label.w; Props.m_EllipsisAtEnd = true; Ui()->DoLabel(&Label, m_Map.m_vSettings[i].m_aCommand, 10.0f, TEXTALIGN_ML, Props); } const int NewSelected = s_ListBox.DoEnd(); if(s_CommandSelectedIndex != NewSelected || s_ListBox.WasItemSelected()) { s_CommandSelectedIndex = NewSelected; if(m_SettingsCommandInput.IsEmpty() || !Input()->ModifierIsPressed()) // Allow ctrl+click to only change selection { m_SettingsCommandInput.Set(m_Map.m_vSettings[s_CommandSelectedIndex].m_aCommand); m_MapSettingsCommandContext.Update(); m_MapSettingsCommandContext.UpdateCursor(true); } m_MapSettingsCommandContext.m_DropdownContext.m_ShouldHide = true; Ui()->SetActiveItem(&m_SettingsCommandInput); } // Map setting input DoMapSettingsEditBox(&m_MapSettingsCommandContext, &ToolBar, FONT_SIZE, List.h); } void CEditor::DoMapSettingsEditBox(CMapSettingsBackend::CContext *pContext, const CUIRect *pRect, float FontSize, float DropdownMaxHeight, int Corners, const char *pToolTip) { // Main method to do the full featured map settings edit box auto *pLineInput = pContext->LineInput(); auto &Context = *pContext; Context.SetFontSize(FontSize); // Set current active context if input is active if(pLineInput->IsActive()) CMapSettingsBackend::ms_pActiveContext = pContext; // Small utility to render a floating part above the input rect. // Use to display either the error or the current argument name const float PartMargin = 4.0f; auto &&RenderFloatingPart = [&](CUIRect *pInputRect, float x, const char *pStr) { CUIRect Background; Background.x = x - PartMargin; Background.y = pInputRect->y - pInputRect->h - 6.0f; Background.w = TextRender()->TextWidth(FontSize, pStr) + 2 * PartMargin; Background.h = pInputRect->h; Background.Draw(ColorRGBA(0, 0, 0, 0.9f), IGraphics::CORNER_ALL, 3.0f); CUIRect Label; Background.VSplitLeft(PartMargin, nullptr, &Label); TextRender()->TextColor(0.8f, 0.8f, 0.8f, 1.0f); Ui()->DoLabel(&Label, pStr, FontSize, TEXTALIGN_ML); TextRender()->TextColor(TextRender()->DefaultTextColor()); }; // If we have a valid command, display the help in the tooltip if(Context.CommandIsValid()) Context.GetCommandHelpText(m_aTooltip, sizeof(m_aTooltip)); CUIRect ToolBar = *pRect; CUIRect Button; ToolBar.VSplitRight(ToolBar.h, &ToolBar, &Button); // Do the unknown command toggle button if(DoButton_FontIcon(&Context.m_AllowUnknownCommands, FONT_ICON_QUESTION, Context.m_AllowUnknownCommands, &Button, 0, "Disallow/allow unknown commands", IGraphics::CORNER_R)) { Context.m_AllowUnknownCommands = !Context.m_AllowUnknownCommands; Context.Update(); } // Color the arguments std::vector vColorSplits; Context.ColorArguments(vColorSplits); // Do and render clearable edit box with the colors if(DoClearableEditBox(pLineInput, &ToolBar, FontSize, IGraphics::CORNER_L, "Enter a server setting.", vColorSplits)) { Context.Update(); // Update the context when contents change Context.m_DropdownContext.m_ShouldHide = false; } // Update/track the cursor if(Context.UpdateCursor()) Context.m_DropdownContext.m_ShouldHide = false; // Calculate x position of the dropdown and the floating part float x = ToolBar.x + Context.CurrentArgPos() - pLineInput->GetScrollOffset(); x = clamp(x, ToolBar.x + PartMargin, ToolBar.x + ToolBar.w); if(pLineInput->IsActive()) { // If line input is active, let's display a floating part for either the current argument name // or for the error, if any. The error is only displayed when the cursor is at the end of the input. const bool IsAtEnd = pLineInput->GetCursorOffset() >= (m_MapSettingsCommandContext.CommentOffset() != -1 ? m_MapSettingsCommandContext.CommentOffset() : pLineInput->GetLength()); if(Context.CurrentArgName() && (!Context.HasError() || !IsAtEnd)) // Render argument name RenderFloatingPart(&ToolBar, x, Context.CurrentArgName()); else if(Context.HasError() && IsAtEnd) // Render error RenderFloatingPart(&ToolBar, ToolBar.x + PartMargin, Context.Error()); } // If we have possible matches for the current argument, let's display an editbox suggestions dropdown const auto &vPossibleCommands = Context.PossibleMatches(); int Selected = DoEditBoxDropdown(&Context.m_DropdownContext, pLineInput, &ToolBar, x - PartMargin, DropdownMaxHeight, Context.CurrentArg() >= 0, vPossibleCommands, MapSettingsDropdownRenderCallback); // If the dropdown just became visible, update the context // This is needed when input loses focus and then we click a command in the map settings list if(Context.m_DropdownContext.m_DidBecomeVisible) { Context.Update(); Context.UpdateCursor(true); } if(!vPossibleCommands.empty()) { // Check if the completion index has changed if(Selected != pContext->m_CurrentCompletionIndex) { // If so, we should autocomplete the selected option if(Selected != -1) { const char *pStr = vPossibleCommands[Selected].m_pValue; int Len = pContext->m_CurrentCompletionIndex == -1 ? str_length(Context.CurrentArgValue()) : (pContext->m_CurrentCompletionIndex < (int)vPossibleCommands.size() ? str_length(vPossibleCommands[pContext->m_CurrentCompletionIndex].m_pValue) : 0); size_t Start = Context.CurrentArgOffset(); size_t End = Start + Len; pLineInput->SetRange(pStr, Start, End); } pContext->m_CurrentCompletionIndex = Selected; } } else { Context.m_DropdownContext.m_ListBox.SetActive(false); } } template int CEditor::DoEditBoxDropdown(SEditBoxDropdownContext *pDropdown, CLineInput *pLineInput, const CUIRect *pEditBoxRect, int x, float MaxHeight, bool AutoWidth, const std::vector &vData, const FDropdownRenderCallback &fnMatchCallback) { // Do an edit box with a possible dropdown // This is a generic method which can display any data we want pDropdown->m_Selected = clamp(pDropdown->m_Selected, -1, (int)vData.size() - 1); if(Input()->KeyPress(KEY_SPACE) && Input()->ModifierIsPressed()) { // Handle Ctrl+Space to show available options pDropdown->m_ShortcutUsed = true; // Remove inserted space pLineInput->SetRange("", pLineInput->GetCursorOffset() - 1, pLineInput->GetCursorOffset()); } if((!pDropdown->m_ShouldHide && !pLineInput->IsEmpty() && (pLineInput->IsActive() || pDropdown->m_MousePressedInside)) || pDropdown->m_ShortcutUsed) { if(!pDropdown->m_Visible) { pDropdown->m_DidBecomeVisible = true; pDropdown->m_Visible = true; } else if(pDropdown->m_DidBecomeVisible) pDropdown->m_DidBecomeVisible = false; if(!pLineInput->IsEmpty() || !pLineInput->IsActive()) pDropdown->m_ShortcutUsed = false; int CurrentSelected = pDropdown->m_Selected; // Use tab to navigate through entries if(Ui()->ConsumeHotkey(CUi::HOTKEY_TAB) && !vData.empty()) { int Direction = Input()->ShiftIsPressed() ? -1 : 1; pDropdown->m_Selected += Direction; if(pDropdown->m_Selected < 0) pDropdown->m_Selected = (int)vData.size() - 1; pDropdown->m_Selected %= vData.size(); } int Selected = RenderEditBoxDropdown(pDropdown, *pEditBoxRect, pLineInput, x, MaxHeight, AutoWidth, vData, fnMatchCallback); if(Selected != -1) pDropdown->m_Selected = Selected; if(CurrentSelected != pDropdown->m_Selected) pDropdown->m_ListBox.ScrollToSelected(); return pDropdown->m_Selected; } else { pDropdown->m_ShortcutUsed = false; pDropdown->m_Visible = false; pDropdown->m_ListBox.SetActive(false); pDropdown->m_Selected = -1; } return -1; } template int CEditor::RenderEditBoxDropdown(SEditBoxDropdownContext *pDropdown, CUIRect View, CLineInput *pLineInput, int x, float MaxHeight, bool AutoWidth, const std::vector &vData, const FDropdownRenderCallback &fnMatchCallback) { // Render a dropdown tied to an edit box/line input auto *pListBox = &pDropdown->m_ListBox; pListBox->SetActive(m_Dialog == DIALOG_NONE && !Ui()->IsPopupOpen() && pLineInput->IsActive()); pListBox->SetScrollbarWidth(15.0f); const int NumEntries = vData.size(); // Setup the rect CUIRect CommandsDropdown = View; CommandsDropdown.y += View.h + 0.1f; CommandsDropdown.x = x; if(AutoWidth) CommandsDropdown.w = pDropdown->m_Width + pListBox->ScrollbarWidth(); pListBox->SetActive(NumEntries > 0); if(NumEntries > 0) { // Draw the background CommandsDropdown.h = minimum(NumEntries * 15.0f + 1.0f, MaxHeight); CommandsDropdown.Draw(ColorRGBA(0.1f, 0.1f, 0.1f, 0.9f), IGraphics::CORNER_ALL, 3.0f); if(Ui()->MouseButton(0) && Ui()->MouseInside(&CommandsDropdown)) pDropdown->m_MousePressedInside = true; // Do the list box int Selected = pDropdown->m_Selected; pListBox->DoStart(15.0f, NumEntries, 1, 3, Selected, &CommandsDropdown); CUIRect Label; int NewIndex = Selected; float LargestWidth = 0; for(int i = 0; i < NumEntries; i++) { const CListboxItem Item = pListBox->DoNextItem(&vData[i], Selected == i); Item.m_Rect.VMargin(4.0f, &Label); SLabelProperties Props; Props.m_MaxWidth = Label.w; Props.m_EllipsisAtEnd = true; // Call the callback to fill the current line string char aBuf[128]; fnMatchCallback(vData.at(i), aBuf, Props.m_vColorSplits); LargestWidth = maximum(LargestWidth, TextRender()->TextWidth(12.0f, aBuf) + 10.0f); if(!Item.m_Visible) continue; Ui()->DoLabel(&Label, aBuf, 12.0f, TEXTALIGN_ML, Props); if(Ui()->ActiveItem() == &vData[i]) { // If we selected an item (by clicking on it for example), then set the active item back to the // line input so we don't loose focus NewIndex = i; Ui()->SetActiveItem(pLineInput); } } pDropdown->m_Width = LargestWidth; int EndIndex = pListBox->DoEnd(); if(NewIndex == Selected) NewIndex = EndIndex; if(pDropdown->m_MousePressedInside && !Ui()->MouseButton(0)) { Ui()->SetActiveItem(pLineInput); pDropdown->m_MousePressedInside = false; } if(NewIndex != Selected) { Ui()->SetActiveItem(pLineInput); return NewIndex; } } return -1; } void CEditor::RenderMapSettingsErrorDialog() { auto &LoadedMapSettings = m_MapSettingsBackend.m_LoadedMapSettings; auto &vSettingsInvalid = LoadedMapSettings.m_vSettingsInvalid; auto &vSettingsValid = LoadedMapSettings.m_vSettingsValid; auto &SettingsDuplicate = LoadedMapSettings.m_SettingsDuplicate; Ui()->MapScreen(); CUIRect Overlay = *Ui()->Screen(); Overlay.Draw(ColorRGBA(0, 0, 0, 0.33f), IGraphics::CORNER_NONE, 0.0f); CUIRect Background; Overlay.VMargin(150.0f, &Background); Background.HMargin(50.0f, &Background); Background.Draw(ColorRGBA(0, 0, 0, 0.80f), IGraphics::CORNER_ALL, 5.0f); CUIRect View; Background.Margin(10.0f, &View); CUIRect Title, ButtonBar, Label; View.HSplitTop(18.0f, &Title, &View); View.HSplitTop(5.0f, nullptr, &View); // some spacing View.HSplitBottom(18.0f, &View, &ButtonBar); View.HSplitBottom(10.0f, &View, nullptr); // some spacing // title bar Title.Draw(ColorRGBA(1, 1, 1, 0.25f), IGraphics::CORNER_ALL, 4.0f); Title.VMargin(10.0f, &Title); Ui()->DoLabel(&Title, "Map settings error", 12.0f, TEXTALIGN_ML); // Render body { static CLineInputBuffered<256> s_Input; static CMapSettingsBackend::CContext s_Context = m_MapSettingsBackend.NewContext(&s_Input); // Some text SLabelProperties Props; CUIRect Text; View.HSplitTop(30.0f, &Text, &View); Props.m_MaxWidth = Text.w; Ui()->DoLabel(&Text, "Below is a report of the invalid map settings found when loading the map. Please fix them before proceeding further.", 10.0f, TEXTALIGN_MC, Props); // Mixed list CUIRect List = View; View.Draw(ColorRGBA(1, 1, 1, 0.25f), IGraphics::CORNER_ALL, 3.0f); const float RowHeight = 18.0f; static CScrollRegion s_ScrollRegion; vec2 ScrollOffset(0.0f, 0.0f); CScrollRegionParams ScrollParams; ScrollParams.m_ScrollUnit = 120.0f; s_ScrollRegion.Begin(&List, &ScrollOffset, &ScrollParams); const float EndY = List.y + List.h; List.y += ScrollOffset.y; List.HSplitTop(20.0f, nullptr, &List); static int s_FixingCommandIndex = -1; auto &&SetInput = [&](const char *pString) { s_Input.Set(pString); s_Context.Update(); s_Context.UpdateCursor(true); Ui()->SetActiveItem(&s_Input); }; CUIRect FixInput; bool DisplayFixInput = false; float DropdownHeight = 110.0f; for(int i = 0; i < (int)m_Map.m_vSettings.size(); i++) { CUIRect Slot; auto pInvalidSetting = std::find_if(vSettingsInvalid.begin(), vSettingsInvalid.end(), [i](const SInvalidSetting &Setting) { return Setting.m_Index == i; }); if(pInvalidSetting != vSettingsInvalid.end()) { // This setting is invalid, only display it if its not a duplicate if(!(pInvalidSetting->m_Type & SInvalidSetting::TYPE_DUPLICATE)) { bool IsFixing = s_FixingCommandIndex == i; List.HSplitTop(RowHeight, &Slot, &List); // Draw a reddish background if setting is marked as deleted if(pInvalidSetting->m_Context.m_Deleted) Slot.Draw(ColorRGBA(0.85f, 0.0f, 0.0f, 0.15f), IGraphics::CORNER_ALL, 3.0f); Slot.VMargin(5.0f, &Slot); Slot.HMargin(1.0f, &Slot); if(!IsFixing && !pInvalidSetting->m_Context.m_Fixed) { // Display "Fix" and "delete" buttons if we're not fixing the command and the command has not been fixed CUIRect FixBtn, DelBtn; Slot.VSplitRight(30.0f, &Slot, &DelBtn); Slot.VSplitRight(5.0f, &Slot, nullptr); DelBtn.HMargin(1.0f, &DelBtn); Slot.VSplitRight(30.0f, &Slot, &FixBtn); Slot.VSplitRight(10.0f, &Slot, nullptr); FixBtn.HMargin(1.0f, &FixBtn); // Delete button if(DoButton_FontIcon(&pInvalidSetting->m_Context.m_Deleted, FONT_ICON_TRASH, pInvalidSetting->m_Context.m_Deleted, &DelBtn, 0, "Delete this command", IGraphics::CORNER_ALL, 10.0f)) pInvalidSetting->m_Context.m_Deleted = !pInvalidSetting->m_Context.m_Deleted; // Fix button if(DoButton_Editor(&pInvalidSetting->m_Context.m_Fixed, "Fix", !pInvalidSetting->m_Context.m_Deleted ? (s_FixingCommandIndex == -1 ? 0 : (IsFixing ? 1 : -1)) : -1, &FixBtn, 0, "Fix this command")) { s_FixingCommandIndex = i; SetInput(pInvalidSetting->m_aSetting); } } else if(IsFixing) { // If we're fixing this command, then display "Done" and "Cancel" buttons // Also setup the input rect CUIRect OkBtn, CancelBtn; Slot.VSplitRight(50.0f, &Slot, &CancelBtn); Slot.VSplitRight(5.0f, &Slot, nullptr); CancelBtn.HMargin(1.0f, &CancelBtn); Slot.VSplitRight(30.0f, &Slot, &OkBtn); Slot.VSplitRight(10.0f, &Slot, nullptr); OkBtn.HMargin(1.0f, &OkBtn); // Buttons static int s_Cancel = 0, s_Ok = 0; if(DoButton_Editor(&s_Cancel, "Cancel", 0, &CancelBtn, 0, "Cancel fixing this command") || Ui()->ConsumeHotkey(CUi::HOTKEY_ESCAPE)) { s_FixingCommandIndex = -1; s_Input.Clear(); } // "Done" button only enabled if the fixed setting is valid // For that we use a local CContext s_Context and use it to check // that the setting is valid and that it is not a duplicate ECollisionCheckResult Res = ECollisionCheckResult::ERROR; s_Context.CheckCollision(vSettingsValid, Res); bool Valid = s_Context.Valid() && Res == ECollisionCheckResult::ADD; if(DoButton_Editor(&s_Ok, "Done", Valid ? 0 : -1, &OkBtn, 0, "Confirm editing of this command") || (s_Input.IsActive() && Valid && Ui()->ConsumeHotkey(CUi::HOTKEY_ENTER))) { // Mark the setting is being fixed pInvalidSetting->m_Context.m_Fixed = true; str_copy(pInvalidSetting->m_aSetting, s_Input.GetString()); // Add it to the list for future collision checks vSettingsValid.emplace_back(s_Input.GetString()); // Clear the input & fixing command index s_FixingCommandIndex = -1; s_Input.Clear(); } } Label = Slot; Props.m_EllipsisAtEnd = true; Props.m_MaxWidth = Label.w; if(IsFixing) { // Setup input rect, which will be used to draw the map settings input later Label.HMargin(1.0, &FixInput); DisplayFixInput = true; DropdownHeight = minimum(DropdownHeight, EndY - FixInput.y - 16.0f); } else { // Draw label in case we're not fixing this setting. // Deleted settings are shown in gray with a red line through them // Fixed settings are shown in green // Invalid settings are shown in red if(!pInvalidSetting->m_Context.m_Deleted) { if(pInvalidSetting->m_Context.m_Fixed) TextRender()->TextColor(0.0f, 1.0f, 0.0f, 1.0f); else TextRender()->TextColor(1.0f, 0.0f, 0.0f, 1.0f); Ui()->DoLabel(&Label, pInvalidSetting->m_aSetting, 10.0f, TEXTALIGN_ML, Props); } else { TextRender()->TextColor(0.3f, 0.3f, 0.3f, 1.0f); Ui()->DoLabel(&Label, pInvalidSetting->m_aSetting, 10.0f, TEXTALIGN_ML, Props); CUIRect Line = Label; Line.y = Label.y + Label.h / 2; Line.h = 1; Line.Draw(ColorRGBA(1, 0, 0, 1), IGraphics::CORNER_NONE, 0.0f); } } TextRender()->TextColor(TextRender()->DefaultTextColor()); } } else { // This setting is valid // Check for duplicates const std::vector &vDuplicates = SettingsDuplicate.at(i); int Chosen = -1; // This is the chosen duplicate setting. -1 means the first valid setting that was found which was not a duplicate for(int d = 0; d < (int)vDuplicates.size(); d++) { int DupIndex = vDuplicates[d]; if(vSettingsInvalid[DupIndex].m_Context.m_Chosen) { Chosen = d; break; } } List.HSplitTop(RowHeight * (vDuplicates.size() + 1) + 2.0f, &Slot, &List); Slot.HMargin(1.0f, &Slot); // Draw a background to highlight group of duplicates if(!vDuplicates.empty()) Slot.Draw(ColorRGBA(1, 1, 1, 0.15f), IGraphics::CORNER_ALL, 3.0f); Slot.VMargin(5.0f, &Slot); Slot.HSplitTop(RowHeight, &Label, &Slot); Label.HMargin(1.0f, &Label); // Draw a "choose" button next to the label in case we have duplicates for this line if(!vDuplicates.empty()) { CUIRect ChooseBtn; Label.VSplitRight(50.0f, &Label, &ChooseBtn); Label.VSplitRight(5.0f, &Label, nullptr); ChooseBtn.HMargin(1.0f, &ChooseBtn); if(DoButton_Editor(&vDuplicates, "Choose", Chosen == -1, &ChooseBtn, 0, "Choose this command")) { if(Chosen != -1) vSettingsInvalid[vDuplicates[Chosen]].m_Context.m_Chosen = false; Chosen = -1; // Choosing this means that we do not choose any of the duplicates } } // Draw the label Props.m_MaxWidth = Label.w; Ui()->DoLabel(&Label, m_Map.m_vSettings[i].m_aCommand, 10.0f, TEXTALIGN_ML, Props); // Draw the list of duplicates, with a "Choose" button for each duplicate // In case a duplicate is also invalid, then we draw a "Fix" button which behaves like the fix button above // Duplicate settings name are shown in light blue, or in purple if they are also invalid Slot.VSplitLeft(10.0f, nullptr, &Slot); for(int DuplicateIndex = 0; DuplicateIndex < (int)vDuplicates.size(); DuplicateIndex++) { auto &Duplicate = vSettingsInvalid.at(vDuplicates[DuplicateIndex]); bool IsFixing = s_FixingCommandIndex == Duplicate.m_Index; bool IsInvalid = Duplicate.m_Type & SInvalidSetting::TYPE_INVALID; ColorRGBA Color(0.329f, 0.714f, 0.859f, 1.0f); CUIRect SubSlot; Slot.HSplitTop(RowHeight, &SubSlot, &Slot); SubSlot.HMargin(1.0f, &SubSlot); if(!IsFixing) { // If not fixing, then display "Choose" and maybe "Fix" buttons. CUIRect ChooseBtn; SubSlot.VSplitRight(50.0f, &SubSlot, &ChooseBtn); SubSlot.VSplitRight(5.0f, &SubSlot, nullptr); ChooseBtn.HMargin(1.0f, &ChooseBtn); if(DoButton_Editor(&Duplicate.m_Context.m_Chosen, "Choose", IsInvalid && !Duplicate.m_Context.m_Fixed ? -1 : Duplicate.m_Context.m_Chosen, &ChooseBtn, 0, "Override with this command")) { Duplicate.m_Context.m_Chosen = !Duplicate.m_Context.m_Chosen; if(Chosen != -1 && Chosen != DuplicateIndex) vSettingsInvalid[vDuplicates[Chosen]].m_Context.m_Chosen = false; Chosen = DuplicateIndex; } if(IsInvalid) { if(!Duplicate.m_Context.m_Fixed) { Color = ColorRGBA(1, 0, 1, 1); CUIRect FixBtn; SubSlot.VSplitRight(30.0f, &SubSlot, &FixBtn); SubSlot.VSplitRight(10.0f, &SubSlot, nullptr); FixBtn.HMargin(1.0f, &FixBtn); if(DoButton_Editor(&Duplicate.m_Context.m_Fixed, "Fix", s_FixingCommandIndex == -1 ? 0 : (IsFixing ? 1 : -1), &FixBtn, 0, "Fix this command (needed before it can be chosen)")) { s_FixingCommandIndex = Duplicate.m_Index; SetInput(Duplicate.m_aSetting); } } else { Color = ColorRGBA(0.329f, 0.714f, 0.859f, 1.0f); } } } else { // If we're fixing, display "Done" and "Cancel" buttons CUIRect OkBtn, CancelBtn; SubSlot.VSplitRight(50.0f, &SubSlot, &CancelBtn); SubSlot.VSplitRight(5.0f, &SubSlot, nullptr); CancelBtn.HMargin(1.0f, &CancelBtn); SubSlot.VSplitRight(30.0f, &SubSlot, &OkBtn); SubSlot.VSplitRight(10.0f, &SubSlot, nullptr); OkBtn.HMargin(1.0f, &OkBtn); static int s_Cancel = 0, s_Ok = 0; if(DoButton_Editor(&s_Cancel, "Cancel", 0, &CancelBtn, 0, "Cancel fixing this command") || Ui()->ConsumeHotkey(CUi::HOTKEY_ESCAPE)) { s_FixingCommandIndex = -1; s_Input.Clear(); } // Use the local CContext s_Context to validate the input // We also need to make sure the fixed setting matches the initial duplicate setting // For example: // sv_deepfly 0 // sv_deepfly 5 <- This is invalid and duplicate. We can only fix it by writing "sv_deepfly 0" or "sv_deepfly 1". // If we write any other setting, like "sv_hit 1", it won't work as it does not match "sv_deepfly". // To do that, we use the context and we check for collision with the current map setting ECollisionCheckResult Res = ECollisionCheckResult::ERROR; s_Context.CheckCollision({m_Map.m_vSettings[i]}, Res); bool Valid = s_Context.Valid() && Res == ECollisionCheckResult::REPLACE; if(DoButton_Editor(&s_Ok, "Done", Valid ? 0 : -1, &OkBtn, 0, "Confirm editing of this command") || (s_Input.IsActive() && Valid && Ui()->ConsumeHotkey(CUi::HOTKEY_ENTER))) { if(Valid) // Just to make sure { // Mark the setting as fixed Duplicate.m_Context.m_Fixed = true; str_copy(Duplicate.m_aSetting, s_Input.GetString()); s_FixingCommandIndex = -1; s_Input.Clear(); } } } Label = SubSlot; Props.m_MaxWidth = Label.w; if(IsFixing) { // Setup input rect in case we are fixing the setting Label.HMargin(1.0, &FixInput); DisplayFixInput = true; DropdownHeight = minimum(DropdownHeight, EndY - FixInput.y - 16.0f); } else { // Otherwise, render the setting label TextRender()->TextColor(Color); Ui()->DoLabel(&Label, Duplicate.m_aSetting, 10.0f, TEXTALIGN_ML, Props); TextRender()->TextColor(TextRender()->DefaultTextColor()); } } } // Finally, add the slot to the scroll region s_ScrollRegion.AddRect(Slot); } // Add some padding to the bottom so the dropdown can actually display some values in case we // fix an invalid setting at the bottom of the list CUIRect PaddingBottom; List.HSplitTop(30.0f, &PaddingBottom, &List); s_ScrollRegion.AddRect(PaddingBottom); // Display the map settings edit box after having rendered all the lines, so the dropdown shows in // front of everything, but is still being clipped by the scroll region. if(DisplayFixInput) DoMapSettingsEditBox(&s_Context, &FixInput, 10.0f, maximum(DropdownHeight, 30.0f)); s_ScrollRegion.End(); } // Confirm button static int s_ConfirmButton = 0, s_CancelButton = 0, s_FixAllButton = 0; CUIRect ConfimButton, CancelButton, FixAllUnknownButton; ButtonBar.VSplitLeft(110.0f, &CancelButton, &ButtonBar); ButtonBar.VSplitRight(110.0f, &ButtonBar, &ConfimButton); ButtonBar.VSplitRight(5.0f, &ButtonBar, nullptr); ButtonBar.VSplitRight(150.0f, &ButtonBar, &FixAllUnknownButton); bool CanConfirm = true; bool CanFixAllUnknown = false; for(auto &InvalidSetting : vSettingsInvalid) { if(!InvalidSetting.m_Context.m_Fixed && !InvalidSetting.m_Context.m_Deleted && !(InvalidSetting.m_Type & SInvalidSetting::TYPE_DUPLICATE)) { CanConfirm = false; if(InvalidSetting.m_Unknown) CanFixAllUnknown = true; break; } } auto &&Execute = [&]() { // Execute will modify the actual map settings according to the fixes that were just made within the dialog. // Fix fixed settings, erase deleted settings for(auto &FixedSetting : vSettingsInvalid) { if(FixedSetting.m_Context.m_Fixed) { str_copy(m_Map.m_vSettings[FixedSetting.m_Index].m_aCommand, FixedSetting.m_aSetting); } } // Choose chosen settings // => Erase settings that don't match // => Erase settings that were not chosen std::vector vSettingsToErase; for(auto &Setting : vSettingsInvalid) { if(Setting.m_Type & SInvalidSetting::TYPE_DUPLICATE) { if(!Setting.m_Context.m_Chosen) vSettingsToErase.emplace_back(Setting.m_aSetting); else vSettingsToErase.emplace_back(m_Map.m_vSettings[Setting.m_CollidingIndex].m_aCommand); } } // Erase deleted settings for(auto &DeletedSetting : vSettingsInvalid) { if(DeletedSetting.m_Context.m_Deleted) { m_Map.m_vSettings.erase( std::remove_if(m_Map.m_vSettings.begin(), m_Map.m_vSettings.end(), [&](const CEditorMapSetting &MapSetting) { return str_comp_nocase(MapSetting.m_aCommand, DeletedSetting.m_aSetting) == 0; }), m_Map.m_vSettings.end()); } } // Erase settings to erase for(auto &Setting : vSettingsToErase) { m_Map.m_vSettings.erase( std::remove_if(m_Map.m_vSettings.begin(), m_Map.m_vSettings.end(), [&](const CEditorMapSetting &MapSetting) { return str_comp_nocase(MapSetting.m_aCommand, Setting.m_aCommand) == 0; }), m_Map.m_vSettings.end()); } m_Map.OnModify(); }; auto &&FixAllUnknown = [&] { // Mark unknown settings as fixed for(auto &InvalidSetting : vSettingsInvalid) if(!InvalidSetting.m_Context.m_Fixed && !InvalidSetting.m_Context.m_Deleted && !(InvalidSetting.m_Type & SInvalidSetting::TYPE_DUPLICATE) && InvalidSetting.m_Unknown) InvalidSetting.m_Context.m_Fixed = true; }; // Fix all unknown settings if(DoButton_Editor(&s_FixAllButton, "Allow all unknown settings", CanFixAllUnknown ? 0 : -1, &FixAllUnknownButton, 0, nullptr)) { FixAllUnknown(); } // Confirm - execute the fixes if(DoButton_Editor(&s_ConfirmButton, "Confirm", CanConfirm ? 0 : -1, &ConfimButton, 0, nullptr) || (CanConfirm && Ui()->ConsumeHotkey(CUi::HOTKEY_ENTER))) { Execute(); m_Dialog = DIALOG_NONE; } // Cancel - we load a new empty map if(DoButton_Editor(&s_CancelButton, "Cancel", 0, &CancelButton, 0, nullptr) || (Ui()->ConsumeHotkey(CUi::HOTKEY_ESCAPE))) { Reset(); m_aFileName[0] = 0; m_Dialog = DIALOG_NONE; } } void CEditor::MapSettingsDropdownRenderCallback(const SPossibleValueMatch &Match, char (&aOutput)[128], std::vector &vColorSplits) { // Check the match argument index. // If it's -1, we're displaying the list of available map settings names // If its >= 0, we're displaying the list of possible values matches for that argument if(Match.m_ArgIndex == -1) { IMapSetting *pInfo = (IMapSetting *)Match.m_pData; vColorSplits = { {str_length(pInfo->m_pName) + 1, -1, ColorRGBA(0.6f, 0.6f, 0.6f, 1)}, // Darker arguments }; if(pInfo->m_Type == IMapSetting::SETTING_INT) { str_format(aOutput, sizeof(aOutput), "%s i[value]", pInfo->m_pName); } else if(pInfo->m_Type == IMapSetting::SETTING_COMMAND) { SMapSettingCommand *pCommand = (SMapSettingCommand *)pInfo; str_format(aOutput, sizeof(aOutput), "%s %s", pCommand->m_pName, pCommand->m_pArgs); } } else { str_copy(aOutput, Match.m_pValue); } } // ---------------------------------------- CMapSettingsBackend::CContext *CMapSettingsBackend::ms_pActiveContext = nullptr; void CMapSettingsBackend::Init(CEditor *pEditor) { CEditorComponent::Init(pEditor); // Register values loader InitValueLoaders(); // Load settings/commands LoadAllMapSettings(); CValuesBuilder Builder(&m_PossibleValuesPerCommand); // Load and parse static map settings so we can use them here for(auto &pSetting : m_vpMapSettings) { // We want to parse the arguments of each map setting so we can autocomplete them later // But that depends on the type of the setting. // If we have a INT setting, then we know we can only ever have 1 argument which is a integer value // If we have a COMMAND setting, then we need to parse its arguments if(pSetting->m_Type == IMapSetting::SETTING_INT) LoadSettingInt(std::static_pointer_cast(pSetting)); else if(pSetting->m_Type == IMapSetting::SETTING_COMMAND) LoadSettingCommand(std::static_pointer_cast(pSetting)); LoadPossibleValues(Builder(pSetting->m_pName), pSetting); } // Init constraints LoadConstraints(); } void CMapSettingsBackend::LoadAllMapSettings() { // Gather all config variables having the flag CFGFLAG_GAME Editor()->ConfigManager()->PossibleConfigVariables("", CFGFLAG_GAME, PossibleConfigVariableCallback, this); // Load list of commands LoadCommand("tune", "s[tuning] f[value]", "Tune variable to value or show current value"); LoadCommand("tune_zone", "i[zone] s[tuning] f[value]", "Tune in zone a variable to value"); LoadCommand("tune_zone_enter", "i[zone] r[message]", "which message to display on zone enter; use 0 for normal area"); LoadCommand("tune_zone_leave", "i[zone] r[message]", "which message to display on zone leave; use 0 for normal area"); LoadCommand("mapbug", "s[mapbug]", "Enable map compatibility mode using the specified bug (example: grenade-doubleexplosion@ddnet.tw)"); LoadCommand("switch_open", "i[switch]", "Whether a switch is deactivated by default (otherwise activated)"); } void CMapSettingsBackend::LoadCommand(const char *pName, const char *pArgs, const char *pHelp) { m_vpMapSettings.emplace_back(std::make_shared(pName, pHelp, pArgs)); } void CMapSettingsBackend::LoadSettingInt(const std::shared_ptr &pSetting) { // We load an int argument here m_ParsedCommandArgs[pSetting].emplace_back(); auto &Arg = m_ParsedCommandArgs[pSetting].back(); str_copy(Arg.m_aName, "value"); Arg.m_Type = 'i'; } void CMapSettingsBackend::LoadSettingCommand(const std::shared_ptr &pSetting) { // This method parses a setting into its arguments (name and type) so we can later // use them to validate the current input as well as display the current argument value // over the line input. m_ParsedCommandArgs[pSetting].clear(); const char *pIterator = pSetting->m_pArgs; char Type; while(*pIterator) { if(*pIterator == '?') // Skip optional values as a map setting should not have optional values pIterator++; Type = *pIterator; pIterator++; while(*pIterator && *pIterator != '[') pIterator++; pIterator++; // skip '[' const char *pNameStart = pIterator; while(*pIterator && *pIterator != ']') pIterator++; size_t Len = pIterator - pNameStart; pIterator++; // Skip ']' dbg_assert(Len + 1 < sizeof(SParsedMapSettingArg::m_aName), "Length of server setting name exceeds limit."); // Append parsed arg m_ParsedCommandArgs[pSetting].emplace_back(); auto &Arg = m_ParsedCommandArgs[pSetting].back(); str_copy(Arg.m_aName, pNameStart, Len + 1); Arg.m_Type = Type; pIterator = str_skip_whitespaces_const(pIterator); } } void CMapSettingsBackend::LoadPossibleValues(const CSettingValuesBuilder &Builder, const std::shared_ptr &pSetting) { // Call the value loader for that setting auto Iter = m_LoaderFunctions.find(pSetting->m_pName); if(Iter == m_LoaderFunctions.end()) return; (*Iter->second)(Builder); } void CMapSettingsBackend::RegisterLoader(const char *pSettingName, const FLoaderFunction &pfnLoader) { // Registers a value loader function for a specific setting name m_LoaderFunctions[pSettingName] = pfnLoader; } void CMapSettingsBackend::LoadConstraints() { // Make an instance of constraint builder CCommandArgumentConstraintBuilder Command(&m_ArgConstraintsPerCommand); // Define constraints like this // This is still a bit sad as we have to do it manually here. Command("tune", 2).Unique(0); Command("tune_zone", 3).Multiple(0).Unique(1); Command("tune_zone_enter", 2).Unique(0); Command("tune_zone_leave", 2).Unique(0); Command("switch_open", 1).Unique(0); Command("mapbug", 1).Unique(0); } void CMapSettingsBackend::PossibleConfigVariableCallback(const SConfigVariable *pVariable, void *pUserData) { CMapSettingsBackend *pBackend = (CMapSettingsBackend *)pUserData; if(pVariable->m_Type == SConfigVariable::VAR_INT) { SIntConfigVariable *pIntVariable = (SIntConfigVariable *)pVariable; pBackend->m_vpMapSettings.emplace_back(std::make_shared( pIntVariable->m_pScriptName, pIntVariable->m_pHelp, pIntVariable->m_Default, pIntVariable->m_Min, pIntVariable->m_Max)); } } void CMapSettingsBackend::CContext::Reset() { m_LastCursorOffset = 0; m_CursorArgIndex = -1; m_pCurrentSetting = nullptr; m_vCurrentArgs.clear(); m_aCommand[0] = '\0'; m_DropdownContext.m_Selected = -1; m_CurrentCompletionIndex = -1; m_DropdownContext.m_ShortcutUsed = false; m_DropdownContext.m_MousePressedInside = false; m_DropdownContext.m_Visible = false; m_DropdownContext.m_ShouldHide = false; m_CommentOffset = -1; ClearError(); } void CMapSettingsBackend::CContext::Update() { UpdateFromString(InputString()); } void CMapSettingsBackend::CContext::UpdateFromString(const char *pStr) { // This is the main method that does all the argument parsing and validating. // It fills pretty much all the context values, the arguments, their position, // if they are valid or not, etc. m_pCurrentSetting = nullptr; m_vCurrentArgs.clear(); m_CommentOffset = -1; const char *pIterator = pStr; // Check for comment const char *pEnd = pStr; int InString = 0; while(*pEnd) { if(*pEnd == '"') InString ^= 1; else if(*pEnd == '\\') // Escape sequences { if(pEnd[1] == '"') pEnd++; } else if(!InString) { if(*pEnd == '#') // Found comment { m_CommentOffset = pEnd - pStr; break; } } pEnd++; } if(m_CommentOffset == 0) return; // End command at start of comment, if any char aInputString[256]; str_copy(aInputString, pStr, m_CommentOffset != -1 ? m_CommentOffset + 1 : sizeof(aInputString)); pIterator = aInputString; // Get the command/setting m_aCommand[0] = '\0'; while(pIterator && *pIterator != ' ' && *pIterator != '\0') pIterator++; str_copy(m_aCommand, aInputString, (pIterator - aInputString) + 1); // Get the command if it is a recognized one for(auto &pSetting : m_pBackend->m_vpMapSettings) { if(str_comp_nocase(m_aCommand, pSetting->m_pName) == 0) { m_pCurrentSetting = pSetting; break; } } // Parse args ParseArgs(aInputString, pIterator); } void CMapSettingsBackend::CContext::ParseArgs(const char *pLineInputStr, const char *pStr) { // This method parses the arguments of the current command, starting at pStr ClearError(); const char *pIterator = pStr; if(!pStr || *pStr == '\0') return; // No arguments // NextArg is used to get the contents of the current argument and go to the next argument position // It outputs the length of the argument in pLength and returns a boolean indicating if the parsing // of that argument is valid or not (only the case when using strings with quotes (")) auto &&NextArg = [&](const char *pArg, int *pLength) { if(*pIterator == '"') { pIterator++; bool Valid = true; bool IsEscape = false; while(true) { if(pIterator[0] == '"' && !IsEscape) break; else if(pIterator[0] == 0) { Valid = false; break; } if(pIterator[0] == '\\' && !IsEscape) IsEscape = true; else if(IsEscape) IsEscape = false; pIterator++; } const char *pEnd = ++pIterator; pIterator = str_skip_to_whitespace_const(pIterator); // Make sure there are no other characters at the end, otherwise the string is invalid. // E.g. "abcd"ef is invalid Valid = Valid && pIterator == pEnd; *pLength = pEnd - pArg; return Valid; } else { pIterator = str_skip_to_whitespace_const(pIterator); *pLength = pIterator - pArg; return true; } }; // Simple validation of string. Checks that it does not contain unescaped " in the middle of it. auto &&ValidateStr = [](const char *pString) -> bool { const char *pIt = pString; bool IsEscape = false; while(*pIt) { if(pIt[0] == '"' && !IsEscape) return false; if(pIt[0] == '\\' && !IsEscape) IsEscape = true; else if(IsEscape) IsEscape = false; pIt++; } return true; }; const int CommandArgCount = m_pCurrentSetting != nullptr ? m_pBackend->m_ParsedCommandArgs.at(m_pCurrentSetting).size() : 0; int ArgIndex = 0; SCommandParseError::EErrorType Error = SCommandParseError::ERROR_NONE; // Also keep track of the visual X position of each argument within the input float PosX = 0; const float WW = m_pBackend->TextRender()->TextWidth(m_FontSize, " "); PosX += m_pBackend->TextRender()->TextWidth(m_FontSize, m_aCommand); // Parsing beings while(*pIterator) { Error = SCommandParseError::ERROR_NONE; pIterator++; // Skip whitespace PosX += WW; // Add whitespace width // Insert argument here char Char = *pIterator; const char *pArgStart = pIterator; int Length; bool Valid = NextArg(pArgStart, &Length); // Get contents and go to next argument position size_t Offset = pArgStart - pLineInputStr; // Compute offset from the start of the input // Add new argument, copy the argument contents m_vCurrentArgs.emplace_back(); auto &NewArg = m_vCurrentArgs.back(); // Fill argument value, with a maximum length of 256 str_copy(NewArg.m_aValue, pArgStart, minimum((int)sizeof(SCurrentSettingArg::m_aValue), Length + 1)); // Validate argument from the parsed argument of the current setting. // If current setting is not valid, then there are no arguments which results in an error. char Type = 'u'; // u = unknown, only possible for unknown commands when m_AllowUnknownCommands is true. if(ArgIndex < CommandArgCount) { SParsedMapSettingArg &Arg = m_pBackend->m_ParsedCommandArgs[m_pCurrentSetting].at(ArgIndex); if(Arg.m_Type == 'r') { // Rest of string, should add all the string if there was no quotes // Otherwise, only get the contents in the quotes, and consider content after that as other arguments if(Char != '"') { while(*pIterator) pIterator++; Length = pIterator - pArgStart; str_copy(NewArg.m_aValue, pArgStart, Length + 1); } if(!Valid) Error = SCommandParseError::ERROR_INVALID_VALUE; } else if(Arg.m_Type == 'i') { // Validate int if(!str_toint(NewArg.m_aValue, nullptr)) Error = SCommandParseError::ERROR_INVALID_VALUE; } else if(Arg.m_Type == 'f') { // Validate float if(!str_tofloat(NewArg.m_aValue, nullptr)) Error = SCommandParseError::ERROR_INVALID_VALUE; } else if(Arg.m_Type == 's') { // Validate string if(!Valid || (Char != '"' && !ValidateStr(NewArg.m_aValue))) Error = SCommandParseError::ERROR_INVALID_VALUE; } // Extended argument validation: // for int settings it checks that the value is in range // for command settings, it checks that the value is one of the possible values if there are any EValidationResult Result = ValidateArg(ArgIndex, NewArg.m_aValue); if(Length && !Error && Result != EValidationResult::VALID) { if(Result == EValidationResult::ERROR) Error = SCommandParseError::ERROR_INVALID_VALUE; // Invalid argument value (invalid int, invalid float) else if(Result == EValidationResult::UNKNOWN) Error = SCommandParseError::ERROR_UNKNOWN_VALUE; // Unknown argument value else if(Result == EValidationResult::INCOMPLETE) Error = SCommandParseError::ERROR_INCOMPLETE; // Incomplete argument in case of possible values else if(Result == EValidationResult::OUT_OF_RANGE) Error = SCommandParseError::ERROR_OUT_OF_RANGE; // Out of range argument value in case of int settings else Error = SCommandParseError::ERROR_UNKNOWN; // Unknown error } Type = Arg.m_Type; } else { // Error: too many arguments if no comment after if(m_CommentOffset == -1) Error = SCommandParseError::ERROR_TOO_MANY_ARGS; else { // Otherwise, check if there are any arguments left between this argument and the comment const char *pSubIt = pArgStart; pSubIt = str_skip_whitespaces_const(pSubIt); if(*pSubIt != '\0') { // If there aren't only spaces between the last argument and the comment, then this is an error Error = SCommandParseError::ERROR_TOO_MANY_ARGS; } else // If there are, then just exit the loop to avoid getting an error { m_vCurrentArgs.pop_back(); break; } } } // Fill argument informations NewArg.m_X = PosX; NewArg.m_Start = Offset; NewArg.m_End = Offset + Length; NewArg.m_Error = Error != SCommandParseError::ERROR_NONE || Length == 0 || m_Error.m_Type != SCommandParseError::ERROR_NONE; NewArg.m_ExpectedType = Type; // Do not emit an error if we allow unknown commands and the current setting is invalid if(m_AllowUnknownCommands && m_pCurrentSetting == nullptr) NewArg.m_Error = false; // Check error and fill the error field with different messages if(Error == SCommandParseError::ERROR_INVALID_VALUE || Error == SCommandParseError::ERROR_UNKNOWN_VALUE || Error == SCommandParseError::ERROR_OUT_OF_RANGE || Error == SCommandParseError::ERROR_INCOMPLETE) { // Only keep first error if(!m_Error.m_aMessage[0]) { int ErrorArgIndex = (int)m_vCurrentArgs.size() - 1; SCurrentSettingArg &ErrorArg = m_vCurrentArgs.back(); SParsedMapSettingArg &SettingArg = m_pBackend->m_ParsedCommandArgs[m_pCurrentSetting].at(ArgIndex); char aFormattedValue[256]; FormatDisplayValue(ErrorArg.m_aValue, aFormattedValue); if(Error == SCommandParseError::ERROR_INVALID_VALUE || Error == SCommandParseError::ERROR_UNKNOWN_VALUE || Error == SCommandParseError::ERROR_INCOMPLETE) { static const std::map s_Names = { {SCommandParseError::ERROR_INVALID_VALUE, "Invalid"}, {SCommandParseError::ERROR_UNKNOWN_VALUE, "Unknown"}, {SCommandParseError::ERROR_INCOMPLETE, "Incomplete"}, }; str_format(m_Error.m_aMessage, sizeof(m_Error.m_aMessage), "%s argument value: %s at position %d for argument '%s'", s_Names.at(Error), aFormattedValue, (int)ErrorArg.m_Start, SettingArg.m_aName); } else { std::shared_ptr pSettingInt = std::static_pointer_cast(m_pCurrentSetting); str_format(m_Error.m_aMessage, sizeof(m_Error.m_aMessage), "Invalid argument value: %s at position %d for argument '%s': out of range [%d, %d]", aFormattedValue, (int)ErrorArg.m_Start, SettingArg.m_aName, pSettingInt->m_Min, pSettingInt->m_Max); } m_Error.m_ArgIndex = ErrorArgIndex; m_Error.m_Type = Error; } } else if(Error == SCommandParseError::ERROR_TOO_MANY_ARGS) { // Only keep first error if(!m_Error.m_aMessage[0]) { if(m_pCurrentSetting != nullptr) { str_copy(m_Error.m_aMessage, "Too many arguments"); m_Error.m_ArgIndex = ArgIndex; break; } else if(!m_AllowUnknownCommands) { char aFormattedValue[256]; FormatDisplayValue(m_aCommand, aFormattedValue); str_format(m_Error.m_aMessage, sizeof(m_Error.m_aMessage), "Unknown server setting: %s", aFormattedValue); m_Error.m_ArgIndex = -1; break; } m_Error.m_Type = Error; } } PosX += m_pBackend->TextRender()->TextWidth(m_FontSize, pArgStart, Length); // Advance argument position ArgIndex++; } } void CMapSettingsBackend::CContext::ClearError() { m_Error.m_aMessage[0] = '\0'; m_Error.m_Type = SCommandParseError::ERROR_NONE; } bool CMapSettingsBackend::CContext::UpdateCursor(bool Force) { // This method updates the cursor offset in this class from // the cursor offset of the line input. // It also updates the argument index where the cursor is at // and the possible values matches if the argument index changes. // Returns true in case the cursor changed position if(!m_pLineInput) return false; size_t Offset = m_pLineInput->GetCursorOffset(); if(Offset == m_LastCursorOffset && !Force) return false; m_LastCursorOffset = Offset; int NewArg = m_CursorArgIndex; // Update current argument under cursor if(m_CommentOffset != -1 && Offset >= (size_t)m_CommentOffset) { NewArg = (int)m_vCurrentArgs.size(); } else { bool FoundArg = false; for(int i = (int)m_vCurrentArgs.size() - 1; i >= 0; i--) { if(Offset >= m_vCurrentArgs[i].m_Start) { NewArg = i; FoundArg = true; break; } } if(!FoundArg) NewArg = -1; } bool ShouldUpdate = NewArg != m_CursorArgIndex; m_CursorArgIndex = NewArg; // Do not show error if current argument is incomplete, as we are editing it if(m_pLineInput != nullptr) { if(Offset == m_pLineInput->GetLength() && m_Error.m_aMessage[0] && m_Error.m_ArgIndex == m_CursorArgIndex && m_Error.m_Type == SCommandParseError::ERROR_INCOMPLETE) ClearError(); } if(m_DropdownContext.m_Selected == -1 || ShouldUpdate || Force) { // Update possible commands from cursor UpdatePossibleMatches(); } return true; } EValidationResult CMapSettingsBackend::CContext::ValidateArg(int Index, const char *pArg) { if(!m_pCurrentSetting) return EValidationResult::ERROR; // Check if this argument is valid against current argument if(m_pCurrentSetting->m_Type == IMapSetting::SETTING_INT) { std::shared_ptr pSetting = std::static_pointer_cast(m_pCurrentSetting); if(Index > 0) return EValidationResult::ERROR; int Value; if(!str_toint(pArg, &Value)) // Try parse the integer return EValidationResult::ERROR; return Value >= pSetting->m_Min && Value <= pSetting->m_Max ? EValidationResult::VALID : EValidationResult::OUT_OF_RANGE; } else if(m_pCurrentSetting->m_Type == IMapSetting::SETTING_COMMAND) { auto &vArgs = m_pBackend->m_ParsedCommandArgs.at(m_pCurrentSetting); if(Index < (int)vArgs.size()) { auto It = m_pBackend->m_PossibleValuesPerCommand.find(m_pCurrentSetting->m_pName); if(It != m_pBackend->m_PossibleValuesPerCommand.end()) { auto ValuesIt = It->second.find(Index); if(ValuesIt != It->second.end()) { // This means that we have possible values for this argument for this setting // In order to validate such arg, we have to check if it maches any of the possible values const bool EqualsAny = std::any_of(ValuesIt->second.begin(), ValuesIt->second.end(), [pArg](auto *pValue) { return str_comp_nocase(pArg, pValue) == 0; }); // If equals, then argument is valid if(EqualsAny) return EValidationResult::VALID; // Here we check if argument is incomplete const bool StartsAny = std::any_of(ValuesIt->second.begin(), ValuesIt->second.end(), [pArg](auto *pValue) { return str_startswith_nocase(pValue, pArg) != nullptr; }); if(StartsAny) return EValidationResult::INCOMPLETE; return EValidationResult::UNKNOWN; } } } // If we get here, it means there are no posssible values for that specific argument. // The validation for specific types such as int and floats were done earlier so if we get here // we know the argument is valid. // String and "rest of string" types are valid by default. return EValidationResult::VALID; } return EValidationResult::ERROR; } void CMapSettingsBackend::CContext::UpdatePossibleMatches() { // This method updates the possible values matches based on the cursor position within the current argument in the line input. // For example ("|" is the cursor): // - Typing "sv_deep|" will show "sv_deepfly" as a possible match in the dropdown // Moving the cursor: "sv_|deep" will show all possible commands starting with "sv_" // - Typing "tune ground_frict|" will show "ground_friction" as possible match // Moving the cursor: "tune ground_|frict" will show all possible values starting with "ground_" for that argument (argument 0 of "tune" setting) m_vPossibleMatches.clear(); m_DropdownContext.m_Selected = -1; if(m_CommentOffset == 0) return; // First case: argument index under cursor is -1 => we're on the command/setting name if(m_CursorArgIndex == -1) { // Use a substring from the start of the input to the cursor offset char aSubString[128]; str_copy(aSubString, m_aCommand, minimum(m_LastCursorOffset + 1, sizeof(aSubString))); // Iterate through available map settings and find those which the beginning matches with the command/setting name we are writing for(auto &pSetting : m_pBackend->m_vpMapSettings) { if(str_startswith_nocase(pSetting->m_pName, aSubString)) { m_vPossibleMatches.emplace_back(SPossibleValueMatch{ pSetting->m_pName, m_CursorArgIndex, pSetting.get(), }); } } // If there are no matches, then the command is unknown if(m_vPossibleMatches.empty() && !m_AllowUnknownCommands) { // Fill the error if we do not allow unknown commands char aFormattedValue[256]; FormatDisplayValue(m_aCommand, aFormattedValue); str_format(m_Error.m_aMessage, sizeof(m_Error.m_aMessage), "Unknown server setting: %s", aFormattedValue); m_Error.m_ArgIndex = -1; } } else { // Second case: we are on an argument if(!m_pCurrentSetting) // If we are on an argument of an unknown setting, we can't handle it => no possible values, ever. return; if(m_pCurrentSetting->m_Type == IMapSetting::SETTING_INT) { // No possible values for int settings. // Maybe we can add "0" and "1" as possible values for settings that are binary. } else { // Get the parsed arguments for the current setting auto &vArgs = m_pBackend->m_ParsedCommandArgs.at(m_pCurrentSetting); // Make sure we are not out of bounds if(m_CursorArgIndex < (int)vArgs.size() && m_CursorArgIndex < (int)m_vCurrentArgs.size()) { // Check if there are possible values for this command auto It = m_pBackend->m_PossibleValuesPerCommand.find(m_pCurrentSetting->m_pName); if(It != m_pBackend->m_PossibleValuesPerCommand.end()) { // If that's the case, then check if there are possible values for the current argument index the cursor is on auto ValuesIt = It->second.find(m_CursorArgIndex); if(ValuesIt != It->second.end()) { // If that's the case, then do the same as previously, we check for each value if they match // with the current argument value auto &CurrentArg = m_vCurrentArgs.at(m_CursorArgIndex); int SubstringLength = minimum(m_LastCursorOffset, CurrentArg.m_End) - CurrentArg.m_Start; // Substring based on the cursor position inside that argument char aSubString[256]; str_copy(aSubString, CurrentArg.m_aValue, SubstringLength + 1); for(auto &pValue : ValuesIt->second) { if(str_startswith_nocase(pValue, aSubString)) { m_vPossibleMatches.emplace_back(SPossibleValueMatch{ pValue, m_CursorArgIndex, nullptr, }); } } } } } } } } bool CMapSettingsBackend::CContext::OnInput(const IInput::CEvent &Event) { if(!m_pLineInput) return false; if(!m_pLineInput->IsActive()) return false; if(Event.m_Flags & (IInput::FLAG_PRESS | IInput::FLAG_TEXT) && !m_pBackend->Input()->ModifierIsPressed() && !m_pBackend->Input()->AltIsPressed()) { // How to make this better? // This checks when we press any key that is not handled by the dropdown // When that's the case, it means we confirm the completion if we have a valid completion index if(Event.m_Key != KEY_TAB && Event.m_Key != KEY_LSHIFT && Event.m_Key != KEY_RSHIFT && Event.m_Key != KEY_UP && Event.m_Key != KEY_DOWN && !(Event.m_Key >= KEY_MOUSE_1 && Event.m_Key <= KEY_MOUSE_WHEEL_RIGHT)) { if(m_CurrentCompletionIndex != -1) { m_CurrentCompletionIndex = -1; m_DropdownContext.m_Selected = -1; Update(); UpdateCursor(true); } } } return false; } const char *CMapSettingsBackend::CContext::InputString() const { if(!m_pLineInput) return nullptr; return m_pBackend->Input()->HasComposition() ? m_CompositionStringBuffer.c_str() : m_pLineInput->GetString(); } const ColorRGBA CMapSettingsBackend::CContext::ms_ArgumentStringColor = ColorRGBA(84 / 255.0f, 1.0f, 1.0f, 1.0f); const ColorRGBA CMapSettingsBackend::CContext::ms_ArgumentNumberColor = ColorRGBA(0.1f, 0.9f, 0.05f, 1.0f); const ColorRGBA CMapSettingsBackend::CContext::ms_ArgumentUnknownColor = ColorRGBA(0.6f, 0.6f, 0.6f, 1.0f); const ColorRGBA CMapSettingsBackend::CContext::ms_CommentColor = ColorRGBA(0.5f, 0.5f, 0.5f, 1.0f); const ColorRGBA CMapSettingsBackend::CContext::ms_ErrorColor = ColorRGBA(240 / 255.0f, 70 / 255.0f, 70 / 255.0f, 1.0f); void CMapSettingsBackend::CContext::ColorArguments(std::vector &vColorSplits) const { // Get argument color based on its type auto &&GetArgumentColor = [](char Type) -> ColorRGBA { if(Type == 'u') return ms_ArgumentUnknownColor; else if(Type == 's' || Type == 'r') return ms_ArgumentStringColor; else if(Type == 'i' || Type == 'f') return ms_ArgumentNumberColor; return ms_ErrorColor; // Invalid arg type }; // Iterate through all the current arguments and color them for(int i = 0; i < ArgCount(); i++) { const auto &Argument = Arg(i); // Color is based on the error flag and the type of the argument auto Color = Argument.m_Error ? ms_ErrorColor : GetArgumentColor(Argument.m_ExpectedType); vColorSplits.emplace_back(Argument.m_Start, Argument.m_End - Argument.m_Start, Color); } if(m_pLineInput && !m_pLineInput->IsEmpty()) { if(!CommandIsValid() && !m_AllowUnknownCommands && m_CommentOffset != 0) { // If command is invalid, override color splits with red, but not comment int ErrorLength = m_CommentOffset == -1 ? -1 : m_CommentOffset; vColorSplits = {{0, ErrorLength, ms_ErrorColor}}; } else if(HasError()) { // If there is an error, then color the wrong part of the input, excluding comment int ErrorLength = m_CommentOffset == -1 ? -1 : m_CommentOffset - ErrorOffset(); vColorSplits.emplace_back(ErrorOffset(), ErrorLength, ms_ErrorColor); } if(m_CommentOffset != -1) { // Color comment if there is one vColorSplits.emplace_back(m_CommentOffset, -1, ms_CommentColor); } } std::sort(vColorSplits.begin(), vColorSplits.end(), [](const STextColorSplit &a, const STextColorSplit &b) { return a.m_CharIndex < b.m_CharIndex; }); } int CMapSettingsBackend::CContext::CheckCollision(ECollisionCheckResult &Result) const { return CheckCollision(m_pBackend->Editor()->m_Map.m_vSettings, Result); } int CMapSettingsBackend::CContext::CheckCollision(const std::vector &vSettings, ECollisionCheckResult &Result) const { return CheckCollision(InputString(), vSettings, Result); } int CMapSettingsBackend::CContext::CheckCollision(const char *pInputString, const std::vector &vSettings, ECollisionCheckResult &Result) const { // Checks for a collision with the current map settings. // A collision is when a setting with the same arguments already exists and that it can't be added multiple times. // For this, we use argument constraints that we define in CMapSettingsCommandObject::LoadConstraints(). // For example, the "tune" command can be added multiple times, but only if the actual tune argument is different, thus // the tune argument must be defined as UNIQUE. // This method CheckCollision(ECollisionCheckResult&) returns an integer which is the index of the colliding line. If no // colliding line was found, then it returns -1. if(m_CommentOffset == 0) { // Ignore comments Result = ECollisionCheckResult::ADD; return -1; } const int InputLength = str_length(pInputString); struct SArgument { char m_aValue[128]; SArgument(const char *pStr) { str_copy(m_aValue, pStr); } }; struct SLineArgs { int m_Index; std::vector m_vArgs; }; // For now we split each map setting corresponding to the setting we want to add by spaces auto &&SplitSetting = [](const char *pStr) { std::vector vaArgs; const char *pIt = pStr; char aBuffer[128]; while((pIt = str_next_token(pIt, " ", aBuffer, sizeof(aBuffer)))) vaArgs.emplace_back(aBuffer); return vaArgs; }; // Define the result of the check Result = ECollisionCheckResult::ERROR; // First case: the command is not a valid (recognized) command. if(!CommandIsValid()) { // If we don't allow unknown commands, then we know there is no collision // and the check results in an error. if(!m_AllowUnknownCommands) return -1; if(InputLength == 0) return -1; // If we get here, it means we allow unknown commands. // For them, we need to check if a similar exact command exists or not in the settings list. // If it does, then we found a collision, and the result is REPLACE. for(int i = 0; i < (int)vSettings.size(); i++) { if(str_comp_nocase(vSettings[i].m_aCommand, pInputString) == 0) { Result = ECollisionCheckResult::REPLACE; return i; } } // If nothing was found, then we must ensure that the command, although unknown, is somewhat valid // by checking if the command contains a space and that there is at least one non-empty argument. const char *pSpace = str_find(pInputString, " "); if(!pSpace || !*(pSpace + 1)) Result = ECollisionCheckResult::ERROR; else Result = ECollisionCheckResult::ADD; return -1; // No collision } // Second case: the command is valid. // In this case, we know we have a valid setting name, which means we can use everything we have in this class which are // related to valid map settings, such as parsed command arguments, etc. const std::shared_ptr &pSetting = Setting(); if(pSetting->m_Type == IMapSetting::SETTING_INT) { // For integer settings, the check is quite simple as we know // we can only ever have 1 argument. // The integer setting cannot be added multiple times, which means if a collision was found, then the only result we // can have is REPLACE. // In this case, the collision is found only by checking the command name for every setting in the current map settings. char aBuffer[256]; auto It = std::find_if(vSettings.begin(), vSettings.end(), [&](const CEditorMapSetting &Setting) { const char *pLineSettingValue = Setting.m_aCommand; // Get the map setting command pLineSettingValue = str_next_token(pLineSettingValue, " ", aBuffer, sizeof(aBuffer)); // Get the first token before the first space return str_comp_nocase(aBuffer, pSetting->m_pName) == 0; // Check if that equals our current command }); if(It == vSettings.end()) { // If nothing was found, then there is no collision and we can add that command to the list Result = ECollisionCheckResult::ADD; return -1; } else { // Otherwise, we can only replace it Result = ECollisionCheckResult::REPLACE; return It - vSettings.begin(); // This is the index of the colliding line } } else if(pSetting->m_Type == IMapSetting::SETTING_COMMAND) { // For "command" settings, this is a bit more complex as we have to use argument constraints. // The general idea is to split every map setting in their arguments separated by spaces. // Then, for each argument, we check if it collides with any of the map settings. When that's the case, // we need to check the constraint of the argument. If set to UNIQUE, then that's a collision and we can only // replace the command in the list. // If set to anything else, we consider that it is not a collision and we move to the next argument. // This system is simple and somewhat flexible as we only need to declare the constraints, the rest should be // handled automatically. std::shared_ptr pSettingCommand = std::static_pointer_cast(pSetting); // Get matching lines for that command std::vector vLineArgs; for(int i = 0; i < (int)vSettings.size(); i++) { const auto &Setting = vSettings.at(i); // Split this setting into its arguments std::vector vArgs = SplitSetting(Setting.m_aCommand); // Only keep settings that match with the current input setting name if(!vArgs.empty() && str_comp_nocase(vArgs[0].m_aValue, pSettingCommand->m_pName) == 0) { // When that's the case, we save them vArgs.erase(vArgs.begin()); vLineArgs.push_back(SLineArgs{ i, vArgs, }); } } // Here is the simple algorithm to check for collisions according to argument constraints bool Error = false; int CollidingLineIndex = -1; for(int ArgIndex = 0; ArgIndex < ArgCount(); ArgIndex++) { bool Collide = false; const char *pValue = Arg(ArgIndex).m_aValue; for(auto &Line : vLineArgs) { // Check first colliding line if(str_comp_nocase(pValue, Line.m_vArgs[ArgIndex].m_aValue) == 0) { Collide = true; CollidingLineIndex = Line.m_Index; Error = m_pBackend->ArgConstraint(pSetting->m_pName, ArgIndex) == CMapSettingsBackend::EArgConstraint::UNIQUE; } if(Error) break; } // If we did not collide with any of the lines for that argument, we're good to go // (or if we had an error) if(!Collide || Error) break; // Otherwise, remove non-colliding args from the list vLineArgs.erase( std::remove_if(vLineArgs.begin(), vLineArgs.end(), [&](const SLineArgs &Line) { return str_comp_nocase(pValue, Line.m_vArgs[ArgIndex].m_aValue) != 0; }), vLineArgs.end()); } // The result is either REPLACE when we found a collision, or ADD Result = Error ? ECollisionCheckResult::REPLACE : ECollisionCheckResult::ADD; return CollidingLineIndex; } return -1; } bool CMapSettingsBackend::CContext::Valid() const { // Check if the entire setting is valid or not if(m_CommentOffset == 0) return true; // A "comment" setting is considered valid. // Check if command is valid if(m_pCurrentSetting) { // Check if all arguments are valid const bool ArgumentsValid = std::all_of(m_vCurrentArgs.begin(), m_vCurrentArgs.end(), [](const SCurrentSettingArg &Arg) { return !Arg.m_Error; }); if(!ArgumentsValid) return false; // Check that we have the same number of arguments return m_vCurrentArgs.size() == m_pBackend->m_ParsedCommandArgs.at(m_pCurrentSetting).size(); } else { // If we have an invalid setting, then we consider the entire setting as valid if we allow unknown commands // as we cannot handle them. return m_AllowUnknownCommands; } } void CMapSettingsBackend::CContext::GetCommandHelpText(char *pStr, int Length) const { if(!m_pCurrentSetting) return; str_copy(pStr, m_pCurrentSetting->m_pHelp, Length); } void CMapSettingsBackend::CContext::UpdateCompositionString() { if(!m_pLineInput) return; const bool HasComposition = m_pBackend->Input()->HasComposition(); if(HasComposition) { const size_t CursorOffset = m_pLineInput->GetCursorOffset(); const size_t DisplayCursorOffset = m_pLineInput->OffsetFromActualToDisplay(CursorOffset); const std::string DisplayStr = std::string(m_pLineInput->GetString()); std::string CompositionBuffer = DisplayStr.substr(0, DisplayCursorOffset) + m_pBackend->Input()->GetComposition() + DisplayStr.substr(DisplayCursorOffset); if(CompositionBuffer != m_CompositionStringBuffer) { m_CompositionStringBuffer = CompositionBuffer; Update(); UpdateCursor(); } } } template void CMapSettingsBackend::CContext::FormatDisplayValue(const char *pValue, char (&aOut)[N]) { const int MaxLength = 32; if(str_length(pValue) > MaxLength) { str_copy(aOut, pValue, MaxLength); str_append(aOut, "..."); } else { str_copy(aOut, pValue); } } bool CMapSettingsBackend::OnInput(const IInput::CEvent &Event) { if(ms_pActiveContext) return ms_pActiveContext->OnInput(Event); return false; } void CMapSettingsBackend::OnUpdate() { if(ms_pActiveContext && ms_pActiveContext->m_pLineInput && ms_pActiveContext->m_pLineInput->IsActive()) ms_pActiveContext->UpdateCompositionString(); } void CMapSettingsBackend::OnMapLoad() { // Load & validate all map settings m_LoadedMapSettings.Reset(); auto &vLoadedMapSettings = Editor()->m_Map.m_vSettings; // Keep a vector of valid map settings, to check collision against: m_vValidLoadedMapSettings // Create a local context with no lineinput, only used to parse the commands CContext LocalContext = NewContext(nullptr); // Iterate through map settings // Two steps: // 1. Save valid and invalid settings // 2. Check for duplicates std::vector> vSettingsInvalid; for(int i = 0; i < (int)vLoadedMapSettings.size(); i++) { CEditorMapSetting &Setting = vLoadedMapSettings.at(i); // Parse the setting using the context LocalContext.UpdateFromString(Setting.m_aCommand); bool Valid = LocalContext.Valid(); ECollisionCheckResult Result = ECollisionCheckResult::ERROR; LocalContext.CheckCollision(Setting.m_aCommand, m_LoadedMapSettings.m_vSettingsValid, Result); if(Valid && Result == ECollisionCheckResult::ADD) m_LoadedMapSettings.m_vSettingsValid.emplace_back(Setting); else vSettingsInvalid.emplace_back(i, Valid, Setting); LocalContext.Reset(); // Empty duplicates for this line, might be filled later m_LoadedMapSettings.m_SettingsDuplicate.insert({i, {}}); } for(const auto &[Index, Valid, Setting] : vSettingsInvalid) { LocalContext.UpdateFromString(Setting.m_aCommand); ECollisionCheckResult Result = ECollisionCheckResult::ERROR; int CollidingLineIndex = LocalContext.CheckCollision(Setting.m_aCommand, m_LoadedMapSettings.m_vSettingsValid, Result); int RealCollidingLineIndex = CollidingLineIndex; if(CollidingLineIndex != -1) RealCollidingLineIndex = std::find_if(vLoadedMapSettings.begin(), vLoadedMapSettings.end(), [&](const CEditorMapSetting &MapSetting) { return str_comp_nocase(MapSetting.m_aCommand, m_LoadedMapSettings.m_vSettingsValid.at(CollidingLineIndex).m_aCommand) == 0; }) - vLoadedMapSettings.begin(); int Type = 0; if(!Valid) Type |= SInvalidSetting::TYPE_INVALID; if(Result == ECollisionCheckResult::REPLACE) Type |= SInvalidSetting::TYPE_DUPLICATE; m_LoadedMapSettings.m_vSettingsInvalid.emplace_back(Index, Setting.m_aCommand, Type, RealCollidingLineIndex, !LocalContext.CommandIsValid()); if(Type & SInvalidSetting::TYPE_DUPLICATE) m_LoadedMapSettings.m_SettingsDuplicate[RealCollidingLineIndex].emplace_back(m_LoadedMapSettings.m_vSettingsInvalid.size() - 1); LocalContext.Reset(); } if(!m_LoadedMapSettings.m_vSettingsInvalid.empty()) Editor()->m_Dialog = DIALOG_MAPSETTINGS_ERROR; } // ------ loaders void CMapSettingsBackend::InitValueLoaders() { // Load the different possible values for some specific settings RegisterLoader("tune", SValueLoader::LoadTuneValues); RegisterLoader("tune_zone", SValueLoader::LoadTuneZoneValues); RegisterLoader("mapbug", SValueLoader::LoadMapBugs); } void SValueLoader::LoadTuneValues(const CSettingValuesBuilder &TuneBuilder) { // Add available tuning names to argument 0 of setting "tune" LoadArgumentTuneValues(TuneBuilder.Argument(0)); } void SValueLoader::LoadTuneZoneValues(const CSettingValuesBuilder &TuneZoneBuilder) { // Add available tuning names to argument 1 of setting "tune_zone" LoadArgumentTuneValues(TuneZoneBuilder.Argument(1)); } void SValueLoader::LoadMapBugs(const CSettingValuesBuilder &BugBuilder) { // Get argument 0 of setting "mapbug" auto ArgBuilder = BugBuilder.Argument(0); // Add available map bugs options ArgBuilder.Add("grenade-doubleexplosion@ddnet.tw"); } void SValueLoader::LoadArgumentTuneValues(CArgumentValuesListBuilder &&ArgBuilder) { // Iterate through available tunings add their name to the list for(int i = 0; i < CTuningParams::Num(); i++) { ArgBuilder.Add(CTuningParams::Name(i)); } }