From ff7df041cccb45b42bd4e374c71faca917def6f6 Mon Sep 17 00:00:00 2001 From: nullsystem <15316579+nullsystem@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:58:46 +0000 Subject: [PATCH] NeoUI - Table headers + main table API + section X-axis scrolling NeoUI table headers and main table API implemented, now have Begin/EndTable and NextTableRow implementation replacing the previous custom paint + button. Now can just layout the table cells with widgets, although at the moment only NeoUI::Label are properly utilized and dealt with. Other widgets are not refactored up for tables yet. The table headers now have dragable resizing and the server browser now have tags column. There's also right-click on the header to show/hide columns. Sections now have X-axis scrolling, mainly used for table and header scrolling support. Re-done NeoUI::Tabs scrolling, now it's held externally and its own thing. OTHERS: * Fix SDR/Steam networking server bot vs player number count * Fix border in smaller resolutions * Added IP Address (hidden by default) column FUTURE TODOs: * Section X-scrollbar controls options/flags * Modes: * No X-scrollbar * Indicator/thin X-scrollbar * Dragable/thick X-scrollbar * Using BeginTable will automatically put the section into Dragable/thick X-scrollbar mode * Some TODOs in source code * Change from only header to any sections to reference other section's scrolls * Refactor painting of widgets to utilize vgui viewports * Possible split of painting (and colors) from NeoUI internals * Vertical layouting that expands horizontally as oppose to the default horizontal layouting that expands vertically * fixes #1566 * fixes #994 * fixes #1815 --- src/game/client/neo/ui/neo_root.cpp | 734 +++++++----- src/game/client/neo/ui/neo_root.h | 17 +- .../client/neo/ui/neo_root_serverbrowser.cpp | 39 +- .../client/neo/ui/neo_root_serverbrowser.h | 25 +- src/game/client/neo/ui/neo_root_settings.cpp | 3 +- src/game/client/neo/ui/neo_theme.cpp | 3 + src/game/client/neo/ui/neo_ui.cpp | 1019 ++++++++++++++--- src/game/client/neo/ui/neo_ui.h | 153 ++- src/game/client/neo/ui/neo_utils.cpp | 2 +- 9 files changed, 1487 insertions(+), 508 deletions(-) diff --git a/src/game/client/neo/ui/neo_root.cpp b/src/game/client/neo/ui/neo_root.cpp index de9f6034ee..7b6862d401 100644 --- a/src/game/client/neo/ui/neo_root.cpp +++ b/src/game/client/neo/ui/neo_root.cpp @@ -69,10 +69,37 @@ constexpr wchar_t WSZ_GAME_TITLE1_b[] = L"C"; constexpr wchar_t WSZ_GAME_TITLE2[] = L"Hrebuild"; #define SZ_WEBSITE "https://neotokyorebuild.github.io" +const wchar_t *TABLE_HEADERS_SERVERBROWSER[GSIW__TOTAL] = { + L"Lock", L"VAC", L"Name", L"IP Address", L"Map", L"Players", L"Ping", L"Tags", +}; +const int TABLE_DEFPROP_SERVERBROWSER[GSIW__TOTAL] = { + 8, 8, 30, -18, 18, 12, 12, 12, // Last item need to set for hide/unhide +}; +const float TABLE_SCALEWIDE_SERVERBROWSER = 1.2f; + +const wchar_t *TABLE_HEADERS_SERVERBLACKLIST[SBLIST_COL__TOTAL] = { + L"Name", L"Type", L"Added on", +}; +const int TABLE_DEFPROP_SERVERBLACKLIST[SBLIST_COL__TOTAL] = { + 60, 10, -1 +}; + +const wchar_t *TABLE_HEADERS_PLAYER[GSPS__TOTAL] = { + L"Score", L"Name", L"Time" +}; +const int TABLE_DEFPROP_PLAYER[GSPS__TOTAL] = { + 15, 65, -1 +}; + +static_assert(sizeof(CNeoRoot::m_iColsWideServerBrowser) == sizeof(TABLE_DEFPROP_SERVERBROWSER)); +static_assert(sizeof(CNeoRoot::m_iColsWideServerBlacklist) == sizeof(TABLE_DEFPROP_SERVERBLACKLIST)); +static_assert(sizeof(CNeoRoot::m_iColsWideDetailedPlayerList) == sizeof(TABLE_DEFPROP_PLAYER)); + enum ENeoPopup { NEOPOPUP_ACTIONSERVER = NeoUI::INTERNALPOPUP_NIL + 1, NEOPOPUP_ACTIONBLACKLIST, + NEOPOPUP_ACTIONSBHEADER, }; ConCommand neo_toggleconsole("neo_toggleconsole", NeoToggleconsole, "toggle the console", FCVAR_DONTRECORD); @@ -115,6 +142,18 @@ void OverrideGameUI() } } +// Check if server is using SDR (Steam Datagram Relay) AKA Steam Networking +static bool NetAdrIsSDR(const servernetadr_t &netAdr) +{ + const uint32 u32IpAdr = netAdr.GetIP(); + const uint8 *u8IpBytes = (uint8 *)(&u32IpAdr); +#ifdef VALVE_BIG_ENDIAN + return (u8IpBytes[0] == 169 && u8IpBytes[1] == 254); +#else + return (u8IpBytes[3] == 169 && u8IpBytes[2] == 254); +#endif +} + // Only use it rarely/cached static bool NetAdrIsFavorite(const servernetadr_t &netAdr) { @@ -479,6 +518,9 @@ void CNeoRoot::ApplySchemeSettings(IScheme *pScheme) m_flWideAs43 = static_cast(tall) * (4.0f / 3.0f); if (m_flWideAs43 > flWide) m_flWideAs43 = flWide; g_iRootSubPanelWide = static_cast(m_flWideAs43 * 0.9f); + m_tabsStateSettings = {}; + m_tabsStateServerBrowser = {}; + m_tabsStateIFF = {}; UpdateControls(); } @@ -523,7 +565,7 @@ void CNeoRoot::OnTick() { if (m_state == STATE_SERVERBROWSER) { - if (m_bSBFiltModified) + if (m_headerModFlagsServerBrowser) { // Pass modified over to the tabs so it doesn't trigger // the filter refresh immeditely @@ -531,7 +573,7 @@ void CNeoRoot::OnTick() { m_serverBrowser[i].m_bModified = true; } - m_bSBFiltModified = false; + m_headerModFlagsServerBrowser = 0; } auto *pSbTab = &m_serverBrowser[m_iServerBrowserTab]; @@ -543,10 +585,11 @@ void CNeoRoot::OnTick() } else if (m_state == STATE_SERVERDETAILS) { - if (m_bSPlayersSortModified) + // NEO TODO MAYBE (nullsystem): Can just reverse list if desending used change + if (m_headerModFlagsPlayers) { m_serverPlayers.UpdateSortedList(); - m_bSPlayersSortModified = false; + m_headerModFlagsPlayers = 0; } } } @@ -995,8 +1038,7 @@ void CNeoRoot::MainLoopSettings(const MainLoopParam param) { NeoUI::BeginSection(NeoUI::SECTIONFLAG_ROWWIDGETS | NeoUI::SECTIONFLAG_EXCLUDECONTROLLER); { - static int siTabsLabelWide = -1; - NeoUI::Tabs(WSZ_TABS_LABELS, ARRAYSIZE(WSZ_TABS_LABELS), &m_ns.iCurTab, NeoUI::TABFLAG_DEFAULT, &siTabsLabelWide); + NeoUI::Tabs(WSZ_TABS_LABELS, ARRAYSIZE(WSZ_TABS_LABELS), &m_ns.iCurTab, NeoUI::TABFLAG_DEFAULT, &m_tabsStateSettings); } NeoUI::EndSection(); if (!P_FN[m_ns.iCurTab].bUISectionManaged) @@ -1176,128 +1218,6 @@ void CNeoRoot::MainLoopNewGame(const MainLoopParam param) NeoUI::EndContext(); } -static constexpr const wchar_t *SBLABEL_NAMES[GSIW__TOTAL] = { - L"Lock", L"VAC", L"Name", L"Map", L"Players", L"Ping", -}; -static const int ROWLAYOUT_TABLESPLIT[GSIW__TOTAL] = { - 8, 8, 40, 20, 12, -1 -}; - -static constexpr const wchar_t *BLACKLISTLABEL_NAMES[SBLIST_COL__TOTAL] = { - L"Name", L"Type", L"Added on", -}; -static const int ROWLAYOUT_BLACKLIST[SBLIST_COL__TOTAL] = { - 60, 10, -1 -}; - - -static void ServerBrowserDrawRow(const gameserveritem_t &server) -{ - int xPos = 0; - const int fontStartYPos = g_uiCtx.fonts[g_uiCtx.eFont].iYOffset; - - if (server.m_bPassword) - { - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - vgui::surface()->DrawPrintText(L"P", 1); - } - xPos += static_cast(g_uiCtx.irWidgetWide * (ROWLAYOUT_TABLESPLIT[GSIW_LOCKED] / 100.0f)); - - if (server.m_bSecure) - { - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - vgui::surface()->DrawPrintText(L"S", 1); - } - xPos += static_cast(g_uiCtx.irWidgetWide * (ROWLAYOUT_TABLESPLIT[GSIW_VAC] / 100.0f)); - - { - wchar_t wszServerName[k_cbMaxGameServerName]; - const int iSize = g_pVGuiLocalize->ConvertANSIToUnicode(server.GetName(), wszServerName, sizeof(wszServerName)); - - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - vgui::surface()->DrawPrintText(wszServerName, iSize - 1); - } - xPos += static_cast(g_uiCtx.irWidgetWide * (ROWLAYOUT_TABLESPLIT[GSIW_NAME] / 100.0f)); - - { - // In lower resolution, it may overlap from name, so paint a background here - vgui::surface()->DrawFilledRect(g_uiCtx.rWidgetArea.x0 + xPos, g_uiCtx.rWidgetArea.y0, - g_uiCtx.rWidgetArea.x1, g_uiCtx.rWidgetArea.y1); - - wchar_t wszMapName[k_cbMaxGameServerMapName]; - const int iSize = g_pVGuiLocalize->ConvertANSIToUnicode(server.m_szMap, wszMapName, sizeof(wszMapName)); - - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - vgui::surface()->DrawPrintText(wszMapName, iSize - 1); - } - xPos += static_cast(g_uiCtx.irWidgetWide * (ROWLAYOUT_TABLESPLIT[GSIW_MAP] / 100.0f)); - - { - // In lower resolution, it may overlap from name, so paint a background here - vgui::surface()->DrawFilledRect(g_uiCtx.rWidgetArea.x0 + xPos, g_uiCtx.rWidgetArea.y0, - g_uiCtx.rWidgetArea.x1, g_uiCtx.rWidgetArea.y1); - - wchar_t wszPlayers[15]; - const int iSize = server.m_nBotPlayers ? V_swprintf_safe(wszPlayers, L"%d/%d (%d)", server.m_nPlayers - server.m_nBotPlayers, server.m_nMaxPlayers, server.m_nBotPlayers) - : V_swprintf_safe(wszPlayers, L"%d/%d", server.m_nPlayers, server.m_nMaxPlayers); - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - vgui::surface()->DrawPrintText(wszPlayers, iSize); - } - xPos += static_cast(g_uiCtx.irWidgetWide * (ROWLAYOUT_TABLESPLIT[GSIW_PLAYERS] / 100.0f)); - - { - wchar_t wszPing[10]; - const int iSize = V_swprintf_safe(wszPing, L"%d", server.m_nPing); - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - vgui::surface()->DrawPrintText(wszPing, iSize); - } -} - -static void ServerBlacklistDrawRow(const ServerBlacklistInfo &blacklist) -{ - int xPos = 0; - const int fontStartYPos = g_uiCtx.fonts[g_uiCtx.eFont].iYOffset; - - { - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - vgui::surface()->DrawPrintText(blacklist.wszName, V_wcslen(blacklist.wszName)); - } - xPos += static_cast(g_uiCtx.irWidgetWide * (ROWLAYOUT_BLACKLIST[SBLIST_COL_NAME] / 100.0f)); - - { - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - const wchar_t *pwszLabel = (blacklist.eType == SBLIST_TYPE_NETADR) ? L"IP" : L"NAME"; - vgui::surface()->DrawPrintText(pwszLabel, V_wcslen(pwszLabel)); - } - xPos += static_cast(g_uiCtx.irWidgetWide * (ROWLAYOUT_BLACKLIST[SBLIST_COL_TYPE] / 100.0f)); - - { - vgui::surface()->DrawSetTextPos(g_uiCtx.rWidgetArea.x0 + xPos + g_uiCtx.iMarginX, g_uiCtx.rWidgetArea.y0 + fontStartYPos); - vgui::surface()->DrawPrintText(blacklist.wszDateTimeAdded, V_wcslen(blacklist.wszDateTimeAdded)); - } -} - -static void DrawSortHint(const bool bDescending) -{ - if (g_uiCtx.eMode != NeoUI::MODE_PAINT || !IN_BETWEEN_AR(0, g_uiCtx.irWidgetLayoutY, g_uiCtx.dPanel.tall)) - { - return; - } - int iHintTall = g_uiCtx.iMarginY / 3; - vgui::surface()->DrawSetColor(COLOR_WHITE); - if (!bDescending) - { - vgui::surface()->DrawFilledRect(g_uiCtx.rWidgetArea.x0, g_uiCtx.rWidgetArea.y0, - g_uiCtx.rWidgetArea.x1, g_uiCtx.rWidgetArea.y0 + iHintTall); - } - else - { - vgui::surface()->DrawFilledRect(g_uiCtx.rWidgetArea.x0, g_uiCtx.rWidgetArea.y1 - iHintTall, - g_uiCtx.rWidgetArea.x1, g_uiCtx.rWidgetArea.y1); - } - vgui::surface()->DrawSetColor(COLOR_NEOPANELACCENTBG); -} - void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) { static const wchar_t *GS_NAMES[GS__TOTAL] = { @@ -1306,6 +1226,9 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) static const wchar_t *ANTICHEAT_LABELS[ANTICHEAT__TOTAL] = { L"", L"On", L"Off" }; + static const wchar_t *TAGS_FILTER_LABELS[TAGSFILTER__TOTAL] = { + L"Tags include", L"Tags exclude" + }; bool bEnterServer = false; const int iTallTotal = g_uiCtx.layout.iRowTall * (g_iRowsInScreen + 2); @@ -1314,14 +1237,43 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) g_uiCtx.dPanel.y = (param.tall / 2) - (iTallTotal / 2); g_uiCtx.dPanel.tall = g_uiCtx.layout.iRowTall * 2; g_uiCtx.colors.sectionBg = COLOR_BLACK_TRANSPARENT; + + const int iTotalHeadersWide = (g_uiCtx.dPanel.wide * TABLE_SCALEWIDE_SERVERBROWSER); + + // NEO TODO (nullsystem): Save and restore it cross sessions + if (NeoUI::MODE_PAINT == g_uiCtx.eMode && g_uiCtx.dPanel.wide > 0) + { + if (0 == m_iColsWideServerBrowser[0]) + { + int iAccX = 0; + for (int i = 0; i < (GSIW__TOTAL - 1); ++i) + { + m_iColsWideServerBrowser[i] = (TABLE_DEFPROP_SERVERBROWSER[i] / 100.0f) * iTotalHeadersWide; + iAccX += m_iColsWideServerBrowser[i]; + } + m_iColsWideServerBrowser[GSIW__TOTAL - 1] = iTotalHeadersWide - iAccX; + } + + if (0 == m_iColsWideServerBlacklist[0]) + { + int iAccX = 0; + for (int i = 0; i < (SBLIST_COL__TOTAL - 1); ++i) + { + m_iColsWideServerBlacklist[i] = (TABLE_DEFPROP_SERVERBLACKLIST[i] / 100.0f) * g_uiCtx.dPanel.wide; + iAccX += m_iColsWideServerBlacklist[i]; + } + m_iColsWideServerBlacklist[SBLIST_COL__TOTAL - 1] = g_uiCtx.dPanel.wide - iAccX; + } + } + NeoUI::BeginContext(&g_uiCtx, param.eMode, m_wszCachedTexts[MMBTN_FINDSERVER], "CtxServerBrowser"); { bool bForceRefresh = false; NeoUI::BeginSection(NeoUI::SECTIONFLAG_ROWWIDGETS); { const int iPrevTab = m_iServerBrowserTab; - static int siGsNamesWide = -1; - NeoUI::Tabs(GS_NAMES, ARRAYSIZE(GS_NAMES), &m_iServerBrowserTab, NeoUI::TABFLAG_DEFAULT, &siGsNamesWide); + NeoUI::Tabs(GS_NAMES, ARRAYSIZE(GS_NAMES), &m_iServerBrowserTab, + NeoUI::TABFLAG_DEFAULT, &m_tabsStateServerBrowser); if (iPrevTab != m_iServerBrowserTab) { m_iSelectedServer = -1; @@ -1339,46 +1291,34 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) g_uiCtx.eButtonTextStyle = NeoUI::TEXTSTYLE_LEFT; int iColTotal = GSIW__TOTAL; - const int *pirLayout = ROWLAYOUT_TABLESPLIT; - const wchar_t * const*pwszNames = SBLABEL_NAMES; + int *pirLayout = m_iColsWideServerBrowser; + const wchar_t **pwszNames = TABLE_HEADERS_SERVERBROWSER; if (m_iServerBrowserTab == GS_BLACKLIST) { - iColTotal = ARRAYSIZE(ROWLAYOUT_BLACKLIST); - pirLayout = ROWLAYOUT_BLACKLIST; - pwszNames = BLACKLISTLABEL_NAMES; + iColTotal = SBLIST_COL__TOTAL; + pirLayout = m_iColsWideServerBlacklist; + pwszNames = TABLE_HEADERS_SERVERBLACKLIST; } - NeoUI::SetPerRowLayout(iColTotal, pirLayout); - for (int i = 0; i < iColTotal; ++i) + m_headerModFlagsServerBrowser |= NeoUI::TableHeader(pwszNames, iColTotal, + pirLayout, &m_sortCtx.col, &m_sortCtx.bDescending, 1); + if (m_iServerBrowserTab != GS_BLACKLIST + && g_uiCtx.eMode == NeoUI::MODE_MOUSEPRESSED + && g_uiCtx.eCode == MOUSE_RIGHT + && g_uiCtx.iHotSection == g_uiCtx.iSection) { - const bool isSortCol = (m_sortCtx.col == i); - vgui::surface()->DrawSetColor(isSortCol ? COLOR_NEOPANELACCENTBG : COLOR_BLACK_TRANSPARENT); - if (NeoUI::Button(pwszNames[i]).bPressed) - { - if (isSortCol) - { - m_sortCtx.bDescending = !m_sortCtx.bDescending; - } - else - { - m_sortCtx.col = i; - } - m_bSBFiltModified = true; - } - - if (isSortCol) - { - DrawSortHint(m_sortCtx.bDescending); - } + NeoUI::OpenPopup(NEOPOPUP_ACTIONSBHEADER, NeoUI::Dim{ + .x = g_uiCtx.iMouseAbsX, + .y = g_uiCtx.iMouseAbsY, + .wide = NeoUI::PopupWideByStr("__IP Address"), + .tall = g_uiCtx.layout.iDefRowTall * GSIW__TOTAL, + }); } - // TODO: Should give proper controls over colors through NeoUI - vgui::surface()->DrawSetColor(COLOR_NEOPANELACCENTBG); - vgui::surface()->DrawSetTextColor(COLOR_WHITE); g_uiCtx.eButtonTextStyle = NeoUI::TEXTSTYLE_CENTER; } NeoUI::EndSection(); - static constexpr int FILTER_ROWS = 5; + static constexpr int FILTER_ROWS = 8; g_uiCtx.dPanel.y += g_uiCtx.dPanel.tall; g_uiCtx.dPanel.tall = g_uiCtx.layout.iRowTall * (g_iRowsInScreen - 1); if (m_bShowFilterPanel) g_uiCtx.dPanel.tall -= g_uiCtx.layout.iRowTall * FILTER_ROWS; @@ -1389,57 +1329,61 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) { if (g_blacklistedServers.IsEmpty()) { - wchar_t wszInfo[128]; - V_swprintf_safe(wszInfo, L"No servers blacklisted"); - NeoUI::HeadingLabel(wszInfo); + NeoUI::BeginIgnoreXOffset(); + { + NeoUI::HeadingLabel(L"No servers blacklisted"); + } + NeoUI::EndIgnoreXOffset(); + NeoUI::PadTableXScroll(m_iColsWideServerBlacklist, SBLIST_COL__TOTAL); } else { - for (int i = 0; i < g_blacklistedServers.Count(); ++i) + NeoUI::BeginTable(m_iColsWideServerBlacklist, SBLIST_COL__TOTAL); { - const ServerBlacklistInfo &blacklist = g_blacklistedServers[i]; - - const auto btn = NeoUI::Button(L""); - if (btn.bPressed || btn.bMouseRightPressed) // Dummy button, draw over it in paint + for (int i = 0; i < g_blacklistedServers.Count(); ++i) { - m_iSelectedServer = i; - if (btn.bMouseRightPressed) - { - NeoUI::OpenPopup(NEOPOPUP_ACTIONBLACKLIST, NeoUI::Dim{ - .x = g_uiCtx.iMouseAbsX, - .y = g_uiCtx.iMouseAbsY, - .wide = NeoUI::PopupWideByStr("Remove from blacklist"), - .tall = g_uiCtx.layout.iDefRowTall, - }); - } - } + const ServerBlacklistInfo &blacklist = g_blacklistedServers[i]; - if (param.eMode == NeoUI::MODE_PAINT) - { - Color drawColor = g_uiCtx.colors.normalBg; - Color textColor = g_uiCtx.colors.normalFg; + NeoUI::NextTableRowFlags rowFlags = NeoUI::NEXTTABLEROWFLAG_SELECTABLE; if (m_iSelectedServer == i) { - drawColor = g_uiCtx.colors.activeBg; - textColor = g_uiCtx.colors.activeFg; + rowFlags |= NeoUI::NEXTTABLEROWFLAG_SELECTED; } - else if (btn.bMouseHover) + const auto btn = NeoUI::NextTableRow(rowFlags); { - drawColor = COLOR_BLACK_TRANSPARENT; + NeoUI::Label(blacklist.wszName); + NeoUI::Label((blacklist.eType == SBLIST_TYPE_NETADR) ? L"IP" : L"NAME"); + NeoUI::Label(blacklist.wszDateTimeAdded); + } + if (btn.bKeyUpPressed || btn.bKeyDownPressed) + { + m_iSelectedServer = LoopAroundInArray(m_iSelectedServer + ((btn.bKeyUpPressed) ? -1 : +1), g_blacklistedServers.Count()); + } + else if (btn.bPressed || btn.bMouseRightPressed) + { + m_iSelectedServer = i; + if (btn.bMouseRightPressed) + { + NeoUI::OpenPopup(NEOPOPUP_ACTIONBLACKLIST, NeoUI::Dim{ + .x = g_uiCtx.iMouseAbsX, + .y = g_uiCtx.iMouseAbsY, + .wide = NeoUI::PopupWideByStr("Remove from blacklist"), + .tall = g_uiCtx.layout.iDefRowTall, + }); + } } - - vgui::surface()->DrawSetColor(drawColor); - vgui::surface()->DrawSetTextColor(textColor); - vgui::surface()->DrawFilledRectArray(&g_uiCtx.rWidgetArea, 1); - ServerBlacklistDrawRow(blacklist); - vgui::surface()->DrawSetColor(g_uiCtx.colors.normalBg); } } + const auto endBtn = NeoUI::EndTable(); + if (endBtn.bKeyUpPressed || endBtn.bKeyDownPressed) + { + m_iSelectedServer = (endBtn.bKeyUpPressed) ? g_blacklistedServers.Count() - 1 : 0; + } } } else { - if (m_serverBrowser[m_iServerBrowserTab].m_filteredServers.IsEmpty()) + if (m_serverBrowser[m_iServerBrowserTab].m_filteredServers.empty()) { wchar_t wszInfo[128]; if (m_serverBrowser[m_iServerBrowserTab].m_bSearching) @@ -1450,7 +1394,12 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) { V_swprintf_safe(wszInfo, L"No %ls queries found. Press Refresh to re-check", GS_NAMES[m_iServerBrowserTab]); } - NeoUI::HeadingLabel(wszInfo); + NeoUI::BeginIgnoreXOffset(); + { + NeoUI::HeadingLabel(wszInfo); + } + NeoUI::EndIgnoreXOffset(); + NeoUI::PadTableXScroll(m_iColsWideServerBrowser, GSIW__TOTAL); } else { @@ -1459,69 +1408,195 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) // expanded to give Y-offset in the form of filtered array range so it only loops // through what's needed instead. const auto *sbTab = &m_serverBrowser[m_iServerBrowserTab]; - for (int i = 0; i < sbTab->m_filteredServers.Size(); ++i) + int iShownServers = 0; + NeoUI::BeginTable(m_iColsWideServerBrowser, GSIW__TOTAL); { - const auto &server = sbTab->m_filteredServers[i]; - bool bSkipServer = false; - if (m_sbFilters.bServerNotFull && server.m_nPlayers == server.m_nMaxPlayers) bSkipServer = true; - else if (m_sbFilters.bHasUsersPlaying && server.m_nPlayers - server.m_nBotPlayers == 0) bSkipServer = true; - else if (m_sbFilters.bIsNotPasswordProtected && server.m_bPassword) bSkipServer = true; - else if (m_sbFilters.iAntiCheat == ANTICHEAT_OFF && server.m_bSecure) bSkipServer = true; - else if (m_sbFilters.iAntiCheat == ANTICHEAT_ON && !server.m_bSecure) bSkipServer = true; - else if (m_sbFilters.iMaxPing != 0 && server.m_nPing > m_sbFilters.iMaxPing) bSkipServer = true; - else if (ServerBlacklisted(server)) bSkipServer = true; - if (bSkipServer) + for (int i = 0; i < sbTab->m_filteredServers.size(); ++i) { - continue; - } + // NEO TODO (nullsystem): Some/most of those filters really should be + // done via the CNeoServerList::RequestList server request through the + // MatchMakingKeyValuePair_t mmFilters list + const auto &server = sbTab->m_filteredServers[i]; + const int iPlayersCount = server.m_nPlayers - (NetAdrIsSDR(server.m_NetAdr) ? 0 : server.m_nBotPlayers); + bool bSkipServer = false; + if (m_sbFilters.bServerNotFull && server.m_nPlayers == server.m_nMaxPlayers) bSkipServer = true; + else if (m_sbFilters.bHasUsersPlaying && iPlayersCount == 0) bSkipServer = true; + else if (m_sbFilters.bIsNotPasswordProtected && server.m_bPassword) bSkipServer = true; + else if (m_sbFilters.iAntiCheat == ANTICHEAT_OFF && server.m_bSecure) bSkipServer = true; + else if (m_sbFilters.iAntiCheat == ANTICHEAT_ON && !server.m_bSecure) bSkipServer = true; + else if (m_sbFilters.iMaxPing != 0 && server.m_nPing > m_sbFilters.iMaxPing) bSkipServer = true; + else if (ServerBlacklisted(server)) bSkipServer = true; + else if (m_sbFilters.iMaxPlayerCount > 0 && iPlayersCount > m_sbFilters.iMaxPlayerCount) bSkipServer = true; + + // NEO NOTE (nullsystem): All of those wchar_t[] strings + // need to be zero-initialized otherwise it can crash + // the client on some text! + wchar_t wszMapName[k_cbMaxGameServerMapName] = {}; + if (false == bSkipServer) + { + g_pVGuiLocalize->ConvertANSIToUnicode( + server.m_szMap, wszMapName, sizeof(wszMapName)); + if (m_sbFilters.wszMapFilter[0] && nullptr == wcsstr(wszMapName, m_sbFilters.wszMapFilter)) bSkipServer = true; + } - const auto btn = NeoUI::Button(L""); - if (btn.bPressed || btn.bMouseRightPressed) // Dummy button, draw over it in paint - { - m_iSelectedServer = i; - if (btn.bMouseRightPressed) + // Tags are separated by "," + wchar_t wszTags[k_cbMaxGameServerTags] = {}; + if (false == bSkipServer) + { + g_pVGuiLocalize->ConvertANSIToUnicode( + server.m_szGameTags, wszTags, sizeof(wszTags)); + if (m_sbFilters.wszTagsFilter[0]) + { + bool bInFilter = false; + + // wcstok will modify string in-place, so copy the strings over first + // and use it on mutWsz... variables instead + wchar_t mutWszTags[k_cbMaxGameServerTags] = {}; + V_wcscpy_safe(mutWszTags, wszTags); + + wchar_t *pwszBufTags = nullptr; + wchar_t *pwszTokenTag = wcstok(mutWszTags, L",", &pwszBufTags); + while (pwszTokenTag && !bInFilter) + { + wchar_t mutWszTagsFilter[k_cbMaxGameServerTags] = {}; + V_wcscpy_safe(mutWszTagsFilter, m_sbFilters.wszTagsFilter); + + wchar_t *pwszBufTagsFilter = nullptr; + wchar_t *pwszBufTokenTagFilter = wcstok(mutWszTagsFilter, L",", &pwszBufTagsFilter); + while (pwszBufTokenTagFilter && !bInFilter) + { + bInFilter = (0 == V_wcscmp(pwszTokenTag, pwszBufTokenTagFilter)); + pwszBufTokenTagFilter = wcstok(nullptr, L",", &pwszBufTagsFilter); + } + + pwszTokenTag = wcstok(nullptr, L",", &pwszBufTags); + } + + if (!bInFilter && m_sbFilters.iTagsFilterType == TAGSFILTER_INCLUDE) + { + bSkipServer = true; + } + else if (bInFilter) + { + bSkipServer = (m_sbFilters.iTagsFilterType == TAGSFILTER_EXCLUDE); + } + } + } + + if (bSkipServer) { - NeoUI::OpenPopup(NEOPOPUP_ACTIONSERVER, NeoUI::Dim{ - .x = g_uiCtx.iMouseAbsX, - .y = g_uiCtx.iMouseAbsY, - .wide = NeoUI::PopupWideByStr("Add to blacklist"), - .tall = g_uiCtx.layout.iDefRowTall * 2, - }); - - // NetAdrIsFavorite cached here for the popup - const auto *gameServer = &m_serverBrowser[m_iServerBrowserTab].m_filteredServers[m_iSelectedServer]; - const servernetadr_t &netAdr = gameServer->m_NetAdr; - m_bFavCacheIsFav = NetAdrIsFavorite(netAdr); - m_favCacheNetAdr = netAdr; + if (m_iSelectedServer == i && m_iUpDownDirection != 0 && m_iUpDownInitialServer >= 0) + { + if (m_iSelectedServer == m_iUpDownInitialServer) + { + // Unlikely it'll hit this case but just in-case + m_iUpDownDirection = 0; + m_iUpDownInitialServer = -1; + m_iSelectedServer = -1; + } + else + { + m_iSelectedServer = LoopAroundInArray(m_iSelectedServer + m_iUpDownDirection, static_cast(sbTab->m_filteredServers.size())); + } + } + continue; } - if (btn.bKeyPressed || btn.bMouseDoublePressed) + ++iShownServers; + m_iUpDownDirection = 0; + m_iUpDownInitialServer = -1; + + wchar_t wszServerName[k_cbMaxGameServerName] = {}; + wchar_t wszPlayers[15] = {}; + wchar_t wszPing[10] = {}; + wchar_t wszIPAddress[64] = {}; + + g_pVGuiLocalize->ConvertANSIToUnicode( + server.GetName(), wszServerName, sizeof(wszServerName)); + if (server.m_nBotPlayers > 0) { - bEnterServer = true; + V_swprintf_safe(wszPlayers, L"%d/%d (%d)", + iPlayersCount, + server.m_nMaxPlayers, + server.m_nBotPlayers); } - } + else + { + V_swprintf_safe(wszPlayers, L"%d/%d", + server.m_nPlayers, + server.m_nMaxPlayers); + } + V_swprintf_safe(wszPing, L"%d", server.m_nPing); + g_pVGuiLocalize->ConvertANSIToUnicode(server.m_NetAdr.GetConnectionAddressString(), wszIPAddress, sizeof(wszIPAddress)); - if (param.eMode == NeoUI::MODE_PAINT) - { - Color textColor = COLOR_WHITE; - Color drawColor = COLOR_BLACK_TRANSPARENT; + NeoUI::NextTableRowFlags rowFlags = NeoUI::NEXTTABLEROWFLAG_SELECTABLE; if (m_iSelectedServer == i) { - drawColor = COLOR_WHITE; - textColor = COLOR_BLACK; + rowFlags |= NeoUI::NEXTTABLEROWFLAG_SELECTED; } - else if (btn.bMouseHover) + const auto btn = NeoUI::NextTableRow(rowFlags); { - drawColor = COLOR_BLACK_TRANSPARENT; + NeoUI::Label((server.m_bPassword) ? L"P" : L""); + NeoUI::Label((server.m_bSecure) ? L"S" : L""); + NeoUI::Label(wszServerName); + NeoUI::Label(wszIPAddress); + NeoUI::Label(wszMapName); + NeoUI::Label(wszPlayers); + NeoUI::Label(wszPing); + NeoUI::Label(wszTags); + } + if (btn.bKeyUpPressed || btn.bKeyDownPressed) + { + m_iUpDownDirection = (btn.bKeyUpPressed) ? -1 : +1; + m_iUpDownInitialServer = i; + m_iSelectedServer = LoopAroundInArray(m_iSelectedServer + m_iUpDownDirection, static_cast(sbTab->m_filteredServers.size())); + if (m_iSelectedServer == m_iUpDownInitialServer) + { + m_iUpDownDirection = 0; + m_iUpDownInitialServer = -1; + } + } + else if (btn.bPressed || btn.bMouseRightPressed) + { + m_iSelectedServer = i; + if (btn.bMouseRightPressed) + { + NeoUI::OpenPopup(NEOPOPUP_ACTIONSERVER, NeoUI::Dim{ + .x = g_uiCtx.iMouseAbsX, + .y = g_uiCtx.iMouseAbsY, + .wide = NeoUI::PopupWideByStr("Add to blacklist"), + .tall = g_uiCtx.layout.iDefRowTall * 2, + }); + + // NetAdrIsFavorite cached here for the popup + const auto *gameServer = &m_serverBrowser[m_iServerBrowserTab].m_filteredServers[m_iSelectedServer]; + const servernetadr_t &netAdr = gameServer->m_NetAdr; + m_bFavCacheIsFav = NetAdrIsFavorite(netAdr); + m_favCacheNetAdr = netAdr; + } + if (btn.bKeyEnterPressed || btn.bMouseDoublePressed) + { + bEnterServer = true; + } } - - vgui::surface()->DrawSetColor(drawColor); - vgui::surface()->DrawSetTextColor(textColor); - vgui::surface()->DrawFilledRect(g_uiCtx.rWidgetArea.x0, g_uiCtx.rWidgetArea.y0, - g_uiCtx.rWidgetArea.x1, g_uiCtx.rWidgetArea.y1); - ServerBrowserDrawRow(server); - vgui::surface()->DrawSetColor(g_uiCtx.colors.normalBg); } } + const auto endBtn = NeoUI::EndTable(); + if (endBtn.bKeyUpPressed || endBtn.bKeyDownPressed) + { + m_iUpDownDirection = (endBtn.bKeyUpPressed) ? -1 : +1; + m_iSelectedServer = (endBtn.bKeyUpPressed) ? static_cast(sbTab->m_filteredServers.size()) - 1 : 0; + m_iUpDownInitialServer = m_iSelectedServer; + if (m_iSelectedServer == m_iUpDownInitialServer) + { + m_iUpDownDirection = 0; + m_iUpDownInitialServer = -1; + } + } + + if (iShownServers == 0) + { + NeoUI::HeadingLabel(L"There are no servers that pass your filter settings"); + } } } } @@ -1533,6 +1608,24 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) NeoUI::BeginSection(NeoUI::SECTIONFLAG_ROWWIDGETS); { NeoUI::SwapFont(NeoUI::FONT_NTNORMAL); + if (m_bShowFilterPanel) + { + NeoUI::SetPerRowLayout(2, NeoUI::ROWLAYOUT_TWOSPLIT); + NeoUI::RingBoxBool(L"Server not full", &m_sbFilters.bServerNotFull); + NeoUI::RingBoxBool(L"Has users playing", &m_sbFilters.bHasUsersPlaying); + NeoUI::RingBoxBool(L"Is not password protected", &m_sbFilters.bIsNotPasswordProtected); + NeoUI::RingBox(L"Anti-cheat", ANTICHEAT_LABELS, ARRAYSIZE(ANTICHEAT_LABELS), &m_sbFilters.iAntiCheat); + NeoUI::SliderInt(L"Maximum ping", &m_sbFilters.iMaxPing, 0, 500, 10, L"No limit"); + NeoUI::TextEdit(L"Map", m_sbFilters.wszMapFilter, SZWSZ_LEN(m_sbFilters.wszMapFilter)); + NeoUI::SliderInt(L"Max player count", &m_sbFilters.iMaxPlayerCount, 0, MAX_PLAYERS-1, 1, L"No limit"); + + NeoUI::BeginMultiWidgetHighlighter(2); + { + NeoUI::RingBox(TAGS_FILTER_LABELS, ARRAYSIZE(TAGS_FILTER_LABELS), &m_sbFilters.iTagsFilterType); + NeoUI::TextEdit(m_sbFilters.wszTagsFilter, SZWSZ_LEN(m_sbFilters.wszTagsFilter)); + } + NeoUI::EndMultiWidgetHighlighter(); + } g_uiCtx.eButtonTextStyle = NeoUI::TEXTSTYLE_CENTER; NeoUI::SetPerRowLayout(6); { @@ -1611,8 +1704,8 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) m_iSelectedServer = -1; ISteamMatchmakingServers *steamMM = steamapicontext->SteamMatchmakingServers(); CNeoServerList *pServerBrowser = &m_serverBrowser[m_iServerBrowserTab]; - pServerBrowser->m_servers.RemoveAll(); - pServerBrowser->m_filteredServers.RemoveAll(); + pServerBrowser->m_servers.clear(); + pServerBrowser->m_filteredServers.clear(); if (pServerBrowser->m_hdlRequest) { steamMM->CancelQuery(pServerBrowser->m_hdlRequest); @@ -1661,16 +1754,6 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) } } } - NeoUI::SwapFont(NeoUI::FONT_NTNORMAL); - if (m_bShowFilterPanel) - { - NeoUI::SetPerRowLayout(2, NeoUI::ROWLAYOUT_TWOSPLIT); - NeoUI::RingBoxBool(L"Server not full", &m_sbFilters.bServerNotFull); - NeoUI::RingBoxBool(L"Has users playing", &m_sbFilters.bHasUsersPlaying); - NeoUI::RingBoxBool(L"Is not password protected", &m_sbFilters.bIsNotPasswordProtected); - NeoUI::RingBox(L"Anti-cheat", ANTICHEAT_LABELS, ARRAYSIZE(ANTICHEAT_LABELS), &m_sbFilters.iAntiCheat); - NeoUI::SliderInt(L"Maximum ping", &m_sbFilters.iMaxPing, 0, 500, 10, L"No limit"); - } } NeoUI::EndSection(); } @@ -1738,6 +1821,28 @@ void CNeoRoot::MainLoopServerBrowser(const MainLoopParam param) NeoUI::EndPopup(); } + if (NeoUI::BeginPopup(NEOPOPUP_ACTIONSBHEADER, NeoUI::POPUPFLAG_COLORHOTASACTIVE)) + { + for (int i = 0; i < GSIW__TOTAL; ++i) + { + if (NeoUI::ButtonCheckbox(TABLE_HEADERS_SERVERBROWSER[i], (m_iColsWideServerBrowser[i] > 0)).bPressed) + { + if (m_iColsWideServerBrowser[i] == 0) + { + m_iColsWideServerBrowser[i] = abs(TABLE_DEFPROP_SERVERBROWSER[i] / 100.0f) * iTotalHeadersWide; + } + else + { + m_iColsWideServerBrowser[i] = -m_iColsWideServerBrowser[i]; + } + NeoUI::ClosePopup(); + break; + } + } + + NeoUI::EndPopup(); + } + NeoUI::EndContext(); } @@ -2016,6 +2121,20 @@ void CNeoRoot::MainLoopServerDetails(const MainLoopParam param) g_uiCtx.dPanel.y = (param.tall / 2) - (iTallTotal / 2); g_uiCtx.dPanel.tall = g_uiCtx.layout.iRowTall * 6; g_uiCtx.colors.sectionBg = COLOR_BLACK_TRANSPARENT; + + // NEO TODO (nullsystem): Save and restore it cross sessions + if (NeoUI::MODE_PAINT == g_uiCtx.eMode && g_uiCtx.dPanel.wide > 0 && + 0 == m_iColsWideDetailedPlayerList[0]) + { + int iAccX = 0; + for (int i = 0; i < (GSPS__TOTAL - 1); ++i) + { + m_iColsWideDetailedPlayerList[i] = (TABLE_DEFPROP_PLAYER[i] / 100.0f) * g_uiCtx.dPanel.wide; + iAccX += m_iColsWideDetailedPlayerList[i]; + } + m_iColsWideDetailedPlayerList[GSPS__TOTAL - 1] = g_uiCtx.dPanel.wide - iAccX; + } + NeoUI::BeginContext(&g_uiCtx, param.eMode, L"Server details", "CtxServerDetail"); { NeoUI::BeginSection(NeoUI::SECTIONFLAG_DEFAULTFOCUS); @@ -2032,8 +2151,23 @@ void CNeoRoot::MainLoopServerDetails(const MainLoopParam param) } if (bP) g_pVGuiLocalize->ConvertANSIToUnicode(gameServer->m_szMap, wszText, sizeof(wszText)); NeoUI::Label(L"Map:", wszText); - if (bP) gameServer->m_nBotPlayers ? V_swprintf_safe(wszText, L"%d/%d (%d)", gameServer->m_nPlayers - gameServer->m_nBotPlayers , gameServer->m_nMaxPlayers, gameServer->m_nBotPlayers) - : V_swprintf_safe(wszText, L"%d/%d", gameServer->m_nPlayers, gameServer->m_nMaxPlayers); + if (bP) + { + if (gameServer->m_nBotPlayers) + { + V_swprintf_safe(wszText, L"%d/%d (%d)", + gameServer->m_nPlayers - ( + (NetAdrIsSDR(gameServer->m_NetAdr)) + ? 0 + : gameServer->m_nBotPlayers), + gameServer->m_nMaxPlayers, + gameServer->m_nBotPlayers); + } + else + { + V_swprintf_safe(wszText, L"%d/%d", gameServer->m_nPlayers, gameServer->m_nMaxPlayers); + } + } NeoUI::Label(L"Players:", wszText); if (bP) V_swprintf_safe(wszText, L"%ls", gameServer->m_bSecure ? L"Enabled" : L"Disabled"); NeoUI::Label(L"VAC:", wszText); @@ -2071,39 +2205,14 @@ void CNeoRoot::MainLoopServerDetails(const MainLoopParam param) } else { - static constexpr const int PLAYER_ROW_PROP[] = { 15, 65, -1 }; - const int iInfoTall = g_uiCtx.dPanel.tall; g_uiCtx.dPanel.y += iInfoTall; g_uiCtx.dPanel.tall = g_uiCtx.layout.iRowTall; NeoUI::BeginSection(); { - NeoUI::SetPerRowLayout(ARRAYSIZE(PLAYER_ROW_PROP), PLAYER_ROW_PROP); - // Headers - static constexpr const wchar_t *PLAYER_HEADERS[GSPS__TOTAL] = { - L"Score", L"Name", L"Time" - }; - for (int i = 0; i < GSPS__TOTAL; ++i) - { - vgui::surface()->DrawSetColor((m_serverPlayers.m_sortCtx.col == i) ? COLOR_NEOPANELACCENTBG : COLOR_BLACK_TRANSPARENT); - if (NeoUI::Button(PLAYER_HEADERS[i]).bPressed) - { - m_bSPlayersSortModified = true; - if (m_serverPlayers.m_sortCtx.col == i) - { - m_serverPlayers.m_sortCtx.bDescending = !m_serverPlayers.m_sortCtx.bDescending; - } - else - { - m_serverPlayers.m_sortCtx.col = static_cast(i); - } - } - - if (m_serverPlayers.m_sortCtx.col == i) - { - DrawSortHint(m_serverPlayers.m_sortCtx.bDescending); - } - } + m_headerModFlagsPlayers |= NeoUI::TableHeader(TABLE_HEADERS_PLAYER, GSPS__TOTAL, + m_iColsWideDetailedPlayerList, &m_serverPlayers.m_sortCtx.col, + &m_serverPlayers.m_sortCtx.bDescending, 1); } NeoUI::EndSection(); @@ -2111,38 +2220,41 @@ void CNeoRoot::MainLoopServerDetails(const MainLoopParam param) g_uiCtx.dPanel.tall = iTallTotal - (2 * g_uiCtx.layout.iRowTall) - iInfoTall; NeoUI::BeginSection(); { - NeoUI::SetPerRowLayout(ARRAYSIZE(PLAYER_ROW_PROP), PLAYER_ROW_PROP); - // Players - rows - for (const auto &player : m_serverPlayers.m_sortedPlayers) + NeoUI::BeginTable(m_iColsWideDetailedPlayerList, GSPS__TOTAL); { - wchar_t wszText[32]; - - V_swprintf_safe(wszText, L"%d", player.iScore); - NeoUI::Label(wszText); - NeoUI::Label(player.wszName); + // Players - rows + for (const auto &player : m_serverPlayers.m_sortedPlayers) { - static constexpr float FL_SECSINMIN = 60.0f; - static constexpr float FL_SECSINHRS = 60.0f * FL_SECSINMIN; - if (player.flTimePlayed < FL_SECSINMIN) - { - V_swprintf_safe(wszText, L"%.0fs", player.flTimePlayed); - } - else if (player.flTimePlayed < FL_SECSINMIN * 60.0f) - { - const int iMin = player.flTimePlayed / FL_SECSINMIN; - const int iSec = player.flTimePlayed - (iMin * FL_SECSINMIN); - V_swprintf_safe(wszText, L"%dm %ds", iMin, iSec); - } - else + wchar_t wszText[32]; + + V_swprintf_safe(wszText, L"%d", player.iScore); + NeoUI::Label(wszText); + NeoUI::Label(player.wszName); { - const int iHrs = player.flTimePlayed / FL_SECSINHRS; - const int iMin = (player.flTimePlayed - (iHrs * FL_SECSINHRS)) / FL_SECSINMIN; - const int iSec = player.flTimePlayed - (iHrs * FL_SECSINHRS) - (iMin * FL_SECSINMIN); - V_swprintf_safe(wszText, L"%dh %dm %ds", iHrs, iMin, iSec); + static constexpr float FL_SECSINMIN = 60.0f; + static constexpr float FL_SECSINHRS = 60.0f * FL_SECSINMIN; + if (player.flTimePlayed < FL_SECSINMIN) + { + V_swprintf_safe(wszText, L"%.0fs", player.flTimePlayed); + } + else if (player.flTimePlayed < FL_SECSINMIN * 60.0f) + { + const int iMin = player.flTimePlayed / FL_SECSINMIN; + const int iSec = player.flTimePlayed - (iMin * FL_SECSINMIN); + V_swprintf_safe(wszText, L"%dm %ds", iMin, iSec); + } + else + { + const int iHrs = player.flTimePlayed / FL_SECSINHRS; + const int iMin = (player.flTimePlayed - (iHrs * FL_SECSINHRS)) / FL_SECSINMIN; + const int iSec = player.flTimePlayed - (iHrs * FL_SECSINHRS) - (iMin * FL_SECSINMIN); + V_swprintf_safe(wszText, L"%dh %dm %ds", iHrs, iMin, iSec); + } } + NeoUI::Label(wszText); } - NeoUI::Label(wszText); } + NeoUI::EndTable(); } NeoUI::EndSection(); } diff --git a/src/game/client/neo/ui/neo_root.h b/src/game/client/neo/ui/neo_root.h index 06b2e1a778..d5eae8dca7 100644 --- a/src/game/client/neo/ui/neo_root.h +++ b/src/game/client/neo/ui/neo_root.h @@ -172,10 +172,10 @@ class CNeoRoot : public vgui::EditablePanel, public CGameEventListener int m_iServerBrowserTab = 0; CNeoServerList m_serverBrowser[GS__TOTAL]; CNeoServerPlayers m_serverPlayers; - ServerBrowserFilters m_sbFilters; - bool m_bSBFiltModified = false; + ServerBrowserFilters m_sbFilters = {}; + NeoUI::TableHeaderModFlags m_headerModFlagsServerBrowser = 0; bool m_bShowFilterPanel = false; - bool m_bSPlayersSortModified = false; + NeoUI::TableHeaderModFlags m_headerModFlagsPlayers = 0; GameServerSortContext m_sortCtx = {}; wchar_t m_wszBindingText[128]; @@ -231,6 +231,17 @@ class CNeoRoot : public vgui::EditablePanel, public CGameEventListener servernetadr_t m_favCacheNetAdr = {}; bool m_bFavCacheIsFav = false; bool m_bAutoRefreshFav = false; + + int m_iColsWideServerBrowser[GSIW__TOTAL] = {}; + int m_iColsWideServerBlacklist[SBLIST_COL__TOTAL] = {}; + int m_iColsWideDetailedPlayerList[GSPS__TOTAL] = {}; + + int m_iUpDownInitialServer = -1; + int m_iUpDownDirection = 0; + + NeoUI::TabsState m_tabsStateSettings = {}; + NeoUI::TabsState m_tabsStateServerBrowser = {}; + NeoUI::TabsState m_tabsStateIFF = {}; }; extern CNeoRoot *g_pNeoRoot; diff --git a/src/game/client/neo/ui/neo_root_serverbrowser.cpp b/src/game/client/neo/ui/neo_root_serverbrowser.cpp index 05bd6063d7..ed73ec86a1 100644 --- a/src/game/client/neo/ui/neo_root_serverbrowser.cpp +++ b/src/game/client/neo/ui/neo_root_serverbrowser.cpp @@ -205,13 +205,14 @@ void CNeoServerList::UpdateFilteredList() } gameserveritem_t curGsi; - if (IN_BETWEEN_AR(0, g_pNeoRoot->m_iSelectedServer, m_filteredServers.Count())) + if (IN_BETWEEN_AR(0, g_pNeoRoot->m_iSelectedServer, m_filteredServers.size())) { V_memcpy(&curGsi, &m_filteredServers[g_pNeoRoot->m_iSelectedServer], sizeof(gameserveritem_t)); } m_filteredServers = m_servers; - if (m_filteredServers.IsEmpty()) + + if (m_filteredServers.empty()) { g_pNeoRoot->m_iSelectedServer = -1; return; @@ -258,6 +259,10 @@ void CNeoServerList::UpdateFilteredList() iLeft = gsiLeft.m_nPing; iRight = gsiRight.m_nPing; break; + case GSIW_IP_ADDRESS: + iLeft = gsiLeft.m_NetAdr.GetIP(); + iRight = gsiRight.m_NetAdr.GetIP(); + break; case GSIW_NAME: default: // no-op, already assigned (default) @@ -272,6 +277,7 @@ void CNeoServerList::UpdateFilteredList() break; case GSIW_PLAYERS: case GSIW_PING: + case GSIW_IP_ADDRESS: if (iLeft != iRight) return (m_pSortCtx->bDescending) ? iLeft < iRight : iLeft > iRight; break; default: @@ -280,10 +286,10 @@ void CNeoServerList::UpdateFilteredList() return (m_pSortCtx->bDescending) ? (V_strcmp(szRight, szLeft) > 0) : (V_strcmp(szLeft, szRight) > 0); }); - if (IN_BETWEEN_AR(0, g_pNeoRoot->m_iSelectedServer, m_filteredServers.Count())) + if (IN_BETWEEN_AR(0, g_pNeoRoot->m_iSelectedServer, m_filteredServers.size())) { g_pNeoRoot->m_iSelectedServer = -1; - for (int i = 0; i < m_filteredServers.Count(); ++i) + for (int i = 0; i < m_filteredServers.size(); ++i) { if (V_memcmp(&curGsi, &m_filteredServers[i], sizeof(gameserveritem_t)) == 0) { @@ -301,6 +307,8 @@ void CNeoServerList::RequestList() return; } + m_servers.clear(); + static MatchMakingKeyValuePair_t mmFilters[] = { {"gamedir", "neo"}, }; @@ -331,9 +339,26 @@ void CNeoServerList::ServerResponded(HServerListRequest hRequest, int iServer) ISteamMatchmakingServers *steamMM = steamapicontext->SteamMatchmakingServers(); gameserveritem_t *pServerDetails = steamMM->GetServerDetails(hRequest, iServer); - if (pServerDetails) + if (pServerDetails && m_servers.size() < 1024) { - m_servers.AddToTail(*pServerDetails); + wchar_t wszServerName[k_cbMaxGameServerName] = {}; + g_pVGuiLocalize->ConvertANSIToUnicode( + pServerDetails->GetName(), wszServerName, sizeof(wszServerName)); + const int iWszLen = V_wcslen(wszServerName); + // NEO TODO (nullsystem): Need to implement fallback fonts in NeoUI or otherwise + // without fallback unicode fonts can corrupt the memory on drawing the text somehow + for (int i = 0; i < iWszLen; ++i) + { + if (wszServerName[i] >= 256) + { + wszServerName[i] = L'?'; + } + } + char szServerName[k_cbMaxGameServerName] = {}; + g_pVGuiLocalize->ConvertUnicodeToANSI(wszServerName, szServerName, sizeof(szServerName)); + Q_UnicodeRepair(szServerName); + pServerDetails->SetName(szServerName); + m_servers.push_back(*pServerDetails); m_bModified = true; } } @@ -350,7 +375,7 @@ void CNeoServerList::RefreshComplete(HServerListRequest hRequest, EMatchMakingSe if (m_iType == GS_BLACKLIST || hRequest != m_hdlRequest) return; m_bSearching = false; - if (response == eNoServersListedOnMasterServer && m_servers.IsEmpty()) + if (response == eNoServersListedOnMasterServer && m_servers.empty()) { m_bModified = true; } diff --git a/src/game/client/neo/ui/neo_root_serverbrowser.h b/src/game/client/neo/ui/neo_root_serverbrowser.h index aa297d5031..894f2a5175 100644 --- a/src/game/client/neo/ui/neo_root_serverbrowser.h +++ b/src/game/client/neo/ui/neo_root_serverbrowser.h @@ -3,6 +3,7 @@ #include #include #include +#include enum EServerBlacklistType { @@ -64,9 +65,11 @@ enum GameServerInfoW GSIW_LOCKED = 0, GSIW_VAC, GSIW_NAME, + GSIW_IP_ADDRESS, GSIW_MAP, GSIW_PLAYERS, GSIW_PING, + GSIW_TAGS, GSIW__TOTAL, }; @@ -80,6 +83,14 @@ enum AntiCheatMode ANTICHEAT__TOTAL, }; +enum ETagsFilterMode +{ + TAGSFILTER_INCLUDE = 0, + TAGSFILTER_EXCLUDE, + + TAGSFILTER__TOTAL, +}; + enum GameServerPlayerSort { GSPS_SCORE = 0, @@ -101,7 +112,7 @@ struct GameServerSortContext struct GameServerPlayerSortContext { - GameServerPlayerSort col = GSPS_SCORE; + int col = GSPS_SCORE; bool bDescending = true; }; @@ -109,8 +120,11 @@ class CNeoServerList : public ISteamMatchmakingServerListResponse { public: GameServerType m_iType; - CUtlVector m_servers; - CUtlVector m_filteredServers; + + // NEO JANK (nullsystem): CUtlVector easily corrupts memory with + // gameserveritem_t results, so using std::vector instead + std::vector m_servers; + std::vector m_filteredServers; void UpdateFilteredList(); void RequestList(); @@ -155,4 +169,9 @@ struct ServerBrowserFilters bool bIsNotPasswordProtected = false; int iAntiCheat = 0; int iMaxPing = 0; + wchar_t wszMapFilter[k_cbMaxGameServerMapName] = {}; + int iMaxPlayerCount = 0; + int iTagsFilterType = 0; + wchar_t wszTagsFilter[k_cbMaxGameServerTags] = {}; }; + diff --git a/src/game/client/neo/ui/neo_root_settings.cpp b/src/game/client/neo/ui/neo_root_settings.cpp index 44a6eb96e4..3191b54197 100644 --- a/src/game/client/neo/ui/neo_root_settings.cpp +++ b/src/game/client/neo/ui/neo_root_settings.cpp @@ -1489,7 +1489,8 @@ void NeoSettings_HUD(NeoSettings *ns) NeoUI::SetPerRowLayout(1); NeoUI::Tabs(IFF_LABELS, iIFFLabelsSize, &optionChosen, - NeoUI::TABFLAG_NOSIDEKEYS | NeoUI::TABFLAG_NOSTATERESETS); + NeoUI::TABFLAG_NOSIDEKEYS | NeoUI::TABFLAG_NOSTATERESETS, + &g_pNeoRoot->m_tabsStateIFF); NeoUI::SetPerRowLayout(2, NeoUI::ROWLAYOUT_TWOSPLIT); // NEO TODO (Adam) Show what the marker looks like somewhere here diff --git a/src/game/client/neo/ui/neo_theme.cpp b/src/game/client/neo/ui/neo_theme.cpp index 7fa00bce5a..b254637183 100644 --- a/src/game/client/neo/ui/neo_theme.cpp +++ b/src/game/client/neo/ui/neo_theme.cpp @@ -27,5 +27,8 @@ void SetupNTRETheme(NeoUI::Context *pNeoUICtx) pColors->sliderHotBg = COLOR_GREY; pColors->sliderActiveBg = COLOR_BLACK; pColors->tabHintsFg = COLOR_WHITE; + pColors->tableHeaderSortIndicatorBg = COLOR_WHITE; + pColors->headerDragNormalBg = COLOR_FADED_WHITE; + pColors->headerDragActiveBg = COLOR_DARK_RED; } diff --git a/src/game/client/neo/ui/neo_ui.cpp b/src/game/client/neo/ui/neo_ui.cpp index 28607a409f..714c85e273 100644 --- a/src/game/client/neo/ui/neo_ui.cpp +++ b/src/game/client/neo/ui/neo_ui.cpp @@ -26,7 +26,15 @@ static Context *c = &g_emptyCtx; const int ROWLAYOUT_TWOSPLIT[] = { 40, -1 }; static constexpr int WDGINFO_ALLOC_STEPS = 64; static constexpr float FL_BORDER_RATIO = 0.2f; -#define NEOUI_SCROLL_THICKNESS() (c->iMarginX * 4) + +// NEO JANK (nullsystem): DrawUnicodeChar is buggy, so use DrawPrintText instead, hence +// single character but a (wide-)string instead of (wide-)char +static constexpr wchar_t WSZ_ARROW_UP[] = L"\u2191"; +static constexpr wchar_t WSZ_ARROW_DOWN[] = L"\u2193"; +static constexpr wchar_t WSZ_ARROW_LEFT[] = L"<"; +static constexpr wchar_t WSZ_ARROW_RIGHT[] = L">"; +static constexpr wchar_t WSZ_CHECK_MARK[] = L"\u2713"; +#define NEOUI_SCROLL_THICKNESS() (c->iMarginX * 3) #define DEBUG_NEOUI 0 // NEO NOTE (nullsystem): !!! Always flip to 0 on master + PR !!! #ifndef DEBUG @@ -160,8 +168,11 @@ void BeginMultiWidgetHighlighter(const int iTotalWidgets) if (bActive) c->uMultiHighlightFlags |= MULTIHIGHLIGHTFLAG_ACTIVE; } -static void DrawBorder(const vgui::IntRect &rect, const int iMargin) +static void DrawBorder(const vgui::IntRect &rect) { + const int iMargin = Max(static_cast(FL_BORDER_RATIO * c->iMarginY), 1); + vgui::surface()->DrawSetColor(c->colors.hotBorder); + vgui::surface()->DrawFilledRect(rect.x0, rect.y0, rect.x1, rect.y0 + iMargin); // top vgui::surface()->DrawFilledRect(rect.x0, rect.y1 - iMargin, rect.x1, rect.y1); // bottom vgui::surface()->DrawFilledRect(rect.x0, rect.y0, rect.x0 + iMargin, rect.y1); // left @@ -172,9 +183,7 @@ void EndMultiWidgetHighlighter() { if (c->eMode == MODE_PAINT && c->uMultiHighlightFlags & MULTIHIGHLIGHTFLAG_HOT) { - const int iHotMargin = static_cast(FL_BORDER_RATIO * c->iMarginY); - vgui::surface()->DrawSetColor(c->colors.hotBorder); - DrawBorder(c->rMultiHighlightArea, iHotMargin); + DrawBorder(c->rMultiHighlightArea); } c->uMultiHighlightFlags = 0; } @@ -272,7 +281,9 @@ void BeginContext(NeoUI::Context *pNextCtx, const NeoUI::Mode eMode, const wchar c->eMode = eMode; c->iLayoutX = 0; c->iLayoutY = 0; + c->irWidgetLayoutX = 0; c->irWidgetLayoutY = 0; + c->irWidgetMaxX = 0; c->iWidget = 0; c->iSection = 0; c->iHasMouseInPanel = 0; @@ -282,12 +293,13 @@ void BeginContext(NeoUI::Context *pNextCtx, const NeoUI::Mode eMode, const wchar c->ibfSectionCanActive = 0; c->ibfSectionCanController = 0; // Different pointer, change context - const bool bFirstCtxUse = (c->pSzCurCtxName != pSzCtxName); - if (bFirstCtxUse) + c->bFirstCtxUse = (c->pSzCurCtxName != pSzCtxName); + if (c->bFirstCtxUse) { c->htSliders.RemoveAll(); c->pSzCurCtxName = pSzCtxName; - c->ibfSectionHasScroll = 0; + c->ibfSectionHasXScroll = 0; + c->ibfSectionHasYScroll = 0; c->iCurPopupId = 0; V_memset(&c->dimPopup, 0, sizeof(Dim)); c->colorEditInfo.r = nullptr; @@ -329,7 +341,7 @@ void BeginContext(NeoUI::Context *pNextCtx, const NeoUI::Mode eMode, const wchar { FontInfo *pFontI = &c->fonts[i]; const int iTall = vgui::surface()->GetFontTall(pFontI->hdl); - pFontI->iYOffset = (c->layout.iRowTall / 2) - (iTall / 2); + pFontI->iYFontOffset = (c->layout.iRowTall / 2) - (iTall / 2); { int iPrevNextWide, iPrevNextTall; vgui::surface()->GetTextSize(pFontI->hdl, L"<", iPrevNextWide, iPrevNextTall); @@ -343,7 +355,7 @@ void BeginContext(NeoUI::Context *pNextCtx, const NeoUI::Mode eMode, const wchar SwapFont(FONT_NTLARGE, true); vgui::surface()->DrawSetTextColor(c->colors.titleFg); vgui::surface()->DrawSetTextPos(c->dPanel.x + c->iMarginX, - c->dPanel.y + -c->layout.iRowTall + c->fonts[FONT_NTLARGE].iYOffset); + c->dPanel.y + -c->layout.iRowTall + c->fonts[FONT_NTLARGE].iYFontOffset); vgui::surface()->DrawPrintText(wszTitle, V_wcslen(wszTitle)); } break; @@ -460,23 +472,33 @@ void EndContext() void BeginSection(const ISectionFlags iSectionFlags) { // Previous frame(s) known this section does scroll - if (c->ibfSectionHasScroll & (1ULL << c->iSection)) + if (c->ibfSectionHasYScroll & (1ULL << c->iSection)) { // NEO TODO (nullsystem): Change how dPanel works to enforce setting per BeginSection // so don't need to shift around wide on scrollbars and keep usage dPanel "immutable" // without extra variable c->dPanel.wide -= NEOUI_SCROLL_THICKNESS(); } + if (c->ibfSectionHasXScroll & (1ULL << c->iSection)) + { + c->dPanel.tall -= NEOUI_SCROLL_THICKNESS(); + } - c->iLayoutX = 0; + c->iLayoutX = -c->iXOffset[c->iSection]; c->iLayoutY = -c->iYOffset[c->iSection]; + c->irWidgetLayoutX = c->iLayoutX; c->irWidgetLayoutY = c->iLayoutY; + c->irWidgetMaxX = 0; c->iWidget = 0; c->iIdxRowParts = -1; c->iIdxVertParts = -1; c->iVertLayoutY = 0; c->iSectionFlags = iSectionFlags; c->uMultiHighlightFlags = MULTIHIGHLIGHTFLAG_NONE; + c->layout.iTableLabelsSize = 0; + c->layout.piTableColsWide = nullptr; + c->curRowFlags = NEXTTABLEROWFLAG_NONE; + c->bBlockSectionMWheel = false; if (iSectionFlags & SECTIONFLAG_POPUP) { @@ -534,44 +556,93 @@ void EndSection() c->iActive = FOCUSOFF_NUM; c->iActiveSection = -1; } + const int iAbsLayoutX = c->irWidgetMaxX + c->iXOffset[c->iSection]; const int iAbsLayoutY = c->irWidgetLayoutY + c->irWidgetTall + c->iYOffset[c->iSection]; + c->wdgInfos[c->iWidget].iXOffsets = iAbsLayoutX; c->wdgInfos[c->iWidget].iYOffsets = iAbsLayoutY; // Scroll handling - const int iScrollThick = NEOUI_SCROLL_THICKNESS(); const int iMWheelJump = c->layout.iDefRowTall; - const bool bHasScroll = (iAbsLayoutY > c->dPanel.tall); - const bool bResetScrollPanelWide = (c->ibfSectionHasScroll & (1ULL << c->iSection)); + const bool bHasXScroll = (iAbsLayoutX > c->dPanel.wide); + const bool bHasYScroll = (iAbsLayoutY > c->dPanel.tall); + const int iScrollThick = NEOUI_SCROLL_THICKNESS(); + const bool bResetXScrollPanelWide = (c->ibfSectionHasXScroll & (1ULL << c->iSection)); + const bool bResetYScrollPanelTall = (c->ibfSectionHasYScroll & (1ULL << c->iSection)); - // Saved for next frame(s) to layout x-axis based on scroll - if (bHasScroll) + // Saved for next frame(s) to layout y-axis based on scroll + if (bHasYScroll) { - c->ibfSectionHasScroll |= (1ULL << c->iSection); + c->ibfSectionHasYScroll |= (1ULL << c->iSection); } else { - c->ibfSectionHasScroll &= ~(1ULL << c->iSection); + c->ibfSectionHasYScroll &= ~(1ULL << c->iSection); } - vgui::IntRect rectScrollArea = { + if (bHasXScroll) + { + c->ibfSectionHasXScroll |= (1ULL << c->iSection); + } + else + { + c->ibfSectionHasXScroll &= ~(1ULL << c->iSection); + } + + vgui::IntRect rectYScrollArea = { .x0 = c->dPanel.x + c->dPanel.wide, .y0 = c->dPanel.y, - .x1 = c->dPanel.x + c->dPanel.wide + iScrollThick, - .y1 = c->dPanel.y + c->dPanel.tall, + .x1 = c->dPanel.x + c->dPanel.wide + (bHasYScroll ? iScrollThick : 0), + .y1 = c->dPanel.y + c->dPanel.tall - (bHasXScroll ? iScrollThick : 0), + }; + // NEO TODO (nullsystem): X-axis scrollbar visibility is optional + vgui::IntRect rectXScrollArea = { + .x0 = c->dPanel.x, + .y0 = c->dPanel.y + c->dPanel.tall, + .x1 = c->dPanel.x + c->dPanel.wide - (bHasYScroll ? iScrollThick : 0), + .y1 = c->dPanel.y + c->dPanel.tall + (bHasXScroll ? iScrollThick : 0), }; - const bool bMouseInScrollbar = bHasScroll && InRect(rectScrollArea, c->iMouseAbsX, c->iMouseAbsY); - const bool bMouseInWheelable = c->bMouseInPanel || bMouseInScrollbar; + EXYMouseDragOffset eMouseInScrollbar = XYMOUSEDRAGOFFSET_NIL; + if (bHasYScroll && InRect(rectYScrollArea, c->iMouseAbsX, c->iMouseAbsY)) + { + eMouseInScrollbar = XYMOUSEDRAGOFFSET_YAXIS; + } + else if (bHasXScroll && InRect(rectXScrollArea, c->iMouseAbsX, c->iMouseAbsY)) + { + eMouseInScrollbar = XYMOUSEDRAGOFFSET_XAXIS; + } - if (c->eMode == MODE_MOUSEWHEELED && bMouseInWheelable) + const bool bMouseInYWheelable = c->bMouseInPanel || (XYMOUSEDRAGOFFSET_YAXIS == eMouseInScrollbar); + const bool bMouseInXWheelable = bHasXScroll && ((!bMouseInYWheelable && c->bMouseInPanel) || (XYMOUSEDRAGOFFSET_XAXIS == eMouseInScrollbar)); + + // y-axis always have precedence over x-axis on being wheelable + if (c->eMode == MODE_MOUSEWHEELED && bMouseInYWheelable) { - if (!bHasScroll) + if (false == c->bBlockSectionMWheel) { - c->iYOffset[c->iSection] = 0; + if (!bHasYScroll) + { + c->iYOffset[c->iSection] = 0; + } + else + { + c->iYOffset[c->iSection] += (c->eCode == MOUSE_WHEEL_UP) ? -iMWheelJump : +iMWheelJump; + c->iYOffset[c->iSection] = clamp(c->iYOffset[c->iSection], 0, iAbsLayoutY - c->dPanel.tall); + } } - else + } + else if (c->eMode == MODE_MOUSEWHEELED && bMouseInXWheelable) + { + if (false == c->bBlockSectionMWheel) { - c->iYOffset[c->iSection] += (c->eCode == MOUSE_WHEEL_UP) ? -iMWheelJump : +iMWheelJump; - c->iYOffset[c->iSection] = clamp(c->iYOffset[c->iSection], 0, iAbsLayoutY - c->dPanel.tall); + if (!bHasXScroll) + { + c->iXOffset[c->iSection] = 0; + } + else + { + c->iXOffset[c->iSection] += (c->eCode == MOUSE_WHEEL_UP) ? -iMWheelJump : +iMWheelJump; + c->iXOffset[c->iSection] = clamp(c->iXOffset[c->iSection], 0, iAbsLayoutX - c->dPanel.wide); + } } } else if (c->eMode == MODE_KEYPRESSED && IsKeyChangeWidgetFocus() && @@ -594,7 +665,7 @@ void EndSection() c->iHotSection = c->iActiveSection; } - if (!bHasScroll) + if (!bHasYScroll) { // Disable scroll if it doesn't need to c->iYOffset[c->iSection] = 0; @@ -604,7 +675,7 @@ void EndSection() // Scrolling up past visible, re-adjust c->iYOffset[c->iSection] = c->wdgInfos[c->iActive].iYOffsets; } - else if (c->wdgInfos[c->iActive].iYOffsets >= (c->iYOffset[c->iSection] + c->dPanel.tall)) + else if ((c->wdgInfos[c->iActive].iYOffsets + c->wdgInfos[c->iActive].iYTall) >= (c->iYOffset[c->iSection] + c->dPanel.tall)) { // Scrolling down post visible, re-adjust c->iYOffset[c->iSection] = c->wdgInfos[c->iActive].iYOffsets - c->dPanel.tall + c->wdgInfos[c->iActive].iYTall; @@ -612,63 +683,140 @@ void EndSection() } // Scroll bar area painting and mouse interaction (no keyboard as that's handled by active widgets) - if (bHasScroll) - { - const int iYStart = c->iYOffset[c->iSection]; - const int iYEnd = iYStart + c->dPanel.tall; - const float flYPercStart = iYStart / static_cast(iAbsLayoutY); - const float flYPercEnd = iYEnd / static_cast(iAbsLayoutY); - vgui::IntRect rectHandle{ - c->dPanel.x + c->dPanel.wide, - c->dPanel.y + static_cast(c->dPanel.tall * flYPercStart), - c->dPanel.x + c->dPanel.wide + iScrollThick, - c->dPanel.y + static_cast(c->dPanel.tall * flYPercEnd) - }; + if (bHasYScroll || bHasXScroll) + { + vgui::IntRect rectYHandle = {}; + vgui::IntRect rectXHandle = {}; - bool bAlterOffset = false; + if (bHasYScroll) + { + const int iYStart = c->iYOffset[c->iSection]; + const int iYEnd = iYStart + c->dPanel.tall; + const float flYPercStart = iYStart / static_cast(iAbsLayoutY); + const float flYPercEnd = iYEnd / static_cast(iAbsLayoutY); + rectYHandle.x0 = c->dPanel.x + c->dPanel.wide; + rectYHandle.y0 = c->dPanel.y + static_cast(c->dPanel.tall * flYPercStart); + rectYHandle.x1 = c->dPanel.x + c->dPanel.wide + iScrollThick; + rectYHandle.y1 = c->dPanel.y + static_cast(c->dPanel.tall * flYPercEnd); + } + if (bHasXScroll) + { + const int iXStart = c->iXOffset[c->iSection]; + const int iXEnd = iXStart + c->dPanel.wide; + const float flXPercStart = iXStart / static_cast(iAbsLayoutX); + const float flXPercEnd = iXEnd / static_cast(iAbsLayoutX); + rectXHandle.x0 = c->dPanel.x + static_cast(c->dPanel.wide * flXPercStart); + rectXHandle.y0 = c->dPanel.y + c->dPanel.tall; + rectXHandle.x1 = c->dPanel.x + static_cast(c->dPanel.wide * flXPercEnd); + rectXHandle.y1 = c->dPanel.y + c->dPanel.tall + iScrollThick; + } + + EXYMouseDragOffset eXYAlterOffset = XYMOUSEDRAGOFFSET_NIL; switch (c->eMode) { case MODE_PAINT: vgui::surface()->DrawSetColor(c->colors.scrollbarBg); - vgui::surface()->DrawFilledRectArray(&rectScrollArea, 1); - vgui::surface()->DrawSetColor(c->abYMouseDragOffset[c->iSection] ? - c->colors.scrollbarHandleActiveBg : c->colors.scrollbarHandleNormalBg); - vgui::surface()->DrawFilledRectArray(&rectHandle, 1); + if (bHasYScroll) vgui::surface()->DrawFilledRectArray(&rectYScrollArea, 1); + if (bHasXScroll) vgui::surface()->DrawFilledRectArray(&rectXScrollArea, 1); + + if (bHasYScroll) + { + vgui::surface()->DrawSetColor( + (c->aeXYMouseDragOffset[c->iSection] == XYMOUSEDRAGOFFSET_YAXIS) + ? c->colors.scrollbarHandleActiveBg + : c->colors.scrollbarHandleNormalBg); + vgui::surface()->DrawFilledRectArray(&rectYHandle, 1); + } + if (bHasXScroll) + { + vgui::surface()->DrawSetColor( + (c->aeXYMouseDragOffset[c->iSection] == XYMOUSEDRAGOFFSET_XAXIS) + ? c->colors.scrollbarHandleActiveBg + : c->colors.scrollbarHandleNormalBg); + vgui::surface()->DrawFilledRectArray(&rectXHandle, 1); + } + if (bHasYScroll && bHasXScroll) + { + vgui::surface()->DrawSetColor(c->colors.sectionBg); + vgui::surface()->DrawFilledRect(c->dPanel.x + c->dPanel.wide, + c->dPanel.y + c->dPanel.tall, + c->dPanel.x + c->dPanel.wide + iScrollThick, + c->dPanel.y + c->dPanel.tall + iScrollThick); + } break; case MODE_MOUSEPRESSED: - c->abYMouseDragOffset[c->iSection] = bMouseInScrollbar; - if (bMouseInScrollbar) + c->aeXYMouseDragOffset[c->iSection] = eMouseInScrollbar; + if (XYMOUSEDRAGOFFSET_YAXIS == eMouseInScrollbar) { + c->aeXYMouseDragOffset[c->iSection] = XYMOUSEDRAGOFFSET_YAXIS; // If not pressed on handle, set the drag at the middle - const bool bInHandle = InRect(rectHandle, c->iMouseAbsX, c->iMouseAbsY); + const bool bInHandle = InRect(rectYHandle, c->iMouseAbsX, c->iMouseAbsY); c->iStartMouseDragOffset[c->iSection] = (bInHandle) ? - (c->iMouseAbsY - rectHandle.y0) :((rectHandle.y1 - rectHandle.y0) / 2.0f); - bAlterOffset = true; + (c->iMouseAbsY - rectYHandle.y0) : ((rectYHandle.y1 - rectYHandle.y0) / 2.0f); + eXYAlterOffset = XYMOUSEDRAGOFFSET_YAXIS; + } + else if (XYMOUSEDRAGOFFSET_XAXIS == eMouseInScrollbar) + { + // If not pressed on handle, set the drag at the middle + const bool bInHandle = InRect(rectXHandle, c->iMouseAbsX, c->iMouseAbsY); + c->iStartMouseDragOffset[c->iSection] = (bInHandle) ? + (c->iMouseAbsX - rectXHandle.x0) : ((rectXHandle.x1 - rectXHandle.x0) / 2.0f); + eXYAlterOffset = XYMOUSEDRAGOFFSET_XAXIS; } break; case MODE_MOUSERELEASED: - c->abYMouseDragOffset[c->iSection] = false; + c->aeXYMouseDragOffset[c->iSection] = XYMOUSEDRAGOFFSET_NIL; break; case MODE_MOUSEMOVED: - bAlterOffset = c->abYMouseDragOffset[c->iSection]; + eXYAlterOffset = c->aeXYMouseDragOffset[c->iSection]; break; default: break; } - if (bAlterOffset) + // NEO TODO (nullsystem): Could we deal with overlap/partial if we restrict paint area for + // each section properly? So it actually paints partially if partial area painted. + if (eXYAlterOffset == XYMOUSEDRAGOFFSET_YAXIS) { - const float flXPercMouse = static_cast(c->iMouseRelY - c->iStartMouseDragOffset[c->iSection]) / static_cast(c->dPanel.tall); + const float flYPercMouse = static_cast(c->iMouseRelY - c->iStartMouseDragOffset[c->iSection]) / static_cast(c->dPanel.tall); // Do not allow smooth scrolling so we don't have to deal with overlap/partial in section widgets - const int iNextYOffset = clamp(flXPercMouse * iAbsLayoutY, 0, (iAbsLayoutY - c->dPanel.tall)); - c->iYOffset[c->iSection] = iNextYOffset - (iNextYOffset % c->layout.iDefRowTall); + const int iNextYOffset = clamp(flYPercMouse * iAbsLayoutY, 0, (iAbsLayoutY - c->dPanel.tall)); + if (iNextYOffset >= (iAbsLayoutY - c->dPanel.tall)) + { + c->iYOffset[c->iSection] = iNextYOffset; + } + else + { + c->iYOffset[c->iSection] = iNextYOffset - (iNextYOffset % c->layout.iDefRowTall); + } + } + else if (eXYAlterOffset == XYMOUSEDRAGOFFSET_XAXIS) + { + const float flXPercMouse = static_cast(c->iMouseRelX - c->iStartMouseDragOffset[c->iSection]) / static_cast(c->dPanel.wide); + // Allow smooth scrolling as we deal with overlap/partial for x-axis + // Unlike Y-axis we don't typically layout for X-axis scroll + const int iNextXOffset = clamp(flXPercMouse * iAbsLayoutX, 0, (iAbsLayoutX - c->dPanel.wide)); + c->iXOffset[c->iSection] = iNextXOffset; } - } + if (bHasYScroll) + { + c->iYOffset[c->iSection] = clamp(c->iYOffset[c->iSection], 0, iAbsLayoutY - c->dPanel.tall); + } + if (bHasXScroll) + { + c->iXOffset[c->iSection] = clamp(c->iXOffset[c->iSection], 0, iAbsLayoutX - c->dPanel.wide); + } + } + // NEO TODO (nullsystem): Change how dPanel works to enforce setting per BeginSection // so don't need to shift around wide on scrollbars and keep usage dPanel "immutable" // without extra variable - if (bResetScrollPanelWide) + if (bResetXScrollPanelWide) + { + c->dPanel.tall += iScrollThick; + } + if (bResetYScrollPanelTall) { c->dPanel.wide += iScrollThick; } @@ -752,7 +900,7 @@ void SetPerRowLayout(const int iColTotal, const int *iColProportions, const int // Bump y-axis with previous row layout before applying new row layout if (c->iWidget > 0 && c->iIdxRowParts > 0 && c->iIdxRowParts < c->layout.iRowPartsTotal) { - c->iLayoutX = 0; + c->iLayoutX = -c->iXOffset[c->iSection]; c->iLayoutY += c->layout.iRowTall; c->irWidgetLayoutY = c->iLayoutY; } @@ -793,12 +941,15 @@ CurrentWidgetState BeginWidget(const WidgetFlag eWidgetFlag) c->wdgInfos = (DynWidgetInfos *)(realloc(c->wdgInfos, sizeof(DynWidgetInfos) * c->iWdgInfosMax)); } + const int iWdgXOffset = (c->bIgnoreXOffset) ? 0 : c->iXOffset[c->iSection]; + const bool bInTable = (c->layout.iTableLabelsSize > 0 && c->layout.piTableColsWide); + if (c->iIdxRowParts < 0) { // NEO NOTE (nullsystem): Don't need to bump c->iLayoutY // because any reset of iIdxRowParts either don't need to // or already done it - c->iLayoutX = 0; + c->iLayoutX = -iWdgXOffset; c->iIdxRowParts = 0; } @@ -823,7 +974,6 @@ CurrentWidgetState BeginWidget(const WidgetFlag eWidgetFlag) { iWdgTall = (1.0f / static_cast(c->layout.iVertPartsTotal)) * c->layout.iRowTall; } - c->iVertLayoutY += iWdgTall; iVertYOffset = c->iVertLayoutY - iWdgTall; @@ -840,19 +990,27 @@ CurrentWidgetState BeginWidget(const WidgetFlag eWidgetFlag) } Assert(c->layout.iRowPartsTotal > 0); - const int iRowPartsTotal = Max(1, c->layout.iRowPartsTotal); + const int iRowPartsTotal = (bInTable) + ? Max(1, c->layout.iTableLabelsSize) + : Max(1, c->layout.iRowPartsTotal); const bool bNextIsLast = (c->iIdxRowParts + 1) >= iRowPartsTotal; // NEO NOTE (nullsystem): If very last partition of the row, use what's left // instead so whatever pixels don't get left out int xWide; - if (bNextIsLast) + if (bNextIsLast && !bInTable) { xWide = c->dPanel.wide - c->iLayoutX; } else { - if (c->layout.iRowParts) + if (bInTable) + { + // If this is less than/equal 0, then the widgets should just check visibility + // and skip over + xWide = Max(0, c->layout.piTableColsWide[c->iIdxRowParts]); + } + else if (c->layout.iRowParts) { xWide = (c->layout.iRowParts[c->iIdxRowParts] / 100.0f) * c->dPanel.wide; } @@ -870,7 +1028,9 @@ CurrentWidgetState BeginWidget(const WidgetFlag eWidgetFlag) }; c->irWidgetWide = c->rWidgetArea.x1 - c->rWidgetArea.x0; c->irWidgetTall = c->rWidgetArea.y1 - c->rWidgetArea.y0; + c->irWidgetLayoutX = c->iLayoutX; c->irWidgetLayoutY = c->iLayoutY; + c->irWidgetMaxX = Max(c->irWidgetMaxX, c->irWidgetLayoutX + c->irWidgetWide); c->wdgInfos[c->iWidget].iYOffsets = c->iLayoutY + c->iYOffset[c->iSection]; c->wdgInfos[c->iWidget].iYTall = c->irWidgetTall; c->wdgInfos[c->iWidget].bCannotActive = (eWidgetFlag & WIDGETFLAG_SKIPACTIVE); @@ -879,20 +1039,13 @@ CurrentWidgetState BeginWidget(const WidgetFlag eWidgetFlag) { if (bNextIsLast) { - c->iLayoutX = 0; + c->iLayoutX = -iWdgXOffset; c->iLayoutY += c->layout.iRowTall; c->iIdxRowParts = 0; } else { - if (c->layout.iRowParts) - { - c->iLayoutX += (c->layout.iRowParts[c->iIdxRowParts] / 100.0f) * c->dPanel.wide; - } - else - { - c->iLayoutX += (1.0f / static_cast(iRowPartsTotal)) * c->dPanel.wide; - } + c->iLayoutX += xWide; ++c->iIdxRowParts; } } @@ -900,10 +1053,14 @@ CurrentWidgetState BeginWidget(const WidgetFlag eWidgetFlag) bool bHot = false; bool bActive = false; bool bNotCurHot = false; - const bool bInView = IN_BETWEEN_AR(0, c->irWidgetLayoutY, c->dPanel.tall); + // Only deal with Y-axis, X-axis only have smooth scrolling and isn't + // as reliable to check if in view or not + const bool bInView = (xWide > 0) && IN_BETWEEN_AR(0, c->irWidgetLayoutY, c->dPanel.tall); + const bool bInteract = bInView && IN_BETWEEN_AR(0, c->irWidgetLayoutY + c->layout.iRowTall - 1, + c->dPanel.tall); // Check mouse hot/active states if WIDGETFLAG_MOUSE flag set - if (eWidgetFlag & WIDGETFLAG_MOUSE && bInView) + if (eWidgetFlag & WIDGETFLAG_MOUSE && bInteract) { bNotCurHot = (c->iHotPersist != c->iWidget || c->iHotPersistSection != c->iSection); const bool bMouseIn = IN_BETWEEN_EQ(c->rWidgetArea.x0, c->iMouseAbsX, c->rWidgetArea.x1 - 1) @@ -962,12 +1119,12 @@ CurrentWidgetState BeginWidget(const WidgetFlag eWidgetFlag) const bool bColorHot = bHot || (c->uMultiHighlightFlags & MULTIHIGHLIGHTFLAG_HOT); const bool bColorActive = ((bActive || (c->uMultiHighlightFlags & MULTIHIGHLIGHTFLAG_ACTIVE)) || (((c->popupFlags & POPUPFLAGS_COLORHOTASACTIVE) == POPUPFLAGS_COLORHOTASACTIVE) && bColorHot)); - if (bColorActive) + if (bColorActive || (bInTable && (c->curRowFlags & NEXTTABLEROWFLAG_SELECTED))) { vgui::surface()->DrawSetColor(c->colors.activeBg); vgui::surface()->DrawSetTextColor(c->colors.activeFg); } - else if (bColorHot) + else if (bColorHot || (bInTable && (c->curRowFlags & NEXTTABLEROWFLAG__HOT))) { vgui::surface()->DrawSetColor(c->colors.hotBg); vgui::surface()->DrawSetTextColor(c->colors.hotFg); @@ -998,9 +1155,7 @@ void EndWidget(const CurrentWidgetState &wdgState) !(c->uMultiHighlightFlags & MULTIHIGHLIGHTFLAG_IN_USE) && wdgState.bHot) { - const int iHotMargin = static_cast(FL_BORDER_RATIO * c->iMarginY); - vgui::surface()->DrawSetColor(c->colors.hotBorder); - DrawBorder(c->rWidgetArea, iHotMargin); + DrawBorder(c->rWidgetArea); } ++c->iWidget; @@ -1034,8 +1189,8 @@ void Pad() void Divider(const wchar_t *wszText) { - Context::Layout tmp; - V_memcpy(&tmp, &c->layout, sizeof(Context::Layout)); + Layout tmp; + V_memcpy(&tmp, &c->layout, sizeof(Layout)); SetPerRowLayout(1, nullptr, tmp.iRowTall); c->eLabelTextStyle = NeoUI::TEXTSTYLE_CENTER; @@ -1056,7 +1211,7 @@ void Divider(const wchar_t *wszText) // Text const auto *pFontI = &c->fonts[c->eFont]; const int x = XPosFromText(wszText, pFontI, c->eLabelTextStyle); - const int y = pFontI->iYOffset; + const int y = pFontI->iYFontOffset; vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + x, c->rWidgetArea.y0 + y); vgui::surface()->DrawPrintText(wszText, V_wcslen(wszText)); @@ -1115,7 +1270,7 @@ void LabelWrap(const wchar_t *wszText) if (c->eMode == MODE_PAINT) { vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + c->iMarginX, - c->rWidgetArea.y0 + pFontI->iYOffset + iYOffset); + c->rWidgetArea.y0 + pFontI->iYFontOffset + iYOffset); vgui::surface()->DrawPrintText(wszText + iStart, iLastSpace - iStart); } ++iLines; @@ -1131,7 +1286,7 @@ void LabelWrap(const wchar_t *wszText) if (c->eMode == MODE_PAINT) { vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + c->iMarginX, - c->rWidgetArea.y0 + pFontI->iYOffset + iYOffset); + c->rWidgetArea.y0 + pFontI->iYFontOffset + iYOffset); vgui::surface()->DrawPrintText(wszText + iStart, iWszSize - iStart); } ++iLines; @@ -1139,15 +1294,17 @@ void LabelWrap(const wchar_t *wszText) } c->iLayoutY += (c->layout.iRowTall * iLines); + c->irWidgetLayoutX = c->iLayoutX; c->irWidgetLayoutY = c->iLayoutY; + c->irWidgetMaxX = Max(c->irWidgetMaxX, c->irWidgetLayoutX + c->irWidgetWide); EndWidget(wdgState); } void HeadingLabel(const wchar_t *wszText) { - Context::Layout tmp; - V_memcpy(&tmp, &c->layout, sizeof(Context::Layout)); + Layout tmp; + V_memcpy(&tmp, &c->layout, sizeof(Layout)); SetPerRowLayout(1, nullptr, tmp.iRowTall); c->eLabelTextStyle = NeoUI::TEXTSTYLE_CENTER; @@ -1171,11 +1328,51 @@ void Label(const wchar_t *wszText, const bool bNotWidget) } if (wdgState.bInView && c->eMode == MODE_PAINT) { - const auto *pFontI = &c->fonts[c->eFont]; - const int x = XPosFromText(wszText, pFontI, c->eLabelTextStyle); - const int y = pFontI->iYOffset; - vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + x, c->rWidgetArea.y0 + y); - vgui::surface()->DrawPrintText(wszText, V_wcslen(wszText)); + const int iLDiff = Max(c->dPanel.x - c->rWidgetArea.x0, 0); + const int iRDiff = Max(c->rWidgetArea.x1 - (c->dPanel.x + c->dPanel.wide), 0); + Dim viewportDim = {}; + if (bNotWidget) + { + viewportDim.x = c->dPanel.x; + viewportDim.y = c->dPanel.y; + viewportDim.wide = c->dPanel.wide; + viewportDim.tall = c->dPanel.tall; + } + else + { + viewportDim.x = Max(c->rWidgetArea.x0, c->dPanel.x); + viewportDim.y = c->rWidgetArea.y0; + viewportDim.wide = c->irWidgetWide - iLDiff - iRDiff; + viewportDim.tall = c->irWidgetTall; + } + // NEO NOTE (nullsystem): Sanity check the viewport makes sense + if (IN_BETWEEN_EQ(c->dPanel.x, viewportDim.x, c->dPanel.x + c->dPanel.wide) + && IN_BETWEEN_EQ(c->dPanel.y, viewportDim.y, c->dPanel.y + c->dPanel.tall) + && IN_BETWEEN_EQ(c->dPanel.y, viewportDim.y + viewportDim.tall, c->dPanel.y + c->dPanel.tall) + && IN_BETWEEN_EQ(1, viewportDim.wide, c->dPanel.wide) + && IN_BETWEEN_EQ(1, viewportDim.tall, c->dPanel.tall)) + { + vgui::surface()->SetFullscreenViewport( + viewportDim.x, viewportDim.y, + viewportDim.wide, viewportDim.tall); + vgui::surface()->PushFullscreenViewport(); + + const auto *pFontI = &c->fonts[c->eFont]; + int x = XPosFromText(wszText, pFontI, c->eLabelTextStyle); + int y = pFontI->iYFontOffset; + if (bNotWidget) + { + vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 - c->dPanel.x + x, c->rWidgetArea.y0 - c->dPanel.y + y); + } + else + { + vgui::surface()->DrawSetTextPos(x - iLDiff, y); + } + vgui::surface()->DrawPrintText(wszText, V_wcslen(wszText)); + + vgui::surface()->PopFullscreenViewport(); + vgui::surface()->SetFullscreenViewport(0, 0, 0, 0); + } } if (!bNotWidget) { @@ -1203,7 +1400,7 @@ void Label(const wchar_t *wszLabel, const wchar_t *wszText) Label(wszText); } -NeoUI::RetButton BaseButton(const wchar_t *wszText, const char *szTexturePath, const EBaseButtonType eType) +NeoUI::RetButton BaseButton(const wchar_t *wszText, const char *szTexturePath, const EBaseButtonType eType, const bool bVal) { const auto wdgState = BeginWidget(WIDGETFLAG_MOUSE | WIDGETFLAG_MARKACTIVE); @@ -1224,7 +1421,7 @@ NeoUI::RetButton BaseButton(const wchar_t *wszText, const char *szTexturePath, c const auto *pFontI = &c->fonts[c->eFont]; const int x = XPosFromText(wszText, pFontI, c->eButtonTextStyle); - const int y = pFontI->iYOffset; + const int y = pFontI->iYFontOffset; vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + x, c->rWidgetArea.y0 + y); vgui::surface()->DrawPrintText(wszText, V_wcslen(wszText)); } break; @@ -1235,6 +1432,30 @@ NeoUI::RetButton BaseButton(const wchar_t *wszText, const char *szTexturePath, c Texture(szTexturePath, c->rWidgetArea.x0, c->rWidgetArea.y0, c->irWidgetWide, c->irWidgetTall); } break; + case BASEBUTTONTYPE_CHECKBOX: + { + vgui::surface()->DrawFilledRectArray(&c->rWidgetArea, 1); + + const auto *pFontI = &c->fonts[c->eFont]; + int iFontTextWidth = 0, iFontTextHeight = 0; + vgui::surface()->GetTextSize(pFontI->hdl, WSZ_CHECK_MARK, iFontTextWidth, iFontTextHeight); + + const int x = (c->layout.iRowTall / 2) - (iFontTextWidth / 2); + const int y = pFontI->iYFontOffset; + + if (bVal) + { + vgui::surface()->DrawSetTextPos( + c->rWidgetArea.x0 + x, + c->rWidgetArea.y0 + y); + vgui::surface()->DrawPrintText(WSZ_CHECK_MARK, 1); + } + + vgui::surface()->DrawSetTextPos( + c->rWidgetArea.x0 + c->layout.iRowTall + c->iMarginX, + c->rWidgetArea.y0 + y); + vgui::surface()->DrawPrintText(wszText, V_wcslen(wszText)); + } break; } } break; @@ -1251,7 +1472,7 @@ NeoUI::RetButton BaseButton(const wchar_t *wszText, const char *szTexturePath, c break; case MODE_KEYPRESSED: { - ret.bKeyPressed = ret.bPressed = (wdgState.bActive && IsKeyEnter()); + ret.bKeyEnterPressed = ret.bPressed = (wdgState.bActive && IsKeyEnter()); } break; default: @@ -1456,6 +1677,11 @@ NeoUI::RetButton ButtonTexture(const char *szTexturePath) return BaseButton(L"", szTexturePath, BASEBUTTONTYPE_IMAGE); } +NeoUI::RetButton ButtonCheckbox(const wchar_t *wszText, const bool bVal) +{ + return BaseButton(wszText, "", BASEBUTTONTYPE_CHECKBOX, bVal); +} + void ResetTextures() { CUtlHashtable *pHtTexMap = &(c->htTexMap); @@ -1516,7 +1742,7 @@ void RingBox(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex) // Center-text label vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + ((c->irWidgetWide / 2) - (iFontWide / 2)), - c->rWidgetArea.y0 + pFontI->iYOffset); + c->rWidgetArea.y0 + pFontI->iYFontOffset); vgui::surface()->DrawPrintText(wszText, V_wcslen(wszText)); // Left-side "<" prev button @@ -1524,14 +1750,14 @@ void RingBox(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex) c->rWidgetArea.x0 + c->layout.iRowTall, c->rWidgetArea.y1); vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + pFontI->iStartBtnXPos, c->rWidgetArea.y0 + pFontI->iStartBtnYPos); - vgui::surface()->DrawPrintText(L"<", 1); + vgui::surface()->DrawPrintText(WSZ_ARROW_LEFT, 1); // Right-side ">" next button vgui::surface()->DrawFilledRect(c->rWidgetArea.x1 - c->layout.iRowTall, c->rWidgetArea.y0, c->rWidgetArea.x1, c->rWidgetArea.y1); vgui::surface()->DrawSetTextPos(c->rWidgetArea.x1 - c->layout.iRowTall + pFontI->iStartBtnXPos, c->rWidgetArea.y0 + pFontI->iStartBtnYPos); - vgui::surface()->DrawPrintText(L">", 1); + vgui::surface()->DrawPrintText(WSZ_ARROW_RIGHT, 1); } break; case MODE_MOUSEPRESSED: @@ -1583,7 +1809,7 @@ void RingBox(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex) } void Tabs(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex, - const TabsFlags flags, int *piTabWide) + const TabsFlags flags, TabsState *pState) { // This is basically a ringbox but different UI const auto wdgState = BeginWidget(WIDGETFLAG_SKIPACTIVE | WIDGETFLAG_MOUSE | WIDGETFLAG_NOHOTBORDER); @@ -1594,10 +1820,15 @@ void Tabs(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex, SwapFont(FONT_NTNORMAL); int iTabWide = 0; - if (piTabWide) + int iTabOffset = 0; + if (pState) { - if (*piTabWide <= 0) + const bool bThisYScroll = (c->ibfSectionHasYScroll & (1ULL << c->iSection)); + if (c->bFirstCtxUse + || pState->iWide <= 0 + || pState->bInYScroll != bThisYScroll) { + const int iTabWideEqual = (c->dPanel.wide / iLabelsSize); for (int i = 0; i < iLabelsSize; ++i) { int iCurTabWide, iTabTall; @@ -1605,39 +1836,65 @@ void Tabs(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex, iTabWide = Max(iCurTabWide, iTabWide); } iTabWide += 2 * c->iMarginX; - *piTabWide = iTabWide; + // Just go use equal tab spacing if the text + X-margins fits + iTabWide = Max(iTabWide, iTabWideEqual); + pState->iWide = iTabWide; + pState->iOffset = 0; + pState->bInYScroll = bThisYScroll; Assert(iTabWide > 0); } - iTabWide = *piTabWide; + iTabWide = pState->iWide; + iTabOffset = pState->iOffset; } else { iTabWide = (c->dPanel.wide / iLabelsSize); } + + const int iOverflowBtnX = (iTabWide > (c->dPanel.wide / iLabelsSize)) + ? c->layout.iRowTall : 0; const int iTotalTabsWidth = (iTabWide * iLabelsSize); - const int iCenterTabsXOffset = iTotalTabsWidth < c->irWidgetWide ? (c->irWidgetWide - iTotalTabsWidth) * 0.5 : 0; + const int iTabOffsetMax = iTotalTabsWidth - c->dPanel.wide + (iOverflowBtnX * 2); bool bResetSectionStates = false; - + bool bShiftOffset = false; + + const bool bHotLeft = wdgState.bHot + && IN_BETWEEN_EQ(c->rWidgetArea.x0, + c->iMouseAbsX, + c->rWidgetArea.x0 + iOverflowBtnX); + const bool bHotRight = wdgState.bHot + && IN_BETWEEN_EQ(c->rWidgetArea.x1 - iOverflowBtnX, + c->iMouseAbsX, + c->rWidgetArea.x1); + const int iMWheelJump = iTabWide * 0.2; switch (c->eMode) { case MODE_PAINT: { - vgui::surface()->SetFullscreenViewport(c->rWidgetArea.x0, c->rWidgetArea.y0, c->irWidgetWide, c->irWidgetTall); + const int iXViewportStart = c->rWidgetArea.x0 + iOverflowBtnX; + const int iXViewportWide = c->irWidgetWide - (iOverflowBtnX * 2); + vgui::surface()->SetFullscreenViewport( + iXViewportStart, + c->rWidgetArea.y0, + iXViewportWide, + c->irWidgetTall); + const int iXViewportEnd = iXViewportStart + iXViewportWide; vgui::surface()->PushFullscreenViewport(); const auto *pFontI = &c->fonts[c->eFont]; for (int i = 0, iXPosTab = 0; i < iLabelsSize; ++i, iXPosTab += iTabWide) { vgui::IntRect tabRect = { - .x0 = c->iXOffset[c->iSection] + iCenterTabsXOffset + iXPosTab, + .x0 = iTabOffset + iXPosTab, .y0 = 0, - .x1 = c->iXOffset[c->iSection] + iCenterTabsXOffset + iXPosTab + iTabWide, + .x1 = iTabOffset + iXPosTab + iTabWide, .y1 = c->irWidgetTall, }; - const bool bHotTab = IN_BETWEEN_EQ(tabRect.x0, c->iMouseAbsX - c->rWidgetArea.x0, tabRect.x1) && - IN_BETWEEN_EQ(tabRect.y0, c->iMouseAbsY - c->rWidgetArea.y0, tabRect.y1); + const bool bHotTab = IN_BETWEEN_EQ(iXViewportStart, c->iMouseAbsX, iXViewportEnd) + && IN_BETWEEN_EQ(tabRect.x0 + iXViewportStart, c->iMouseAbsX, tabRect.x1 + iXViewportStart) + && IN_BETWEEN_EQ(tabRect.y0 + c->rWidgetArea.y0, c->iMouseAbsY, tabRect.y1 + c->rWidgetArea.y0); const bool bActiveTab = i == *iIndex; if (bHotTab || bActiveTab) { @@ -1647,40 +1904,56 @@ void Tabs(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex, const wchar_t *wszText = wszLabelsList[i]; int wide, tall; vgui::surface()->GetTextSize(c->fonts[c->eFont].hdl, wszText, wide, tall); - vgui::surface()->DrawSetTextPos(c->iXOffset[c->iSection] + iCenterTabsXOffset + iXPosTab + ((iTabWide - wide) * 0.5), pFontI->iYOffset); + vgui::surface()->DrawSetTextPos(iTabOffset + iXPosTab + ((iTabWide - wide) * 0.5), pFontI->iYFontOffset); vgui::surface()->DrawSetTextColor(bActiveTab ? c->colors.activeFg : bHotTab ? c->colors.hotFg : c->colors.normalFg); vgui::surface()->DrawPrintText(wszText, V_wcslen(wszText)); if (bHotTab) { - const int iHotTabMargin = static_cast(FL_BORDER_RATIO * c->iMarginY); - vgui::surface()->DrawSetColor(c->colors.hotBorder); - DrawBorder(tabRect, iHotTabMargin); + DrawBorder(tabRect); } } - if (c->iXOffset[c->iSection] != 0) - { - // more tab content to the left - vgui::surface()->DrawSetColor(COLOR_WHITE); - vgui::surface()->DrawFilledRect(0, 0, 2, c->irWidgetTall); - } - const int iTabsWiderBy = c->dPanel.wide - iTotalTabsWidth; - if (iTabsWiderBy < 0 && c->iXOffset[c->iSection] != iTabsWiderBy) - { - // more tab content to the right - vgui::surface()->DrawSetColor(COLOR_WHITE); - vgui::surface()->DrawFilledRect(c->irWidgetWide, 0, c->irWidgetWide-2, c->irWidgetTall); - } - vgui::surface()->PopFullscreenViewport(); vgui::surface()->SetFullscreenViewport(0, 0, 0, 0); + if (iOverflowBtnX > 0) + { + const bool bIndicateLeft = iTabOffset < 0; + const bool bIndicateRight = iTabOffset > -iTabOffsetMax; + + // Show left button + vgui::surface()->DrawSetColor(bHotLeft ? c->colors.activeBg : c->colors.normalBg); + vgui::surface()->DrawSetTextColor(bHotLeft ? c->colors.activeFg : c->colors.normalFg); + vgui::surface()->DrawFilledRect( + c->rWidgetArea.x0, + c->rWidgetArea.y0, + c->rWidgetArea.x0 + iOverflowBtnX, + c->rWidgetArea.y1); + + vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + pFontI->iStartBtnXPos, + c->rWidgetArea.y0 + pFontI->iStartBtnYPos); + vgui::surface()->DrawPrintText(bIndicateLeft ? WSZ_ARROW_LEFT : L"|", 1); + + // Show right button + vgui::surface()->DrawSetColor(bHotRight ? c->colors.activeBg : c->colors.normalBg); + vgui::surface()->DrawSetTextColor(bHotRight ? c->colors.activeFg : c->colors.normalFg); + vgui::surface()->DrawFilledRect( + c->rWidgetArea.x1 - iOverflowBtnX, + c->rWidgetArea.y0, + c->rWidgetArea.x1, + c->rWidgetArea.y1); + + vgui::surface()->DrawSetTextPos(c->rWidgetArea.x1 - iOverflowBtnX + pFontI->iStartBtnXPos, + c->rWidgetArea.y0 + pFontI->iStartBtnYPos); + vgui::surface()->DrawPrintText(bIndicateRight ? WSZ_ARROW_RIGHT : L"|", 1); + } + if (!(flags & TABFLAG_NOSIDEKEYS)) { // Draw the side-hints text // NEO NOTE (nullsystem): F# as 1 is thinner than 3/not monospaced font int iFontWidth, iFontHeight; vgui::surface()->GetTextSize(c->fonts[c->eFont].hdl, L"F #", iFontWidth, iFontHeight); - const int iHintYPos = c->rWidgetArea.y0 + pFontI->iYOffset; + const int iHintYPos = c->rWidgetArea.y0 + pFontI->iYFontOffset; vgui::surface()->DrawSetTextColor(c->colors.tabHintsFg); vgui::surface()->DrawSetTextPos(c->dPanel.x - c->iMarginX - iFontWidth, iHintYPos); @@ -1691,14 +1964,25 @@ void Tabs(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex, } break; case MODE_MOUSEPRESSED: + case MODE_MOUSEDOUBLEPRESSED: { - if (wdgState.bHot && c->eCode == MOUSE_LEFT && c->iMouseAbsX >= (c->rWidgetArea.x0 + iCenterTabsXOffset) && c->iMouseAbsX <= c->rWidgetArea.x0 + iCenterTabsXOffset + (iTabWide * iLabelsSize)) + if (wdgState.bHot && c->eCode == MOUSE_LEFT) { - const int iNextIndex = ((-c->iXOffset[c->iSection] + c->iMouseAbsX - c->rWidgetArea.x0 - iCenterTabsXOffset) / iTabWide); - if (iNextIndex != *iIndex) + if (bHotLeft || bHotRight) { - *iIndex = clamp(iNextIndex, 0, iLabelsSize - 1); - bResetSectionStates = true; + *iIndex = clamp(*iIndex + ((bHotLeft) ? -1 : +1), 0, iLabelsSize - 1); + bShiftOffset = true; + } + else if (c->iMouseAbsX >= (c->rWidgetArea.x0 + iOverflowBtnX) + && c->iMouseAbsX <= c->rWidgetArea.x0 + iOverflowBtnX + (iTabWide * iLabelsSize)) + { + const int iNextIndex = ((-iTabOffset + c->iMouseAbsX - c->rWidgetArea.x0 - iOverflowBtnX) / iTabWide); + if (iNextIndex != *iIndex) + { + *iIndex = clamp(iNextIndex, 0, iLabelsSize - 1); + bResetSectionStates = true; + bShiftOffset = true; + } } } } @@ -1715,40 +1999,50 @@ void Tabs(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex, { *iIndex += (bLeftKey) ? -1 : +1; *iIndex = LoopAroundInArray(*iIndex, iLabelsSize); - const int distanceToStartOfButton = *iIndex * iTabWide; - if (-c->iXOffset[c->iSection] > distanceToStartOfButton) - { - c->iXOffset[c->iSection] = -distanceToStartOfButton; - } - const int distanceToEndOfButton = (*iIndex + 1) * iTabWide; - if (-c->iXOffset[c->iSection] + c->irWidgetWide < distanceToEndOfButton) - { - c->iXOffset[c->iSection] = c->irWidgetWide - distanceToEndOfButton; - } + bShiftOffset = true; bResetSectionStates = true; } } } break; case MODE_MOUSEWHEELED: - if (!(c->bMouseInPanel)) + if (wdgState.bHot && iOverflowBtnX > 0) { - break; + iTabOffset += (c->eCode == MOUSE_WHEEL_UP) ? iMWheelJump : -iMWheelJump; + iTabOffset = clamp(iTabOffset, -iTabOffsetMax, 0); + c->bBlockSectionMWheel = true; } - c->iXOffset[c->iSection] += (c->eCode == MOUSE_WHEEL_UP) ? iMWheelJump : -iMWheelJump; // NEO TODO (Adam) customizable horizontal scroll direction? - c->iXOffset[c->iSection] = clamp(c->iXOffset[c->iSection], -(iTotalTabsWidth - c->dPanel.wide), 0); break; default: break; } + if (bShiftOffset) + { + const int distanceToStartOfButton = *iIndex * iTabWide; + if (-iTabOffset > distanceToStartOfButton) + { + iTabOffset = -distanceToStartOfButton; + } + const int distanceToEndOfButton = (*iIndex + 1) * iTabWide; + if (-iTabOffset + c->irWidgetWide < distanceToEndOfButton) + { + iTabOffset = c->irWidgetWide - distanceToEndOfButton - (iOverflowBtnX * 2); + } + } if (bResetSectionStates && !(flags & TABFLAG_NOSTATERESETS)) { V_memset(c->iYOffset, 0, sizeof(c->iYOffset)); + V_memset(c->iXOffset, 0, sizeof(c->iXOffset)); c->iActive = FOCUSOFF_NUM; c->iActiveSection = -1; c->iHot = FOCUSOFF_NUM; c->iHotSection = -1; + iTabOffset = 0; + } + if (pState) + { + pState->iOffset = iTabOffset; } EndWidget(wdgState); @@ -1826,15 +2120,15 @@ void Slider(const wchar_t *wszLeftLabel, float *flValue, const float flMin, cons vgui::surface()->DrawFilledRect(c->rWidgetArea.x0, c->rWidgetArea.y0, c->rWidgetArea.x0 + c->layout.iRowTall, c->rWidgetArea.y1); vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + pFontI->iStartBtnXPos, - c->rWidgetArea.y0 + pFontI->iStartBtnYPos); - vgui::surface()->DrawPrintText(L"<", 1); + c->rWidgetArea.y0 + pFontI->iStartBtnYPos); + vgui::surface()->DrawPrintText(WSZ_ARROW_LEFT, 1); // Right-side ">" next button vgui::surface()->DrawFilledRect(c->rWidgetArea.x1 - c->layout.iRowTall, c->rWidgetArea.y0, c->rWidgetArea.x1, c->rWidgetArea.y1); vgui::surface()->DrawSetTextPos(c->rWidgetArea.x1 - c->layout.iRowTall + pFontI->iStartBtnXPos, - c->rWidgetArea.y0 + pFontI->iStartBtnYPos); - vgui::surface()->DrawPrintText(L">", 1); + c->rWidgetArea.y0 + pFontI->iStartBtnYPos); + vgui::surface()->DrawPrintText(WSZ_ARROW_RIGHT, 1); // Center-text text dimensions const bool bSpecial = (wszSpecialText && !wdgState.bActive && *flValue == 0.0f); @@ -1868,7 +2162,7 @@ void Slider(const wchar_t *wszLeftLabel, float *flValue, const float flMin, cons // Center-text text render vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + iFontStartX, - c->rWidgetArea.y0 + pFontI->iYOffset); + c->rWidgetArea.y0 + pFontI->iYFontOffset); vgui::surface()->DrawPrintText(bSpecial ? wszSpecialText : pSInfo->wszText, bSpecial ? V_wcslen(wszSpecialText) : V_wcslen(pSInfo->wszText)); @@ -1880,9 +2174,9 @@ void Slider(const wchar_t *wszLeftLabel, float *flValue, const float flMin, cons const int iMarkX = iFontStartX + iFontWide; vgui::surface()->DrawSetColor(c->colors.cursor); vgui::surface()->DrawFilledRect(c->rWidgetArea.x0 + iMarkX, - c->rWidgetArea.y0 + pFontI->iYOffset, + c->rWidgetArea.y0 + pFontI->iYFontOffset, c->rWidgetArea.x0 + iMarkX + c->iMarginX, - c->rWidgetArea.y0 + pFontI->iYOffset + iFontTall); + c->rWidgetArea.y0 + pFontI->iYFontOffset + iFontTall); } } } @@ -2245,7 +2539,7 @@ void TextEdit(wchar_t *wszText, const int iMaxWszTextSize, const TextEditFlags f int iFontWide, iFontTall; vgui::surface()->GetTextSize(pFontI->hdl, wszDbgText, iFontWide, iFontTall); vgui::surface()->DrawSetTextPos(c->rWidgetArea.x1 - c->iMarginX - iFontWide, - c->rWidgetArea.y0 + pFontI->iYOffset); + c->rWidgetArea.y0 + pFontI->iYFontOffset); vgui::surface()->DrawPrintText(wszDbgText, V_wcslen(wszDbgText)); vgui::surface()->DrawSetTextColor(c->colors.normalFg); #endif // defined(DEBUG) && DEBUG_NEOUI @@ -2293,14 +2587,14 @@ void TextEdit(wchar_t *wszText, const int iMaxWszTextSize, const TextEditFlags f bIsCursor ? c->colors.cursor : c->colors.textSelectionBg); vgui::surface()->DrawFilledRect( c->rWidgetArea.x0 + c->iMarginX + iXStart, - c->rWidgetArea.y0 + ((bIsCursor) ? pFontI->iYOffset : 0), + c->rWidgetArea.y0 + ((bIsCursor) ? pFontI->iYFontOffset : 0), c->rWidgetArea.x0 + c->iMarginX + iXEnd, - (bIsCursor) ? c->rWidgetArea.y0 + pFontI->iYOffset + vgui::surface()->GetFontTall(pFontI->hdl) : c->rWidgetArea.y1); + (bIsCursor) ? c->rWidgetArea.y0 + pFontI->iYFontOffset + vgui::surface()->GetFontTall(pFontI->hdl) : c->rWidgetArea.y1); vgui::surface()->DrawSetColor(c->colors.normalBg); } } vgui::surface()->DrawSetTextPos(c->rWidgetArea.x0 + c->iMarginX, - c->rWidgetArea.y0 + pFontI->iYOffset); + c->rWidgetArea.y0 + pFontI->iYFontOffset); const wchar_t *pwszPrintText = (bIsPassword) ? staticWszPasswordChars : wszText; vgui::surface()->DrawPrintText(pwszPrintText, iWszTextSize); } @@ -2613,6 +2907,405 @@ void TextEdit(wchar_t *wszText, const int iMaxWszTextSize, const TextEditFlags f EndWidget(wdgState); } +TableHeaderModFlags TableHeader(const wchar_t **wszColNamesList, const int iColsTotal, + int *piColsWide, int *piSortIndex, bool *pbSortDescending, + const int iRelSyncSectionXOffset) +{ + TableHeaderModFlags modFlags = 0; + + const auto wdgState = BeginWidget(WIDGETFLAG_SKIPACTIVE | WIDGETFLAG_MOUSE | WIDGETFLAG_NOHOTBORDER); + const int iDragWide = 5; // TODO size by resolution + + // Sanity check if iLabelsSize dynamically changes + *piSortIndex = clamp(*piSortIndex, 0, iColsTotal - 1); + + if (wdgState.bInView) + { + switch (c->eMode) + { + case MODE_PAINT: + { + bool bHotDrag = false; + int prevX1 = c->rWidgetArea.x0 - c->iXOffset[c->iSection + iRelSyncSectionXOffset]; + const auto *pFontI = &c->fonts[c->eFont]; + for (int i = 0; i < iColsTotal; ++i) + { + if (piColsWide[i] <= 0) + { + continue; + } + + vgui::IntRect headerCellRect = { + .x0 = prevX1, + .y0 = c->rWidgetArea.y0, + .x1 = prevX1 + piColsWide[i], + .y1 = c->rWidgetArea.y0 + c->irWidgetTall, + }; + prevX1 = headerCellRect.x1; + + // Header cell background section + const bool bHotCell = + IN_BETWEEN_EQ(headerCellRect.x0, c->iMouseAbsX, headerCellRect.x1 - iDragWide) + && IN_BETWEEN_EQ(headerCellRect.y0, c->iMouseAbsY, headerCellRect.y1); + const bool bSortCell = (i == *piSortIndex); + + // Drag area section + bHotDrag = bHotDrag || + ( IN_BETWEEN_EQ(headerCellRect.x1 - iDragWide, c->iMouseAbsX, headerCellRect.x1) + && IN_BETWEEN_EQ(headerCellRect.y0, c->iMouseAbsY, headerCellRect.y1)); + + const int iLDiff = Max(c->rWidgetArea.x0 - headerCellRect.x0, 0); + const int iRDiff = Max(headerCellRect.x1 - c->rWidgetArea.x1, 0); + const int iXTotalWide = headerCellRect.x1 - headerCellRect.x0; + const int iXWide = Max(iXTotalWide - iLDiff - iRDiff, 0); + if (iXWide > 0) + { + vgui::surface()->SetFullscreenViewport( + Max(headerCellRect.x0, c->rWidgetArea.x0), + headerCellRect.y0, + iXWide, + c->irWidgetTall); + vgui::surface()->PushFullscreenViewport(); + + // Rendering + if (bHotCell || bSortCell) + { + vgui::surface()->DrawSetColor(bSortCell ? c->colors.activeBg : c->colors.hotBg); + vgui::surface()->DrawFilledRect(0, 0, iXWide - ((iRDiff > 0) ? 0 : iDragWide), c->irWidgetTall); + } + + vgui::surface()->DrawSetColor((c->iInDrag == (i + 1)) + ? c->colors.headerDragActiveBg + : c->colors.headerDragNormalBg); + vgui::surface()->DrawFilledRect(iXTotalWide - iLDiff - iDragWide, 0, iXTotalWide - iLDiff, c->irWidgetTall); + + // Text section + const wchar_t *wszText = wszColNamesList[i]; + vgui::surface()->DrawSetTextPos(c->iMarginX - iLDiff, pFontI->iYFontOffset); + vgui::surface()->DrawSetTextColor(bSortCell ? c->colors.activeFg : bHotCell ? c->colors.hotFg : c->colors.normalFg); + vgui::surface()->DrawPrintText(wszText, V_wcslen(wszText)); + if (bSortCell) + { + const auto *pFontI = &c->fonts[c->eFont]; + vgui::surface()->DrawSetColor(c->colors.tableHeaderSortIndicatorBg); + + const int iSortIndX = iXWide - c->layout.iRowTall + pFontI->iStartBtnXPos; + const int iSortIndY = pFontI->iStartBtnYPos; + vgui::surface()->DrawSetTextPos(iSortIndX, iSortIndY); + + vgui::surface()->DrawPrintText((*pbSortDescending) ? WSZ_ARROW_UP : WSZ_ARROW_DOWN, 1); + } + if (bHotCell) + { + DrawBorder(vgui::IntRect{0, 0, iXWide - ((iRDiff > 0) ? 0 : iDragWide), c->irWidgetTall}); + } + + vgui::surface()->PopFullscreenViewport(); + vgui::surface()->SetFullscreenViewport(0, 0, 0, 0); + } + } + vgui::surface()->SetCursor((bHotDrag || c->iInDrag > 0) + ? vgui::dc_sizewe + : vgui::dc_arrow); + } + break; + case MODE_MOUSEPRESSED: + case MODE_MOUSEDOUBLEPRESSED: + { + if (wdgState.bHot && c->eCode == MOUSE_LEFT) + { + int cellX0 = 0; + int cellX1 = c->rWidgetArea.x0 - c->iXOffset[c->iSection + iRelSyncSectionXOffset]; + for (int i = 0; i < iColsTotal; ++i) + { + if (piColsWide[i] <= 0) + { + continue; + } + + // y-axis already covered by wdgState.bHot, just check x-axis + cellX0 = cellX1; + cellX1 = cellX0 + piColsWide[i]; + + const bool bMouseInCell = IN_BETWEEN_EQ(cellX0, c->iMouseAbsX, cellX1); + if (bMouseInCell) + { + const bool bMouseInDrag = IN_BETWEEN_EQ(cellX1 - iDragWide, c->iMouseAbsX, cellX1); + if (bMouseInDrag) + { + c->iInDrag = i + 1; + } + else + { + const bool bSortCell = (i == *piSortIndex); + if (bSortCell) + { + *pbSortDescending = !*pbSortDescending; + modFlags |= TABLEHEADERMODFLAG_DESCENDINGCHANGED; + } + else + { + *piSortIndex = i; + modFlags |= TABLEHEADERMODFLAG_INDEXCHANGED; + } + c->bValueEdited = true; + } + break; + } + } + } + } + break; + case MODE_MOUSERELEASED: + { + c->iInDrag = 0; + } + break; + case MODE_MOUSEMOVED: + { + if (c->iInDrag > 0) + { + const int iDragIdx = c->iInDrag - 1; + int iAccWide = 0; + for (int i = 0; i < iColsTotal; ++i) + { + if (piColsWide[i] <= 0) + { + continue; + } + + iAccWide += piColsWide[i]; + if (iDragIdx == i) + { + // Alter by the difference in drag + const int iAbsColX1 = c->rWidgetArea.x0 + iAccWide - + c->iXOffset[c->iSection + iRelSyncSectionXOffset]; + const int iDiff = c->iMouseAbsX - iAbsColX1; + piColsWide[i] = Max(piColsWide[i] + iDiff, 0); + iAccWide += iDiff; + + // If dragging the end, expand/retract offset + // This is assuming c->dPanel.wide on next section equals to this + if (iDragIdx == (iColsTotal - 1)) + { + c->iXOffset[c->iSection + iRelSyncSectionXOffset] = Max(iAccWide - c->dPanel.wide, 0); + } + break; + } + } + } + } + break; + default: + break; + } + } + + EndWidget(wdgState); + return modFlags; +} + +void BeginTable(const int *piColsWide, const int iLabelsSize) +{ + // Bump y-axis with previous row layout before applying table layout + if (c->iWidget > 0 && c->iIdxRowParts > 0 && c->iIdxRowParts < c->layout.iRowPartsTotal) + { + c->iLayoutX = -c->iXOffset[c->iSection]; + c->iLayoutY += c->layout.iRowTall; + c->irWidgetLayoutY = c->iLayoutY; + } + + Assert(0 == c->layout.iTableLabelsSize); + Assert(nullptr == c->layout.piTableColsWide); + + Assert(iLabelsSize > 0); + Assert(piColsWide); + + c->layout.iTableLabelsSize = iLabelsSize; + c->layout.piTableColsWide = piColsWide; + c->curRowFlags = NEXTTABLEROWFLAG_NONE; + c->iLastTableRowWidget = 0; + + c->iIdxRowParts = -1; +} + +NeoUI::RetButton EndTable() +{ + RetButton ret = {}; + + if (c->iWidget > 0 && c->iIdxRowParts > 0 && c->iIdxRowParts < c->layout.iRowPartsTotal) + { + c->iLayoutX = -c->iXOffset[c->iSection]; + c->iLayoutY += c->layout.iRowTall; + c->irWidgetLayoutY = c->iLayoutY; + } + + if ((c->curRowFlags & NEXTTABLEROWFLAG__HASROWSELECTABLES) + && (false == (c->curRowFlags & NEXTTABLEROWFLAG__HASROWSELECTED)) + && (c->eMode == MODE_KEYPRESSED)) + { + ret.bKeyUpPressed = IsKeyUpWidget(); + ret.bKeyDownPressed = IsKeyDownWidget(); + if (ret.bKeyUpPressed || ret.bKeyDownPressed) + { + c->iActiveSection = c->iSection; + c->iActive = ret.bKeyUpPressed ? c->iLastTableRowWidget : 0; + } + } + + c->layout.iTableLabelsSize = 0; + c->layout.piTableColsWide = nullptr; + c->curRowFlags = NEXTTABLEROWFLAG_NONE; + + c->iIdxRowParts = -1; + return ret; +} + +NeoUI::RetButton NextTableRow(const NextTableRowFlags flags) +{ + Assert(c->layout.iTableLabelsSize > 0 && c->layout.piTableColsWide); + + if (c->iWidget > 0 && c->iIdxRowParts > 0 && c->iIdxRowParts < c->layout.iRowPartsTotal) + { + c->iLayoutX = -c->iXOffset[c->iSection]; + c->iLayoutY += c->layout.iRowTall; + c->irWidgetLayoutY = c->iLayoutY; + } + + RetButton ret = {}; + c->curRowFlags = ((flags & NEXTTABLEROWFLAG__EXTERNAL) | (c->curRowFlags & NEXTTABLEROWFLAG__PERSISTS)); + + if (flags & NEXTTABLEROWFLAG_SELECTABLE) + { + vgui::IntRect rRowArea = { + .x0 = c->dPanel.x + c->iLayoutX + c->iXOffset[c->iSection], + .y0 = c->dPanel.y + c->iLayoutY, + .x1 = c->dPanel.x + c->iLayoutX + c->iXOffset[c->iSection] + c->dPanel.wide, + .y1 = c->dPanel.y + c->iLayoutY + c->layout.iRowTall, + }; + + // Not a full Begin/EndWidget, but just fills in wdgInfos mainly + // for key-pressed up/down Y-axis slider adjustments + c->irWidgetTall = c->layout.iRowTall; + c->wdgInfos[c->iWidget].iYOffsets = c->iLayoutY + c->iYOffset[c->iSection]; + c->wdgInfos[c->iWidget].iYTall = c->irWidgetTall; + c->wdgInfos[c->iWidget].bCannotActive = false; + + bool bMouseIn = false; + const bool bFullyVisible = (rRowArea.y0 >= c->dPanel.y) && (rRowArea.y1 <= (c->dPanel.y + c->dPanel.tall)); + if (bFullyVisible) + { + bMouseIn = IN_BETWEEN_EQ(rRowArea.x0, c->iMouseAbsX, rRowArea.x1 - 1) + && IN_BETWEEN_EQ(rRowArea.y0, c->iMouseAbsY, rRowArea.y1 - 1); + if (bMouseIn) + { + c->curRowFlags |= NEXTTABLEROWFLAG__HOT; + } + } + ret.bMouseHover = bMouseIn; + + switch (c->eMode) + { + case MODE_PAINT: + { + if (bFullyVisible) + { + Color color; + if (c->curRowFlags & NEXTTABLEROWFLAG_SELECTED) + { + color = c->colors.activeBg; + } + else if (c->curRowFlags & NEXTTABLEROWFLAG__HOT) + { + color = c->colors.hotBg; + } + + if (color.a() > 0) + { + vgui::surface()->DrawSetColor(color); + vgui::surface()->DrawFilledRectArray(&rRowArea, 1); + } + + if (c->curRowFlags & NEXTTABLEROWFLAG__HOT) + { + DrawBorder(rRowArea); + } + } + } + break; + case MODE_MOUSEPRESSED: + { + ret.bMousePressed = ret.bPressed = (ret.bMouseHover && c->eCode == MOUSE_LEFT); + ret.bMouseRightPressed = (ret.bMouseHover && c->eCode == MOUSE_RIGHT); + } + break; + case MODE_MOUSEDOUBLEPRESSED: + { + ret.bMouseDoublePressed = ret.bPressed = (ret.bMouseHover && c->eCode == MOUSE_LEFT); + } + break; + case MODE_KEYPRESSED: + { + if (c->curRowFlags & NEXTTABLEROWFLAG_SELECTED) + { + ret.bKeyEnterPressed = ret.bPressed = IsKeyEnter(); + if (false == (c->curRowFlags & NEXTTABLEROWFLAG__UPDOWNPROCESSED)) + { + ret.bKeyUpPressed = IsKeyUpWidget(); + ret.bKeyDownPressed = IsKeyDownWidget(); + } + if (ret.bKeyUpPressed || ret.bKeyDownPressed) + { + c->curRowFlags |= NEXTTABLEROWFLAG__UPDOWNPROCESSED; + c->iActiveSection = c->iSection; + c->iActive = c->iWidget; + } + } + } + break; + default: + break; + } + + if (ret.bPressed) + { + c->iActive = c->iWidget; + c->iActiveSection = c->iSection; + } + + if (c->curRowFlags & NEXTTABLEROWFLAG_SELECTED) + { + c->curRowFlags |= NEXTTABLEROWFLAG__HASROWSELECTED; + } + c->curRowFlags |= NEXTTABLEROWFLAG__HASROWSELECTABLES; + c->iLastTableRowWidget = c->iWidget; + + ++c->iWidget; + } + + return ret; +} + +void PadTableXScroll(const int *piColsWide, const int iLabelsSize) +{ + int iTotalX = 0; + for (int i = 0; i < iLabelsSize; ++i) + { + iTotalX += piColsWide[i]; + } + c->irWidgetMaxX = Max(c->irWidgetMaxX, iTotalX - c->iXOffset[c->iSection]); +} + +void BeginIgnoreXOffset() +{ + c->bIgnoreXOffset = true; +} + +void EndIgnoreXOffset() +{ + c->bIgnoreXOffset = false; +} + bool Bind(const ButtonCode_t eCode) { return c->eMode == MODE_KEYPRESSED && c->eCode == eCode; diff --git a/src/game/client/neo/ui/neo_ui.h b/src/game/client/neo/ui/neo_ui.h index b7f3d00d06..d6d5acb541 100644 --- a/src/game/client/neo/ui/neo_ui.h +++ b/src/game/client/neo/ui/neo_ui.h @@ -107,7 +107,7 @@ struct Dim struct FontInfo { vgui::HFont hdl; - int iYOffset; + int iYFontOffset; int iStartBtnXPos; int iStartBtnYPos; }; @@ -138,6 +138,7 @@ enum EBaseButtonType { BASEBUTTONTYPE_TEXT = 0, BASEBUTTONTYPE_IMAGE, + BASEBUTTONTYPE_CHECKBOX, }; struct SliderInfo @@ -150,6 +151,7 @@ struct SliderInfo struct DynWidgetInfos { + int iXOffsets; int iYOffsets; int iYTall; bool bCannotActive; @@ -218,6 +220,32 @@ enum PopupFlag_ }; typedef int PopupFlags; +enum NextTableRowFlag_ +{ + // Public flags, used as NextTableRow options + NEXTTABLEROWFLAG_NONE = 0, + NEXTTABLEROWFLAG_SELECTABLE = 1 << 0, // Mark this row as selectable + NEXTTABLEROWFLAG_SELECTED = 1 << 1, // Mark as selected (only if selectable) + + // Internal only flags + NEXTTABLEROWFLAG__EXTERNAL = ((1 << 8) - 1), // Mask of all external/options flags below start of internal + NEXTTABLEROWFLAG__HOT = 1 << 8, // Mouse hovering the row + NEXTTABLEROWFLAG__UPDOWNPROCESSED = 1 << 9, // Up/down button already been processed + NEXTTABLEROWFLAG__HASROWSELECTED = 1 << 10, // There's a row selection in this table + NEXTTABLEROWFLAG__HASROWSELECTABLES = 1 << 11, // Any row in this table can be selected + + // Flags that'll persists when going to the next row + NEXTTABLEROWFLAG__PERSISTS = NEXTTABLEROWFLAG__UPDOWNPROCESSED | NEXTTABLEROWFLAG__HASROWSELECTED | NEXTTABLEROWFLAG__HASROWSELECTABLES, +}; +typedef int NextTableRowFlags; + +enum EXYMouseDragOffset +{ + XYMOUSEDRAGOFFSET_NIL = 0, + XYMOUSEDRAGOFFSET_YAXIS, + XYMOUSEDRAGOFFSET_XAXIS, +}; + struct Colors { Color normalFg; @@ -241,8 +269,38 @@ struct Colors Color sliderHotBg; Color sliderActiveBg; Color tabHintsFg; + Color tableHeaderSortIndicatorBg; + Color headerDragNormalBg; + Color headerDragActiveBg; +}; + +// NEO NOTE (nullsystem): +// If iRowParts = nullptr, iRowPartsTotal will be used of an equal proportions split +struct Layout +{ + // iRowParts: int percentage of partitioning + // EX: { 60, 20, -1 } -> 60%, 20%, 20% | 3 widgets per row + + // Main layout + int iRowPartsTotal; + const int *iRowParts; + int iRowTall; + int iDefRowTall; + + // Vertical partitioning + int iVertPartsTotal; + const int *iVertParts; + + // Table-only layout + int iTableLabelsSize; + const int *piTableColsWide; // This is in pixels unlike iRowParts }; +// NEO TODO (nullsystem): Re-arrange variables by levels of variable +// lifecycles from "permanent" -> multi-pass context -> single context pass -> section pass +// And should make it easier to just zero them in one go for each level +// where appropriate. Also turn more variables to zero-initialization +// by default. struct Context { Mode eMode; @@ -262,18 +320,16 @@ struct Context bool bMouseInPanel; int iHasMouseInPanel; - // NEO NOTE (nullsystem): - // If iRowParts = nullptr, iRowPartsTotal will be used of an equal proportions split - struct Layout - { - int iRowPartsTotal; - const int *iRowParts; - int iRowTall; - int iDefRowTall; - - int iVertPartsTotal; - const int *iVertParts; - }; + // If a widget captures mouse wheel, don't let + // section deal with it afterward + bool bBlockSectionMWheel; + + // This run is a context reset + bool bFirstCtxUse; + + // Ignore X offset when putting in widget + bool bIgnoreXOffset; + Layout layout; // Themes @@ -289,7 +345,9 @@ struct Context vgui::IntRect rWidgetArea; int irWidgetWide; int irWidgetTall; + int irWidgetLayoutX; int irWidgetLayoutY; + int irWidgetMaxX; // Layout management // "Static" sizing @@ -305,7 +363,7 @@ struct Context int iVertLayoutY; int iYOffset[MAX_SECTIONS] = {}; int iXOffset[MAX_SECTIONS] = {}; - bool abYMouseDragOffset[MAX_SECTIONS] = {}; + EXYMouseDragOffset aeXYMouseDragOffset[MAX_SECTIONS] = {}; int iStartMouseDragOffset[MAX_SECTIONS] = {}; // Saved infos for EndSection managing scrolling @@ -323,7 +381,8 @@ struct Context // Caches/read-ahead this section has scroll is // known in previous frame(s) - uint64_t ibfSectionHasScroll = 0; + uint64_t ibfSectionHasXScroll = 0; + uint64_t ibfSectionHasYScroll = 0; int iHot; int iHotSection; @@ -376,16 +435,26 @@ struct Context // Sound paths const char *pszSoundBtnPressed = "ui/buttonclickrelease.wav"; const char *pszSoundBtnRollover = "ui/buttonrollover.wav"; + + // Table + int iInDrag = 0; + NextTableRowFlags curRowFlags; + int iLastTableRowWidget = 0; }; struct RetButton { + // Button + table row bool bPressed; - bool bKeyPressed; + bool bKeyEnterPressed; bool bMousePressed; bool bMouseHover; bool bMouseDoublePressed; bool bMouseRightPressed; + + // Table row only + bool bKeyUpPressed; // bPressed not set if this set + bool bKeyDownPressed; // bPressed not set if this set }; struct LabelExOpt @@ -454,7 +523,8 @@ void EndOverrideFgColor(); /*1W*/ void Label(const wchar_t *wszText, const LabelExOpt &opt); /*2W*/ void Label(const wchar_t *wszLabel, const wchar_t *wszText); -// If piTabWide == nullptr, fills the whole layout width, otherwise only length of the tabs. +// If pState == nullptr, fills the whole layout width, otherwise, +// will automatically choose either whole layout width or only length of the tabs. // Initialize the variable to -1 and NeoUI::Tabs will auto-figure it out. enum TabsFlag_ { @@ -463,12 +533,21 @@ enum TabsFlag_ TABFLAG_NOSTATERESETS = 1 << 1, // Don't reset scroll/hot/active states }; typedef int TabsFlags; +struct TabsState +{ + int iWide; + int iOffset; + bool bInYScroll; +}; /*1W*/ void Tabs(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex, - const TabsFlags flags = TABFLAG_DEFAULT, int *piTabWide = nullptr); -/*1W*/ RetButton BaseButton(const wchar_t *wszText, const char *szTexturePath, const EBaseButtonType eType); + const TabsFlags flags = TABFLAG_DEFAULT, + TabsState *pState = nullptr); +/*1W*/ RetButton BaseButton(const wchar_t *wszText, const char *szTexturePath, + const EBaseButtonType eType, const bool bVal = false); /*1W*/ RetButton Button(const wchar_t *wszText); /*2W*/ RetButton Button(const wchar_t *wszLeftLabel, const wchar_t *wszText); /*1W*/ RetButton ButtonTexture(const char *szTexturePath); +/*1W*/ RetButton ButtonCheckbox(const wchar_t *wszText, const bool bVal); /*1W*/ void RingBoxBool(bool *bChecked); /*2W*/ void RingBoxBool(const wchar_t *wszLeftLabel, bool *bChecked); /*1W*/ void RingBox(const wchar_t **wszLabelsList, const int iLabelsSize, int *iIndex); @@ -495,6 +574,42 @@ typedef int TextEditFlags; /*2W*/ void TextEdit(const wchar_t *wszLeftLabel, wchar_t *wszText, const int iMaxWszTextSize, const TextEditFlags flags = TEXTEDITFLAG_NONE); /*SW*/ void ImageTexture(const char *szTexturePath, const wchar_t *wszErrorMsg = L"", const char *szTextureGroup = ""); +// Table widgets + functionalities +// NEO TODO (nullsystem): iColProportions non-const, resizable within TableHeader +enum TableHeaderModFlag_ +{ + TABLEHEADERMODFLAG_NONE = 0, + TABLEHEADERMODFLAG_INDEXCHANGED = 1 << 0, + TABLEHEADERMODFLAG_DESCENDINGCHANGED = 1 << 1, +}; +typedef int TableHeaderModFlags; +// Use like: modFlags |= NeoUI::TableHeader(...) to detect sort modifications and deal with it +// at a later tick point. +// +// iRelSyncSectionXOffset = relative position to this header, sync to the +// X-offset of the next/prev section +/*SW*/ [[nodiscard]] TableHeaderModFlags TableHeader(const wchar_t **wszColNamesList, const int iColsTotal, + int *piColsWide, int *piSortIndex, bool *pbSortDescending, + const int iRelSyncSectionXOffset = 0); +// NEO TODO (nullsystem): A mode for the table where it'll show y-axis scrollbar but only requires +// and returns filter view of loop only necessary that's in-view +// NEO TODO (nullsystem): Instead of iRelSyncSectionXOffset, have it as part of an option for each +// section to sync their scrollbar with each other? + +// piColsWide - X px size wide of each columns, if negative it's a hidden column +void BeginTable(const int *piColsWide, const int iLabelsSize); +// EndTable's RetButton mainly to catch Up/Down on a selectable table without anything selected +// From the given up/down action can initialize the row index there +RetButton EndTable(); +RetButton NextTableRow(const NextTableRowFlags flags = NEXTTABLEROWFLAG_NONE); +// If not going to really show a table and instead some message on why it's empty +// but still want the X-scroll to appear and usable +void PadTableXScroll(const int *piColsWide, const int iLabelsSize); + +// Ignore X-offset when putting in widget +void BeginIgnoreXOffset(); +void EndIgnoreXOffset(); + // NeoUI::Texture is non-widget, but utilizes NeoUI's image/texture handling enum TextureOptFlags { diff --git a/src/game/client/neo/ui/neo_utils.cpp b/src/game/client/neo/ui/neo_utils.cpp index afa8601456..c3c31b35f2 100644 --- a/src/game/client/neo/ui/neo_utils.cpp +++ b/src/game/client/neo/ui/neo_utils.cpp @@ -252,7 +252,7 @@ void NeoUtils::SerializeVTFDXTSprayToBuffer(CUtlBuffer *buffer, const uint8 *dat void bpr( int level, CUtlBuffer& buf, char const *fmt, ... ) { - char txt[ 4096 ]; + char txt[ 4096 ] = {}; va_list argptr; va_start( argptr, fmt ); _vsnprintf( txt, sizeof( txt ) - 1, fmt, argptr );