From b2e173f1aac12a425f9404e748e1289dae73336b Mon Sep 17 00:00:00 2001 From: Maximilian Wittfeld Date: Sat, 2 Nov 2024 06:47:58 +0100 Subject: [PATCH] feat(console): rework / selectable and copiable text feat(console): selectable / copiable text This allows to select and copy log entries that are displayed in the big, win (client) and svgui (server) console. tweak(console): rework - Consoles' text input now gains focus automatically when no other keyboard-interactive items are active. - Fixed viewport overflow issues that caused the entire console window to become scrollable. - Reduced window padding to align with inner item spacing. - The autoscroll ConVar is now only initialized on the client side. - svgui (server console) now supports disabling autoscrolling. - Ensured proper synchronization of the autoscroll state between the big console and the win console. - The minicon will no longer gain focus when its viewport is clicked, maintaining its position behind the 'big' console. - The draw FPS item will not gain focus upon activation. - Enhanced code style and performed general code cleanup. --- code/components/conhost-server/component.lua | 3 + code/components/conhost-v2/component.lua | 2 + .../conhost-v2/include/Textselect.hpp | 83 +++++ .../conhost-v2/src/ConsoleHostGui.cpp | 202 +++++++---- .../conhost-v2/src/ConsoleHostImpl.cpp | 5 +- code/components/conhost-v2/src/DrawFPS.cpp | 2 +- code/components/conhost-v2/src/Textselect.cpp | 338 ++++++++++++++++++ 7 files changed, 564 insertions(+), 71 deletions(-) create mode 100644 code/components/conhost-v2/include/Textselect.hpp create mode 100644 code/components/conhost-v2/src/Textselect.cpp diff --git a/code/components/conhost-server/component.lua b/code/components/conhost-server/component.lua index 1f4b25cc0d..c9095f8078 100644 --- a/code/components/conhost-server/component.lua +++ b/code/components/conhost-server/component.lua @@ -7,6 +7,8 @@ return function() includedirs { "components/conhost-v2/include/", "components/conhost-v2/src/", + "../vendor/range-v3/include/", + "../vendor/utfcpp/source", } files { @@ -14,6 +16,7 @@ return function() "components/conhost-v2/src/ConsoleHostGui.cpp", "components/conhost-v2/src/DevGui.cpp", "components/conhost-v2/src/backends/**.cpp", + "components/conhost-v2/src/Textselect.cpp", } end end diff --git a/code/components/conhost-v2/component.lua b/code/components/conhost-v2/component.lua index 5bfc9c1136..fef7cb77b8 100644 --- a/code/components/conhost-v2/component.lua +++ b/code/components/conhost-v2/component.lua @@ -17,6 +17,8 @@ return function() includedirs { '../vendor/vulkan-headers/include/' } end + includedirs { "../vendor/range-v3/include/", "../vendor/utfcpp/source" } + filter {} links { 'delayimp' } diff --git a/code/components/conhost-v2/include/Textselect.hpp b/code/components/conhost-v2/include/Textselect.hpp new file mode 100644 index 0000000000..b3e6c27f87 --- /dev/null +++ b/code/components/conhost-v2/include/Textselect.hpp @@ -0,0 +1,83 @@ +// Copyright 2024 Aidan Sun and the ImGuiTextSelect contributors +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +#include + +// Manages text selection in a GUI window. +// This class only works if the window only has text, and line wrapping is not supported. +// The window should also have the "NoMove" flag set so mouse drags can be used to select text. +class TextSelect { + // Cursor position in the window. + struct CursorPos { + std::size_t x = std::string_view::npos; // X index of character + std::size_t y = std::string_view::npos; // Y index of character + + // Checks if this position is invalid. + bool isInvalid() const { + // Invalid cursor positions are indicated by std::string::npos + return x == std::string_view::npos || y == std::string_view::npos; + } + }; + + // Text selection in the window. + struct Selection { + std::size_t startX; + std::size_t startY; + std::size_t endX; + std::size_t endY; + }; + + // Selection bounds + // In a selection, the start and end positions may not be in order (the user can click and drag left/up which + // reverses start and end). + CursorPos selectStart; + CursorPos selectEnd; + + // Accessor functions to get line information + // This class only knows about line numbers so it must be provided with functions that give it text data. + std::function getLineAtIdx; // Gets the string given a line number + std::function getNumLines; // Gets the total number of lines + std::function getTextOffset; // Gets the offset of the text + + // Gets the user selection. Start and end are guaranteed to be in order. + Selection getSelection(); + + // Processes mouse down (click/drag) events. + void handleMouseDown(const ImVec2& cursorPosStart); + + // Processes scrolling events. + static void handleScrolling(); + + // Draws the text selection rectangle in the window. + void drawSelection(const ImVec2& cursorPosStart); + +public: + // Sets the text accessor functions. + // getLineAtIdx: Function taking a std::size_t (line number) and returning the string in that line + // getNumLines: Function returning a std::size_t (total number of lines of text) + // getTextOffset: Function taking a std::size_t (line number) and returning the offset of the text as a float + template + TextSelect(const T& getLineAtIdx, const U& getNumLines, const V& getTextOffset) : getLineAtIdx(getLineAtIdx), getNumLines(getNumLines), getTextOffset(getTextOffset) {} + + template + TextSelect(const T& getLineAtIdx, const U& getNumLines) : TextSelect(getLineAtIdx, getNumLines, []() { return 0.0f; }) {} + + // Checks if there is an active selection in the text. + bool hasSelection() const { + return !selectStart.isInvalid() && !selectEnd.isInvalid(); + } + + // Copies the selected text to the clipboard. + void copy(); + + // Selects all text in the window. + void selectAll(); + + // Draws the text selection rectangle and handles user input. + void update(std::size_t itemOffset = 0); +}; diff --git a/code/components/conhost-v2/src/ConsoleHostGui.cpp b/code/components/conhost-v2/src/ConsoleHostGui.cpp index 77e1b80873..0eb3e1dbf6 100644 --- a/code/components/conhost-v2/src/ConsoleHostGui.cpp +++ b/code/components/conhost-v2/src/ConsoleHostGui.cpp @@ -11,6 +11,7 @@ #define GImGui ImGui::GetCurrentContext() #include +#include #include #include @@ -100,10 +101,45 @@ static CRGBA HSLToRGB(HSL hsl) { struct FiveMConsoleBase { - boost::circular_buffer Items{ 2500 }; - boost::circular_buffer ItemKeys{ 2500 }; + const size_t BufferSize = 2500; + boost::circular_buffer Items{ BufferSize }; + boost::circular_buffer ItemKeys{ BufferSize }; - std::recursive_mutex ItemsMutex; + std::recursive_mutex ItemsMutex; + + int ItemsAdded = 0; + + virtual std::string_view GetLineAtIdx(const size_t idx) + { + if (idx >= Items.size()) return ""; + + return Items[idx]; + } + + virtual size_t GetNumLines() + { + return Items.size(); + } + + virtual float GetTextOffset(const size_t idx) + { + if (idx >= Items.size()) return 0.0f; + + const std::string& itemKey = ItemKeys[idx]; + if (itemKey.empty()) return 0.0f; + + const float textSize = ImGui::CalcTextSize(itemKey.c_str()).x; + return ImGui::GetCursorPosX() + textSize + (textSize > 0 ? 16.0f : 0.0f); + } + + FiveMConsoleBase() + : textSelect( + [this](const size_t idx) { return GetLineAtIdx(idx); }, + [this]() { return GetNumLines(); }, + [this](const size_t idx) { return GetTextOffset(idx); } + ) {} + + TextSelect textSelect; virtual void RunCommandQueue() { @@ -128,6 +164,11 @@ struct FiveMConsoleBase ItemKeys.push_back(key); Items.push_back(buf); + if (Items.size() == BufferSize) + { + ItemsAdded++; + } + OnAddLog(key, buf); } } @@ -156,7 +197,7 @@ struct FiveMConsoleBase if (strlen(key.c_str()) > 0 && strlen(item.c_str()) > 0) { - auto hue = int{ HashRageString(key.c_str()) % 360 }; + const auto hue = static_cast(HashRageString(key) % 360); auto color = HSLToRGB(HSL{ hue, 0.8f, 0.4f }); color.alpha = alpha * 255.0f; @@ -236,6 +277,10 @@ static void OpenLogFile() ShellExecuteW(NULL, L"open", fileName.c_str(), NULL, NULL, SW_SHOWNORMAL); } +static void SetAutoScroll(bool enabled); + +static std::shared_ptr> g_conAutoScroll; + #endif struct CfxBigConsole : FiveMConsoleBase @@ -243,7 +288,6 @@ struct CfxBigConsole : FiveMConsoleBase char InputBuf[1024]; bool ScrollToBottom; bool AutoScrollEnabled; - ConVar* m_conAutoScroll; ImVector History; int HistoryPos; // -1: new line, 0..History.Size-1 browsing history. ImVector Commands; @@ -263,9 +307,12 @@ struct CfxBigConsole : FiveMConsoleBase Commands.push_back("QUIT"); Commands.push_back("NETGRAPH"); Commands.push_back("STRDBG"); - - m_conAutoScroll = new ConVar("con_autoScroll", ConVar_Archive | ConVar_UserPref, true); - AutoScrollEnabled = m_conAutoScroll->GetValue(); + +#ifndef IS_FXSERVER + AutoScrollEnabled = g_conAutoScroll->GetValue(); +#else + AutoScrollEnabled = true; +#endif } virtual ~CfxBigConsole() @@ -307,24 +354,24 @@ struct CfxBigConsole : FiveMConsoleBase ImGui::SetNextWindowPos(ImVec2(ImGui::GetMainViewport()->Pos.x + 0, ImGui::GetMainViewport()->Pos.y + g_menuHeight)); ImGui::SetNextWindowSize(ImVec2(ImGui::GetIO().DisplaySize.x, #ifndef IS_FXSERVER - ImGui::GetFrameHeightWithSpacing() * 12.0f + ImGui::GetFrameHeightWithSpacing() * 12.0f #else - ImGui::GetIO().DisplaySize.y - g_menuHeight + ImGui::GetIO().DisplaySize.y - g_menuHeight #endif ), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, { 4.0f, 3.0f }); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus; - + constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings; return ImGui::Begin(title, nullptr, flags); } virtual void EndWindow() { ImGui::End(); - ImGui::PopStyleVar(); + ImGui::PopStyleVar(2); } void Draw(const char* title, bool* p_open) override @@ -332,7 +379,7 @@ struct CfxBigConsole : FiveMConsoleBase if (!PreStartWindow()) { return; - } + } if (!StartWindow(title, p_open)) { @@ -340,40 +387,10 @@ struct CfxBigConsole : FiveMConsoleBase return; } - std::unique_lock lock(ItemsMutex); - - /*ImGui::TextWrapped("This example implements a console with basic coloring, completion and history. A more elaborate implementation may want to store entries along with extra data such as timestamp, emitter, etc."); - ImGui::TextWrapped("Enter 'HELP' for help, press TAB to use text completion."); - - // TODO: display items starting from the bottom + std::unique_lock lock(ItemsMutex); - if (ImGui::SmallButton("Add Dummy Text")) { AddLog("%d some text", Items.Size); AddLog("some more text"); AddLog("display very important message here!"); } ImGui::SameLine(); - if (ImGui::SmallButton("Add Dummy Error")) AddLog("[error] something went wrong"); ImGui::SameLine(); - if (ImGui::SmallButton("Clear")) ClearLog(); ImGui::SameLine(); - if (ImGui::SmallButton("Scroll to bottom")) ScrollToBottom = true; - //static float t = 0.0f; if (ImGui::GetTime() - t > 0.02f) { t = ImGui::GetTime(); AddLog("Spam %f", t); } - - ImGui::Separator(); + ImGui::BeginChild("ScrollingRegion", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() - 8.0f), false); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0)); - static ImGuiTextFilter filter; - filter.Draw("Filter (\"incl,-excl\") (\"error\")", 180); - ImGui::PopStyleVar(); - ImGui::Separator();*/ - - ImGui::BeginChild("ScrollingRegion", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false, 0); - - // Display every line as a separate entry so we can change their color or add custom widgets. If you only want raw text you can use ImGui::TextUnformatted(log.begin(), log.end()); - // NB- if you have thousands of entries this approach may be too inefficient and may require user-side clipping to only process visible items. - // You can seek and display only the lines that are visible using the ImGuiListClipper helper, if your elements are evenly spaced and you have cheap random access to the elements. - // To use the clipper we could replace the 'for (int i = 0; i < Items.Size; i++)' loop with: - // ImGuiListClipper clipper(Items.Size); - // while (clipper.Step()) - // for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) - // However take note that you can not use this code as is if a filter is active because it breaks the 'cheap random-access' property. We would need random-access on the post-filtered list. - // A typical application wanting coarse clipping and filtering may want to pre-compute an array of indices that passed the filtering test, recomputing this array when user changes the filter, - // and appending newly elements as they are inserted. This is left as a task to the user until we can manage to improve this example code! - // If your items are of variable size you may want to implement code similar to what ImGuiListClipper does. Or split your data into fixed height items to allow random-seeking into your list. ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4, 1)); // Tighten spacing ImGuiListClipper clipper(Items.size()); while (clipper.Step()) @@ -384,17 +401,35 @@ struct CfxBigConsole : FiveMConsoleBase } } + textSelect.update(ItemsAdded); + ItemsAdded = 0; + + if (ImGui::BeginPopupContextWindow("CopyPopup")) + { + ImGui::BeginDisabled(!textSelect.hasSelection()); + if (ImGui::MenuItem("Copy", "Ctrl+C")) + { + textSelect.copy(); + } + ImGui::EndDisabled(); + + if (ImGui::MenuItem("Select all", "Ctrl+A")) + { + textSelect.selectAll(); + } + ImGui::EndPopup(); + } + if (ScrollToBottom) + { ImGui::SetScrollHereY(); + } ScrollToBottom = false; ImGui::PopStyleVar(); ImGui::EndChild(); ImGui::Separator(); - // Command-line - float w = 0.0f; - if (ImGui::BeginTable("InputTable", 2, ImGuiTableFlags_SizingStretchProp)) { // Set up columns: first column stretches, second column has fixed width @@ -411,27 +446,23 @@ struct CfxBigConsole : FiveMConsoleBase { char* input_end = InputBuf + strlen(InputBuf); while (input_end > InputBuf && input_end[-1] == ' ') + { input_end--; + } *input_end = 0; - if (InputBuf[0]) + if (InputBuf[0]) + { ExecCommand(InputBuf); + } strcpy(InputBuf, ""); } ImGui::PopItemWidth(); - -#ifndef IS_FXSERVER - ImGui::TableNextColumn(); - - static bool shouldOpenLog; - - if (shouldOpenLog) - { - OpenLogFile(); - shouldOpenLog = false; - } + ImGui::TableNextColumn(); - if (ImGui::IsItemHovered() || (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && !ImGui::IsAnyItemActive() && !ImGui::IsMouseClicked(0))) + if (ImGui::IsWindowAppearing() || !ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow) || + (ImGui::IsWindowFocused() && !ImGui::IsAnyItemActive() && !ImGui::IsMouseClicked(0)) || + ImGui::IsKeyPressed(ImGuiKey_Tab)) { ImGui::SetKeyboardFocusHere(-1); } @@ -443,19 +474,25 @@ struct CfxBigConsole : FiveMConsoleBase if (preAutoScrollValue != AutoScrollEnabled) { - m_conAutoScroll->GetHelper()->SetRawValue(AutoScrollEnabled); +#ifndef IS_FXSERVER + SetAutoScroll(AutoScrollEnabled); +#else if (AutoScrollEnabled) { // Force scroll to bottom on enabling autoscroll ScrollToBottom = true; } - } - +#endif + } + +#ifndef IS_FXSERVER ImGui::SameLine(); if (ImGui::Button("Open log")) { - shouldOpenLog = true; - } + OpenLogFile(); + } + + ImGui::CaptureKeyboardFromApp(true); #endif ImGui::EndTable(); @@ -749,7 +786,7 @@ struct MiniConsole : CfxBigConsole ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4, 4)); // Tighten spacing - if (ImGui::Begin("MiniCon", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoFocusOnAppearing)) + if (ImGui::Begin("MiniCon", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoInputs)) { auto t = msec(); @@ -918,6 +955,31 @@ void DrawWinConsole(bool* pOpen) g_consoles[2]->Draw("WinConsole", pOpen); } +#ifndef IS_FXSERVER +static void SetAutoScroll(const bool enabled) +{ + std::unique_lock _(g_consolesMutex); + + if (auto* bigConsole = dynamic_cast(g_consoles[0].get())) { + bigConsole->AutoScrollEnabled = enabled; + if (enabled) + { + bigConsole->ScrollToBottom = true; + } + } + + if (auto* winConsole = dynamic_cast(g_consoles[2].get())) { + winConsole->AutoScrollEnabled = enabled; + if (enabled) + { + winConsole->ScrollToBottom = true; + } + } + + g_conAutoScroll->GetHelper()->SetRawValue(enabled); +} +#endif + #include void SendPrintMessage(const std::string& channel, const std::string& message) @@ -1025,6 +1087,10 @@ static InitFunction initFunction([]() static InitFunction initFunctionCon([]() { +#ifndef IS_FXSERVER + g_conAutoScroll = std::make_shared>("con_autoScroll", ConVar_Archive | ConVar_UserPref, true); +#endif + console::GetDefaultContext()->GetCommandManager()->AccessDeniedEvent.Connect([](std::string_view commandName) { if (!IsNonProduction()) diff --git a/code/components/conhost-v2/src/ConsoleHostImpl.cpp b/code/components/conhost-v2/src/ConsoleHostImpl.cpp index be9550d9ac..ff89c0115b 100644 --- a/code/components/conhost-v2/src/ConsoleHostImpl.cpp +++ b/code/components/conhost-v2/src/ConsoleHostImpl.cpp @@ -478,14 +478,14 @@ void OnConsoleFrameDraw(int width, int height, bool usedSharedD3D11) } } + DrawMiniConsole(); + if (g_consoleFlag) { DrawDevGui(); DrawConsole(); } - DrawMiniConsole(); - if (g_winConsole) { DrawWinConsole(&g_winConsole); @@ -659,6 +659,7 @@ static HookFunction initFunction([]() io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; io.ConfigDockingWithShift = true; io.ConfigWindowsResizeFromEdges = true; + io.ConfigWindowsMoveFromTitleBarOnly = true; io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; diff --git a/code/components/conhost-v2/src/DrawFPS.cpp b/code/components/conhost-v2/src/DrawFPS.cpp index 5be406b395..b368ad1745 100644 --- a/code/components/conhost-v2/src/DrawFPS.cpp +++ b/code/components/conhost-v2/src/DrawFPS.cpp @@ -36,7 +36,7 @@ static InitFunction initFunction([]() ImGui::SetNextWindowPos(ImVec2(ImGui::GetMainViewport()->Pos.x + 10, ImGui::GetMainViewport()->Pos.y + 10), 0, ImVec2(0.0f, 0.0f)); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - if (ImGui::Begin("DrawFps", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize)) + if (ImGui::Begin("DrawFps", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoFocusOnAppearing)) { if (fpsTracker.CanGet()) { diff --git a/code/components/conhost-v2/src/Textselect.cpp b/code/components/conhost-v2/src/Textselect.cpp new file mode 100644 index 0000000000..966ef78242 --- /dev/null +++ b/code/components/conhost-v2/src/Textselect.cpp @@ -0,0 +1,338 @@ +// Copyright 2024 Aidan Sun and the ImGuiTextSelect contributors +// SPDX-License-Identifier: MIT + +#include "StdInc.h" + +#include +#include +#include +#include +#include + +#define IMGUI_DEFINE_MATH_OPERATORS +#define GImGui ImGui::GetCurrentContext() +#include +#include +#include + +#include + +#include "Textselect.hpp" + +// Simple word boundary detection, accounts for Latin Unicode blocks only. +static bool isBoundary(char32_t c) { + using Range = std::array; + std::array ranges{ + Range{ 0x20, 0x2F }, + Range{ 0x3A, 0x40 }, + Range{ 0x5B, 0x60 }, + Range{ 0x7B, 0xBF } + }; + + return ranges::find_if(ranges, [c](const Range& r) { return c >= r[0] && c <= r[1]; }) != ranges.end(); +} + +// Gets the number of UTF-8 characters (not bytes) in a string. +static std::size_t utf8Length(std::string_view s) { + return utf8::unchecked::distance(s.begin(), s.end()); +} + +// Gets the display width of a substring. +static float substringSizeX(std::string_view s, const std::size_t start, const std::size_t length = std::string_view::npos) { + // Convert char-based start and length into byte-based iterators + auto stringStart = s.begin(); + utf8::unchecked::advance(stringStart, start); + + auto stringEnd = stringStart; + if (length == std::string_view::npos) stringEnd = s.end(); + else utf8::unchecked::advance(stringEnd, std::min(utf8Length(s), length)); + + // Dereferencing std::string_view::end() may be undefined behavior in some compilers, + // because of that, we need to get the pointer value manually if stringEnd == s.end(). + const char* endPtr = stringEnd == s.end() ? s.data() + s.size() : &*stringEnd; + + // Calculate text size between start and end + return ImGui::CalcTextSize(&*stringStart, endPtr).x; +} + +// Gets the index of the character the mouse cursor is over. +static std::size_t getCharIndex(const std::string_view s, const float cursorPosX, const std::size_t start, const std::size_t end) { + // Ignore cursor position when it is invalid + if (cursorPosX < 0) return 0; + + // Check for exit conditions + if (s.empty()) return 0; + if (end < start) return utf8Length(s); + + // Midpoint of given string range + const std::size_t midIdx = start + (end - start) / 2; + + // Display width of the entire string up to the midpoint, gives the x-position where the (midIdx + 1)th char starts + const float widthToMid = substringSizeX(s, 0, midIdx + 1); + + // Same as above but exclusive, gives the x-position where the (midIdx)th char starts + + // Perform a recursive binary search to find the correct index + // If the mouse position is between the (midIdx)th and (midIdx + 1)th character positions, the search ends + if (cursorPosX < substringSizeX(s, 0, midIdx)) return getCharIndex(s, cursorPosX, start, midIdx - 1); + if (cursorPosX > widthToMid) return getCharIndex(s, cursorPosX, midIdx + 1, end); + return midIdx; +} + +// Wrapper for getCharIndex providing the initial bounds. +static std::size_t getCharIndex(const std::string_view s, const float cursorPosX) { + return getCharIndex(s, cursorPosX, 0, utf8Length(s)); +} + +// Gets the scroll delta for the given cursor position and window bounds. +static float getScrollDelta(const float v, const float min, const float max) { + const float deltaScale = 10.0f * ImGui::GetIO().DeltaTime; + constexpr float maxDelta = 100.0f; + + if (v < min) return std::max(-(min - v), -maxDelta) * deltaScale; + else if (v > max) return std::min(v - max, maxDelta) * deltaScale; + + return 0.0f; +} + +TextSelect::Selection TextSelect::getSelection() { + // Start and end may be out of order (ordering is based on Y position) + const bool startBeforeEnd = selectStart.y < selectEnd.y || (selectStart.y == selectEnd.y && selectStart.x < selectEnd.x); + + // Reorder X points if necessary + const std::size_t startX = startBeforeEnd ? selectStart.x : selectEnd.x; + const std::size_t endX = startBeforeEnd ? selectEnd.x : selectStart.x; + + // Get min and max Y positions for start and end + const std::size_t startY = std::min(selectStart.y, selectEnd.y); + const std::size_t endY = std::max(selectStart.y, selectEnd.y); + + if (const std::size_t numLines = getNumLines(); startY >= numLines || endY >= numLines) + { + selectStart = { std::string_view::npos, std::string_view::npos }; + selectEnd = { std::string_view::npos, std::string_view::npos }; + return { 0, 0, 0, 0 }; + } + + return { startX, startY, endX, endY }; +} + +void TextSelect::handleMouseDown(const ImVec2& cursorPosStart) { + const float textHeight = ImGui::GetTextLineHeightWithSpacing(); + const ImVec2 mousePos = ImGui::GetMousePos() - cursorPosStart; + + // Get Y position of mouse cursor, in terms of line number (capped to the index of the last line) + const std::size_t y = std::min(static_cast(std::floor(mousePos.y / textHeight)), getNumLines() - 1); + + std::string_view currentLine = getLineAtIdx(y); + const float offset = getTextOffset(y); + const std::size_t x = getCharIndex(currentLine, mousePos.x - offset); + + // Get mouse click count and determine action + if (const int mouseClicks = ImGui::GetMouseClickedCount(ImGuiMouseButton_Left); mouseClicks > 0) { + const ImGuiIO& io = ImGui::GetIO(); + if (mouseClicks % 3 == 0) { + // Triple click - select line + selectStart = { 0, y }; + selectEnd = { utf8Length(currentLine), y }; + } else if (mouseClicks % 2 == 0) { + // Double click - select word + // Initialize start and end iterators to current cursor position + utf8::unchecked::iterator startIt{ currentLine.data() }; + utf8::unchecked::iterator endIt{ currentLine.data() }; + for (std::size_t i = 0; i < x; i++) { + ++startIt; + ++endIt; + } + + bool isCurrentBoundary = isBoundary(*startIt); + + // Scan to left until a word boundary is reached + for (std::size_t startInv = 0; startInv <= x; startInv++) { + if (isBoundary(*startIt) != isCurrentBoundary) break; + selectStart = { x - startInv, y }; + --startIt; + } + + // Scan to right until a word boundary is reached + for (std::size_t end = x; end <= utf8Length(currentLine); end++) { + selectEnd = { end, y }; + if (isBoundary(*endIt) != isCurrentBoundary) break; + ++endIt; + } + } else if (io.KeyShift) { + // Single click with shift - select text from start to click + // The selection starts from the beginning if no start position exists + if (selectStart.isInvalid()) selectStart = { 0, 0 }; + + selectEnd = { x, y }; + } else { + // Single click - set start position, invalidate end position + selectStart = { x, y }; + selectEnd = { std::string_view::npos, std::string_view::npos }; + } + } else if (ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { + // Mouse dragging - set end position + selectEnd = { x, y }; + } +} + +void TextSelect::handleScrolling() { + // Window boundaries + const ImVec2 windowMin = ImGui::GetWindowPos(); + const ImVec2 windowMax = windowMin + ImGui::GetWindowSize(); + + // Get current and active window information from Dear ImGui state + ImGuiWindow* currentWindow = ImGui::GetCurrentWindow(); + const ImGuiWindow* activeWindow = GImGui->ActiveIdWindow; + + const ImGuiID scrollXID = ImGui::GetWindowScrollbarID(currentWindow, ImGuiAxis_X); + const ImGuiID scrollYID = ImGui::GetWindowScrollbarID(currentWindow, ImGuiAxis_Y); + const ImGuiID activeID = ImGui::GetActiveID(); + const bool scrollbarsActive = activeID == scrollXID || activeID == scrollYID; + + // Do not handle scrolling if: + // - There is no active window + // - The current window is not active + // - The user is scrolling via the scrollbars + if (activeWindow == nullptr || activeWindow->ID != currentWindow->ID || scrollbarsActive) return; + + // Get scroll deltas from mouse position + const ImVec2 mousePos = ImGui::GetMousePos(); + const float scrollXDelta = getScrollDelta(mousePos.x, windowMin.x, windowMax.x); + const float scrollYDelta = getScrollDelta(mousePos.y, windowMin.y, windowMax.y); + + // If there is a nonzero delta, scroll in that direction + if (std::abs(scrollXDelta) > 0.0f) ImGui::SetScrollX(ImGui::GetScrollX() + scrollXDelta); + if (std::abs(scrollYDelta) > 0.0f) ImGui::SetScrollY(ImGui::GetScrollY() + scrollYDelta); +} + +void TextSelect::drawSelection(const ImVec2& cursorPosStart) { + if (!hasSelection()) return; + + // Start and end positions + auto [startX, startY, endX, endY] = getSelection(); + + // Add a rectangle to the draw list for each line contained in the selection + for (std::size_t i = startY; i <= endY; i++) { + const std::string_view line = getLineAtIdx(i); + const float offset = getTextOffset(i); + + // Display sizes + // The width of the space character is used for the width of newlines. + const float newlineWidth = ImGui::CalcTextSize(" ").x; + const float textHeight = ImGui::GetTextLineHeightWithSpacing(); + + // The first and last rectangles should only extend to the selection boundaries + // The middle rectangles (if any) enclose the entire line + some extra width for the newline. + const float minX = (i == startY ? substringSizeX(line, 0, startX) : 0) + offset; + const float maxX = (i == endY ? substringSizeX(line, 0, endX) : substringSizeX(line, 0) + newlineWidth) + offset; + + // Rectangle height equals text height + const float minY = static_cast(i) * textHeight; + const float maxY = static_cast(i + 1) * textHeight; + + // Get rectangle corner points offset from the cursor's start position in the window + ImVec2 rectMin = cursorPosStart + ImVec2{ minX, minY }; + ImVec2 rectMax = cursorPosStart + ImVec2{ maxX, maxY }; + + // Draw the rectangle + const ImU32 color = ImGui::GetColorU32(ImGuiCol_TextSelectedBg); + ImGui::GetWindowDrawList()->AddRectFilled(rectMin, rectMax, color); + } +} + +void TextSelect::copy() { + if (!hasSelection()) return; + + auto [startX, startY, endX, endY] = getSelection(); + + // Collect selected text in a single string + std::string selectedText; + + for (std::size_t i = startY; i <= endY; i++) { + // Similar logic to drawing selections + const std::size_t subStart = i == startY ? startX : 0; + std::string_view line = getLineAtIdx(i); + + auto stringStart = line.begin(); + utf8::unchecked::advance(stringStart, subStart); + + auto stringEnd = stringStart; + if (i == endY) utf8::unchecked::advance(stringEnd, endX - subStart); + else stringEnd = line.end(); + + std::string_view lineToAdd = line.substr(stringStart - line.begin(), stringEnd - stringStart); + selectedText += lineToAdd; + + // If lines before the last line don't already end with newlines, add them in + if (!(lineToAdd.empty() && lineToAdd.back() == '\n') && i < endY) selectedText += '\n'; + + } + + if (!selectedText.empty()) + { + ImGui::SetClipboardText(selectedText.c_str()); + } +} + +void TextSelect::selectAll() { + const std::size_t lastLineIdx = getNumLines() - 1; + const std::string_view lastLine = getLineAtIdx(lastLineIdx); + + // Set the selection range from the beginning to the end of the last line + selectStart = { 0, 0 }; + selectEnd = { utf8Length(lastLine), lastLineIdx }; +} + +void TextSelect::update(const std::size_t itemOffset) { + if (itemOffset > 0) + { + if (selectStart.y >= itemOffset) + { + selectStart.y -= itemOffset; + } + else + { + selectStart.y = 0; + } + + if (selectEnd.y >= itemOffset) + { + selectEnd.y -= itemOffset; + } + else + { + selectStart = { std::string_view::npos, std::string_view::npos }; + selectEnd = { std::string_view::npos, std::string_view::npos }; + } + } + + // ImGui::GetCursorStartPos() is in window coordinates so it is added to the window position + const ImVec2 cursorPosStart = ImGui::GetWindowPos() + ImGui::GetCursorStartPos(); + + // Switch cursors if the window is hovered + const bool hovered = ImGui::IsWindowHovered(); + if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_TextInput); + + // Handle mouse events + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + if (hovered) handleMouseDown(cursorPosStart); + else handleScrolling(); + } + + drawSelection(cursorPosStart); + + const ImGuiIO& io = ImGui::GetIO(); + + // Keyboard shortcuts + if (ImGui::IsWindowFocused()) { + ImGui::CaptureKeyboardFromApp(true); + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A)) { + selectAll(); + } + else if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) { + copy(); + } + } +}