ocornut / imgui

Dear ImGui: Bloat-free Graphical User interface for C++ with minimal dependencies
MIT License
61.15k stars 10.3k forks source link

Multiple TextWrapped + SameLine #2313

Open Unit2Ed opened 5 years ago

Unit2Ed commented 5 years ago

Version/Branch of Dear ImGui:

Version: 1.60 Branch: master

Back-end/Renderer/Compiler/OS

Back-ends: imgui_impl_dx11cpp

My Issue/Question:

I'm attempting to change formatting in the middle of a block of text, to support hyperlinks, emoji, colouring etc.

Rather than rely on a built-in markup system (eg #902) I determine the formatting externally and issue multiple ImGui commands to produce the text I need. This is fine when you don't want the text to wrap, but it becomes tricky if you use SameLine with wrapping because each subsequent command attempts to squeeze into the remaining space at the edge of the window, which grows smaller and smaller.

So far I've got it working by using CalcWrapWidthForPos/CalcWordWrapPositionA/TextUnformatted, and it works quite well. I've made a slight modification to CalcWordWrapPositionA - passing in a _linestart parameter that sets the _linewidth local for the start position of only the first line (eg start the first line at 500, and wrap at 600, causing the second line to start back at 0). The screenshot below shows this in action, and what an equivalent sequence of PushWrapPos+TextUnformatted+SameLine commands looks like at the moment.

The code I've written to do this is quite verbose; it would be possible to wrap it up, but I think there's the possibility of integrating something like this line_start parameter deeper into ImGui so that it can do it natively.

Do you think integrating this concept deeper into ImGui is a good idea? Do you have a better idea of how to accomplish this complex text formatting?

I'd appreciate any input you've got on this. Thank you!

Screenshots/Video image

Standalone, minimal, complete and verifiable example: (see https://github.com/ocornut/imgui/issues/2261)

// This is my modified copy of CalcWordWrapPositionA:
const char* ImFont::CalcWordWrapPositionA(float scale, const char* text, const char* text_end, float wrap_width, float line_start) const // CHANGE: New line_start parameter
{
    // Simple word-wrapping for English, not full-featured. Please submit failing cases!
    // FIXME: Much possible improvements (don't cut things like "word !", "word!!!" but cut within "word,,,,", more sensible support for punctuations, support for Unicode punctuations, etc.)

    // For references, possible wrap point marked with ^
    //  "aaa bbb, ccc,ddd. eee   fff. ggg!"
    //      ^    ^    ^   ^   ^__    ^    ^

    // List of hardcoded separators: .,;!?'"

    // Skip extra blanks after a line returns (that includes not counting them in width computation)
    // e.g. "Hello    world" --> "Hello" "World"

    // Cut words that cannot possibly fit within one line.
    // e.g.: "The tropical fish" with ~5 characters worth of width --> "The tr" "opical" "fish"

    float line_width = line_start / scale; // CHANGE: This used to start at 0
    float word_width = 0.0f;
    float blank_width = 0.0f;
    wrap_width /= scale; // We work with unscaled widths to avoid scaling every characters

    const char* word_end = text;
    const char* prev_word_end = line_start > 0.0f ? word_end : NULL;
    bool inside_word = true;

    const char* s = text;
    while (s < text_end)
    {
        unsigned int c = (unsigned int)*s;
        const char* next_s;
        if (c < 0x80)
            next_s = s + 1;
        else
            next_s = s + ImTextCharFromUtf8(&c, s, text_end);
        if (c == 0)
            break;

        if (c < 32)
        {
            if (c == '\n')
            {
                line_width = word_width = blank_width = 0.0f;
                inside_word = true;
                s = next_s;
                continue;
            }
            if (c == '\r')
            {
                s = next_s;
                continue;
            }
        }

        const float char_width = ((int)c < IndexAdvanceX.Size ? IndexAdvanceX[(int)c] : FallbackAdvanceX);
        if (ImCharIsSpace(c))
        {
            if (inside_word)
            {
                line_width += blank_width;
                blank_width = 0.0f;
                word_end = s;
            }
            blank_width += char_width;
            inside_word = false;
        }
        else
        {
            word_width += char_width;
            if (inside_word)
            {
                word_end = next_s;
            }
            else
            {
                prev_word_end = word_end;
                line_width += word_width + blank_width;
                word_width = blank_width = 0.0f;
            }

            // Allow wrapping after punctuation.
            inside_word = !(c == '.' || c == ',' || c == ';' || c == '!' || c == '?' || c == '\"');
        }

        // We ignore blank width at the end of the line (they can be skipped)
        if (line_width + word_width >= wrap_width)
        {
            // Words that cannot possibly fit within an entire line will be cut anywhere.
            if (word_width < wrap_width)
                s = prev_word_end ? prev_word_end : word_end;
            break;
        }

        s = next_s;
    }

    return s;
}

// And this is my test case
if (ImGui::Begin("Text Test", nullptr, ImGuiWindowFlags_NoTitleBar))
{
    struct Segment
    {
        Segment(const char* text, bool underline = false)
            : textStart(text)
            , textEnd(text + strlen(text))
            , colour(colour)
            , underline(underline)
        {}

        const char* textStart;
        const char* textEnd;
        ImColor     colour;
        bool        underline;
    };

    Segment segs[] = { Segment("this is a really super duper long segment that should wrap all on its own "), Segment("http://google.com", ImColor(127,127,255,255), true), Segment(" Short text "), Segment("http://github.com", ImColor(127,127,255,255), true) };

    ImGui::TextColored(ImColor(0, 255, 0, 255), "Half-manual wrapping");

    const float wrapWidth = ImGui::GetWindowContentRegionWidth();
    for (int i = 0; i < sizeof(segs) / sizeof(segs[0]); ++i)
    {
        const char* textStart = segs[i].textStart;
        const char* textEnd = segs[i].textEnd ? segs[i].textEnd : textStart + strlen(textStart);

        ImFont* Font = ImGui::GetFont();

        do
        {
            float widthRemaining = ImGui::CalcWrapWidthForPos(ImGui::GetCursorScreenPos(), 0.0f);
            const char* drawEnd = Font->CalcWordWrapPositionA(1.0f, textStart, textEnd, wrapWidth, wrapWidth - widthRemaining);
            if (textStart == drawEnd)
            {
                ImGui::NewLine();
                drawEnd = Font->CalcWordWrapPositionA(1.0f, textStart, textEnd, wrapWidth, wrapWidth - widthRemaining);
            }

            ImGui::PushStyleColor(ImGuiCol_Text, (ImU32)segs[i].colour);
            ImGui::TextUnformatted(textStart, textStart==drawEnd ? nullptr : drawEnd);
            ImGui::PopStyleColor();
            if (segs[i].underline)
            {
                ImVec2 lineEnd = ImGui::GetItemRectMax();
                ImVec2 lineStart = lineEnd;
                lineStart.x = ImGui::GetItemRectMin().x;
                ImGui::GetWindowDrawList()->AddLine(lineStart, lineEnd, segs[i].colour);

                if (ImGui::IsItemHovered(ImGuiHoveredFlags_RectOnly))
                    ImGui::SetMouseCursor(ImGuiMouseCursor_TextInput);
            }

            if (textStart == drawEnd || drawEnd == textEnd)
            {
                ImGui::SameLine(0.0f, 0.0f);
                break;
            }

            textStart = drawEnd;

            while (textStart < textEnd)
            {
                const char c = *textStart;
                if (ImCharIsSpace(c)) { textStart++; }
                else if (c == '\n') { textStart++; break; }
                else { break; }
            }
        } while (true);
    }

    ImGui::NewLine();

    ImGui::Separator();
    ImGui::TextColored(ImColor(0, 255, 0, 255), "Broken native wrapping");

    ImGui::PushTextWrapPos(ImGui::GetContentRegionAvailWidth());
    for (int i = 0; i < sizeof(segs) / sizeof(segs[0]); ++i)
    {
        ImGui::TextUnformatted(segs[i].textStart, segs[i].textEnd);
        ImGui::SameLine();
    }
    ImGui::PopTextWrapPos();
}
ImGui::End();
ocornut commented 5 years ago

Hello @Unit2Ed and thanks for the details.

I understand the problem.

For a similar issue discussed in the past (not on the github) I had this noted as a shy TODO.txt entry: - font/draw: need to be able to specify wrap start position.

I think it is probable that we can eventually add this parameter in CalcWordWrapPositionA() one way or another, but I believe it would be worthwhile that you first copy the contents of this function locally and push the feature as far as you can/need first.

The reason is - I'm currently accumulating list of TODO related to text/font functions and I expect to do larger refactor of them at some point. There's maybe a dozen of desirable text/font changes and it would be saner to tackle them together and maybe redesign the API of those low-level functions instead of adding more parameters. Underline?

(Some of my recent not-published work on performance measurement are partly ground work to allow me to do this refactor backed by metrics about performance regression - namely because functions like CalcTextSizeA is among one of the known perf bottleneck for very large UI and I can't nilly willy change them without some basic metrics).

Juliette and Doug who are making the game Avoyd have made a Markdown renderer for dear imgui and stumbled on the same problem as you did. They may have a workaround or might be tempted to share their code as-is. There's a work-around in the form of calling CalcWordWrapPositionA yourself and only submitting the text until the end of the line, but modifying the function as you did is a better approach.

I'll keep this in mind and will see if I can tackle a similar feature (perhaps adding a Markdown function in the imgui_club repository would be nice).


For future reference: same test case with simple compilation fix (color was missing from the Segment constructor + using ImU32 and IM_COL32, and ImCharIsSpace() was renamed to ImCharIsBlankA().

// And this is my test case
if (ImGui::Begin("Text Test", nullptr, ImGuiWindowFlags_NoTitleBar))
{
struct Segment
{
    Segment(const char* text, ImU32 col = 0, bool underline = false)
        : textStart(text)
        , textEnd(text + strlen(text))
        , colour(col)
        , underline(underline)
    {}

    const char* textStart;
    const char* textEnd;
    ImU32       colour;
    bool        underline;
};

Segment segs[] = 
{ 
    Segment("this is a really super duper long segment that should wrap all on its own "), 
    Segment("http://google.com", IM_COL32(127,127,255,255), true), 
    Segment(" Short text "), 
    Segment("http://github.com", IM_COL32(127,127,255,255), true)
};

ImGui::TextColored(ImColor(0, 255, 0, 255), "Half-manual wrapping");

const float wrapWidth = ImGui::GetWindowContentRegionWidth();
for (int i = 0; i < IM_ARRAYSIZE(segs); ++i)
{
    const char* textStart = segs[i].textStart;
    const char* textEnd = segs[i].textEnd ? segs[i].textEnd : textStart + strlen(textStart);

    ImFont* Font = ImGui::GetFont();

    do
    {
        float widthRemaining = ImGui::CalcWrapWidthForPos(ImGui::GetCursorScreenPos(), 0.0f);
        const char* drawEnd = Font->CalcWordWrapPositionA(1.0f, textStart, textEnd, wrapWidth, wrapWidth - widthRemaining);
        if (textStart == drawEnd)
        {
            ImGui::NewLine();
            drawEnd = Font->CalcWordWrapPositionA(1.0f, textStart, textEnd, wrapWidth, wrapWidth - widthRemaining);
        }

        if (segs[i].colour)
            ImGui::PushStyleColor(ImGuiCol_Text, segs[i].colour);
        ImGui::TextUnformatted(textStart, textStart == drawEnd ? nullptr : drawEnd);
        if (segs[i].colour)
            ImGui::PopStyleColor();
        if (segs[i].underline)
        {
            ImVec2 lineEnd = ImGui::GetItemRectMax();
            ImVec2 lineStart = lineEnd;
            lineStart.x = ImGui::GetItemRectMin().x;
            ImGui::GetWindowDrawList()->AddLine(lineStart, lineEnd, segs[i].colour);

            if (ImGui::IsItemHovered(ImGuiHoveredFlags_RectOnly))
                ImGui::SetMouseCursor(ImGuiMouseCursor_TextInput);
        }

        if (textStart == drawEnd || drawEnd == textEnd)
        {
            ImGui::SameLine(0.0f, 0.0f);
            break;
        }

        textStart = drawEnd;

        while (textStart < textEnd)
        {
            const char c = *textStart;
            if (ImCharIsBlankA(c)) { textStart++; }
            else if (c == '\n') { textStart++; break; }
            else { break; }
        }
    } while (true);
}

ImGui::NewLine();

ImGui::Separator();
ImGui::TextColored(ImColor(0, 255, 0, 255), "Broken native wrapping");

ImGui::PushTextWrapPos(ImGui::GetContentRegionAvailWidth());
for (int i = 0; i < sizeof(segs) / sizeof(segs[0]); ++i)
{
    ImGui::TextUnformatted(segs[i].textStart, segs[i].textEnd);
    ImGui::SameLine();
}
ImGui::PopTextWrapPos();
}
ImGui::End();
dougbinks commented 5 years ago

Our workaround for markdown is in this gist:

https://gist.github.com/dougbinks/65d125e0c11fba81c5e78c546dcfe7af

We plan to remove the internal code dependencies and make this available at some point but have been a bit saturated with work, but hopefully it's understandable. The bulk of the work you would need is in the RenderTextWrapped function.

dougbinks commented 5 years ago

Links to this in action:

https://twitter.com/dougbinks/status/1005779748407119872 https://twitter.com/ocornut/status/1005483584860442625

We'll try and open source this properly this week.

dougbinks commented 5 years ago

We've updated the gist to remove dependencies, and will open source this properly later.

Unit2Ed commented 5 years ago

Thank you for the detailed response @ocornut (the missing colour constructor param was because I'd considered removing that feature from the snippet to make it more concise, but decided against it and forgot I'd already started removing it - sorry about that!).

I believe that in the short term I'll do as you suggest - maintain & extend the sample I showed to add support for the formatting features I need (as it already mostly works).

On the side I'll experiment locally with what a native solution might look like; I believe changing TextWrapPosStack to a start/end pair (with PushTextWrapPos defaulting the start to the current CursorX), and then tweaking how CalcWrapWidthForPos/CalcTextSize are used, would do the trick. Multiple calls to TextWrapped would produce the same results as they do now, but manual Push/PopTextWrapPos sandwiching regular Text calls would produce a nice flow of text.

Thank you for the gist @dougbinks; markdown support is something I've been considering too, so this sample will be useful.

juliettef commented 5 years ago

Following up on @dougbinks' comment we've open-sourced imgui_markdown.

fourst4r commented 4 years ago

On the off chance that this hasn't been suggested before, may I suggest a BeginText/EndText style API as a solution? You could embed other widgets inline easily in the text block with proper wrapping, this, for example, would allow someone to simply use a SmallButton for a hyperlink, and images for custom emojis etc. I admit I am naive to the inner workings of ImGui so I don't know how feasible this is.

This is the OP's example with this imagined API.

ImGui::BeginText("TextID", ImGuiTextFlagsWrap|ImGuiTextFlagsSameLine);
ImGui::Text("this is a really super duper long segment that should wrap all on its own ");
ImGui::SimpleButton("http://google.com"); // style it to look like a hyperlink if you want
ImGui::Text(" Short text ");
ImGui::SimpleButton("http://github.com");
ImGui::EndText();
CodingMadness commented 2 years ago

@ocornut I currently have the exact issue in my code with textwrapping occuring very wierdly at the edge of the window-width. Is there any news to this how to make text-spans properly wrap for next lines ? Any help on this is much appreciated.