ocornut/imgui

Tables: content covering the entire row or spanning multiple columns

azmr opened this issue ยท 12 comments

azmr commented
Dear ImGui 1.80 WIP (17905)
--------------------------------
sizeof(size_t): 8, sizeof(ImDrawIdx): 2, sizeof(ImDrawVert): 20
define: __cplusplus=201402
define: _WIN32
define: _WIN64
define: _MSC_VER=1916
define: __clang_version__=9.0.0 (tags/RELEASE_900/final)
--------------------------------
io.BackendPlatformName: imgui_impl_win32
io.BackendRendererName: imgui_impl_dx9
io.ConfigFlags: 0x00000001
 NavEnableKeyboard
io.ConfigInputTextCursorBlink
io.ConfigWindowsResizeFromEdges
io.ConfigWindowsMemoryCompactTimer = 60.0f
io.BackendFlags: 0x0000000E
 HasMouseCursors
 HasSetMousePos
 RendererHasVtxOffset
--------------------------------
io.Fonts: 1 fonts, Flags: 0x00000000, TexSize: 512,128
io.DisplaySize: 944.00,1012.00
io.DisplayFramebufferScale: 1.00,1.00
--------------------------------
style.WindowPadding: 8.00,4.00
style.WindowBorderSize: 1.00
style.FramePadding: 10.00,6.00
style.FrameRounding: 3.00
style.FrameBorderSize: 0.00
style.ItemSpacing: 8.00,4.00
style.ItemInnerSpacing: 8.00,4.00

Branch: tables (using cimgui-generated code)

My Issue/Question:

I'm liking the tables API so far but I've hit the edge of what I can easily see how to do.
I'm trying to have some content (a graph currently) span all columns in a new row of the table.

Some approaches I've tried/considered:

  • Put the graph in the first column & turn on NoClipX - I don't seem to be able to set this midway through the table
  • Add a 0-width column before the first with NoClipX - I don't seem to be able to actually have it at 0-width, even with NoKeepColumnsVisible on the table
  • EndTable, do the graph, then BeginTable again - this is currently slightly awkward given that there are other IDs pushed from TreeNodes on the table. I'd also like to be able to have them all in the same scroll section when I continue the table (although I suppose I could wrap it again in a child window - but then the headers would get lost).
    This would be harder to do if I wanted to nest a table inside the row of another table and have both scrollable and using the same ID (so that their columns move in tandem).
  • I imagine I could copy & mess around with the BeginCell/EndCell code to make the draw rect cover the whole row. This seems like it's likely to break with future versions...

If you were to expand the API, some options might include:

  • A utility function for temporarily popping multiple layers "safely" before repushing them (this is quite possibly overkill).
  • A simpler solution might be for PopID to return the ImGuiID that it takes off the IDStack, to make it easier to collate them on the user-side before pushing them again.
  • An addition to ImGuiTableRowFlags to specify full-row content
  • A more general API for "merging" cells

Screenshots/Video
image

Hello,
Thanks for the detailed issue.

The way Selectable() with ImGuiSelectableFlags_SpanAllColumns works is that it calls PushTableBackground() but it is rather specialized because it push both a full-width clipping rectangle and draw in a specific "background" channel used by cell background and borders. It might work for you and will push the clip rect but WorkRect/ContentsRect won't be set so item width will need to be specified manually.

More general cell-spanning is currently unimplemented and it is in the TODO list (visible in the main Tables thread), but "full row spanning" should be easier to implement as we already have a suitable clip rect.

Some complication coming to mind:

  • it won't be super easy to make it work with the columns borders traversing that row (ideally we'd need the columns borders to stop rendering over that row).
  • what happens for columns auto-sizing become tricky or ambiguous. how does the contents you push into multiple columns affect their desired contents and/or the full table desired contents used for auto-sizing?

I'll have to think about if there are quick paths to get that done, but in the meanwhile if you push a clip rect while in any column you should be able to render that stuff.

Hello again Andrew,

In my initial answer I overlooked one important feature of tables, which is that same-id table share their settings and storage.
Added a demo for it now:

tables_synced

What it means is that in some usage contexts, you might be able to solve your problem by just closing the table, outputting your stuff and then reopening. Now, arguably this is a bit of a workaround and it is likely to incur noticeable overhead if you use it a lot (e.g. for every line). But it may be a good workaround for more occasional use.

azmr commented

Thanks Omar.
Based on your previous recommendation I ended up using a clip rect, which I think is probably the more appropriate solution in my case.
I'll bear this in mind for other, similar situations though.

Hello Omar.

What it means is that in some usage contexts, you might be able to solve your problem by just closing the table, outputting your stuff and then reopening.

Thank you for this suggestion, it helped me a lot!

I would like to point out a small pitfall that I encountered. The ImGui::BeginTableEx function internally constructs an ID based on the user provided ID and the instance number. The instance number is incremented each time you reopen a table with the same ID. This aspect is documented in imgui_internal.h:

struct ImGuiTable {
    ImS16 InstanceCurrent; // Count of BeginTable() calls with same ID in the same frame (generally 0). This is a little bit similar to BeginCount for a window, but multiple table with same ID look are multiple tables, they are just synched.

This means that the ID stack of widgets inside the table(s) can change upon user interaction. In my case this messed with the internal state and behavior of TreeNodeEx widgets. I was able to solve this by manipulating the ID stack directly:

#include "imgui_internal.h"
void f() {
    if (ImGui::BeginTable(outer_table_id, column_count, flags)) {
        bool outer_table_closed = false;
        for (auto const &row : rows) {
            if (outer_table_closed && ImGui::BeginTable(outer_table_id, column_count, flags)) {
                outer_table_closed = false;
            }
            ImGui::TableNextRow();
            ImGui::TableNextColumn();
            bool tree_node_open;
            {
                auto &id_stack = ImGui::GetCurrentWindow()->IDStack;
                auto const outer_table_instance_id = id_stack.back();
                id_stack.pop_back();
                tree_node_open = ImGui::TreeNodeEx("unique tree node", ImGuiTreeNodeFlags_NoTreePushOnOpen);
                id_stack.push_back(outer_table_instance_id);
            }
            if (tree_node_open) {
                ImGui::EndTable();
                outer_table_closed = true;
                ImGui::Indent();
                if (ImGui::BeginTable( /* ... */ )) {
                    ImGui::EndTable();
                }
                ImGui::Unindent();
            }
        }
        if (outer_table_closed == false) {
            ImGui::EndTable();
        }
    }
}

In my opinion this also happens to look better than having the nested table inside a row of the outer table. Win-win : )

Thanks again for the great library, Omar!
Good luck

Hello again Andrew,

In my initial answer I overlooked one important feature of tables, which is that same-id table share their settings and storage.
Added a demo for it now:

tables_synced

What it means is that in some usage contexts, you might be able to solve your problem by just closing the table, outputting your stuff and then reopening. Now, arguably this is a bit of a workaround and it is likely to incur noticeable overhead if you use it a lot (e.g. for every line). But it may be a good workaround for more occasional use.

Thanks for your great idea!
However, I have found an issue with this, if those synced tables do not have same height, for instance, synced table 0 has 3 rows while synced table 1 has 6 rows, I cannot drag inner border on lower part of synced table 1 (the hit test on that part will fail). I have found the related code shown below:

    // At this point OuterRect height may be zero or under actual final height, so we rely on temporal coherency and
    // use the final height from last frame. Because this is only affecting _interaction_ with columns, it is not
    // really problematic (whereas the actual visual will be displayed in EndTable() and using the current frame height).
    // Actual columns highlight/render will be performed in EndTable() and not be affected.
    const float hit_half_width = TABLE_RESIZE_SEPARATOR_HALF_THICKNESS;
    const float hit_y1 = table->OuterRect.Min.y;
    const float hit_y2_body = ImMax(table->OuterRect.Max.y, hit_y1 + table->LastOuterHeight);
    const float hit_y2_head = hit_y1 + table->LastFirstRowHeight;

The reason is because the hit test button is built based on LastOuterHeight, so if the last ended table has less height than the current, the button will not cover the entire current height.

Thanks for your great idea!
However, I have found an issue with this, if those synced tables do not have same height, for instance, synced table 0 has 3 rows while synced table 1 has 6 rows, I cannot drag inner border on lower part of synced table 1 (the hit test on that part will fail). I have found the related code shown below:

Correct, there is a big bug there with synchronized tables.
I will work on a fix and post when done (also see #3955 which reported the same bug separately).
EDIT That bug was fixed see #3955

This would be a great feature and I'd like to upvote. I'm currently writing some code that displays HTML tables in ImGui and was hoping to find the eqivalent of <th colspan="3"> in the ImGui Demo window.

azmr commented

I found this issue again searching for a solution to basically the same problem! ๐Ÿ˜‚

I went back to the code I used to solve this initially (which was specialized to that use-case), and after playing around with it for a bit I've ended up with something more general purpose.
I thought I'd post it here in case it's useful for anyone else.

It pushes/pops a new ClipRect and temporarily modifies the WorkRect (which doesn't have a Push/Pop API) before restoring it. (I don't actually know if the WorkRect needs to be restored - it appears to work without doing so - but it seemed prudent to clear up after myself!)
It's written for cimgui bindings, but should be easy enough to change for the normal API.

There may well be edge cases where this breaks, but so far it seems to work.

/// Make contents in a table cover the entire row rather than just a single column.
///
/// Always covers the entire row (not just the remaining columns);
/// can sort of coexist with per-column data, but may not be as intended.
/// Accounts for:
/// - scrollbar
/// - header row
/// - column reordering
/// - hidden columns
static inline float
igTableFullRowBegin()
{
    ImGuiTable *table = igGetCurrentTable();

    // Set to the first visible column, so that all contents starts from the leftmost point
    for (ImGuiTableColumnIdx *clmn_idx = table->DisplayOrderToIndex.Data,
         *end = table->DisplayOrderToIndex.DataEnd;
         clmn_idx < end; ++clmn_idx)
    {   if (igTableSetColumnIndex(*clmn_idx)) break;   }

    ImRect *work_rect    = &igGetCurrentWindow()->WorkRect;
    float   restore_x    = work_rect->Max.x;
    ImRect  bg_clip_rect = table->BgClipRect; // NOTE: this accounts for header column & scrollbar

    igPushClipRect(bg_clip_rect.Min, bg_clip_rect.Max, 0); // ensure that both our own drawing...
    work_rect->Max.x = bg_clip_rect.Max.x;                 // ...and Dear ImGui drawing will be visible across the entire row

    return restore_x;
}

static inline void
igTableFullRowEnd(float restore_x)
{
    igGetCurrentWindow()->WorkRect.Max.x = restore_x;
    igPopClipRect();
}

Usage:

float restore_x = igTableFullRowBegin();
igSeparatorText("Look - the whole row!"); // whatever content you want here
igTableFullRowEnd(restore_x);
azmr commented

@howprice I haven't tried it but I believe the same technique could be used to create arbitrary-length colspan=N, with a few modifications:

  • There's no need to set the column to the first one.
  • Expand the WorkRect to the end of the (N-1)th column.
  • Contract the right edge of the pushed ClipRect to the new WorkRect.Max.x value.

You could make this work with columns that can be reordered, but I'm not sure if it makes any sense to do so.

v-ein commented

@azmr thanks for that code snippet!

I've noticed that in a table with auto-fit enabled (ImGuiTableFlags_SizingFixedFit), the contents between igTableFullRowBegin and igTableFullRowEnd affects the width of the first column (i.e. this cell is used by auto-fit, too).

To prevent that, one needs to save/restore window->DC.CursorMaxPos.x in addition to saving WorkRect.Max.x (table uses CursorMaxPos.x to determine the contents width for the current column). However, in this case, if the full-width row is wider than the table itself (i.e. the sum of all column widths), it will be cut off.


@ocornut can you please take a look at that code snippet above and give some comments on whether it "feels right", taking into account how the table is rendered? All those channels, merging, layout, etc. - I haven't reached the zen of it yet. I'm curious to know whether such manipulations can degrade performance or lead to other unexpected side effects

P.S. One expected side effect is that vertical inner borders cross through that full-width row (as you mentioned before); I circumvent it by using another nested table with opaque background -- nasty but it works.

I don't have bandwidth/energy to investigate this presently, sorry I have to juggle with things to take care of.

For full row it is a much simpler problem, and you can do what ImGuiSelectableFlags_SpanAllColumns does aka call TablePushBackgroundChannel()/TablePopBackgroundChannel().

One problem with "spanning multiple columns" is that the amount of possible columns pairs is large, so for efficiency we ought to maybe dynamically allocate draw channels for each unique pair that's been being.

For the bordering and other decorations, my expected approach will be to introduce a ImGuiTableRowData structure stored in ImGuiTableTempData where for each visible row we push row metadata, and then TableDrawBorders() called in EndTable() can use that data to do all the rendering. If we use that approach it would have the advantage that we can also steer things toward removing the row bg/cell bg and horizontal line rendering code from TableEndRow() and doing all the bg/decoration rendering as one step which is likely going to be advantageous in term of simplicity and maybe performances.

PS: The code is generally not easy to work with because of performance concerns. It's easy to get things done, but not as easy optimally.

Note that for widgets which may lend themselves to be drawn over full row (and probably SeparatorText is one of those case) we can also add a specialized flag.