diff --git a/docs/CHANGELOG.txt b/docs/CHANGELOG.txt index 9cfe4cef7..1e2b4f690 100644 --- a/docs/CHANGELOG.txt +++ b/docs/CHANGELOG.txt @@ -100,6 +100,8 @@ Other Changes: with the font, position, clip rectangle and string of every text run before its glyphs are emitted, and `ImGuiItemFlags_NoTextSelect` to let such a hook skip items. Enables text selection/copy of any displayed text without changes to widget code. +- Misc: added misc/text_select/ extension using the hook above: mouse selection + (click/drag, word, line, block), highlight, and Ctrl+C/Cmd+C copy of any drawn text. - ImVec2: added a check for cases where a custom `IM_VEC2_CLASS_EXTRA` macro adds an extra `operator=` to ImVec2. This isn't supported but should not be needed as you should have a constructor to convert from your type to ImVec2. diff --git a/misc/README.txt b/misc/README.txt index 58c00ee8c..1a53ecf79 100644 --- a/misc/README.txt +++ b/misc/README.txt @@ -16,6 +16,10 @@ misc/freetype/ Font atlas builder/rasterizer using FreeType instead of stb_truetype. Benefit from better FreeType rasterization, in particular for small fonts. +misc/text_select/ + Text selection extension: select and copy any text Dear ImGui draws, by screen position, + without changes to widget code. Relies on ImDrawListSharedData::TextHook. + misc/single_file/ Single-file header stub. We use this to validate compiling all *.cpp files in a same compilation unit. diff --git a/misc/text_select/imgui_text_select.cpp b/misc/text_select/imgui_text_select.cpp new file mode 100644 index 000000000..a3b1025bf --- /dev/null +++ b/misc/text_select/imgui_text_select.cpp @@ -0,0 +1,1405 @@ +// dear imgui: text selection extension +// See imgui_text_select.h + +#include "imgui_text_select.h" +#include "imgui_internal.h" +#include + +struct ImGuiTextSelectFragment +{ + float Y0, Y1, X0, X1; // In the container's local coordinates + ImVec2 ScreenPos; + float SpaceWidth; + const char* SoftWrapJoin; // Starts a line produced by word wrapping, not by '\n': copied after the previous line (static string). NULL otherwise + bool Visible; + int Order; // Submission order, to keep sorting stable + int TextOffset; + int TextLen; +}; + +struct ImGuiTextSelectRegion +{ + ImGuiWindow* Window; + float StartY; +}; + +struct ImGuiTextSelectDisabledRect +{ + ImGuiWindow* RootWindow; + ImRect Rect; +}; + +// One end of a selection: a point (Begin == End) or a word on one line, in local coordinates (TextSelectScreenToLocal()). +// The anchor's window and table are the container: both ends and every selected glyph stay inside it. +struct ImGuiTextSelectRange +{ + ImGuiID WindowId = 0; + ImVec2 Begin, End; + ImGuiID TableId = 0; + bool MenuBar = false; + ImGuiWindow* Window = NULL; // Resolved each frame +}; + +// A glyph edge to draw a caret or bookend at, at the end of the frame once every text run has been seen. +struct ImGuiTextSelectMark +{ + bool Valid = false; + float X0, X1, Y0, Y1; + ImDrawList* DrawList; + ImVec4 ClipRect; +}; + +struct ImGuiTextSelectState +{ + ImGuiContext* Ctx = NULL; + bool Enabled = true; + ImGuiTextSelectCopyFeedbackCallback CopyFeedback = NULL; + void* CopyFeedbackUserData = NULL; + double FlashStartTime = -1.0; // Built-in copy feedback: the highlight turns opaque and fades back + bool Delimiters[128] = {}; + + ImGuiTextSelectRange Anchor; + ImGuiTextSelectRange Focus; + bool Block = false; + int DragUnit = 0; // 0: point, 1: word (double-click), 2: line (triple-click) + bool Dragging = false; + ImGuiID DragId = 0; + ImVec2 ScrollAccum; + ImVec2 ScrollTime; + + // Resolved at NewFrame + ImGuiWindow* RootWindow = NULL; + float LineTolerance = 0.0f; + bool Active = false; // Non-empty selection + bool WantCaret = false; // Anchor without a selection, in the focused root window + bool Copying = false; + bool ItemUnclipByLogBackup = false; + bool BoxSelectUnclip = false; + ImGuiBoxSelectState BoxSelectBackup; + bool ClickPending = false; + bool ClickOnExcludedText = false; + bool AltClick = false; // Alt+Click: taken before widgets see it, so it always selects + bool AltMouseLocked = false; + + // Word/line lookup under the mouse, on click and drag frames + bool Probe = false; + ImGuiWindow* ProbeWindow = NULL; + ImGuiTextSelectRange ProbeContainer; + ImVec2 ProbePos; + bool ProbeLineFound = false; + float ProbeLineY0 = 0.0f, ProbeLineY1 = 0.0f; + bool ProbeWordFound = false; + float ProbeWordX0 = 0.0f, ProbeWordX1 = 0.0f; + bool ProbeLineExtentFound = false; // Non-blank glyphs on the mouse's line, across all runs in the window + float ProbeLineX0 = 0.0f, ProbeLineX1 = 0.0f; + float ProbeTextDistEm = FLT_MAX; + bool ProbeGlyphHit = false; // The mouse is over a selectable glyph + ImGuiID ProbeGlyphItemId = 0; // Last item when that glyph was drawn, e.g. the Selectable owning its label + bool ProbeGlyphStealable = false; // That glyph belongs to a TreeNode or row-spanning Selectable: a drag on it may select + + // A click on a stealable widget's text: a drag past the threshold turns it into a selection, unless the widget used the drag + bool StealPending = false; + ImGuiID StealItemId = 0; + bool StealExtend = false; + ImGuiTextSelectRange StealRange; + + ImGuiTextSelectMark Caret; + float CaretDist = FLT_MAX; + ImGuiTextSelectMark First; + ImGuiTextSelectMark Last; + + ImVector Regions; + ImVector DisabledRects; + ImVector PrevDisabledRects; + + ImGuiWindow* NextCopyTextWindow = NULL; + bool CopyRequested = false; // TextSelectSelectAndCopy(): copy on the next frame without a key press + bool SelectionSetByCall = false; // TextSelectSelectAndCopy() this frame: the widget click that called it keeps the selection + ImGuiTextBuffer RequestedCopyText; // Replaces the copied text when not empty + ImGuiWindow* SoftWrapWindow = NULL; + const char* SoftWrapJoin = " "; + ImGuiTextBuffer NextCopyText; + + ImVector Fragments; + ImGuiTextBuffer FragmentText; + ImGuiTextBuffer CopyText; + ImVector CopyLineOffsets; + ImVector CopyLines; +}; + +static void TextSelectHook(ImDrawList* draw_list, ImFont* font, float font_size, const ImVec2& pos, const ImVec4& clip_rect, const char* text_begin, const char* text_end, float wrap_width, void* user_data); + +static ImGuiTextSelectState* TextSelectGetState() +{ + ImGuiContext* ctx = ImGui::GetCurrentContext(); + if (ctx == NULL || ctx->DrawListSharedData.TextHook != TextSelectHook) + return NULL; + return (ImGuiTextSelectState*)ctx->DrawListSharedData.TextHookUserData; +} + +static void TextSelectSetDelimiters(ImGuiTextSelectState* st, const char* delimiters) +{ + memset(st->Delimiters, 0, sizeof(st->Delimiters)); + for (const char* p = delimiters; *p; p++) + if ((unsigned char)*p < 128) + st->Delimiters[(unsigned char)*p] = true; +} + +// 0: blank, 1: word character, 2: delimiter. A run of the same class is one word. +static int TextSelectCharClass(const ImGuiTextSelectState* st, unsigned int c) +{ + if (c <= ' ') + return 0; + return (c < 128 && st->Delimiters[c]) ? 2 : 1; +} + +static void TextSelectClearState(ImGuiTextSelectState* st) +{ + st->Anchor = ImGuiTextSelectRange(); + st->Focus = ImGuiTextSelectRange(); + st->Block = false; + st->RootWindow = NULL; + st->Active = st->WantCaret = false; +} + +// Local = screen - Pos + Scroll, without the scroll in the menu bar and on an axis inside a table's frozen rows or columns +// (the window's DecoInnerSize): frozen rows then come before every scrolling row, frozen columns before every scrolling column. +static ImVec2 TextSelectScreenToLocal(const ImGuiWindow* window, const ImVec2& p, bool menu_bar) +{ + const float x = p.x - window->Pos.x, y = p.y - window->Pos.y; + if (menu_bar) + return ImVec2(x, y); + return ImVec2(x >= window->DecoInnerSizeX1 ? x + window->Scroll.x : x, y >= window->DecoInnerSizeY1 ? y + window->Scroll.y : y); +} + +static ImVec2 TextSelectLocalToScreen(const ImGuiWindow* window, const ImVec2& l, bool frozen_x, bool frozen_y) +{ + return ImVec2(l.x + window->Pos.x - (frozen_x ? 0.0f : window->Scroll.x), l.y + window->Pos.y - (frozen_y ? 0.0f : window->Scroll.y)); +} + +static ImVec2 TextSelectLocalToScreen(const ImGuiWindow* window, const ImVec2& l, bool menu_bar) +{ + return TextSelectLocalToScreen(window, l, menu_bar || l.x < window->DecoInnerSizeX1, menu_bar || l.y < window->DecoInnerSizeY1); +} + +static bool TextSelectInMenuBar(const ImGuiWindow* window, const ImVec2& pos) +{ + return (window->Flags & ImGuiWindowFlags_MenuBar) != 0 && window->MenuBarRect().Contains(pos); +} + +static void TextSelectFrozenText(ImGuiContext& g, const ImGuiWindow* window, bool* frozen_x, bool* frozen_y) +{ + const ImGuiTable* table = g.CurrentTable; + *frozen_x = *frozen_y = window->DC.MenuBarAppending; + if (*frozen_x || table == NULL || table->InnerWindow != window) + return; + *frozen_y = !table->IsUnfrozenRows; + *frozen_x = table->CurrentColumn >= 0 && table->Columns[table->CurrentColumn].DisplayOrder < table->FreezeColumnsCount; +} + +static bool TextSelectInDisabledRect(const ImVector& rects, const ImGuiWindow* hovered, const ImVec2& pos) +{ + if (hovered == NULL) + return false; + for (const ImGuiTextSelectDisabledRect& r : rects) + if (r.RootWindow == hovered->RootWindow && r.Rect.Contains(pos)) + return true; + return false; +} + +static bool TextSelectOnTitleBar(const ImGuiWindow* window, const ImVec2& pos) +{ + const ImGuiWindow* root = window->RootWindow; + return (root->Flags & ImGuiWindowFlags_NoTitleBar) == 0 && root->TitleBarRect().Contains(pos); +} + +// Reading order. Points within LineTolerance vertically are on the same line. +static bool TextSelectPointLess(const ImGuiTextSelectState* st, const ImVec2& a, const ImVec2& b) +{ + if (ImFabs(a.y - b.y) < st->LineTolerance) + return a.x < b.x; + return a.y < b.y; +} + +// In local coordinates +static void TextSelectGetRange(const ImGuiTextSelectState* st, ImVec2* out_begin, ImVec2* out_end) +{ + const ImVec2 a0 = st->Anchor.Begin, a1 = st->Anchor.End, f0 = st->Focus.Begin, f1 = st->Focus.End; + if (st->Block) + { + *out_begin = ImMin(ImMin(a0, a1), ImMin(f0, f1)); + *out_end = ImMax(ImMax(a0, a1), ImMax(f0, f1)); + return; + } + *out_begin = TextSelectPointLess(st, f0, a0) ? f0 : a0; + *out_end = TextSelectPointLess(st, a1, f1) ? f1 : a1; +} + +// Text in a frozen row or column does not follow the scroll, so it sees the range at a different screen position +static void TextSelectGetScreenRange(const ImGuiTextSelectState* st, const ImGuiWindow* window, ImVec2* out_begin, ImVec2* out_end) +{ + ImVec2 begin, end; + TextSelectGetRange(st, &begin, &end); + const ImGuiWindow* container = st->Anchor.Window; + if (window != container) + { + *out_begin = TextSelectLocalToScreen(container, begin, st->Anchor.MenuBar); + *out_end = TextSelectLocalToScreen(container, end, st->Anchor.MenuBar); + return; + } + bool frozen_x, frozen_y; + TextSelectFrozenText(*st->Ctx, window, &frozen_x, &frozen_y); + *out_begin = TextSelectLocalToScreen(container, begin, frozen_x, frozen_y); + *out_end = TextSelectLocalToScreen(container, end, frozen_x, frozen_y); +} + +static ImVec2 TextSelectToLocalOffset(const ImGuiTextSelectState* st, const ImGuiWindow* window, const ImVec2& pos) +{ + const ImGuiWindow* container = st->Anchor.Window; + if (window != container) + { + const ImVec2 local = TextSelectScreenToLocal(container, pos, st->Anchor.MenuBar); + return ImVec2(local.x - pos.x, local.y - pos.y); + } + bool frozen_x, frozen_y; + TextSelectFrozenText(*st->Ctx, window, &frozen_x, &frozen_y); + return ImVec2((frozen_x ? 0.0f : window->Scroll.x) - window->Pos.x, (frozen_y ? 0.0f : window->Scroll.y) - window->Pos.y); +} + +static ImGuiTable* TextSelectFindTableAt(ImGuiContext& g, const ImGuiWindow* window, const ImVec2& pos) +{ + ImGuiTable* best = NULL; + for (int n = 0; n < g.Tables.GetMapSize(); n++) + if (ImGuiTable* table = g.Tables.TryGetMapData(n)) + if (table->InnerWindow == window && table->LastFrameActive == g.FrameCount && table->OuterRect.Contains(pos) && (best == NULL || table->OuterRect.GetArea() < best->OuterRect.GetArea())) + best = table; + return best; +} + +static void TextSelectSetContainerTable(ImGuiContext& g, ImGuiTextSelectRange* r, const ImVec2& pos) +{ + ImGuiTable* table = TextSelectFindTableAt(g, r->Window, pos); + r->TableId = table != NULL ? table->ID : 0; +} + +static ImRect TextSelectContainerRect(const ImGuiTextSelectRange& container) +{ + if (container.MenuBar) + return container.Window->MenuBarRect(); + ImRect r = container.Window->InnerClipRect; + const ImGuiTable* table = container.TableId != 0 ? ImGui::TableFindByID(container.TableId) : NULL; + if (table == NULL) + return r; + r.ClipWithFull(table->OuterRect); + return r; +} + +static ImVec2 TextSelectClampToContainer(const ImGuiTextSelectRange& container, const ImVec2& pos) +{ + const ImRect r = TextSelectContainerRect(container); + return ImVec2(ImClamp(pos.x, r.Min.x, ImMax(r.Min.x, r.Max.x - 1.0f)), ImClamp(pos.y, r.Min.y, ImMax(r.Min.y, r.Max.y - 1.0f))); +} + +static bool TextSelectInContainer(ImGuiContext& g, const ImGuiTextSelectRange& container, ImGuiWindow* window) +{ + if (container.Window == NULL || !ImGui::IsWindowChildOf(window, container.Window, false, false)) + return false; + if (window == container.Window && window->DC.MenuBarAppending != container.MenuBar) + return false; + const ImGuiTable* current = g.CurrentTable; + if (current != NULL && current->InnerWindow == window && current->CurrentColumn >= 0 && !current->Columns[current->CurrentColumn].IsEnabled) + return false; + if (container.TableId == 0) + return true; + for (int n = g.TablesTempDataStacked - 1; n >= 0; n--) + { + const ImGuiTable* table = g.Tables.GetByIndex(g.TablesTempData[n].TableIndex); + if (table->ID == container.TableId) + return true; + } + return false; +} + +// A glyph is selected when it lies after the begin point and before the end point in reading order. +// The line containing a point (y0 <= point.y < y1) compares the glyph center against the point's x. +// Child windows inside the container take part only when their rectangle overlaps the rectangle spanned by the two +// selection ends, so a selection inside one of two side-by-side children leaves the other one out. +static bool TextSelectChildrenOverlap(const ImGuiWindow* window, const ImGuiWindow* container, const ImVec2& sel_begin, const ImVec2& sel_end) +{ + const ImRect sel(ImMin(sel_begin.x, sel_end.x) - 1.0f, ImMin(sel_begin.y, sel_end.y) - 1.0f, ImMax(sel_begin.x, sel_end.x) + 1.0f, ImMax(sel_begin.y, sel_end.y) + 1.0f); + for (const ImGuiWindow* w = window; w != NULL && w != container; w = w->ParentWindow) + if (!w->OuterRectClipped.Overlaps(sel)) + return false; + return true; +} + +static bool TextSelectIsSelected(bool block, const ImVec2& sel_begin, const ImVec2& sel_end, float center_x, float y0, float y1) +{ + if (block) + return center_x >= sel_begin.x && center_x < sel_end.x && y1 > sel_begin.y && y0 <= sel_end.y; + const bool after_begin = (y1 <= sel_begin.y) ? false : (y0 > sel_begin.y || center_x >= sel_begin.x); + const bool before_end = (y0 > sel_end.y) ? false : (y1 <= sel_end.y || center_x < sel_end.x); + return after_begin && before_end; +} + +static ImU32 TextSelectHighlightColor(ImGuiTextSelectState* st) +{ + ImGuiContext& g = *st->Ctx; + ImVec4 col = g.Style.Colors[ImGuiCol_TextSelectedBg]; + if (st->FlashStartTime >= 0.0) + { + const float t = (float)((g.Time - st->FlashStartTime) / IMGUI_TEXT_SELECT_FLASH_DURATION); + if (t >= 1.0f) + st->FlashStartTime = -1.0; + else + col.w = ImLerp(1.0f, col.w, ImMax(t, 0.0f)); + } + col.w *= g.Style.Alpha; + return ImGui::ColorConvertFloat4ToU32(col); +} + +static void TextSelectSetMark(ImGuiTextSelectMark* mark, ImDrawList* draw_list, const ImVec4& clip, float x0, float x1, float y0, float y1) +{ + mark->Valid = true; + mark->X0 = x0; mark->X1 = x1; mark->Y0 = y0; mark->Y1 = y1; + mark->DrawList = draw_list; + mark->ClipRect = clip; +} + +static void TextSelectEmit(ImGuiTextSelectState* st, ImDrawList* draw_list, const ImVec4& clip, const char* text, const char* text_end, float x0, float x1, float y0, float y1, float space_width, const char* soft_wrap_join, const ImVec2& to_local) +{ + const ImVec2 r_min(ImMax(x0, clip.x), ImMax(y0, clip.y)); + const ImVec2 r_max(ImMin(x1, clip.z), ImMin(y1, clip.w)); + const bool visible = r_min.x < r_max.x && r_min.y < r_max.y; + if (visible) + { + draw_list->AddRectFilled(r_min, r_max, TextSelectHighlightColor(st)); + const ImGuiTextSelectMark& first = st->First; + if (!first.Valid || y1 <= first.Y0 || (y0 < first.Y1 && x0 < first.X0)) + TextSelectSetMark(&st->First, draw_list, clip, x0, x1, y0, y1); + const ImGuiTextSelectMark& last = st->Last; + if (!last.Valid || y0 >= last.Y1 || (y1 > last.Y0 && x1 > last.X1)) + TextSelectSetMark(&st->Last, draw_list, clip, x0, x1, y0, y1); + } + if (!st->Copying || text == NULL) + return; + ImGuiTextSelectFragment f; + f.Y0 = y0 + to_local.y; f.Y1 = y1 + to_local.y; f.X0 = x0 + to_local.x; f.X1 = x1 + to_local.x; + f.ScreenPos = ImVec2(x0, y0); + f.SpaceWidth = space_width; + f.SoftWrapJoin = soft_wrap_join; + f.Visible = visible; + f.Order = st->Fragments.Size; + f.TextOffset = st->FragmentText.size(); + f.TextLen = (int)(text_end - text); + st->FragmentText.append(text, text_end); + st->Fragments.push_back(f); +} + +static void TextSelectNoteExcludedClick(ImGuiTextSelectState* st, ImFont* font, float font_size, const ImVec2& pos, const ImVec4& clip, const char* text_begin, const char* text_end, float wrap_width) +{ + ImGuiContext& g = *st->Ctx; + if (!st->ClickPending || st->ClickOnExcludedText || g.CurrentWindow == NULL || g.CurrentWindow != g.HoveredWindow) + return; + const ImVec2 size = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text_begin, text_end); + ImRect bb(pos, ImVec2(pos.x + size.x, pos.y + size.y)); + bb.ClipWith(ImRect(clip.x, clip.y, clip.z, clip.w)); + if (bb.Contains(g.IO.MousePos)) + st->ClickOnExcludedText = true; +} + +// Lays the run out as ImFont::RenderText() does, visiting only the lines one of the three tasks needs: +// highlight/copy (lines inside the selection), caret (the anchor's line), probe (the mouse's line). +static void TextSelectHook(ImDrawList* draw_list, ImFont* font, float font_size, const ImVec2& pos, const ImVec4& clip, const char* text_begin, const char* text_end, float wrap_width, void* user_data) +{ + ImGuiTextSelectState* st = (ImGuiTextSelectState*)user_data; + ImGuiContext& g = *st->Ctx; + ImGuiWindow* window = g.CurrentWindow; + const bool has_copy_text = st->NextCopyTextWindow != NULL && st->NextCopyTextWindow == window; + if (has_copy_text) + st->NextCopyTextWindow = NULL; + const char* soft_wrap_first = st->SoftWrapWindow != NULL && st->SoftWrapWindow == window ? st->SoftWrapJoin : NULL; + if (soft_wrap_first != NULL) + st->SoftWrapWindow = NULL; + if (window == NULL || draw_list != window->DrawList || (TextSelectOnTitleBar(window, pos) && clip.y < window->InnerClipRect.Min.y)) + return; + if (text_end == NULL) + text_end = text_begin + strlen(text_begin); + if (g.CurrentItemFlags & ImGuiItemFlags_NoTextSelect) + { + TextSelectNoteExcludedClick(st, font, font_size, pos, clip, text_begin, text_end, wrap_width); + return; + } + + bool do_select = st->Active && TextSelectInContainer(g, st->Anchor, window); + const bool do_caret = st->WantCaret && window == st->Anchor.Window; + const bool do_probe = st->Probe && window == st->ProbeWindow && (st->ProbeContainer.Window == NULL || TextSelectInContainer(g, st->ProbeContainer, window)); + if (!do_select && !do_caret && !do_probe) + return; + + ImVec2 sel_begin(FLT_MAX, FLT_MAX), sel_end(-FLT_MAX, -FLT_MAX); + if (do_select) + { + TextSelectGetScreenRange(st, window, &sel_begin, &sel_end); + do_select = TextSelectChildrenOverlap(window, st->Anchor.Window, sel_begin, sel_end); + if (!do_select && !do_caret && !do_probe) + return; + } + const ImVec2 caret_pos = do_caret ? TextSelectLocalToScreen(st->Anchor.Window, st->Anchor.Begin, st->Anchor.MenuBar) : ImVec2(0.0f, 0.0f); + const ImVec2 probe_pos = st->ProbePos; + const ImVec2 to_local = (do_select && st->Copying) ? TextSelectToLocalOffset(st, window, pos) : ImVec2(0.0f, 0.0f); + const bool unclipped = st->Copying && window == st->Anchor.Window; + float copy_x0 = clip.x, copy_x1 = clip.z; + if (unclipped && st->Anchor.TableId != 0 && g.CurrentTable != NULL && g.CurrentTable->CurrentColumn >= 0) + { + // A column's ClipRect is its cell span cut by the visible area: an edge the clip reaches was cut by scrolling + const ImGuiTableColumn& column = g.CurrentTable->Columns[g.CurrentTable->CurrentColumn]; + if (clip.x <= column.ClipRect.Min.x + 0.5f) + copy_x0 = column.MinX; + if (clip.z >= column.ClipRect.Max.x - 0.5f) + copy_x1 = column.MaxX; + } + + float max_y = -FLT_MAX; + if (do_select) + max_y = unclipped ? sel_end.y : ImMin(sel_end.y, clip.w); + if (do_caret) + max_y = ImMax(max_y, caret_pos.y); + if (do_probe) + max_y = ImMax(max_y, probe_pos.y); + + const bool snap = (draw_list->Flags & ImDrawListFlags_TextNoPixelSnap) == 0; + const float origin_x = snap ? IM_TRUNC(pos.x) : pos.x; + float x = origin_x; + float y = snap ? IM_TRUNC(pos.y) : pos.y; + ImFontBaked* baked = font->GetFontBaked(font_size); + const float scale = font_size / baked->Size; + const float space_width = baked->GetCharAdvance((ImWchar)' ') * scale; + const float em_width = baked->GetCharAdvance((ImWchar)'M') * scale; + const bool word_wrap = wrap_width > 0.0f; + bool copy_text_emitted = false; + + const char* soft_wrapped = soft_wrap_first; + const char* span_begin = NULL; + const char* span_end = NULL; + float span_x0 = 0.0f, span_x1 = 0.0f; + auto flush = [&]() + { + if (span_begin == NULL) + return; + const char* line_start = span_x0 == origin_x ? soft_wrapped : NULL; + if (has_copy_text) + { + TextSelectEmit(st, draw_list, clip, copy_text_emitted ? NULL : st->NextCopyText.begin(), st->NextCopyText.end(), span_x0, span_x1, y, y + font_size, space_width, line_start, to_local); + copy_text_emitted = true; + } + else + { + TextSelectEmit(st, draw_list, clip, span_begin, span_end, span_x0, span_x1, y, y + font_size, space_width, line_start, to_local); + } + span_begin = NULL; + }; + + // Per-line task flags, set at each line start + bool line_select = false, line_caret = false, line_probe = false; + int run_class = -1; + float run_x0 = 0.0f; + bool probe_hit = false; + bool probe_run = false; + float probe_run_x0 = 0.0f, probe_run_x1 = 0.0f; + auto end_line = [&]() + { + flush(); + if (probe_run) + st->ProbeTextDistEm = ImMin(st->ProbeTextDistEm, ImMax(ImMax(probe_run_x0 - probe_pos.x, probe_pos.x - probe_run_x1), 0.0f) / em_width); + probe_run = false; + if (probe_hit && !st->ProbeWordFound) + { + st->ProbeWordFound = true; + st->ProbeWordX1 = x; + } + probe_hit = false; + run_class = -1; + }; + auto begin_line = [&]() + { + const float y1 = y + font_size; + line_select = do_select && y1 > sel_begin.y && y <= sel_end.y && (unclipped || (y1 >= clip.y && y <= clip.w)); + line_caret = do_caret && y <= caret_pos.y && caret_pos.y < y1; + line_probe = do_probe && y <= probe_pos.y && probe_pos.y < y1; + if (line_probe && !st->ProbeLineFound) + { + st->ProbeLineFound = true; + st->ProbeLineY0 = y; + st->ProbeLineY1 = y1; + } + }; + + const char* s = text_begin; + const char* word_wrap_eol = NULL; + begin_line(); + while (s < text_end) + { + if (y > max_y) + break; + if (word_wrap) + { + if (word_wrap_eol == NULL) + word_wrap_eol = ImFontCalcWordWrapPositionEx(font, font_size, s, text_end, wrap_width - (x - origin_x)); + if (s >= word_wrap_eol) + { + end_line(); + x = origin_x; + y += font_size; + soft_wrapped = " "; + word_wrap_eol = NULL; + s = ImTextCalcWordWrapNextLineStart(s, text_end); + begin_line(); + continue; + } + } + if (x == origin_x && !line_select && !line_caret && !line_probe) + { + if (word_wrap) + { + s = word_wrap_eol; + } + else + { + const char* line_end = (const char*)ImMemchr(s, '\n', text_end - s); + s = line_end ? line_end + 1 : text_end; + y += font_size; + soft_wrapped = NULL; + begin_line(); + } + continue; + } + + const char* char_begin = s; + unsigned int c = (unsigned int)*s; + if (c < 0x80) + s += 1; + else + s += ImTextCharFromUtf8(&c, s, text_end); + if (c == '\n') + { + end_line(); + x = origin_x; + y += font_size; + soft_wrapped = NULL; + begin_line(); + continue; + } + if (c == '\r') + continue; + + const float advance = baked->GetCharAdvance((ImWchar)c) * scale; + if (line_select) + { + const bool visible_x = x + advance > copy_x0 && x < copy_x1; + if (visible_x && TextSelectIsSelected(st->Block, sel_begin, sel_end, x + advance * 0.5f, y, y + font_size)) + { + if (span_begin == NULL) + { + span_begin = char_begin; + span_x0 = x; + } + span_end = s; + span_x1 = x + advance; + } + else + { + flush(); + } + } + if (line_caret) + { + const float d0 = ImFabs(caret_pos.x - x); + const float d1 = ImFabs(caret_pos.x - (x + advance)); + if (ImMin(d0, d1) < st->CaretDist) + { + st->CaretDist = ImMin(d0, d1); + const float cx = d0 <= d1 ? x : x + advance; + TextSelectSetMark(&st->Caret, draw_list, clip, cx, cx, y, y + font_size); + } + } + if (line_probe && c > ' ') + { + st->ProbeLineX0 = st->ProbeLineExtentFound ? ImMin(st->ProbeLineX0, x) : x; + st->ProbeLineX1 = st->ProbeLineExtentFound ? ImMax(st->ProbeLineX1, x + advance) : x + advance; + st->ProbeLineExtentFound = true; + probe_run_x0 = probe_run ? probe_run_x0 : x; + probe_run_x1 = x + advance; + probe_run = true; + } + if (line_probe && !st->ProbeWordFound) + { + const int cls = TextSelectCharClass(st, c); + if (cls != run_class) + { + if (probe_hit) + { + st->ProbeWordFound = true; + st->ProbeWordX1 = x; + probe_hit = false; + } + run_class = cls; + run_x0 = x; + } + if (!st->ProbeWordFound && !probe_hit && probe_pos.x >= x && probe_pos.x < x + advance) + { + probe_hit = true; + st->ProbeGlyphHit = true; + st->ProbeGlyphItemId = g.LastItemData.ID; + // Only TreeNode() and a row-spanning Selectable() set these: buttons, sliders, tabs and headers must keep their drags + st->ProbeGlyphStealable = (g.LastItemData.StatusFlags & (ImGuiItemStatusFlags_HasDisplayRect | ImGuiItemStatusFlags_HasClipRect)) != 0; + st->ProbeWordX0 = run_x0; + st->ProbeLineY0 = y; + st->ProbeLineY1 = y + font_size; + } + } + x += advance; + } + end_line(); +} + +static int IMGUI_CDECL TextSelectCompareByLine(const void* lhs, const void* rhs) +{ + const ImGuiTextSelectFragment* a = (const ImGuiTextSelectFragment*)lhs; + const ImGuiTextSelectFragment* b = (const ImGuiTextSelectFragment*)rhs; + const float ca = a->Y0 + a->Y1, cb = b->Y0 + b->Y1; + if (ca != cb) + return ca < cb ? -1 : 1; + return a->Order - b->Order; +} + +static int IMGUI_CDECL TextSelectCompareByX(const void* lhs, const void* rhs) +{ + const ImGuiTextSelectFragment* a = (const ImGuiTextSelectFragment*)lhs; + const ImGuiTextSelectFragment* b = (const ImGuiTextSelectFragment*)rhs; + if (a->X0 != b->X0) + return a->X0 < b->X0 ? -1 : 1; + return a->Order - b->Order; +} + +static void TextSelectFinishCopy(ImGuiTextSelectState* st) +{ + ImVector& frags = st->Fragments; + const char* src = st->FragmentText.begin(); + ImGuiTextBuffer& out = st->CopyText; + out.clear(); + st->CopyLineOffsets.resize(0); + st->CopyLines.resize(0); + if (frags.Size > 0) + ImQsort(frags.Data, (size_t)frags.Size, sizeof(ImGuiTextSelectFragment), TextSelectCompareByLine); + + float prev_line_bottom = 0.0f; + for (int line_start = 0; line_start < frags.Size; ) + { + const float line_bottom = frags[line_start].Y1; + int line_end = line_start + 1; + while (line_end < frags.Size && (frags[line_end].Y0 + frags[line_end].Y1) * 0.5f < line_bottom) + line_end++; + ImQsort(frags.Data + line_start, (size_t)(line_end - line_start), sizeof(ImGuiTextSelectFragment), TextSelectCompareByX); + + const ImGuiTextSelectFragment& first = frags[line_start]; + if (line_start > 0) + { + if (first.SoftWrapJoin != NULL && !st->Block) + out.append(first.SoftWrapJoin); + else + out.append(first.Y0 - prev_line_bottom > first.Y1 - first.Y0 ? "\n\n" : "\n"); + } + const int line_offset = out.size(); + const ImGuiTextSelectFragment* prev = NULL; + for (int n = line_start; n < line_end; n++) + { + const ImGuiTextSelectFragment& f = frags[n]; + if (prev != NULL) + { + // Same text drawn twice at an overlapping position (e.g. shadowed or faux-bold text) + if (f.X0 < prev->X1 && f.TextLen == prev->TextLen && memcmp(src + f.TextOffset, src + prev->TextOffset, (size_t)f.TextLen) == 0) + continue; + const float gap = f.X0 - prev->X1; + if (gap > f.SpaceWidth * 3.0f) + out.append("\t"); + else if (gap > f.SpaceWidth * 0.5f) + out.append(" "); + } + out.append(src + f.TextOffset, src + f.TextOffset + f.TextLen); + prev = &f; + } + while (out.size() > line_offset && (out.Buf[out.size() - 1] == ' ' || out.Buf[out.size() - 1] == '\t')) + { + out.Buf.pop_back(); + out.Buf.back() = 0; + } + st->CopyLineOffsets.push_back(line_offset); + ImGuiTextSelectCopyLine line; + line.Pos = first.ScreenPos; + line.Visible = first.Visible; + line.Text = line.TextEnd = NULL; + st->CopyLines.push_back(line); + prev_line_bottom = line_bottom; + line_start = line_end; + } + frags.resize(0); + st->FragmentText.clear(); + if (!st->RequestedCopyText.empty()) + { + // The highlighted lines only place the feedback; the clipboard gets the requested text as one line + if (st->CopyLines.Size > 1) + st->CopyLines.resize(1); + out.clear(); + out.append(st->RequestedCopyText.begin(), st->RequestedCopyText.end()); + st->RequestedCopyText.clear(); + st->CopyLineOffsets.resize(0); + if (st->CopyLines.Size == 1) + st->CopyLineOffsets.push_back(0); + } + if (out.empty()) + return; + + // Resolve line pointers once the buffer no longer grows + for (int n = 0; n < st->CopyLines.Size; n++) + { + st->CopyLines[n].Text = out.begin() + st->CopyLineOffsets[n]; + st->CopyLines[n].TextEnd = (n + 1 < st->CopyLines.Size) ? out.begin() + st->CopyLineOffsets[n + 1] - 1 : out.end(); + } + ImGui::SetClipboardText(out.c_str()); + if (st->CopyFeedback != NULL) + { + // The callback runs in EndFrame() where the current window is the implicit fallback window: writing to it + // (e.g. via GetCurrentWindow()) would show it, and as a new viewport it would take the platform focus. + ImGuiWindow* fallback_window = st->Ctx->CurrentWindow; + const bool fallback_write_accessed = fallback_window ? fallback_window->WriteAccessed : false; + st->CopyFeedback(out.c_str(), st->CopyLines.Data, st->CopyLines.Size, st->CopyFeedbackUserData); + if (fallback_window) + fallback_window->WriteAccessed = fallback_write_accessed; + } + else + st->FlashStartTime = st->Ctx->Time; +} + + +static void TextSelectResolve(ImGuiTextSelectState* st) +{ + ImGuiContext& g = *st->Ctx; + st->Active = st->WantCaret = false; + st->RootWindow = NULL; + if (!st->Enabled || st->Anchor.WindowId == 0) + return; + st->Anchor.Window = ImGui::FindWindowByID(st->Anchor.WindowId); + st->Focus.Window = ImGui::FindWindowByID(st->Focus.WindowId); + if (st->Anchor.Window == NULL || st->Focus.Window != st->Anchor.Window || (st->Anchor.TableId != 0 && ImGui::TableFindByID(st->Anchor.TableId) == NULL)) + { + TextSelectClearState(st); + return; + } + st->RootWindow = st->Anchor.Window->RootWindow; + ImVec2 sel_begin, sel_end; + TextSelectGetRange(st, &sel_begin, &sel_end); + st->Active = sel_begin.x != sel_end.x || (!st->Block && sel_begin.y != sel_end.y); + st->WantCaret = !st->Active && g.NavWindow != NULL && g.NavWindow->RootWindow == st->RootWindow; +} + +// ImGuiListClipper and tables also submit the rows and columns inside BoxSelectState.UnclipRect +static void TextSelectBeginCopy(ImGuiTextSelectState* st) +{ + ImGuiContext& g = *st->Ctx; + st->Copying = true; + st->Fragments.resize(0); + st->FragmentText.clear(); + st->ItemUnclipByLogBackup = g.ItemUnclipByLog; + g.ItemUnclipByLog = true; + ImGuiBoxSelectState* bs = &g.BoxSelectState; + st->BoxSelectUnclip = !bs->IsActive && !bs->IsStarting && !st->Anchor.MenuBar; + if (!st->BoxSelectUnclip) + return; + st->BoxSelectBackup = *bs; + ImVec2 sel_begin, sel_end; + TextSelectGetRange(st, &sel_begin, &sel_end); + bs->ID = ImHashStr("##TextSelectCopy"); + bs->IsActive = true; + bs->Window = st->Anchor.Window; + bs->UnclipMode = true; + const ImGuiWindow* window = st->Anchor.Window; + bs->UnclipRect = ImRect(-FLT_MAX, TextSelectLocalToScreen(window, sel_begin, false, false).y, FLT_MAX, TextSelectLocalToScreen(window, sel_end, false, false).y); +} + +static void TextSelectEndCopy(ImGuiTextSelectState* st) +{ + ImGuiContext& g = *st->Ctx; + st->Copying = false; + g.ItemUnclipByLog = st->ItemUnclipByLogBackup; + if (st->BoxSelectUnclip) + g.BoxSelectState = st->BoxSelectBackup; + st->BoxSelectUnclip = false; +} + +static const ImGuiTextSelectRange* TextSelectExtendContainer(ImGuiTextSelectState* st) +{ + ImGuiContext& g = *st->Ctx; + const bool has_anchor = st->RootWindow != NULL; + if (st->ClickPending) + return (has_anchor && g.IO.KeyShift && g.HoveredWindow != NULL && g.HoveredWindow->RootWindow == st->RootWindow) ? &st->Anchor : NULL; + if (st->StealPending) + { + if (st->StealExtend && has_anchor) + return &st->Anchor; + st->StealRange.Window = ImGui::FindWindowByID(st->StealRange.WindowId); + return st->StealRange.Window != NULL ? &st->StealRange : NULL; + } + return (st->Dragging && has_anchor) ? &st->Anchor : NULL; +} + +static void TextSelectNewFrame(ImGuiContext* ctx, ImGuiContextHook* hook) +{ + ImGuiTextSelectState* st = (ImGuiTextSelectState*)hook->UserData; + ImGuiContext& g = *ctx; + const ImGuiIO& io = g.IO; + st->PrevDisabledRects.swap(st->DisabledRects); + st->DisabledRects.resize(0); + st->Regions.resize(0); + st->NextCopyTextWindow = NULL; + st->SoftWrapWindow = NULL; + st->Copying = false; + st->LineTolerance = g.FontSize * 0.5f; + st->Caret.Valid = st->First.Valid = st->Last.Valid = false; + st->CaretDist = FLT_MAX; + + if (st->Dragging) + { + if (!io.MouseDown[ImGuiMouseButton_Left] || g.ActiveId != st->DragId) + { + st->Dragging = false; + if (g.ActiveId == st->DragId) + ImGui::ClearActiveID(); + } + else + { + ImGui::KeepAliveID(st->DragId); + } + } + + if (st->AltMouseLocked && !io.MouseDown[ImGuiMouseButton_Left]) + { + st->AltMouseLocked = false; + if (io.MouseReleased[ImGuiMouseButton_Left]) + ImGui::SetKeyOwner(ImGuiKey_MouseLeft, st->DragId, ImGuiInputFlags_LockThisFrame); + } + + st->ClickPending = st->Enabled && ImGui::IsMouseClicked(ImGuiMouseButton_Left); + st->ClickOnExcludedText = false; + // Holding the active id from NewFrame keeps every widget from hovering or activating (see ItemHoverable()) + st->AltClick = st->ClickPending && io.KeyAlt && g.HoveredWindow != NULL && g.ActiveId == 0 && !TextSelectOnTitleBar(g.HoveredWindow, io.MousePos); + if (st->AltClick) + { + st->DragId = ImHashStr("##TextSelectDrag"); + ImGui::SetActiveID(st->DragId, g.HoveredWindow); + ImGui::SetKeyOwner(ImGuiKey_MouseLeft, st->DragId, ImGuiInputFlags_LockUntilRelease); + st->AltMouseLocked = true; + } + TextSelectResolve(st); + const ImGuiTextSelectRange* container = TextSelectExtendContainer(st); + st->ProbeContainer = container != NULL ? *container : ImGuiTextSelectRange(); + st->ProbeWindow = container != NULL ? container->Window : g.HoveredWindow; + st->ProbePos = container != NULL ? TextSelectClampToContainer(*container, io.MousePos) : io.MousePos; + st->Probe = st->Enabled && st->ProbeWindow != NULL && (st->ClickPending || st->Dragging || st->StealPending); + st->ProbeLineFound = st->ProbeWordFound = st->ProbeLineExtentFound = st->ProbeGlyphHit = st->ProbeGlyphStealable = false; + st->ProbeTextDistEm = FLT_MAX; + st->ProbeGlyphItemId = 0; + + if (st->CopyRequested) + { + st->CopyRequested = false; + if (st->Active) + { + TextSelectBeginCopy(st); + return; + } + } + st->RequestedCopyText.clear(); + if (!(io.KeyCtrl || io.KeySuper) || !(ImGui::IsKeyPressed(ImGuiKey_C, false) || ImGui::IsKeyPressed(ImGuiKey_Insert, false))) + return; + if (!st->Active) + { + IMGUI_DEBUG_LOG_IO("[textselect] copy key: no selection (anchor %s)\n", st->Anchor.WindowId != 0 ? "set" : "none"); + return; + } + const bool owns_window = (g.HoveredWindow != NULL && g.HoveredWindow->RootWindow == st->RootWindow) || (g.NavWindow != NULL && g.NavWindow->RootWindow == st->RootWindow); + const char* blocked = io.WantTextInput ? "text input active" : !owns_window ? "selection window neither hovered nor focused" : TextSelectInDisabledRect(st->PrevDisabledRects, g.HoveredWindow, io.MousePos) ? "mouse over a disabled region" : NULL; + IMGUI_DEBUG_LOG_IO("[textselect] copy key: %s (hovered '%s', nav '%s', selection root '%s')\n", blocked ? blocked : "copying", g.HoveredWindow ? g.HoveredWindow->Name : "", g.NavWindow ? g.NavWindow->Name : "", st->RootWindow->Name); + if (blocked == NULL) + { + TextSelectBeginCopy(st); + // Nobody else acts on this copy key press + ImGui::SetKeyOwner(ImGuiKey_C, ImGuiKeyOwner_Any, ImGuiInputFlags_LockThisFrame); + ImGui::SetKeyOwner(ImGuiKey_Insert, ImGuiKeyOwner_Any, ImGuiInputFlags_LockThisFrame); + } +} + +// The probed line (unit 2) or word (unit 1) under the mouse, else the mouse point, snapped vertically to the probed line. +static ImGuiTextSelectRange TextSelectRangeAtMouse(const ImGuiTextSelectState* st, ImGuiWindow* window, int unit, bool menu_bar) +{ + const ImVec2 mouse = st->ProbePos; + const float y = st->ProbeLineFound ? (st->ProbeLineY0 + st->ProbeLineY1) * 0.5f : mouse.y; + ImGuiTextSelectRange r; + r.WindowId = window->ID; + r.Window = window; + r.MenuBar = menu_bar; + float x0 = mouse.x, x1 = mouse.x; + if (unit == 2 && st->ProbeLineExtentFound && window == st->ProbeWindow) + { + x0 = st->ProbeLineX0; + x1 = st->ProbeLineX1; + } + else if (unit >= 1 && st->ProbeWordFound && window == st->ProbeWindow) + { + x0 = st->ProbeWordX0; + x1 = st->ProbeWordX1; + } + r.Begin = TextSelectScreenToLocal(window, ImVec2(x0, y), menu_bar); + r.End = TextSelectScreenToLocal(window, ImVec2(x1, y), menu_bar); + return r; +} + +static void TextSelectDrawMark(const ImGuiTextSelectMark& mark, float x, ImU32 col, float thickness) +{ + ImDrawList* draw_list = mark.DrawList; + draw_list->PushClipRect(ImVec2(mark.ClipRect.x, mark.ClipRect.y), ImVec2(mark.ClipRect.z, mark.ClipRect.w), true); + draw_list->AddRectFilled(ImVec2(x - thickness * 0.5f, mark.Y0), ImVec2(x + thickness * 0.5f, mark.Y1), col); + draw_list->PopClipRect(); +} + +static void TextSelectDrawMarks(ImGuiTextSelectState* st) +{ + if (!st->Caret.Valid && !st->First.Valid && !st->Last.Valid) + return; + ImGuiContext& g = *st->Ctx; + ImVec4 col_v = g.Style.Colors[ImGuiCol_TextSelectedBg]; + col_v.w = 1.0f; + const ImU32 col = ImGui::ColorConvertFloat4ToU32(col_v); + const float thickness = ImMax(2.0f, IM_TRUNC(g.FontSize / 8.0f)); + if (st->WantCaret && st->Caret.Valid) + TextSelectDrawMark(st->Caret, st->Caret.X0, col, thickness); + if (!st->Active) + return; + // A bookend only marks a true end of the selection: its point must be within the vertical clip range + ImVec2 sel_begin, sel_end; + TextSelectGetRange(st, &sel_begin, &sel_end); + sel_begin = TextSelectLocalToScreen(st->Anchor.Window, sel_begin, st->Anchor.MenuBar); + sel_end = TextSelectLocalToScreen(st->Anchor.Window, sel_end, st->Anchor.MenuBar); + if (st->First.Valid && sel_begin.y >= st->First.ClipRect.y && sel_begin.y <= st->First.ClipRect.w) + TextSelectDrawMark(st->First, st->First.X0, col, thickness); + if (st->Last.Valid && sel_end.y >= st->Last.ClipRect.y && sel_end.y <= st->Last.ClipRect.w) + TextSelectDrawMark(st->Last, st->Last.X1, col, thickness); +} + +static bool TextSelectIsFreeClick(const ImGuiTextSelectState* st, ImGuiWindow* hovered) +{ + ImGuiContext& g = *st->Ctx; + if (hovered == NULL || st->ClickOnExcludedText || TextSelectInDisabledRect(st->DisabledRects, hovered, g.IO.MousePos)) + return false; + if (st->AltClick) + return true; + // Title bars keep moving windows + return g.ActiveId == 0 && g.HoveredId == 0 && !TextSelectOnTitleBar(hovered, g.IO.MousePos); +} + +// With ImGuiWindowBgClickFlags_Move (io.ConfigWindowsMoveFromTitleBarOnly off), a drag on the background moves the window +static bool TextSelectBgDragMovesWindow(const ImGuiWindow* window) +{ + return (window->BgClickFlags & ImGuiWindowBgClickFlags_Move) != 0 && (window->RootWindow->Flags & ImGuiWindowFlags_NoMove) == 0; +} + +static void TextSelectStartDrag(ImGuiTextSelectState* st, ImGuiWindow* window, int unit) +{ + st->DragUnit = unit; + st->ScrollAccum = st->ScrollTime = ImVec2(0.0f, 0.0f); + st->DragId = ImHashStr("##TextSelectDrag"); + st->Dragging = true; + ImGui::SetActiveID(st->DragId, window); + ImGui::FocusWindow(window); +} + +static void TextSelectScrollWithDrag(ImGuiTextSelectState* st) +{ + ImGuiContext& g = *st->Ctx; + ImGuiWindow* window = st->Anchor.Window; + if (st->Anchor.MenuBar) + return; + const ImRect r = TextSelectContainerRect(st->Anchor); + const ImGuiTable* table = st->Anchor.TableId != 0 ? ImGui::TableFindByID(st->Anchor.TableId) : NULL; + const bool table_in_window = table != NULL && table->InnerWindow == table->OuterWindow; + for (int axis = 0; axis < 2; axis++) + { + const float mouse = g.IO.MousePos[axis]; + const float dist = (mouse > r.Max[axis]) ? mouse - r.Max[axis] : (mouse < r.Min[axis]) ? mouse - r.Min[axis] : 0.0f; + const float scroll = window->Scroll[axis]; + bool can_scroll = (dist < 0.0f && scroll > 0.0f) || (dist > 0.0f && scroll < window->ScrollMax[axis]); + if (table_in_window && ((dist < 0.0f && table->OuterRect.Min[axis] >= r.Min[axis]) || (dist > 0.0f && table->OuterRect.Max[axis] <= r.Max[axis]))) + can_scroll = false; + if (!can_scroll) + { + st->ScrollAccum[axis] = st->ScrollTime[axis] = 0.0f; + continue; + } + // Lines per second: 8 at the edge, x4 farther out, doubling every second the mouse stays out + st->ScrollTime[axis] += g.IO.DeltaTime; + const float speed = ImLinearRemapClamp(g.FontSize, g.FontSize * 5.0f, 1.0f, 4.0f, ImAbs(dist)) * ImPow(2.0f, ImMin(st->ScrollTime[axis], 16.0f)); + st->ScrollAccum[axis] += g.FontSize * 8.0f * speed * ImSign(dist) * g.IO.DeltaTime; + const float step = ImTrunc(st->ScrollAccum[axis]); + if (step == 0.0f) + continue; + if (axis == 0) + ImGui::SetScrollX(window, scroll + step); + else + ImGui::SetScrollY(window, scroll + step); + st->ScrollAccum[axis] -= step; + } +} + +static bool TextSelectIsContainerScrollbar(const ImGuiTextSelectState* st, ImGuiID id) +{ + if (id == 0 || st->RootWindow == NULL) + return false; + for (ImGuiWindow* w = st->Anchor.Window; w != NULL; w = (w == w->RootWindow) ? NULL : w->ParentWindow) + if (!w->MemoryCompacted && (id == ImGui::GetWindowScrollbarID(w, ImGuiAxis_X) || id == ImGui::GetWindowScrollbarID(w, ImGuiAxis_Y))) + return true; + return false; +} + +static void TextSelectEndFrame(ImGuiContext* ctx, ImGuiContextHook* hook) +{ + ImGuiTextSelectState* st = (ImGuiTextSelectState*)hook->UserData; + ImGuiContext& g = *ctx; + st->Regions.resize(0); + if (st->Copying) + { + TextSelectEndCopy(st); + TextSelectFinishCopy(st); + } + TextSelectDrawMarks(st); + if (!st->Enabled) + { + TextSelectClearState(st); + return; + } + + const ImGuiIO& io = g.IO; + const bool selection_set_by_call = st->SelectionSetByCall; + st->SelectionSetByCall = false; + if (st->ClickPending) + { + // Widgets activate on click before EndFrame(), window moving starts inside EndFrame(): ActiveId == 0 means no widget took it. + ImGuiWindow* hovered = g.HoveredWindow; + st->StealPending = false; + if (selection_set_by_call) + return; + if (!TextSelectIsFreeClick(st, hovered)) + { + if (st->AltClick && g.ActiveId == st->DragId) + ImGui::ClearActiveID(); + const ImGuiWindow* anchor_window = st->Anchor.WindowId != 0 ? ImGui::FindWindowByID(st->Anchor.WindowId) : NULL; + const bool extend = io.KeyShift && hovered != NULL && anchor_window != NULL && anchor_window->RootWindow == hovered->RootWindow; + if (!io.KeyShift && !TextSelectIsContainerScrollbar(st, g.ActiveId)) + TextSelectClearState(st); + if (hovered != NULL && g.ActiveId != 0 && st->ProbeGlyphHit && st->ProbeGlyphItemId == g.ActiveId && st->ProbeGlyphStealable && g.InputTextState.ID != g.ActiveId) + { + st->StealPending = true; + st->StealItemId = g.ActiveId; + st->StealExtend = extend; + st->StealRange = TextSelectRangeAtMouse(st, hovered, 0, TextSelectInMenuBar(hovered, io.MousePos)); + TextSelectSetContainerTable(g, &st->StealRange, io.MousePos); + } + return; + } + const int clicks = io.MouseClickedCount[ImGuiMouseButton_Left]; + const int unit = clicks >= 3 ? 2 : clicks == 2 ? 1 : 0; + const bool extend = st->ProbeContainer.Window != NULL; + ImGuiTextSelectRange at_mouse = TextSelectRangeAtMouse(st, extend ? st->ProbeContainer.Window : hovered, unit, extend ? st->ProbeContainer.MenuBar : TextSelectInMenuBar(hovered, io.MousePos)); + const bool move_window = !st->AltClick && !st->ProbeGlyphHit && st->ProbeTextDistEm > 1.0f && TextSelectBgDragMovesWindow(hovered); + if (move_window && !extend) + { + TextSelectClearState(st); + return; + } + if (!extend) + { + TextSelectSetContainerTable(g, &at_mouse, io.MousePos); + st->Anchor = at_mouse; + } + st->Block = io.KeyShift && io.KeyAlt; + st->Focus = at_mouse; + + if (!move_window) + TextSelectStartDrag(st, hovered, unit); + } + else if (st->StealPending) + { + ImGuiWindow* window = ImGui::FindWindowByID(st->StealRange.WindowId); + if (!io.MouseDown[ImGuiMouseButton_Left] || g.ActiveId != st->StealItemId || g.DragDropActive || g.ActiveIdHasBeenEditedBefore || window == NULL) + { + st->StealPending = false; + } + else if (ImGui::IsMouseDragging(ImGuiMouseButton_Left)) + { + st->StealPending = false; + st->StealRange.Window = window; + if (!st->StealExtend || st->Anchor.WindowId == 0) + st->Anchor = st->StealRange; + st->Block = io.KeyShift && io.KeyAlt; + st->Focus = TextSelectRangeAtMouse(st, st->Anchor.Window, 0, st->Anchor.MenuBar); + TextSelectStartDrag(st, window, 0); + } + } + else if (st->Dragging && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) + { + st->Block = io.KeyShift && io.KeyAlt; + if (st->Anchor.Window != NULL && st->RootWindow != NULL) + { + st->Focus = TextSelectRangeAtMouse(st, st->Anchor.Window, st->DragUnit, st->Anchor.MenuBar); + TextSelectScrollWithDrag(st); + } + } + else if (st->Anchor.WindowId != 0 && !io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) + { + TextSelectClearState(st); + } +} + +static void TextSelectShutdown(ImGuiContext* ctx, ImGuiContextHook* hook) +{ + ImGuiTextSelectState* st = (ImGuiTextSelectState*)hook->UserData; + if (ctx->DrawListSharedData.TextHookUserData == st) + { + ctx->DrawListSharedData.TextHook = NULL; + ctx->DrawListSharedData.TextHookUserData = NULL; + IM_DELETE(st); + } +} + +void ImGui::TextSelectInstall() +{ + ImGuiContext* ctx = GetCurrentContext(); + if (ctx == NULL || ctx->DrawListSharedData.TextHook == TextSelectHook) + return; + IM_ASSERT(ctx->DrawListSharedData.TextHook == NULL && "Another text hook is installed"); + ImGuiTextSelectState* st = IM_NEW(ImGuiTextSelectState)(); + st->Ctx = ctx; + TextSelectSetDelimiters(st, IMGUI_TEXT_SELECT_DEFAULT_DELIMITERS); + ctx->DrawListSharedData.TextHook = TextSelectHook; + ctx->DrawListSharedData.TextHookUserData = st; + + ImGuiContextHook hook; + hook.UserData = st; + hook.Type = ImGuiContextHookType_NewFramePost; + hook.Callback = TextSelectNewFrame; + AddContextHook(ctx, &hook); + hook.Type = ImGuiContextHookType_EndFramePre; + hook.Callback = TextSelectEndFrame; + AddContextHook(ctx, &hook); + hook.Type = ImGuiContextHookType_Shutdown; + hook.Callback = TextSelectShutdown; + AddContextHook(ctx, &hook); +} + +void ImGui::TextSelectSetEnabled(bool enabled) +{ + if (ImGuiTextSelectState* st = TextSelectGetState()) + st->Enabled = enabled; +} + +void ImGui::TextSelectSetCopyFeedbackCallback(ImGuiTextSelectCopyFeedbackCallback callback, void* user_data) +{ + if (ImGuiTextSelectState* st = TextSelectGetState()) + { + st->CopyFeedback = callback; + st->CopyFeedbackUserData = user_data; + } +} + +void ImGui::TextSelectSetWordDelimiters(const char* delimiters) +{ + if (ImGuiTextSelectState* st = TextSelectGetState()) + TextSelectSetDelimiters(st, delimiters ? delimiters : IMGUI_TEXT_SELECT_DEFAULT_DELIMITERS); +} + +void ImGui::TextSelectPushDisabledRegion() +{ + PushItemFlag(ImGuiItemFlags_NoTextSelect, true); + ImGuiTextSelectState* st = TextSelectGetState(); + if (st == NULL) + return; + ImGuiWindow* window = GetCurrentWindow(); + ImGuiTextSelectRegion region; + region.Window = window; + region.StartY = window->DC.CursorPos.y; + st->Regions.push_back(region); +} + +void ImGui::TextSelectPopDisabledRegion() +{ + ImGuiTextSelectState* st = TextSelectGetState(); + if (st != NULL && st->Regions.Size > 0) + { + const ImGuiTextSelectRegion region = st->Regions.back(); + st->Regions.pop_back(); + ImGuiWindow* window = region.Window; + ImGuiTextSelectDisabledRect r; + r.RootWindow = window->RootWindow; + r.Rect = ImRect(window->OuterRectClipped.Min.x, region.StartY, window->OuterRectClipped.Max.x, ImMax(window->DC.CursorPos.y, window->DC.CursorMaxPos.y)); + r.Rect.ClipWith(window->InnerClipRect); + st->DisabledRects.push_back(r); + } + PopItemFlag(); +} + +void ImGui::TextSelectSetNextCopyText(const char* text, const char* text_end) +{ + ImGuiTextSelectState* st = TextSelectGetState(); + if (st == NULL || !st->Copying) + return; + if (text == NULL) + { + st->NextCopyTextWindow = NULL; + return; + } + st->NextCopyText.clear(); + st->NextCopyText.append(text, text_end); + st->NextCopyTextWindow = st->Ctx->CurrentWindow; +} + +static void TextSelectProbeUnit(ImGuiTextSelectState* st, ImGuiWindow* window, const ImVec2& p_min, const ImVec2& p_max) +{ + ImGuiContext& g = *st->Ctx; + const ImVec4 clip = window->DrawList->_CmdHeader.ClipRect; + ImRect bb(p_min, p_max); + bb.ClipWith(ImRect(clip.x, clip.y, clip.z, clip.w)); + if (st->ClickPending && window == g.HoveredWindow && bb.Contains(g.IO.MousePos)) + st->ClickOnExcludedText = false; + const bool do_probe = st->Probe && window == st->ProbeWindow && (st->ProbeContainer.Window == NULL || TextSelectInContainer(g, st->ProbeContainer, window)); + const ImVec2 pos = st->ProbePos; + if (!do_probe || pos.y < p_min.y || pos.y >= p_max.y) + return; + if (!st->ProbeLineFound) + { + st->ProbeLineFound = true; + st->ProbeLineY0 = p_min.y; + st->ProbeLineY1 = p_max.y; + } + st->ProbeLineX0 = st->ProbeLineExtentFound ? ImMin(st->ProbeLineX0, p_min.x) : p_min.x; + st->ProbeLineX1 = st->ProbeLineExtentFound ? ImMax(st->ProbeLineX1, p_max.x) : p_max.x; + st->ProbeLineExtentFound = true; + st->ProbeTextDistEm = ImMin(st->ProbeTextDistEm, ImMax(ImMax(p_min.x - pos.x, pos.x - p_max.x), 0.0f) / ImGui::CalcTextSize("M").x); + if (!st->ProbeWordFound && pos.x >= p_min.x && pos.x < p_max.x) + { + st->ProbeWordFound = true; + st->ProbeWordX0 = p_min.x; + st->ProbeWordX1 = p_max.x; + st->ProbeGlyphHit = true; + st->ProbeGlyphItemId = g.LastItemData.ID; + st->ProbeGlyphStealable = false; + st->ProbeLineY0 = p_min.y; + st->ProbeLineY1 = p_max.y; + } +} + +void ImGui::TextSelectAddText(const ImVec2& p_min, const ImVec2& p_max, const char* text, const char* text_end) +{ + ImGuiTextSelectState* st = TextSelectGetState(); + ImGuiWindow* window = st != NULL ? st->Ctx->CurrentWindow : NULL; + if (st == NULL || window == NULL) + return; + TextSelectProbeUnit(st, window, p_min, p_max); + if (st->WantCaret && window == st->Anchor.Window) + { + const ImVec2 caret_pos = TextSelectLocalToScreen(st->Anchor.Window, st->Anchor.Begin, st->Anchor.MenuBar); + const float d0 = ImFabs(caret_pos.x - p_min.x); + const float d1 = ImFabs(caret_pos.x - p_max.x); + if (p_min.y <= caret_pos.y && caret_pos.y < p_max.y && ImMin(d0, d1) < st->CaretDist) + { + st->CaretDist = ImMin(d0, d1); + const float cx = d0 <= d1 ? p_min.x : p_max.x; + TextSelectSetMark(&st->Caret, window->DrawList, window->DrawList->_CmdHeader.ClipRect, cx, cx, p_min.y, p_max.y); + } + } + if (!st->Active || !TextSelectInContainer(*st->Ctx, st->Anchor, window)) + return; + ImVec2 sel_begin, sel_end; + TextSelectGetScreenRange(st, window, &sel_begin, &sel_end); + if (!TextSelectChildrenOverlap(window, st->Anchor.Window, sel_begin, sel_end) || !TextSelectIsSelected(st->Block, sel_begin, sel_end, (p_min.x + p_max.x) * 0.5f, p_min.y, p_max.y)) + return; + if (text_end == NULL) + text_end = text + strlen(text); + TextSelectEmit(st, window->DrawList, window->DrawList->_CmdHeader.ClipRect, text, text_end, p_min.x, p_max.x, p_min.y, p_max.y, CalcTextSize(" ").x, NULL, TextSelectToLocalOffset(st, window, p_min)); +} + +void ImGui::TextSelectMarkSoftWrap(const char* join) +{ + if (ImGuiTextSelectState* st = TextSelectGetState()) + { + st->SoftWrapWindow = st->Ctx->CurrentWindow; + st->SoftWrapJoin = join; + } +} + +void ImGui::TextSelectSelectAndCopy(const ImVec2& p_min, const ImVec2& p_max, const char* copy_text, const char* copy_text_end) +{ + ImGuiTextSelectState* st = TextSelectGetState(); + ImGuiWindow* window = st != NULL ? st->Ctx->CurrentWindow : NULL; + if (window == NULL || !st->Enabled) + return; + // Half a line in from top and bottom: an item rect includes padding that reaches into the neighbouring lines + const float y_inset = ImMin(st->Ctx->FontSize * 0.5f, (p_max.y - p_min.y) * 0.5f); + const bool menu_bar = window->DC.MenuBarAppending; + st->Anchor = st->Focus = ImGuiTextSelectRange(); + st->Anchor.WindowId = st->Focus.WindowId = window->ID; + st->Anchor.Window = st->Focus.Window = window; + st->Anchor.MenuBar = menu_bar; + st->Anchor.Begin = st->Anchor.End = TextSelectScreenToLocal(window, ImVec2(p_min.x, p_min.y + y_inset), menu_bar); + st->Focus.Begin = st->Focus.End = TextSelectScreenToLocal(window, ImVec2(p_max.x, p_max.y - y_inset), menu_bar); + st->Block = false; + st->CopyRequested = true; + st->SelectionSetByCall = true; + st->RequestedCopyText.clear(); + if (copy_text != NULL) + st->RequestedCopyText.append(copy_text, copy_text_end); +} + +void ImGui::TextSelectSelectAll() +{ + ImGuiTextSelectState* st = TextSelectGetState(); + ImGuiWindow* window = st != NULL ? st->Ctx->CurrentWindow : NULL; + if (window == NULL) + return; + const float far_away = 1e8f; + st->Anchor = st->Focus = ImGuiTextSelectRange(); + st->Anchor.WindowId = st->Focus.WindowId = window->ID; + st->Anchor.Window = st->Focus.Window = window; + st->Anchor.Begin = st->Anchor.End = ImVec2(0.0f, 0.0f); + st->Focus.Begin = st->Focus.End = ImVec2(far_away, far_away); + st->Block = false; +} + +bool ImGui::TextSelectHasSelection(bool current_window_only) +{ + ImGuiTextSelectState* st = TextSelectGetState(); + if (st == NULL || !st->Active) + return false; + const ImGuiWindow* window = st->Ctx->CurrentWindow; + return !current_window_only || (window != NULL && window->RootWindow == st->RootWindow); +} + +bool ImGui::TextSelectIsCopying() +{ + ImGuiTextSelectState* st = TextSelectGetState(); + return st != NULL && st->Copying; +} + +void ImGui::TextSelectClear() +{ + if (ImGuiTextSelectState* st = TextSelectGetState()) + TextSelectClearState(st); +} diff --git a/misc/text_select/imgui_text_select.h b/misc/text_select/imgui_text_select.h new file mode 100644 index 000000000..f5af87a62 --- /dev/null +++ b/misc/text_select/imgui_text_select.h @@ -0,0 +1,87 @@ +// dear imgui: text selection extension +// Select and copy any text Dear ImGui draws, by screen position, without changing how widgets render it. +// - Click where no widget takes the click to set the anchor (a caret shows at the nearest glyph edge), then +// Shift+Click or drag to set the other end. Double-Click selects a word, Double-Click+drag extends by words, +// Shift+Double-Click extends the selection to include the whole word. Triple-Click selects the line (all text on +// that line of the window), Triple-Click+drag extends by lines, Shift+Triple-Click extends to the whole line. +// - Shift+Alt+Click, or Shift+Alt held while dragging, selects a block: the glyphs inside the rectangle spanned +// by the two ends, copied as one line per row. +// - Both ends stay inside the container the anchor was set in: its window and, when set in a table, that table. Dragging +// past the container's edge scrolls it, faster the longer the mouse stays out, and extends the selection to the +// nearest point inside it. A table's frozen rows and columns come before its scrolling ones. +// - Every glyph of the container drawn between the two ends in reading order is highlighted, with bookend markers on +// the first and last selected glyphs. Child windows inside the container take part only when they overlap the +// rectangle spanned by the two ends (selecting in one of two side-by-side children leaves the other out). +// - A word is a run of word characters, or a run of delimiter characters (see TextSelectSetWordDelimiters()). +// - Ctrl+C (Cmd+C on macOS) or Ctrl+Insert copies the selection in reading order: lines from top to bottom, +// fragments on a line from left to right, wide gaps (e.g. table columns) as tabs, soft-wrapped lines joined by a space +// (or as given to TextSelectMarkSoftWrap()). The container's rows and columns scrolled out of view are submitted for +// that one frame (through ImGuiListClipper and tables' box-select unclip support) and copied; hidden columns are not. +// - Escape, or a click on a widget other than the container's scrollbars, clears the selection. +// - When io.ConfigWindowsMoveFromTitleBarOnly is off, a click on background farther than the width of an 'M' (in the +// text's font) from any text on its line clears the selection and a drag there moves the window; closer, it selects. +// - A click on a widget's text stays the widget's click. On a TreeNode or a row-spanning Selectable, dragging it past +// the drag threshold turns it into a selection instead, unless the widget already used the drag (edited a value, +// started a drag and drop). Any other widget starts a selection only with Alt held. +// Requires ImDrawListSharedData::TextHook, which ImDrawList::AddText() calls before emitting glyphs. +// Opt-out: PushItemFlag(ImGuiItemFlags_NoTextSelect, true) for text, TextSelectPushDisabledRegion() for an area. + +#pragma once + +#include "imgui.h" + +#ifndef IMGUI_TEXT_SELECT_FLASH_DURATION +#define IMGUI_TEXT_SELECT_FLASH_DURATION 0.20f // Seconds the highlight takes to fade back from opaque after a copy +#endif + +#ifndef IMGUI_TEXT_SELECT_DEFAULT_DELIMITERS +#define IMGUI_TEXT_SELECT_DEFAULT_DELIMITERS "'\"`|:,()[]{}<>" +#endif + +struct ImGuiTextSelectCopyLine +{ + ImVec2 Pos; // Screen position of the line's first copied glyph + bool Visible; // The first glyph was inside its clip rectangle (not scrolled out of view) + const char* Text; // Points into the full copied text + const char* TextEnd; +}; + +// Called after a copy has set the clipboard, instead of the built-in feedback (the highlight flashes opaque and +// fades back over IMGUI_TEXT_SELECT_FLASH_DURATION). Pointers are valid only during the call. +typedef void (*ImGuiTextSelectCopyFeedbackCallback)(const char* text, const ImGuiTextSelectCopyLine* lines, int lines_count, void* user_data); + +namespace ImGui +{ + // Installs into the current context. Safe to call every frame. The state is freed with the context. + void TextSelectInstall(); + void TextSelectSetEnabled(bool enabled); + void TextSelectSetCopyFeedbackCallback(ImGuiTextSelectCopyFeedbackCallback callback, void* user_data); + // ASCII characters that end a word, besides blanks. Other characters, including all non-ASCII, are word characters. + void TextSelectSetWordDelimiters(const char* delimiters); + + // Text drawn in between is ignored (as ImGuiItemFlags_NoTextSelect), and clicks on that stretch of the current + // window, from the cursor at Push to the cursor at Pop, do not set the anchor. + void TextSelectPushDisabledRegion(); + void TextSelectPopDisabledRegion(); + + // The next text drawn in the current window copies as this text, once, when any of its glyphs is selected. + // E.g. the full value of a text drawn truncated. Only needed while TextSelectIsCopying(). NULL clears it, e.g. after + // drawing in case nothing reached AddText() (a truncation down to just the ellipsis character). + void TextSelectSetNextCopyText(const char* text, const char* text_end = NULL); + // Treats a rectangle as one selectable unit with the given copy text, e.g. for text drawn with + // ImGuiItemFlags_NoTextSelect or without AddText() (animated digits). Highlights it when selected. + void TextSelectAddText(const ImVec2& p_min, const ImVec2& p_max, const char* text, const char* text_end = NULL); + + // The next text drawn in the current window continues the previous line (a soft wrap done by the caller): + // copies join the two with join (a static string, "" for a word cut in two) instead of a newline. + void TextSelectMarkSoftWrap(const char* join = " "); + // Selects everything in the current window and its child windows (e.g. on Ctrl+A in a text view). + void TextSelectSelectAll(); + // Selects the text inside a rectangle of the current window and copies it on the next frame, with the usual copy + // feedback, e.g. from a click handler. copy_text, when given, is what reaches the clipboard instead. + void TextSelectSelectAndCopy(const ImVec2& p_min, const ImVec2& p_max, const char* copy_text = NULL, const char* copy_text_end = NULL); + + bool TextSelectHasSelection(bool current_window_only = false); // current_window_only: in the current window's root + bool TextSelectIsCopying(); // True during the frame whose text is being collected for a copy + void TextSelectClear(); +}