Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Table range select #7424

Open
tasiek30 opened this issue Mar 20, 2024 · 16 comments
Open

Table range select #7424

tasiek30 opened this issue Mar 20, 2024 · 16 comments

Comments

@tasiek30
Copy link

Version/Branch of Dear ImGui:

Version 1.90.1, Branch: docking

Back-ends:

imgui_impl_win32.cpp + imgui_impl_opengl3.cpp

Compiler, OS:

Windows 11 + GCC 12.2.0

Full config/build information:

No response

Details:

I have question what is best method to implement range select in table.

I have to implement table with scalar input. In this table I need to be able to select an area and change the values ​​in all selected cells.
Also I have to know which cells have been changed.

At this moment i pass to my function vectors that hold cell state.

Problems:

  • Current selection method need to hold each cell flag, and cell background is set in next loop iteration
  • Selection is not limited to only one table (video)
  • After i.e. change window size keyboard focus is lost

Maybe someone have some ideas how to improve my implementation?

Screenshots/Video:

table.mp4

Minimal, Complete and Verifiable Example code:

static ImVec2 sel_start, sel_end;
static bool selActive;

static bool SelectionRect(ImVec2& start_pos, ImVec2& end_pos, ImGuiMouseButton mouse_button)
{
    //IM_ASSERT(start_pos != NULL);
    //IM_ASSERT(end_pos != NULL);
    bool selecting = false;
    if (ImGui::IsMouseClicked(mouse_button))
        start_pos = ImGui::GetMousePos();
    if (ImGui::IsMouseDown(mouse_button)) {
        selecting = true;
        end_pos = ImGui::GetMousePos();
        ImDrawList* draw_list = ImGui::GetForegroundDrawList(); //ImGui::GetWindowDrawList();
        draw_list->AddRect(start_pos, end_pos, ImGui::GetColorU32(IM_COL32(0, 130, 216, 255)));   // Border
        draw_list->AddRectFilled(start_pos, end_pos, ImGui::GetColorU32(IM_COL32(0, 130, 216, 50)));    // Background
    }
    //return ImGui::IsMouseReleased(mouse_button);
    return selecting;
}

static bool isSelected(ImVec2& sel_min, ImVec2& sel_max, ImVec2& item_min, ImVec2& item_max ) {
    ImVec2 tmp_min = sel_min;
    ImVec2 tmp_max = sel_max;

    if(tmp_min.x > tmp_max.x) {
        float x_min = tmp_min.x;
        float x_max = tmp_max.x;
        tmp_min.x = x_max;
        tmp_max.x = x_min;
    }

    if(tmp_min.y > tmp_max.y) {
        float y_min = tmp_min.y;
        float y_max = tmp_max.y;
        tmp_min.y = y_max;
        tmp_max.y = y_min;
    }

    if(    tmp_min.x < item_max.x
        && tmp_min.y < item_max.y
        && tmp_max.x > item_min.x
        && tmp_max.y > item_min.y) {
        return true;
    }
    else {

    }
    return false;
}
void * getter(void * base, int index, int stride) {
    return base+(index*stride);
}
bool GUI::wdgMapNew(const char* label,
        int sizeX,
        int sizeY,
        float* valX,
        float* valY,
        float* valV,
        bool* selected,
        bool* edited,
        int lenX,
        int lenY,
        int stride,
        const char* formatX,
        const char* formatY,
        const char* formatV) {

    bool isEdited = false;

    bool firstCellFlag = false;
    bool setSelectionFlag = false;
    float toSet = 0.0f;

    ImGuiTableFlags flags =
                    ImGuiTableFlags_Borders | ImGuiTableFlags_NoPadInnerX | ImGuiTableFlags_NoPadOuterX;
                    //ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY |
                    //ImGuiTableFlags_NoHostExtendX | ImGuiTableFlags_NoHostExtendY;
    ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0));
    ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(0, 0));

    if ( ImGui::BeginTable(label, sizeX+1, flags) ) {
        //bool selActive = SelectionRect(sel_start, sel_end);


        ImGui::PushID(label);
        ImGui::TableNextColumn(); // first empty, could be i.e. units? bar/rpm

        for(int x=0; x<sizeX; x++) {
            if(x >= lenX) {
                break;
            }
            ImGui::TableNextColumn();
            ImGui::PushID(x);
            ImGui::SetNextItemWidth(-FLT_MIN);
            ImGui::InputScalar("##AxisX", ImGuiDataType_Float, getter(valX, x, stride), NULL, NULL, formatX);
            ImGui::PopID();
        }

        ImGui::PushStyleColor(ImGuiCol_FrameBg, ImGui::GetStyleColorVec4(ImGuiCol_ChildBg));
        for(int y=0; y<sizeY; y++) {
            ImGui::TableNextRow();
            ImGui::TableNextColumn();

            if(y < lenY) {
                ImGui::PushID(y);
                ImGui::SetNextItemWidth(-FLT_MIN);
                ImGui::InputScalar("##AxisY", ImGuiDataType_Float, getter(valY, y, stride), NULL, NULL, formatY);
                ImGui::PopID();
            }

            for(int x=0; x<sizeX; x++) {
                int index = y*lenX + x;
                if(index >= lenX*lenY) {
                    break;
                }
                ImGui::TableNextColumn();
                ImGui::PushID(index);
                float prev = *(float*)getter(valV, index, stride);
                bool * pIsSelected = (bool*)getter(selected, index, stride);
                if(*pIsSelected) {
                    ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 130, 216, 50));

                    if(!firstCellFlag) {
                        firstCellFlag = true;
                        if(selActive) {
                            ImGui::SetKeyboardFocusHere();
                        }
                    }
                    if(setSelectionFlag) {
                        *(float*)getter(valV, index, stride) = toSet;
                    }
                }

                ImGui::SetNextItemWidth(-FLT_MIN);

                if (ImGui::InputScalar("##Data", ImGuiDataType_Float, getter(valV, index, stride), NULL, NULL, formatV, ImGuiInputTextFlags_EnterReturnsTrue) ) {
                    // on enter edit all other values
                    *(bool*)getter(edited, index, stride) = true;
                }
                else {
                    *(bool*)getter(edited, index, stride) = false;
                }

                // catch event when there is no change in value, but i want to set other cells
                bool enterPressed = ImGui::IsKeyPressed(ImGuiKey_Enter) | ImGui::IsKeyPressed(ImGuiKey_KeypadEnter);
                if( enterPressed && ImGui::IsItemDeactivated() ) {
                    setSelectionFlag = true;
                    toSet = *(float*)getter(valV, index, stride);
                    isEdited = true;
                }

                // check if item changed
                if(isEdited) {
                    if(prev != *(float*)getter(valV, index, stride)) {
                        *(bool*)getter(edited, index, stride) = true;
                    }
                    else {
                        *(bool*)getter(edited, index, stride) = false;
                    }
                }
                if(*pIsSelected) {
                    ImGui::PopStyleColor();
                }
                ImVec2 cell_start = ImGui::GetItemRectMin();
                ImVec2 cell_end = ImGui::GetItemRectMax();
                if(selActive) {
                    *pIsSelected = isSelected(sel_start, sel_end, cell_start, cell_end);
                }
                ImGui::PopID();
            }
        }
        ImGui::PopStyleColor();

        ImGui::PopID();

        ImGui::EndTable();

        bool isActive = ImGui::IsItemActive();
        bool isHovered = ImGui::IsItemHovered(ImGuiHoveredFlags_RectOnly);
        bool isFocused = ImGui::IsItemFocused();

        if(ImGui::IsItemHovered(ImGuiHoveredFlags_RectOnly)) {
            selActive = SelectionRect(sel_start, sel_end);
        }
        else {
           // selActive = false;
        }

        ImGui::Text("active: %d, hovered: %d, focused: %d, selActive: %d", isActive, isHovered, isFocused, selActive);
    }
    ImGui::PopStyleVar(2);

    return isEdited;
}
typedef struct {
    float val;
    bool isSelected;
    bool isEdited;
}TABLE_CELL;

class TABLE {
public:
    TABLE() {};
    TABLE(unsigned int sizeX, unsigned int sizeY) {
        resize(sizeX, sizeY);
    }
    ~TABLE() {};

    int cellSize(void) { return sizeof(TABLE_CELL); }

    void resize(unsigned int sizeX, unsigned int sizeY) {
        X.resize(sizeX);
        Y.resize(sizeY);
        V.resize(sizeX*sizeY);
    }

public:
    std::vector<TABLE_CELL> X;
    std::vector<TABLE_CELL> Y;
    std::vector<TABLE_CELL> V;
};

static bool wdgTable(const char* label, TABLE &t) {
    return GUI::wdgMapNew(label,
                        t.X.size(),
                        t.Y.size(),
                        &t.X[0].val,
                        &t.Y[0].val,
                        &t.V[0].val,
                        &t.V[0].isSelected,
                        &t.V[0].isEdited,
                        t.X.size(),
                        t.Y.size(),
                        t.cellSize(),
                        "%.0f", "%.0f");
}
@PathogenDavid
Copy link
Contributor

Have you tried the range_select branch to see if it meets your needs?

I know Omar is looking for feedback on it, and in the near future it should be the canonical way to handle what you're doing.

It can be merged into docking with some minor conflict resolution.

@tasiek30
Copy link
Author

I need couple days to try it :)

@tasiek30
Copy link
Author

This feature looks very promising. But actually i don't know if there is posibility to select input widgets i.e. InputScalar?. Also i have some strange behavior during rectangle selection (video)

Nagrywanie.2024-04-18.134417.mp4
    ImGuiMultiSelectIO* ms_io = ImGui::BeginMultiSelect(ImGuiMultiSelectFlags_ClearOnEscape | ImGuiMultiSelectFlags_ClearOnClickVoid | ImGuiMultiSelectFlags_BoxSelect2d);

    static ImGuiSelectionBasicStorage selection;
    selection.ApplyRequests(ms_io, 16*16);

    ImGui::Text("Selection: %d/%d", selection.Size, 16*16);
    if ( ImGui::BeginTable("MAP NEW", 16, flags) ) {
        ImGui::PushStyleColor(ImGuiCol_FrameBg, ImGui::GetStyleColorVec4(ImGuiCol_ChildBg));
        for(int y=0; y<16; y++) {
            ImGui::TableNextRow();
            for(int x=0; x<16; x++) {
                ImGui::TableNextColumn();
                ImGui::PushID(y+x*16);
                ImGui::SetNextItemWidth(50.0);

                bool IsSelected = selection.Contains((ImGuiID)(y+x*16));

                ImGui::SetNextItemSelectionUserData(y+x*16);

                if(IsSelected) {
                    ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 130, 216, 50));
                }

                char label[64];
                sprintf(label, "%d", y+x*16);
                ImGui::Selectable(label, IsSelected);
                //ImGui::Text(label);
                //ImGui::Checkbox(label, (bool*)&data[y][x]);
                /*if (ImGui::InputScalar("##Data", ImGuiDataType_Float, &data[y][x], NULL, NULL, "%.2f", ImGuiInputTextFlags_EnterReturnsTrue) ) {
                    dataToSet = data[y][x];
                }*/

                if(IsSelected) {
                    ImGui::PopStyleColor();
                }

                ImGui::PopID();
            }
        }
        ImGui::PopStyleColor();
        ImGui::EndTable();
    }

    ms_io = ImGui::EndMultiSelect();

    selection.ApplyRequests(ms_io, 16*16);

@ocornut
Copy link
Owner

ocornut commented Apr 18, 2024

Thank you for your thoughtful and careful repro, I will investigate it.

An InputScalar() is not selectable but I'm not sure what it would mean to select it.
Would the underlying intent to e.g. select many fields and type in all of them together ?

@ocornut
Copy link
Owner

ocornut commented Apr 18, 2024

Note how you are using "y + x * 16" everywhere, meaning your selectables are not submitted in the same sequential order as their value, and by default ImGuiSelectionBasicStorage assume that value passed to ImGui::SetNextItemSelectionUserData() are interpolable indices.

[A] If you instead use:

//int idx = y + x * 16; // Broken
int idx = x + y * 16; // OK

(and change all values to use idx)

Note the value order now goes left-to-right, top-to-bottom:
image

Then it works, but note that shift+down/up assume a type of selection that's not necessarily what you want here (I think we may need a flag to make shift+select use 2d coordinates rather than sequential?). That's the case for all three alternatives.

[B] Alternatively, you change change the idx->stored selection id mapping:

selection.AdapterIndexToStorageId = [](ImGuiSelectionBasicStorage*, int idx)
{
    int x = idx % 16;
    int y = idx / 16;
    return (ImGuiID)(y + x * 16);
};
int idx = x + y * 16; // Submission index
int id = y + x * 16; // ID (== selection.AdapterIndexToStorageId(idx))
//IM_ASSERT(selection.AdapterIndexToStorageId(&selection, idx) == id);
[...]
ImGui::PushID(idx); // <-- here it doesn't matter which you use as long as it is unique
[...]
ImGui::SetNextItemSelectionUserData(idx); // <-- here you pass index
[...]
bool IsSelected = selection.Contains((ImGuiID)(id)); // <-- Stored selection ID, == selection.AdapterIndexToStorageId(idx)

image

[C] A third alternative would to submit items in the same order as the id you want to use. aka fill entire column first, but it may be harder to perform clipping there.

(
I also found an issue when using box-select in a window that is not a child window.
The current logic prevents focusing, steals hovers and nav id. I pushed a mitigation (a304677) to allow clicking on title bar at least, and will need to revisit some of the logic for box-select.
)

This is really useful feedback as I found two things to improve already. Thanks!

@ocornut
Copy link
Owner

ocornut commented Apr 18, 2024

(I also found an issue when using box-select in a window that is not a child window.
The current logic prevents focusing, steals hovers and nav id. I pushed a mitigation to allow clicking on title bar at least, and will need to revisit some of the logic for box-select.)

Pushed a better fix d60299d for both ScopeWindow and ScopeRect cases.

@tasiek30
Copy link
Author

Thank you for your thoughtful and careful repro, I will investigate it.

An InputScalar() is not selectable but I'm not sure what it would mean to select it. Would the underlying intent to e.g. select many fields and type in all of them together ?

Yes exactly. I need to select cells and be able to change them to same value, increase all or even extrapolate (smooth)

@ocornut
Copy link
Owner

ocornut commented Apr 19, 2024

Yes exactly. I need to select cells and be able to change them to same value, increase all or even extrapolate (smooth)

I think in this case it makes sense to display and focus a single InputText() widget, and when edited apply the value to all of selection.
Aka you don't need (and you cannot have) multiple active InputText(), but one can represent the selection.

@tasiek30
Copy link
Author

Is this possible to draw this InputText Over Selectable?

@ocornut
Copy link
Owner

ocornut commented Apr 19, 2024

Yes, you need to pass ImGuiSelectableFlags_AllowOverlap to the `Selectable().

See Demo->Layout->Overlap Mode or Demo->Widgets->Selectables->Rendering more items on the same line

@ocornut
Copy link
Owner

ocornut commented Apr 19, 2024

I am going to try working on a simple demo to demonstrate a grid with text editable items that allows multi-write like this.

@ocornut
Copy link
Owner

ocornut commented Apr 19, 2024

I wrote a draft of it but it doesn't allow to multi-edit as currently multi-select system has a bias toward unselecting others when e.g. pressing enter on an item, so may need an improvement of multi-select.

void DemoSelectableEditableGrid()
{
    ImGui::Begin("Selection #7424");
    {
        const int COUNT_X = 10;
        const int COUNT_Y = 16;
        const int COUNT = COUNT_X * COUNT_Y;
        static ImGuiSelectionBasicStorage selection;
        static float data[COUNT];
        static int editing_n = -1;
        static int focus_n_next = -1;

        // FIXME: don't clear selection when clicking selected item
        ImGuiMultiSelectIO* ms_io = ImGui::BeginMultiSelect(ImGuiMultiSelectFlags_ClearOnEscape | ImGuiMultiSelectFlags_BoxSelect2d);
        selection.ApplyRequests(ms_io, COUNT);
        ImGui::Text("Selection: %d/%d", selection.Size, COUNT);

        const int focus_n_curr = focus_n_next;
        focus_n_next = -1;
        if (ImGui::BeginTable("Array", COUNT_X, ImGuiTableFlags_Borders))
        {
            for (int n = 0; n < COUNT; n++)
            {
                ImGui::TableNextColumn(); // Next cell w/ auto-wrap
                ImGui::PushID(n);

                const bool is_selected = selection.Contains((ImGuiID)n);
                ImGui::SetNextItemSelectionUserData(n);

                ImVec2 p = ImGui::GetCursorScreenPos();
                if (focus_n_curr == n)
                    ImGui::SetKeyboardFocusHere(0);

                if (editing_n != n)
                {
                    char label[64];
                    sprintf(label, "%g###sel", data[n]);
                    ImGui::Selectable(label, is_selected);
                    if (ImGui::IsItemClicked() && ImGui::IsMouseDoubleClicked(0))
                        editing_n = focus_n_next = n;
                    if (ImGui::IsItemFocused() && ImGui::IsKeyPressed(ImGuiKey_Enter))
                        editing_n = focus_n_next = n;
                }
                if (editing_n == n)
                {
                    // May be easier if we had a public-facing version of TempInputXXX functions
                    ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, ImGui::GetColorU32(ImGuiCol_Header));
                    ImGui::SetCursorScreenPos(p);
                    ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0));
                    ImGui::SetNextItemWidth(-FLT_MIN);
                    // FIXME: May want to use InputText() and convert empty string to 0.0f (vs InputFloat preserve value)
                    ImGui::InputFloat("###edit", &data[n], 0.0f, 0.0f, "%g");
                    if (ImGui::IsItemDeactivated())
                    {
                        editing_n = -1;
                        if (ImGui::IsItemFocused() && !ImGui::IsMouseClicked(0))
                            focus_n_next = n;
                    }
                    ImGui::PopStyleVar();
                }
                ImGui::PopID();
            }
            ImGui::EndTable();
        }
        ms_io = ImGui::EndMultiSelect();
        selection.ApplyRequests(ms_io, COUNT);
    }
    ImGui::End();
}

selectable_editable_grid

Honestly this is the kind of thing where there are lots of subtleties which are not trivial to get right/perfect with dear imgui, so it'll require more work. It'll be an interesting demo.

@tasiek30
Copy link
Author

Hello

Thank @ocornut for Your reply. I think it is almost done.... One bad thing is that i have changed a little ImGui source code. This should be done in more sophisticated way.

table.mp4
void DemoSelectableEditableGrid()
{
    ImGui::Begin("Selection #7424");
    {
        const int COUNT_X = 10;
        const int COUNT_Y = 16;
        const int COUNT = COUNT_X * COUNT_Y;
        static ImGuiSelectionBasicStorage selection;
        static float data[COUNT];
        static int editing_n = -1;
        static int focus_n_next = -1;
        static bool set_flag = false;
        static bool change_flag = false;
        float change = 0.0f;

        // FIXME: don't clear selection when clicking selected item
        ImGuiMultiSelectIO* ms_io = ImGui::BeginMultiSelect(
                ImGuiMultiSelectFlags_ClearOnEscape
                | ImGuiMultiSelectFlags_BoxSelect2d
                | ImGuiMultiSelectFlags_ClearOnClickVoid
                );
        selection.ApplyRequests(ms_io, COUNT);
        ImGui::Text("Selection: %d/%d", selection.Size, COUNT);

        const int focus_n_curr = focus_n_next;
        focus_n_next = -1;
        if (ImGui::BeginTable("Array", COUNT_X, ImGuiTableFlags_Borders))
        {
            if(ImGui::IsKeyPressed(ImGuiKey_KeypadAdd)) {
                change_flag = true;
                change = 1.0f;
            }
            else if(ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract)) {
                change_flag = true;
                change = -1.0f;
            }

            for (int n = 0; n < COUNT; n++)
            {
                ImGui::TableNextColumn(); // Next cell w/ auto-wrap
                ImGui::PushID(n);

                const bool is_selected = selection.Contains((ImGuiID)n);
                ImGui::SetNextItemSelectionUserData(n);

                ImVec2 p = ImGui::GetCursorScreenPos();
                if (focus_n_curr == n)
                    ImGui::SetKeyboardFocusHere(0);

                char label[64];
                sprintf(label, "%g###sel", data[n]);
                ImGui::SetNextItemAllowOverlap();
                ImGui::Selectable(label, is_selected);

                if (ImGui::IsItemClicked() && ImGui::IsMouseDoubleClicked(0)) {
                    editing_n = focus_n_next = n;
                }
                if (ImGui::IsItemFocused() && ImGui::IsKeyPressed(ImGuiKey_Enter)) {
                    editing_n = focus_n_next = n;
                }

                if (editing_n == n)
                {
                    // May be easier if we had a public-facing version of TempInputXXX functions
                    ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, ImGui::GetColorU32(ImGuiCol_Header));
                    ImGui::SetCursorScreenPos(p);
                    ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0));
                    ImGui::SetNextItemWidth(-FLT_MIN);
                    // FIXME: May want to use InputText() and convert empty string to 0.0f (vs InputFloat preserve value)
                    ImGui::SetKeyboardFocusHere(0);

                    ImGui::InputFloat("###edit", &data[n], 0.0f, 0.0f, "%g");
                    if (ImGui::IsItemDeactivated())
                    {
                        set_flag = true;
                        change = data[n];
                        editing_n = -1;
                        if (ImGui::IsItemFocused() && !ImGui::IsMouseClicked(0))
                            focus_n_next = n;
                    }
                    ImGui::PopStyleVar();
                }
                ImGui::PopID();
            }
            ImGui::EndTable();
        }
        ms_io = ImGui::EndMultiSelect();
        selection.ApplyRequests(ms_io, COUNT);

        if(set_flag) {
            set_flag = false;
            for(int n = 0; n < COUNT; n++) {
                if(selection.Contains((ImGuiID)n) ) {
                    data[n] = change;
                }
            }
        }

        if(change_flag) {
            change_flag = false;
            for(int n = 0; n < COUNT; n++) {
                if(selection.Contains((ImGuiID)n) ) {
                    data[n] += change;
                }
            }
        }

    }
    ImGui::End();
}

In Source code i just commented out clear request when focus is lost. ImGuiMultiSelectIO* ImGui::BeginMultiSelect(ImGuiMultiSelectFlags flags) in imgui_widgets.cpp

    else if (g.NavJustMovedFromFocusScopeId == ms->FocusScopeId)
    {
        // Also clear on leaving scope (may be optional?)
        if ((ms->KeyMods & (ImGuiMod_Ctrl | ImGuiMod_Shift)) == 0 && (flags & (ImGuiMultiSelectFlags_NoAutoClear | ImGuiMultiSelectFlags_NoAutoSelect)) == 0) {
            //request_clear = true;
        }
    }

@ocornut
Copy link
Owner

ocornut commented Apr 25, 2024

In Source code i just commented out clear request when focus is lost. ImGuiMultiSelectIO* ImGui::BeginMultiSelect(ImGuiMultiSelectFlags flags) in imgui_widgets.cpp

This is not specifically when focus is lost but when LEAVING the current scope.
Can you clarify why you want/need to disable it? (then I can see if it's worth adding an option for it)

@tasiek30
Copy link
Author

In Source code i just commented out clear request when focus is lost. ImGuiMultiSelectIO* ImGui::BeginMultiSelect(ImGuiMultiSelectFlags flags) in imgui_widgets.cpp

This is not specifically when focus is lost but when LEAVING the current scope. Can you clarify why you want/need to disable it? (then I can see if it's worth adding an option for it)

It was the easiest way to keep selection after enter pressed and switch focus to InputFloat

@ocornut
Copy link
Owner

ocornut commented Apr 25, 2024

It was the easiest way to keep selection after enter pressed and switch focus to InputFloat

OK so that's a workaround but I will find a way to design a solution for it. Thanks for clarifying!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants