// *****************************************************************************
// * This file is part of the FreeFileSync project. It is distributed under    *
// * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0          *
// * Copyright (C) Zenju (zenju AT freefilesync DOT org) - All Rights Reserved *
// *****************************************************************************

#include "grid.h"
#include <cassert>
#include <chrono>
#include <wx/settings.h>
#include <wx/tooltip.h>
#include <wx/timer.h>
#include <zen/basic_math.h>
#include <zen/string_tools.h>
#include <zen/scope_guard.h>
#include <zen/utf.h>
#include <zen/zstring.h>
#include <zen/format_unit.h>
#include "color_tools.h"
#include "dc.h"

    #include <gtk/gtk.h>

using namespace zen;

/* wxWidgets 3.3 defaults to system-powered double-buffering (WS_EX_COMPOSITED) on Windows:
    => ~60% higher CPU time (test case: scrolling large file list via keyboard) see comment in file_grid.cpp :((

        "wxMSW now uses double buffering by default, meaning that updating the
         windows using wxClientDC doesn't work any longer, which is consistent with
         the behaviour of wxGTK with Wayland backend and of wxOSX, but not with the
         traditional historic behaviour of wxMSW (or wxGTK/X11).
         You may call MSWDisableComposited() to restore the previous behaviour [...]"

    WS_EX_COMPOSITED    "Paints all *descendants* of a window in bottom-to-top painting order using double-buffering."

    => can only be set for *top* window below the wxFrame/wxDialog otherwise SetWindowLongPtr(WS_EX_COMPOSITED) fails!!!

    => use MSWDisableComposited() to remove WS_EX_COMPOSITED from top window under wxFrame/wxDialog if perf issue.
       SetDoubleBuffered(false) OTOH is useless as it doesn't affect parent windows and silently fails!
       IsDoubleBuffered() however correctly checks parents: CONSISTENCY, people, for fucks sake!
     
    CAVEAT: MSWDisableComposited() leads to severe flickering for other child windows (e.g. wxStaticBitmap, wxBitmapButton)
        that lack custom double-buffering. It's even worse since wxWidgets in its wisdom sets WS_EX_COMPOSITED
        together with CS_HREDRAW/CS_VREDRAW, https://github.com/vadz/wxWidgets/blob/8de0694a5e9c9d7c24e0af2ccf71454df5e6b9d0/src/msw/window.cpp#L507
        and MSWDisableComposited() only removes former attribute.      */


//let's NOT create wxWidgets objects statically:
wxColor GridData::getColorSelectionGradientFrom() { return {137, 172, 255}; } //blue: HSL: 158, 255, 196   HSV: 222, 0.46, 1
wxColor GridData::getColorSelectionGradientTo  () { return {225, 234, 255}; } //      HSL: 158, 255, 240   HSV: 222, 0.12, 1

int GridData::getColumnGapLeft() { return dipToWxsize(4); }


namespace
{
//------------------------------ Grid Parameters --------------------------------
wxColor getColorLabelText(bool enabled) { return wxSystemSettings::GetColour(enabled ? wxSYS_COLOUR_BTNTEXT : wxSYS_COLOUR_GRAYTEXT); }
wxColor getColorGridLine()              { return wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW); }

wxColor getColorLabelGradientFrom()
{
    if (wxSystemSettings::GetAppearance().IsDark()) //upper gradient part must always be lighter than lower part!
    {
        const wxColor backCol = wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE);

        auto liftChannel = [](unsigned char c) { return static_cast<unsigned char>(std::clamp(c + 25, 0, 255)); };

        return wxColor(liftChannel(backCol.Red  ()),
                       liftChannel(backCol.Green()),
                       liftChannel(backCol.Blue ()));
    }
    else
        return wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW);
}
wxColor getColorLabelGradientTo() { return wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE); }

wxColor getColorLabelGradientFocusFrom() { return wxSystemSettings::GetAppearance().IsDark() ? wxSystemSettings::GetColour(wxSYS_COLOUR_BTNHIGHLIGHT) : getColorLabelGradientFrom(); }
wxColor getColorLabelGradientFocusTo  () { return wxSystemSettings::GetAppearance().IsDark() ? getColorLabelGradientTo() : GridData::getColorSelectionGradientFrom(); }

const double MOUSE_DRAG_ACCELERATION_DIP = 1.5; //unit: [rows / (DIP * sec)] -> same value like Explorer!
const int DEFAULT_COL_LABEL_BORDER_DIP   =  6; //top + bottom border in addition to label height
const int COLUMN_MOVE_DELAY_DIP          =  5; //unit: [pixel] (from Explorer)
const int COLUMN_MIN_WIDTH_DIP           = 40; //only honored when resizing manually!
const int ROW_LABEL_BORDER_DIP           =  3;
const int COLUMN_RESIZE_TOLERANCE_DIP    =  6; //unit [pixel]
const int COLUMN_FILL_GAP_TOLERANCE_DIP  = 10; //enlarge column to fill full width when resizing
const int COLUMN_MOVE_MARKER_WIDTH_DIP   =  3;

const bool fillGapAfterColumns = true; //draw rows/column label to fill full window width; may become an instance variable some time?

/* IsEnabled() vs IsThisEnabled() since wxWidgets 2.9.5:

    void wxWindowBase::NotifyWindowOnEnableChange(), called from bool wxWindowBase::Enable(), fails to refresh
    child elements when disabling a IsTopLevel() dialog, e.g. when showing a modal dialog.
    The unfortunate effect on XP for using IsEnabled() when rendering the grid is that the user can move the modal dialog
    and *draw* with it on the background while the grid refreshes as disabled incrementally!

    => Don't use IsEnabled() since it considers the top level window, but a disabled top-level should NOT
    lead to child elements being rendered disabled!

    => IsThisEnabled() OTOH is too shallow and does not consider parent windows which are not top level.

    The perfect solution would be a bool renderAsEnabled() { return "IsEnabled() but ignore effects of showing a modal dialog"; }

    However "IsThisEnabled()" is good enough (same as old IsEnabled() on wxWidgets 2.8.12) and it avoids this pathetic behavior on XP.
    (Similar problem on Win 7: e.g. directly click sync button without comparing first)

    => 2018-07-30: roll our own:                            */
bool renderAsEnabled(wxWindow& win)
{
    if (win.IsTopLevel())
        return true;

    if (wxWindow* parent = win.GetParent())
        return win.IsThisEnabled() && renderAsEnabled(*parent);
    else
        return win.IsThisEnabled();
}
}

//----------------------------------------------------------------------------------------------------------------
namespace zen
{
wxDEFINE_EVENT(EVENT_GRID_MOUSE_LEFT_DOUBLE, GridClickEvent);
wxDEFINE_EVENT(EVENT_GRID_MOUSE_LEFT_DOWN,   GridClickEvent);
wxDEFINE_EVENT(EVENT_GRID_MOUSE_RIGHT_DOWN,  GridClickEvent);
wxDEFINE_EVENT(EVENT_GRID_SELECT_RANGE, GridSelectEvent);
wxDEFINE_EVENT(EVENT_GRID_COL_LABEL_MOUSE_LEFT,  GridLabelClickEvent);
wxDEFINE_EVENT(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, GridLabelClickEvent);
wxDEFINE_EVENT(EVENT_GRID_COL_RESIZE, GridColumnResizeEvent);
wxDEFINE_EVENT(EVENT_GRID_CONTEXT_MENU, GridContextMenuEvent);
}
//----------------------------------------------------------------------------------------------------------------

void GridData::renderRowBackgound(wxDC& dc, const wxRect& rect, size_t row, bool enabled, bool selected, HoverArea rowHover)
{
    if (enabled)
    {
        if (selected)
            dc.GradientFillLinear(rect, getColorSelectionGradientFrom(), getColorSelectionGradientTo(), wxEAST);
        //else: clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); -> already the default
    }
    else
        clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_BTNFACE));
}


void GridData::renderCell(wxDC& dc, const wxRect& rect, size_t row, ColumnType colType, bool enabled, bool selected, HoverArea rowHover)
{
    wxDCTextColourChanger textColor(dc);
    if (enabled && selected) //accessibility: always set *both* foreground AND background colors!
        textColor.Set(*wxBLACK);

    wxRect rectTmp = drawCellBorder(dc, rect);

    rectTmp.x     += getColumnGapLeft();
    rectTmp.width -= getColumnGapLeft();
    drawCellText(dc, rectTmp, getValue(row, colType));
}


int GridData::getBestSize(const wxReadOnlyDC& dc, size_t row, ColumnType colType)
{
    return dc.GetTextExtent(getValue(row, colType)).GetWidth() + 2 * getColumnGapLeft() + dipToWxsize(1); //gap on left and right side + border
}


wxRect GridData::drawCellBorder(wxDC& dc, const wxRect& rect) //returns remaining rectangle
{
    clearArea(dc, {rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height}, getColorGridLine()); //right border
    clearArea(dc, {rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)}, getColorGridLine()); //bottom border

    return {rect.x, rect.y, rect.width - dipToWxsize(1), rect.height - dipToWxsize(1)};
}


void GridData::drawCellText(wxDC& dc, const wxRect& rect, const std::wstring_view text, int alignment, const wxSize* textExtentHint)
{
    /* Performance Notes (Windows):
        - wxDC::GetTextExtent() is by far the most expensive call (20x more expensive than wxDC::DrawText())
        - wxDC::DrawLabel() is inefficiently implemented; internally calls: wxDC::GetMultiLineTextExtent(), wxDC::GetTextExtent(), wxDC::DrawText()
        - wxDC::GetMultiLineTextExtent() calls wxDC::GetTextExtent()
        - wxDC::DrawText also calls wxDC::GetTextExtent()!!
        => wxDC::DrawLabel() boils down to 3(!) calls to wxDC::GetTextExtent()!!!
        - wxDC::DrawLabel results in GetTextExtent() call even for empty strings!!!
        => NEVER EVER call wxDC::DrawLabel() cruft and directly call wxDC::DrawText()!                   */
    assert(!contains(text, L'\n'));
    if (rect.width <= 0 || rect.height <= 0 || text.empty())
        return;

    //truncate large texts and add ellipsis
    wxString textTrunc(&text[0], text.size());
    wxSize extentTrunc = textExtentHint ? *textExtentHint : dc.GetTextExtent(textTrunc);
    assert(!textExtentHint || *textExtentHint == dc.GetTextExtent(textTrunc)); //"trust, but verify" :>

    if (extentTrunc.GetWidth() > rect.width)
    {
        //unlike File Explorer, we truncate UTF-16 correctly: e.g. CJK-Ideograph encodes to TWO wchar_t: utfTo<std::wstring>("\xf0\xa4\xbd\x9c");
        size_t low  = 0;                   //number of Unicode chars!
        size_t high = unicodeLength(text); //
        if (high > 1)
            for (;;)
            {
                if (high - low <= 1)
                {
                    if (low == 0)
                    {
                        textTrunc   = ELLIPSIS;
                        extentTrunc = dc.GetTextExtent(ELLIPSIS);
                    }
                    break;
                }
                const size_t middle = (low + high) / 2; //=> never 0 when "high - low > 1"

                /*const*/ wxString candidate = getUnicodeSubstring<wxString>(text, 0, middle) + ELLIPSIS;
                const wxSize extentCand = dc.GetTextExtent(candidate); //perf: most expensive call of this routine!

                if (extentCand.GetWidth() <= rect.width)
                {
                    low = middle;
                    textTrunc   = std::move(candidate);
                    extentTrunc = extentCand;
                }
                else
                    high = middle;
            }
    }

    wxPoint pt = rect.GetTopLeft();
    if (alignment & wxALIGN_RIGHT) //note: wxALIGN_LEFT == 0!
        pt.x += rect.width - extentTrunc.GetWidth();
    else if (alignment & wxALIGN_CENTER_HORIZONTAL)
        pt.x += numeric::intDivFloor(rect.width - extentTrunc.GetWidth(), 2); //round down negative values, too!

    if (alignment & wxALIGN_BOTTOM) //note: wxALIGN_TOP == 0!
        pt.y += rect.height - extentTrunc.GetHeight();
    else if (alignment & wxALIGN_CENTER_VERTICAL)
        pt.y += numeric::intDivFloor(rect.height - extentTrunc.GetHeight(), 2); //round down negative values, too!

    //std::optional<RecursiveDcClipper> clip; -> redundant!? RecursiveDcClipper already used during grid cell rendering
    //if (extentTrunc.GetWidth() > rect.width)
    //    clip.emplace(dc, rect);

    dc.DrawText(textTrunc, pt);
}


void GridData::renderColumnLabel(wxDC& dc, const wxRect& rect, ColumnType colType, bool enabled, bool highlighted)
{
    wxRect rectRemain = drawColumnLabelBackground(dc, rect, highlighted);

    rectRemain.x     += getColumnGapLeft();
    rectRemain.width -= getColumnGapLeft();
    drawColumnLabelText(dc, rectRemain, getColumnLabel(colType), enabled);
}


wxRect GridData::drawColumnLabelBackground(wxDC& dc, const wxRect& rect, bool highlighted)
{
    if (highlighted)
        dc.GradientFillLinear(rect, getColorLabelGradientFocusFrom(), getColorLabelGradientFocusTo(), wxSOUTH);
    else //regular background gradient
        dc.GradientFillLinear(rect, getColorLabelGradientFrom(), getColorLabelGradientTo(), wxSOUTH);

    //left border
    clearArea(dc, wxRect(rect.GetTopLeft(), wxSize(dipToWxsize(1), rect.height)), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW));

    //right border
    dc.GradientFillLinear(wxRect(rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height),
                          getColorLabelGradientFrom(), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW), wxSOUTH);

    //bottom border
    clearArea(dc, wxRect(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW));

    return rect.Deflate(dipToWxsize(1), dipToWxsize(1));
}


void GridData::drawColumnLabelText(wxDC& dc, const wxRect& rect, const std::wstring& text, bool enabled)
{
    wxDCTextColourChanger textColor(dc, getColorLabelText(enabled)); //accessibility: always set both foreground AND background colors!
    drawCellText(dc, rect, text, wxALIGN_LEFT | wxALIGN_CENTER_VERTICAL);
}

//----------------------------------------------------------------------------------------------------------------
/*                    SubWindow
                         /|\
        __________________|__________________
        |           |            |          |
    CornerWin  RowLabelWin  ColLabelWin  MainWin        */

class Grid::SubWindow : public wxWindow
{
public:
    SubWindow(Grid& parent) :
        wxWindow(&parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS | wxBORDER_NONE, wxASCII_STR(wxPanelNameStr)),
        parent_(parent)
    {
        //https://wiki.wxwidgets.org/Flicker-Free_Drawing
        SetBackgroundStyle(wxBG_STYLE_PAINT); //get's rid of needless wxEVT_ERASE_BACKGROUND
        Bind(wxEVT_PAINT, [this](wxPaintEvent& event) { onPaintEvent(event); });
        Bind(wxEVT_SIZE,  [this](wxSizeEvent&  event) { Refresh(); event.Skip(); });

        Bind(wxEVT_CHILD_FOCUS, [](wxChildFocusEvent& event) {}); //wxGTK::wxScrolledWindow automatically scrolls to child window when child gets focus -> prevent!

        Bind(wxEVT_LEFT_DOWN,    [this](wxMouseEvent& event) { onMouseLeftDown  (event); });
        Bind(wxEVT_LEFT_UP,      [this](wxMouseEvent& event) { onMouseLeftUp    (event); });
        Bind(wxEVT_LEFT_DCLICK,  [this](wxMouseEvent& event) { onMouseLeftDouble(event); });
        Bind(wxEVT_RIGHT_DOWN,   [this](wxMouseEvent& event) { onMouseRightDown (event); });
        Bind(wxEVT_RIGHT_UP,     [this](wxMouseEvent& event) { onMouseRightUp   (event); });
        Bind(wxEVT_MOTION,       [this](wxMouseEvent& event) { onMouseMovement  (event); });
        Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& event) { onLeaveWindow    (event); });
        Bind(wxEVT_MOUSEWHEEL,   [this](wxMouseEvent& event) { onMouseWheel     (event); });
        Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent& event) { onMouseCaptureLost(event); });

        Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event)
        {
            if (!sendEventToParent(event)) //let parent collect all key events
                event.Skip();
        });
        //Bind(wxEVT_KEY_UP,   [this](wxKeyEvent& event) { onKeyUp  (event); }); -> superfluous?

        assert(GetClientAreaOrigin() == wxPoint()); //generally assumed when dealing with coordinates below
    }
    Grid&       refParent()       { return parent_; }
    const Grid& refParent() const { return parent_; }

    template <class T>
    bool sendEventToParent(T&& event) //take both "rvalue + lvalues", return "true" if a suitable event handler function was found and executed, and the function did not call wxEvent::Skip.
    {
        return parent_.GetEventHandler()->ProcessEvent(event);
    }

protected:
    void setToolTip(const std::wstring& text) //proper fix for wxWindow
    {
        if (text != GetToolTipText())
        {
            if (text.empty())
                UnsetToolTip(); //wxGTK doesn't allow wxToolTip with empty text!
            else
            {
                wxToolTip* tt = GetToolTip();
                if (!tt)
                {
                    //wxWidgets bug: tooltip multiline property is defined by first tooltip text containing newlines or not (same is true for maximum width)
                    tt = new wxToolTip(L"a                                                                b\n\
                                                           a                                                                b"); //ugly, but working (on Windows)
                    SetToolTip(tt); //pass ownership
                }
                tt->SetTip(text);
            }
        }
    }

private:
    virtual void render(wxDC& dc, const wxRect& rect) = 0;

    virtual void onMouseLeftDown  (wxMouseEvent& event) { event.Skip(); }
    virtual void onMouseLeftUp    (wxMouseEvent& event) { event.Skip(); }
    virtual void onMouseLeftDouble(wxMouseEvent& event) { event.Skip(); }
    virtual void onMouseRightDown (wxMouseEvent& event) { event.Skip(); }
    virtual void onMouseRightUp   (wxMouseEvent& event) { event.Skip(); }
    virtual void onMouseMovement  (wxMouseEvent& event) { event.Skip(); }
    virtual void onLeaveWindow    (wxMouseEvent& event) { event.Skip(); }
    virtual void onMouseCaptureLost(wxMouseCaptureLostEvent& event) { event.Skip(); }

    void onMouseWheel(wxMouseEvent& event)
    {
        /*  MSDN, WM_MOUSEWHEEL: "Sent to the focus window when the mouse wheel is rotated.
            The DefWindowProc function propagates the message to the window's parent.
            There should be no internal forwarding of the message, since DefWindowProc propagates
            it up the parent chain until it finds a window that processes it."

            On macOS there is no such propagation! => we need a redirection (the same wxGrid implements)

            new wxWidgets 3.0 screw-up for GTK2: wxScrollHelperEvtHandler::ProcessEvent() ignores wxEVT_MOUSEWHEEL events
            thereby breaking the scenario of redirection to parent we need here (but also breaking their very own wxGrid sample)
            => call wxScrolledWindow mouse wheel handler directly                          */

        //wxWidgets never ceases to amaze: multi-line scrolling is implemented maximally inefficient by repeating wxEVT_SCROLLWIN_LINEUP!! => WTF!
        if (event.GetWheelAxis() == wxMOUSE_WHEEL_VERTICAL && //=> reimplement wxScrollHelperBase::HandleOnMouseWheel() in a non-retarded way
            !event.IsPageScroll())
        {
            mouseRotateRemainder_ += -event.GetWheelRotation();
            int rotations = mouseRotateRemainder_ / event.GetWheelDelta();
            mouseRotateRemainder_ -= rotations * event.GetWheelDelta();

            if (rotations == 0) //macOS generates tiny GetWheelRotation()! => don't allow! Always scroll a single row at least!
            {
                rotations = -numeric::sign(event.GetWheelRotation());
                mouseRotateRemainder_ = 0;
            }

            const int rowsDelta = rotations * event.GetLinesPerAction();
            parent_.scrollDelta(0, rowsDelta);
        }
        else
            parent_.HandleOnMouseWheel(event);

        onMouseMovement(event);
        event.Skip(false);

        //if (!sendEventToParent(event))
        //   event.Skip();
    }

    void onPaintEvent(wxPaintEvent& event)
    {

        DynBufPaintDC dc(*this, doubleBuffer_);
        assert(GetSize() == GetClientSize());

        const wxRegion& updateReg = GetUpdateRegion();
        for (wxRegionIterator it = updateReg; it; ++it)
            render(dc, it.GetRect());
    }

    Grid& parent_;
    std::optional<wxBitmap> doubleBuffer_;
    int mouseRotateRemainder_ = 0;
};

//----------------------------------------------------------------------------------------------------------------
//----------------------------------------------------------------------------------------------------------------

class Grid::CornerWin : public SubWindow
{
public:
    explicit CornerWin(Grid& parent) : SubWindow(parent) {}

private:
    bool AcceptsFocus() const override { return false; }

    void render(wxDC& dc, const wxRect& /*rect*/) override
    {
        const wxRect& rect = GetClientRect(); //would be overkill to support GetUpdateRegion()!

        clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW));
        //caveat: wxSYS_COLOUR_BTNSHADOW is partially transparent on macOS!

        dc.GradientFillLinear(rect, getColorLabelGradientFrom(), getColorLabelGradientTo(), wxSOUTH);

        //left border
        dc.GradientFillLinear(wxRect(rect.GetTopLeft(), wxSize(dipToWxsize(1), rect.height)),
                              getColorLabelGradientFrom(), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW), wxSOUTH);

        //left border2
        clearArea(dc, wxRect(rect.x + dipToWxsize(1), rect.y, dipToWxsize(1), rect.height),
                  wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW));

        //right border
        dc.GradientFillLinear(wxRect(rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height),
                              getColorLabelGradientFrom(), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW), wxSOUTH);

        //bottom border
        clearArea(dc, wxRect(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)),
                  wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW));
    }
};

//----------------------------------------------------------------------------------------------------------------
//----------------------------------------------------------------------------------------------------------------

class Grid::RowLabelWin : public SubWindow
{
public:
    explicit RowLabelWin(Grid& parent) :
        SubWindow(parent),
        rowHeight_(parent.GetCharHeight() + dipToWxsize(2) + dipToWxsize(1)) {} //default height; don't call any functions on "parent" other than those from wxWindow during construction!
    //2 for some more space, 1 for bottom border (gives 15 + 2 + 1 on Windows, 17 + 2 + 1 on Ubuntu)

    int getBestWidth(ptrdiff_t rowFrom, ptrdiff_t rowTo)
    {
        wxInfoDC dc(this);
        dc.SetFont(GetFont()); //harmonize with RowLabelWin::render()!

        int bestWidth = 0;
        for (ptrdiff_t i = rowFrom; i <= rowTo; ++i)
            bestWidth = std::max(bestWidth, dc.GetTextExtent(formatRowNum(i)).GetWidth() + dipToWxsize(2 * ROW_LABEL_BORDER_DIP));
        return bestWidth;
    }

    size_t getLogicalHeight() const { return refParent().getRowCount() * rowHeight_; }

    ptrdiff_t getRowAtPos(ptrdiff_t posY) const //returns < 0 on invalid input, else row number within: [0, rowCount]; rowCount if out of range
    {
        if (posY < 0)
            return -1;

        const size_t row = posY / rowHeight_;
        return std::min(row, refParent().getRowCount());
    }

    int getRowHeight() const { return rowHeight_; } //guarantees to return size >= 1 !
    void setRowHeight(int height) { assert(height > 0); rowHeight_ = std::max(1, height); }

    wxRect getRowLabelArea(size_t row) const //returns empty rect if row not found
    {
        assert(GetClientAreaOrigin() == wxPoint());
        if (row < refParent().getRowCount())
            return wxRect(wxPoint(0, rowHeight_ * row),
                          wxSize(GetClientSize().GetWidth(), rowHeight_));
        return wxRect();
    }

private:
    static std::wstring formatRowNum(size_t row) { return formatNumber(row + 1); } //convert number to std::wstring including thousands separator

    bool AcceptsFocus() const override { return false; }

    void render(wxDC& dc, const wxRect& rect) override
    {
        clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW));

        const bool enabled = renderAsEnabled(*this);

        dc.SetFont(GetFont()); //harmonize with RowLabelWin::getBestWidth()!

        const auto& [rowFirst, rowLast] = refParent().getVisibleRows(rect);
        for (auto row = rowFirst; row < rowLast; ++row)
        {
            wxRect rectRowLabel = getRowLabelArea(row); //returns empty rect if row not found
            if (rectRowLabel.height > 0)
            {
                rectRowLabel.y = refParent().CalcScrolledPosition(rectRowLabel.GetTopLeft()).y;
                drawRowLabel(dc, rectRowLabel, row, enabled);
            }
        }
    }

    void drawRowLabel(wxDC& dc, const wxRect& rect, size_t row, bool enabled)
    {
        //clearArea(dc, rect, getColorRowLabel());
        dc.GradientFillLinear(rect, getColorLabelGradientFrom(), getColorLabelGradientTo(), wxEAST); //clear overlapping cells

        //top border
        clearArea(dc, wxRect(rect.x, rect.y, rect.width, dipToWxsize(1)), wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW));

        //left border
        clearArea(dc, wxRect(rect.x, rect.y, dipToWxsize(1), rect.height), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW));

        //right border
        clearArea(dc, wxRect(rect.x + rect.width - dipToWxsize(1), rect.y, dipToWxsize(1), rect.height), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW));

        //bottom border
        clearArea(dc, wxRect(rect.x, rect.y + rect.height - dipToWxsize(1), rect.width, dipToWxsize(1)), wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW));

        //label text
        wxRect textRect = rect;
        textRect.Deflate(dipToWxsize(1));

        wxDCTextColourChanger textColor(dc, getColorLabelText(enabled)); //accessibility: always set both foreground AND background colors!
        GridData::drawCellText(dc, textRect, formatRowNum(row), wxALIGN_CENTRE);
    }

    void onMouseLeftDown(wxMouseEvent& event) override { redirectMouseEvent(event); }
    void onMouseLeftUp  (wxMouseEvent& event) override { redirectMouseEvent(event); }
    void onMouseMovement(wxMouseEvent& event) override { redirectMouseEvent(event); }
    void onLeaveWindow  (wxMouseEvent& event) override { redirectMouseEvent(event); }
    void onMouseCaptureLost(wxMouseCaptureLostEvent& event) override { refParent().getMainWin().GetEventHandler()->ProcessEvent(event); }

    void redirectMouseEvent(wxMouseEvent& event)
    {
        event.m_x = 0; //simulate click on left side of mainWin_!

        wxWindow& mainWin = refParent().getMainWin();
        mainWin.GetEventHandler()->ProcessEvent(event);

        if (event.ButtonDown() && wxWindow::FindFocus() != &mainWin)
            mainWin.SetFocus();
    }

    int rowHeight_;
};


namespace
{
class ColumnResizing
{
public:
    ColumnResizing(wxWindow& wnd, size_t col, int startWidth, int clientPosX) :
        wnd_(wnd), col_(col), startWidth_(startWidth), clientPosX_(clientPosX)
    {
        wnd_.CaptureMouse();
    }
    ~ColumnResizing()
    {
        if (wnd_.HasCapture())
            wnd_.ReleaseMouse();
    }

    size_t getColumn      () const { return col_; }
    int    getStartWidth  () const { return startWidth_; }
    int    getStartPosX   () const { return clientPosX_; }

private:
    wxWindow& wnd_;
    const size_t col_;
    const int    startWidth_;
    const int    clientPosX_;
};


class ColumnMove
{
public:
    ColumnMove(wxWindow& wnd, size_t colFrom, int clientPosX) :
        wnd_(wnd),
        colFrom_(colFrom),
        colTo_(colFrom),
        clientPosX_(clientPosX) { wnd_.CaptureMouse(); }
    ~ColumnMove() { if (wnd_.HasCapture()) wnd_.ReleaseMouse(); }

    size_t  getColumnFrom() const { return colFrom_; }
    size_t& refColumnTo() { return colTo_; }
    int     getStartPosX () const { return clientPosX_; }

    bool isRealMove() const { return !singleClick_; }
    void setRealMove() { singleClick_ = false; }

private:
    wxWindow& wnd_;
    const size_t colFrom_;
    size_t colTo_;
    const int clientPosX_;
    bool singleClick_ = true;
};
}

//----------------------------------------------------------------------------------------------------------------

class Grid::ColLabelWin : public SubWindow
{
public:
    explicit ColLabelWin(Grid& parent) : SubWindow(parent),
        labelFont_(GetFont().Bold())
    {
        //coordinate with ColLabelWin::render():
        colLabelHeight_ = dipToWxsize(2 * DEFAULT_COL_LABEL_BORDER_DIP) + labelFont_.GetPixelSize().GetHeight();
    }

    int getColumnLabelHeight() const { return colLabelHeight_; }
    void setColumnLabelHeight(int height) { colLabelHeight_ = std::max(0, height); }

private:
    bool AcceptsFocus() const override { return false; }

    void render(wxDC& dc, const wxRect& rect) override
    {
        clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW));
        //caveat: system colors can be partially transparent on macOS

        dc.SetFont(labelFont_); //coordinate with "colLabelHeight" in Grid constructor
        dc.SetTextForeground(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT));

        const bool enabled = renderAsEnabled(*this);

        wxPoint labelAreaTL(refParent().CalcScrolledPosition(wxPoint(0, 0)).x, 0); //client coordinates

        const std::vector<ColumnWidth>& absWidths = refParent().getColWidths(); //resolve stretched widths
        for (size_t col = 0; col < absWidths.size(); ++col)
        {
            const int width  = absWidths[col].width; //don't use unsigned for calculations!

            if (labelAreaTL.x > rect.GetRight())
                return; //done, rect is fully covered
            if (labelAreaTL.x + width > rect.x)
                drawColumnLabel(dc, wxRect(labelAreaTL, wxSize(width, colLabelHeight_)), col, absWidths[col].type, enabled);
            labelAreaTL.x += width;
        }
        if (labelAreaTL.x > rect.GetRight())
            return; //done, rect is fully covered

        //fill gap after columns and cover full width
        if (fillGapAfterColumns)
        {
            int totalWidth = 0;
            for (const ColumnWidth& cw : absWidths)
                totalWidth += cw.width;
            const int clientWidth = GetClientSize().GetWidth(); //need reliable, stable width in contrast to rect.width

            if (totalWidth < clientWidth)
                drawColumnLabel(dc, wxRect(labelAreaTL, wxSize(clientWidth - totalWidth, colLabelHeight_)), absWidths.size(), ColumnType::none, enabled);
        }
    }

    void drawColumnLabel(wxDC& dc, const wxRect& rect, size_t col, ColumnType colType, bool enabled)
    {
        if (auto prov = refParent().getDataProvider())
        {
            const bool isHighlighted = activeResizing_    ? col == activeResizing_   ->getColumn    () : //highlight_ column on mouse-over
                                       activeClickOrMove_ ? col == activeClickOrMove_->getColumnFrom() :
                                       highlightCol_      ? col == *highlightCol_                      :
                                       false;

            RecursiveDcClipper clip(dc, rect);
            prov->renderColumnLabel(dc, rect, colType, enabled, isHighlighted);

            //draw move target location
            if (refParent().allowColumnMove_)
                if (activeClickOrMove_ && activeClickOrMove_->isRealMove())
                {
                    const int markerWidth = dipToWxsize(COLUMN_MOVE_MARKER_WIDTH_DIP);

                    if (col + 1 == activeClickOrMove_->refColumnTo()) //handle pos 1, 2, .. up to "at end" position
                        dc.GradientFillLinear(wxRect(rect.x + rect.width - markerWidth, rect.y, markerWidth, rect.height), getColorLabelGradientFrom(), colDropMarkerColor_, wxSOUTH);
                    else if (col == activeClickOrMove_->refColumnTo() && col == 0) //pos 0
                        dc.GradientFillLinear(wxRect(rect.GetTopLeft(), wxSize(markerWidth, rect.height)), getColorLabelGradientFrom(), colDropMarkerColor_, wxSOUTH);
                }
        }
    }

    std::optional<ColAction> clientPosToColumnAction(const wxPoint& pos) const
    {
        if (0 <= pos.y && pos.y < colLabelHeight_)
            if (const int absPosX = refParent().CalcUnscrolledPosition(pos).x;
                absPosX >= 0)
            {
                const int resizeTolerance = refParent().allowColumnResize_ ? dipToWxsize(COLUMN_RESIZE_TOLERANCE_DIP) : 0;
                const std::vector<ColumnWidth>& absWidths = refParent().getColWidths(); //resolve stretched widths

                int accuWidth = 0;
                for (size_t col = 0; col < absWidths.size(); ++col)
                {
                    accuWidth += absWidths[col].width;
                    if (std::abs(absPosX - accuWidth) < resizeTolerance)
                    {
                        ColAction out;
                        out.wantResize = true;
                        out.col        = col;
                        return out;
                    }
                    else if (absPosX < accuWidth)
                    {
                        ColAction out;
                        out.wantResize = false;
                        out.col        = col;
                        return out;
                    }
                }
            }
        return {};
    }

    size_t clientPosToMoveTargetColumn(const wxPoint& pos) const
    {
        const int absPosX = refParent().CalcUnscrolledPosition(pos).x;
        const std::vector<ColumnWidth>& absWidths = refParent().getColWidths(); //resolve negative/stretched widths

        int accWidth = 0;
        for (size_t col = 0; col < absWidths.size(); ++col)
        {
            const int width = absWidths[col].width; //beware dreaded unsigned conversions!
            accWidth += width;

            if (absPosX < accWidth - width / 2)
                return col;
        }
        return absWidths.size();
    }

    void onMouseLeftDown(wxMouseEvent& event) override
    {
        //if (FindFocus() != &refParent().getMainWin()) -> clicking column label shouldn't change input focus, right!? e.g. resizing column, sorting...(other grid)
        //    refParent().getMainWin().SetFocus();

        activeResizing_   .reset();
        activeClickOrMove_.reset();

        if (std::optional<ColAction> action = clientPosToColumnAction(event.GetPosition()))
        {
            if (action->wantResize)
            {
                if (!event.LeftDClick()) //double-clicks never seem to arrive here; why is this checked at all???
                    if (std::optional<int> colWidth = refParent().getColWidth(action->col))
                        activeResizing_.emplace(*this, action->col, *colWidth, event.GetPosition().x);
            }
            else //a move or single click
                activeClickOrMove_.emplace(*this, action->col, event.GetPosition().x);
        }
        event.Skip();
    }

    void onMouseLeftUp(wxMouseEvent& event) override
    {
        activeResizing_.reset(); //nothing else to do, actual work done by onMouseMovement()

        if (activeClickOrMove_)
        {
            if (activeClickOrMove_->isRealMove())
            {
                if (refParent().allowColumnMove_)
                {
                    const size_t colFrom = activeClickOrMove_->getColumnFrom();
                    size_t       colTo   = activeClickOrMove_->refColumnTo();

                    if (colTo > colFrom) //simulate "colFrom" deletion
                        --colTo;

                    refParent().moveColumn(colFrom, colTo);
                }
            }
            else //notify single label click
            {
                const wxPoint mousePos = GetPosition() + event.GetPosition();
                if (const std::optional<ColumnType> colType = refParent().colToType(activeClickOrMove_->getColumnFrom()))
                    sendEventToParent(GridLabelClickEvent(EVENT_GRID_COL_LABEL_MOUSE_LEFT, *colType, mousePos));
            }
            activeClickOrMove_.reset();
        }

        refParent().updateWindowSizes(); //looks strange if done during onMouseMovement()
        refParent().Refresh();
        event.Skip();
    }

    void onMouseLeftDouble(wxMouseEvent& event) override
    {
        if (std::optional<ColAction> action = clientPosToColumnAction(event.GetPosition()))
            if (action->wantResize)
            {
                //auto-size visible range on double-click
                const int bestWidth = refParent().getBestColumnSize(action->col); //return -1 on error
                if (bestWidth >= 0)
                {
                    refParent().setColumnWidth(bestWidth, action->col, GridEventPolicy::allow);
                    refParent().Refresh(); //refresh main grid as well!
                }
            }
        event.Skip();
    }

    void onMouseRightDown(wxMouseEvent& event) override
    {
        evalMouseMovement(event.GetPosition()); //update highlight in obscure cases (e.g. right-click while other context menu is open)

        const wxPoint mousePos = GetPosition() + event.GetPosition();

        if (const std::optional<ColAction> action = clientPosToColumnAction(event.GetPosition()))
        {
            if (const std::optional<ColumnType> colType = refParent().colToType(action->col))
                sendEventToParent(GridLabelClickEvent(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, *colType, mousePos)); //notify right click
            else assert(false);
        }
        else
            //notify right click (on free space after last column)
            if (fillGapAfterColumns)
                sendEventToParent(GridLabelClickEvent(EVENT_GRID_COL_LABEL_MOUSE_RIGHT, ColumnType::none, mousePos));

        //update mouse highlight (e.g. mouse position changed after showing context menu) => needed on Linux/macOS
        evalMouseMovement(ScreenToClient(wxGetMousePosition()));

        event.Skip();
    }

    void onMouseMovement(wxMouseEvent& event) override
    {
        evalMouseMovement(event.GetPosition());
        event.Skip();
    }

    void evalMouseMovement(wxPoint clientPos)
    {
        if (activeResizing_)
        {
            const auto col     = activeResizing_->getColumn();
            const int newWidth = activeResizing_->getStartWidth() + clientPos.x - activeResizing_->getStartPosX();

            //set width tentatively
            refParent().setColumnWidth(newWidth, col, GridEventPolicy::allow);

            //check if there's a small gap after last column, if yes, fill it
            const int gapWidth = GetClientSize().GetWidth() - refParent().getColWidthsSum(GetClientSize().GetWidth());
            if (std::abs(gapWidth) < dipToWxsize(COLUMN_FILL_GAP_TOLERANCE_DIP))
                refParent().setColumnWidth(newWidth + gapWidth, col, GridEventPolicy::allow);

            Refresh();
            refParent().Refresh(); //refresh columns on main grid as well!
        }
        else if (activeClickOrMove_)
        {
            const int clientPosX = clientPos.x;
            if (std::abs(clientPosX - activeClickOrMove_->getStartPosX()) > dipToWxsize(COLUMN_MOVE_DELAY_DIP)) //real move (not a single click)
            {
                activeClickOrMove_->setRealMove();
                activeClickOrMove_->refColumnTo() = clientPosToMoveTargetColumn(clientPos);
                Refresh();
            }
        }
        else
        {
            if (const std::optional<ColAction> action = clientPosToColumnAction(clientPos))
            {
                setMouseHighlight(action->col);

                if (action->wantResize)
                    SetCursor(wxCURSOR_SIZEWE); //window-local only! :)
                else
                    SetCursor(*wxSTANDARD_CURSOR); //NOOP when setting same cursor
            }
            else
            {
                setMouseHighlight(std::nullopt);
                SetCursor(*wxSTANDARD_CURSOR);
            }
        }

        const std::wstring toolTip = [&]
        {
            if (const ColumnType colType = refParent().getColumnAtWinPos(clientPos.x).colType; //returns ColumnType::none if no column at x position!
                colType != ColumnType::none)
                if (auto prov = refParent().getDataProvider())
                    return prov->getToolTip(colType);
            return std::wstring();
        }();
        setToolTip(toolTip);
    }

    void onMouseCaptureLost(wxMouseCaptureLostEvent& event) override
    {
        if (activeResizing_ || activeClickOrMove_)
        {
            activeResizing_   .reset();
            activeClickOrMove_.reset();
            Refresh();
        }
        setMouseHighlight(std::nullopt);
        //event.Skip(); -> we DID handle it!
    }

    void onLeaveWindow(wxMouseEvent& event) override
    {
        if (!activeResizing_ && !activeClickOrMove_)
            //wxEVT_LEAVE_WINDOW does not respect mouse capture! -> however highlight is drawn unconditionally during move/resize!
            setMouseHighlight(std::nullopt);

        event.Skip();
    }

    void setMouseHighlight(const std::optional<size_t>& hl)
    {
        if (highlightCol_ != hl)
        {
            highlightCol_ = hl;
            Refresh();
        }
    }

    std::optional<ColumnResizing> activeResizing_;
    std::optional<ColumnMove>     activeClickOrMove_;
    std::optional<size_t>         highlightCol_;

    int colLabelHeight_ = 0;
    const wxFont labelFont_;

    const wxColor colDropMarkerColor_ = enhanceContrast(*wxBLUE, //primarily needed for dark mode!
                                                        getColorLabelGradientTo(), 5 /*contrastRatioMin*/); //W3C recommends >= 4.5 for text
};

//----------------------------------------------------------------------------------------------------------------

class Grid::MainWin : public SubWindow
{
public:
    MainWin(Grid& parent,
            RowLabelWin& rowLabelWin,
            ColLabelWin& colLabelWin) : SubWindow(parent),
        rowLabelWin_(rowLabelWin),
        colLabelWin_(colLabelWin)
    {
        Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& event)
        {
            if (event.GetKeyCode() == WXK_ESCAPE && activeSelection_) //allow Escape key to cancel active selection!
            {
                wxMouseCaptureLostEvent evt;
                GetEventHandler()->ProcessEvent(evt); //better integrate into event handling rather than calling onMouseCaptureLost() directly!?
                return;
            }

            /* using keyboard: => clear distracting mouse highlights

               wxEVT_KEY_DOWN evaluation order:
                 1. this callback
                 2. Grid::SubWindow ... sendEventToParent()
                 3. clients binding to Grid wxEVT_KEY_DOWN
                 4. Grid::onKeyDown()                           */
            setMouseHighlight(std::nullopt);

            event.Skip();
        });
    }

    ~MainWin() { assert(!gridUpdatePending_); }

    size_t getCursor() const { return cursorRow_; }
    size_t getAnchor() const { return selectionAnchor_; }

    void setCursor(size_t newCursorRow, size_t newAnchorRow)
    {
        cursorRow_       = newCursorRow;
        selectionAnchor_ = newAnchorRow;
        activeSelection_.reset(); //e.g. user might search with F3 while holding down left mouse button
    }

private:
    void render(wxDC& dc, const wxRect& rect) override
    {
        clearArea(dc, rect, wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); //CONTRACT! expected by GridData::renderRowBackgound()!

        const bool enabled = renderAsEnabled(*this);

        if (auto prov = refParent().getDataProvider())
        {
            dc.SetFont(GetFont()); //harmonize with Grid::getBestColumnSize()
            dc.SetTextForeground(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT));

            const std::vector<ColumnWidth>& absWidths = refParent().getColWidths(); //resolve stretched widths

            int totalRowWidth = 0;
            for (const ColumnWidth& cw : absWidths)
                totalRowWidth += cw.width;

            //fill gap after columns and cover full width
            if (fillGapAfterColumns)
                totalRowWidth = std::max(totalRowWidth, GetClientSize().GetWidth());

            RecursiveDcClipper dummy(dc, rect); //do NOT draw background on cells outside of invalidated rect invalidating foreground text!

            const wxPoint gridAreaTL(refParent().CalcScrolledPosition(wxPoint(0, 0))); //client coordinates
            const int rowHeight = rowLabelWin_.getRowHeight();
            const auto& [rowFirst, rowLast] = refParent().getVisibleRows(rect);

            for (auto row = rowFirst; row < rowLast; ++row)
            {
                //draw background lines
                const wxRect rowRect(gridAreaTL + wxPoint(0, row * rowHeight), wxSize(totalRowWidth, rowHeight));
                const bool drawSelected = drawAsSelected(row);
                const HoverArea rowHover = getRowHoverToDraw(row);

                RecursiveDcClipper dummy2(dc, rowRect);
                prov->renderRowBackgound(dc, rowRect, row, enabled, drawSelected, rowHover);

                //draw cells column by column
                wxRect cellRect = rowRect;
                for (const ColumnWidth& cw : absWidths)
                {
                    cellRect.width = cw.width;

                    if (cellRect.x > rect.GetRight())
                        break; //done

                    if (cellRect.x + cw.width > rect.x)
                    {
                        RecursiveDcClipper dummy3(dc, cellRect);
                        prov->renderCell(dc, cellRect, row, cw.type, enabled, drawSelected, rowHover);
                    }
                    cellRect.x += cw.width;
                }
            }
        }
    }

    HoverArea getRowHoverToDraw(ptrdiff_t row) const
    {
        if (activeSelection_)
        {
            if (activeSelection_->getFirstClick().row_ == row)
                return activeSelection_->getFirstClick().hoverArea_;
        }
        else if (highlight_)
        {
            if (makeSigned(highlight_->row) == row)
                return highlight_->rowHover;
        }
        return HoverArea::none;
    }

    bool drawAsSelected(size_t row) const
    {
        if (activeSelection_) //check if user is currently selecting with mouse
        {
            const size_t rowFrom = std::min(activeSelection_->getStartRow(), activeSelection_->getCurrentRow());
            const size_t rowTo   = std::max(activeSelection_->getStartRow(), activeSelection_->getCurrentRow());

            if (rowFrom <= row && row <= rowTo)
                return activeSelection_->isPositiveSelect(); //overwrite default
        }
        return refParent().isSelected(row);
    }

    void onMouseLeftDown (wxMouseEvent& event) override { onMouseDown(event); }
    void onMouseLeftUp   (wxMouseEvent& event) override { onMouseUp  (event); }
    void onMouseRightDown(wxMouseEvent& event) override { onMouseDown(event); }
    void onMouseRightUp  (wxMouseEvent& event) override { onMouseUp  (event); }

    void onMouseLeftDouble(wxMouseEvent& event) override
    {
        if (auto prov = refParent().getDataProvider())
        {
            const wxPoint   mousePos = GetPosition() + event.GetPosition();
            const ptrdiff_t rowCount = refParent().getRowCount();
            const ptrdiff_t      row = refParent().getRowAtWinPos   (event.GetPosition().y); //return -1 for invalid position; >= rowCount if out of range
            const ColumnPosInfo  cpi = refParent().getColumnAtWinPos(event.GetPosition().x); //returns ColumnType::none if no column at x position!
            const HoverArea rowHover = [&]
            {
                if (0 <= row && row < rowCount && cpi.colType != ColumnType::none)
                {
                    wxInfoDC dc(this);
                    dc.SetFont(GetFont());
                    return prov->getMouseHover(dc, row, cpi.colType, cpi.cellRelativePosX, cpi.colWidth);
                }
                return HoverArea::none;
            }();

            //client is interested in all double-clicks, even those outside of the grid!
            sendEventToParent(GridClickEvent(EVENT_GRID_MOUSE_LEFT_DOUBLE, row, rowHover, mousePos));
        }
        event.Skip();
    }

    void onMouseDown(wxMouseEvent& event) //handle left and right mouse button clicks (almost) the same
    {
        if (activeSelection_) //allow other mouse button to cancel active selection!
        {
            wxMouseCaptureLostEvent evt;
            GetEventHandler()->ProcessEvent(evt);
            return;
        }

        if (auto prov = refParent().getDataProvider())
        {
            evalMouseMovement(event.GetPosition()); //update highlight in obscure cases (e.g. right-click while other context menu is open)

            const wxPoint   mousePos = GetPosition() + event.GetPosition();
            const ptrdiff_t rowCount = refParent().getRowCount();
            const ptrdiff_t      row = refParent().getRowAtWinPos   (event.GetPosition().y); //return -1 for invalid position; >= rowCount if out of range
            const ColumnPosInfo  cpi = refParent().getColumnAtWinPos(event.GetPosition().x); //returns ColumnType::none if no column at x position!
            const HoverArea rowHover = [&]
            {
                if (0 <= row && row < rowCount && cpi.colType != ColumnType::none)
                {
                    wxInfoDC dc(this);
                    dc.SetFont(GetFont());
                    return prov->getMouseHover(dc, row, cpi.colType, cpi.cellRelativePosX, cpi.colWidth);
                }
                return HoverArea::none;
            }();

            assert(row >= 0);
            //row < 0 was possible in older wxWidgets: https://github.com/wxWidgets/wxWidgets/commit/2c69d27c0d225d3a331c773da466686153185320#diff-9f11c8f2cb1f734f7c0c1071aba491a5
            //=> pressing "Menu Key" simulated mouse-right-button down + up at position 0xffff/0xffff!

            GridClickEvent mouseEvent(event.RightDown() ? EVENT_GRID_MOUSE_RIGHT_DOWN : EVENT_GRID_MOUSE_LEFT_DOWN, row, rowHover, mousePos);

            if (const bool processed = sendEventToParent(mouseEvent); //allow client to swallow event!
                !processed)
            {
                if (wxWindow::FindFocus() != this) //doesn't seem to happen automatically for right mouse button
                    SetFocus();

                if (event.RightDown() && (row < 0 || refParent().isSelected(row))) //=> open context menu *immediately* and do *not* start a new selection
                    sendEventToParent(GridContextMenuEvent(mousePos));
                else if (row >= 0)
                {
                    if (event.ControlDown())
                        activeSelection_.emplace(*this, row, !refParent().isSelected(row) /*positive*/, false /*gridWasCleared*/, mouseEvent);
                    else if (event.ShiftDown())
                    {
                        refParent().clearSelection(GridEventPolicy::deny);
                        activeSelection_.emplace(*this, selectionAnchor_, true /*positive*/, true /*gridWasCleared*/, mouseEvent);
                    }
                    else
                    {
                        refParent().clearSelection(GridEventPolicy::deny);
                        activeSelection_.emplace(*this, row, true /*positive*/, true /*gridWasCleared*/, mouseEvent);
                        //DO NOT emit range event for clearing selection! would be inconsistent with keyboard handling (moving cursor neither emits range event)
                        //and is also harmful when range event is considered a final action
                        //e.g. cfg grid would prematurely show a modal dialog after changed config
                    }
                }
            }

            //update mouse highlight (e.g. mouse position changed after showing context menu) => needed on Linux/macOS
            evalMouseMovement(ScreenToClient(wxGetMousePosition()));
        }
        event.Skip(); //allow changing focus
    }

    void onMouseUp(wxMouseEvent& event)
    {
        if (activeSelection_)
        {
            const size_t rowCount = refParent().getRowCount();
            if (rowCount > 0)
            {
                if (activeSelection_->getCurrentRow() < rowCount)
                {
                    cursorRow_ = activeSelection_->getCurrentRow();
                    selectionAnchor_ = activeSelection_->getStartRow(); //allowed to be "out of range"
                }
                else if (activeSelection_->getStartRow() < rowCount) //don't change cursor if "to" and "from" are out of range
                {
                    cursorRow_ = rowCount - 1;
                    selectionAnchor_ = activeSelection_->getStartRow(); //allowed to be "out of range"
                }
                else //total selection "out of range"
                    selectionAnchor_ = cursorRow_;
            }
            //slight deviation from Explorer: change cursor while dragging mouse! -> unify behavior with shift + direction keys
            const wxPoint mousePos = GetPosition() + event.GetPosition();
            const size_t            rowFrom = activeSelection_->getStartRow();
            const size_t              rowTo = activeSelection_->getCurrentRow();
            const bool             positive = activeSelection_->isPositiveSelect();
            const GridClickEvent mouseClick = activeSelection_->getFirstClick();
            assert((mouseClick.GetEventType() == EVENT_GRID_MOUSE_RIGHT_DOWN) == event.RightUp());

            activeSelection_.reset(); //release mouse capture *before* sending the event (which might show a modal popup dialog requiring the mouse!!!)

            const size_t rowFirst = std::min(rowFrom, rowTo);     //sort + convert to half-open range
            const size_t rowLast  = std::max(rowFrom, rowTo) + 1; //
            refParent().selectRange2(rowFirst, rowLast, positive, &mouseClick, GridEventPolicy::allow);

            if (mouseClick.GetEventType() == EVENT_GRID_MOUSE_RIGHT_DOWN)
                sendEventToParent(GridContextMenuEvent(mousePos)); //... *not* mouseClick.mousePos_
        }
#if 0
        if (!event.RightUp())
            if (auto prov = refParent().getDataProvider())
            {
                //this one may point to row which is not in visible area!
                const wxPoint   mousePos = GetPosition() + event.GetPosition();
                const ptrdiff_t rowCount = refParent().getRowCount();
                const ptrdiff_t      row = refParent().getRowAtWinPos   (event.GetPosition().y); //return -1 for invalid position; >= rowCount if out of range
                const ColumnPosInfo  cpi = refParent().getColumnAtWinPos(event.GetPosition().x); //returns ColumnType::none if no column at x position!
                const HoverArea rowHover = [&]
                {
                    if (0 <= row && row < rowCount && cpi.colType != ColumnType::none)
                    {
                        wxInfoDC dc(this);
                        dc.SetFont(GetFont());
                        return prov->getMouseHover(dc, row, cpi.colType, cpi.cellRelativePosX, cpi.colWidth);
                    }
                    return HoverArea::none;
                }();
                //notify click event after the range selection! e.g. this makes sure the selection is applied before showing a context menu
                sendEventToParent(GridClickEvent(EVENT_GRID_MOUSE_LEFT_UP, row, rowHover, mousePos));
            }
#endif
        //update mouse highlight (e.g. mouse position changed after showing context menu)
        //=> macOS no mouse movement event is generated after a mouse button click (unlike on Windows)
        evalMouseMovement(ScreenToClient(wxGetMousePosition()));

        event.Skip(); //allow changing focus
    }

    void onMouseMovement(wxMouseEvent& event) override
    {
        evalMouseMovement(event.GetPosition());
        event.Skip();
    }

    void evalMouseMovement(wxPoint clientPos)
    {
        if (auto prov = refParent().getDataProvider())
        {
            const ptrdiff_t rowCount = refParent().getRowCount();
            const ptrdiff_t      row = refParent().getRowAtWinPos   (clientPos.y); //return -1 for invalid position; >= rowCount if out of range
            const ColumnPosInfo  cpi = refParent().getColumnAtWinPos(clientPos.x); //returns ColumnType::none if no column at x position!
            const HoverArea rowHover = [&]
            {
                if (0 <= row && row < rowCount && cpi.colType != ColumnType::none)
                {
                    wxInfoDC dc(this);
                    dc.SetFont(GetFont());
                    return prov->getMouseHover(dc, row, cpi.colType, cpi.cellRelativePosX, cpi.colWidth);
                }
                return HoverArea::none;
            }();

            const std::wstring toolTip = [&]
            {
                if (0 <= row && row < rowCount && cpi.colType != ColumnType::none)
                    return prov->getToolTip(row, cpi.colType, rowHover);
                return std::wstring();
            }();
            setToolTip(toolTip); //change even during mouse selection!

            if (activeSelection_)
                activeSelection_->evalMousePos(); //call on both mouse movement + timer event!
            else
                setMouseHighlight(rowHover != HoverArea::none ? std::make_optional<MouseHighlight>({static_cast<size_t>(row), rowHover}) : std::nullopt);
        }
    }

    void onMouseCaptureLost(wxMouseCaptureLostEvent& event) override
    {
        if (activeSelection_)
        {
            if (activeSelection_->gridWasCleared())
                refParent().clearSelection(GridEventPolicy::allow); //see onMouseDown(); selection is "completed" => emit GridSelectEvent

            activeSelection_.reset();
            Refresh();
        }
        setMouseHighlight(std::nullopt);
        //event.Skip(); -> we DID handle it!
    }

    void onLeaveWindow(wxMouseEvent& event) override
    {
        if (!activeSelection_) //wxEVT_LEAVE_WINDOW does not respect mouse capture!
            setMouseHighlight(std::nullopt);

        //CAVEAT: we can get wxEVT_MOTION *after* wxEVT_LEAVE_WINDOW: see RowLabelWin::redirectMouseEvent()
        //        => therefore we also redirect wxEVT_LEAVE_WINDOW, but user will see a little flicker when moving between RowLabelWin and MainWin
        event.Skip();
    }

    class MouseSelection : private wxEvtHandler
    {
    public:
        MouseSelection(MainWin& wnd, size_t rowStart, bool positive, bool gridWasCleared, const GridClickEvent& firstClick) :
            wnd_(wnd), rowStart_(rowStart), rowCurrent_(rowStart), positiveSelect_(positive), gridWasCleared_(gridWasCleared), firstClick_(firstClick)
        {
            wnd_.CaptureMouse();
            timer_.Bind(wxEVT_TIMER, [this](wxTimerEvent& event) { evalMousePos(); });
            timer_.Start(100); //timer interval in ms
            evalMousePos();
            wnd_.Refresh();
        }
        ~MouseSelection() { if (wnd_.HasCapture()) wnd_.ReleaseMouse(); }

        size_t getStartRow     () const { return rowStart_;       }
        size_t getCurrentRow   () const { return rowCurrent_;     }
        bool   isPositiveSelect() const { return positiveSelect_; } //are we selecting or unselecting?
        bool   gridWasCleared  () const { return gridWasCleared_; }

        const GridClickEvent& getFirstClick() const { return firstClick_; }

        void evalMousePos()
        {
            const auto now = std::chrono::steady_clock::now();
            const double deltaSecs = std::chrono::duration<double>(now - lastEvalTime_).count(); //unit: [sec]
            lastEvalTime_ = now;

            const wxPoint clientPos = wnd_.ScreenToClient(wxGetMousePosition());
            const wxSize clientSize = wnd_.GetClientSize();
            assert(wnd_.GetClientAreaOrigin() == wxPoint());

            //scroll while dragging mouse
            const int overlapPixY = clientPos.y < 0 ? clientPos.y :
                                    clientPos.y >= clientSize.GetHeight() ? clientPos.y - (clientSize.GetHeight() - 1) : 0;
            const int overlapPixX = clientPos.x < 0 ? clientPos.x :
                                    clientPos.x >= clientSize.GetWidth() ? clientPos.x - (clientSize.GetWidth() - 1) : 0;

            int pixelsPerUnitY = 0;
            wnd_.refParent().GetScrollPixelsPerUnit(nullptr, &pixelsPerUnitY);
            assert(pixelsPerUnitY > 0);
            if (pixelsPerUnitY <= 0)
                return;

            const double mouseDragSpeedIncScrollU = MOUSE_DRAG_ACCELERATION_DIP * wnd_.rowLabelWin_.getRowHeight() / pixelsPerUnitY; //unit: [scroll units / (DIP * sec)]
            //design alternative: "Dynamic autoscroll based on escape velocity": https://devblogs.microsoft.com/oldnewthing/20210128-00/?p=104768

            auto autoScroll = [&](int overlapPix, double& toScroll)
            {
                if (overlapPix != 0)
                {
                    const double scrollSpeed = wnd_.ToDIP(overlapPix) * mouseDragSpeedIncScrollU; //unit: [scroll units / sec]
                    toScroll += scrollSpeed * deltaSecs;
                }
                else
                    toScroll = 0;
            };

            autoScroll(overlapPixX, toScrollX_);
            autoScroll(overlapPixY, toScrollY_);

            if (static_cast<int>(toScrollX_) != 0 || static_cast<int>(toScrollY_) != 0)
            {
                wnd_.refParent().scrollDelta(static_cast<int>(toScrollX_), static_cast<int>(toScrollY_)); //
                toScrollX_ -= static_cast<int>(toScrollX_); //rounds down for positive numbers, up for negative,
                toScrollY_ -= static_cast<int>(toScrollY_); //exactly what we want
            }

            //select current row *after* scrolling
            wxPoint clientPosTrimmed = clientPos;
            clientPosTrimmed.y = std::clamp(clientPosTrimmed.y, 0, clientSize.GetHeight() - 1); //do not select row outside client window!

            const ptrdiff_t newRow = wnd_.refParent().getRowAtWinPos(clientPosTrimmed.y); //return -1 for invalid position; >= rowCount if out of range
            assert(newRow >= 0);
            if (newRow >= 0)
                if (rowCurrent_ != newRow)
                {
                    rowCurrent_ = newRow;
                    wnd_.Refresh();
                }
        }

    private:
        MainWin& wnd_;
        const size_t rowStart_;
        ptrdiff_t rowCurrent_;
        const bool positiveSelect_;
        const bool gridWasCleared_;
        const GridClickEvent firstClick_;
        wxTimer timer_;
        double toScrollX_ = 0; //count outstanding scroll unit fractions while dragging mouse
        double toScrollY_ = 0; //
        std::chrono::steady_clock::time_point lastEvalTime_ = std::chrono::steady_clock::now();
    };

    void ScrollWindow(int dx, int dy, const wxRect* rect) override
    {
        wxWindow::ScrollWindow(dx, dy, rect);
        rowLabelWin_.ScrollWindow(0, dy, rect);
        colLabelWin_.ScrollWindow(dx, 0, rect);

        //attention, wxGTK call sequence: wxScrolledWindow::Scroll() -> wxScrolledHelperNative::Scroll() -> wxScrolledHelperNative::DoScroll()
        //which *first* calls us, MainWin::ScrollWindow(), and *then* internally updates m_yScrollPosition
        //=> we cannot use CalcUnscrolledPosition() here which gives the wrong/outdated value!!!
        //=> we need to update asynchronously:
        //=> don't send async event repeatedly => severe performance issues on wxGTK!
        //=> can't use idle event neither: too few idle events on Windows, e.g. NO idle events while mouse drag-scrolling!
        //=> solution: send single async event at most!
        if (!gridUpdatePending_) //without guarding, the number of outstanding async events can become very high during scrolling!! test case: Ubuntu: 170; Windows: 20
        {
            gridUpdatePending_ = true;

            GetEventHandler()->CallAfter([this]
            {
                refParent().updateWindowSizes(false); //row label width has changed -> do *not* update scrollbars: recursion on wxGTK! -> still a problem, now that this function is called async??
                rowLabelWin_.Update(); //update while dragging scroll thumb

                assert(gridUpdatePending_);
                gridUpdatePending_ = false;
            });
        }
    }

    void refreshRow(size_t row)
    {
        const wxRect& rowArea = rowLabelWin_.getRowLabelArea(row); //returns empty rect if row not found
        const wxPoint topLeft = refParent().CalcScrolledPosition(wxPoint(0, rowArea.y)); //logical -> window coordinates
        wxRect cellArea(topLeft, wxSize(refParent().getColWidthsSum(GetClientSize().GetWidth()), rowArea.height));
        RefreshRect(cellArea);
    }

    struct MouseHighlight
    {
        size_t row = 0;
        HoverArea rowHover = HoverArea::none;

        bool operator==(const MouseHighlight&) const = default;
    };

    void setMouseHighlight(const std::optional<MouseHighlight>& hl)
    {
        assert(!hl || (hl->row < refParent().getRowCount() && hl->rowHover != HoverArea::none));
        if (highlight_ != hl)
        {
            if (highlight_)
                refreshRow(highlight_->row);

            highlight_ = hl;

            if (highlight_)
                refreshRow(highlight_->row);
        }
    }


    RowLabelWin& rowLabelWin_;
    ColLabelWin& colLabelWin_;

    std::optional<MouseSelection> activeSelection_; //bound while user is selecting with mouse
    std::optional<MouseHighlight> highlight_;

    size_t cursorRow_ = 0;
    size_t selectionAnchor_ = 0;
    bool gridUpdatePending_ = false;
};

//----------------------------------------------------------------------------------------------------------------
//----------------------------------------------------------------------------------------------------------------

Grid::Grid(wxWindow* parent,
           wxWindowID id,
           const wxPoint& pos,
           const wxSize& size,
           long style,
           const wxString& name) : wxScrolledWindow(parent, id, pos, size, style | wxWANTS_CHARS, name)
{
    cornerWin_   = new CornerWin  (*this); //
    rowLabelWin_ = new RowLabelWin(*this); //owership handled by "this"
    colLabelWin_ = new ColLabelWin(*this); //
    mainWin_     = new MainWin    (*this, *rowLabelWin_, *colLabelWin_); //

    SetTargetWindow(mainWin_);

    SetInitialSize(size); //"Most controls will use this to set their initial size" -> why not

    assert(GetClientSize() == GetSize() && GetWindowBorderSize() == wxSize()); //borders are NOT allowed for Grid
    //reason: updateWindowSizes() wants to use "GetSize()" as a "GetClientSize()" including scrollbars

    //https://wiki.wxwidgets.org/Flicker-Free_Drawing
    SetBackgroundStyle(wxBG_STYLE_PAINT); //get's rid of needless wxEVT_ERASE_BACKGROUND
    //wxEVT_PAINT: "If you have an EVT_PAINT() handler, you must create a wxPaintDC object within it even if you don't actually use it."
    //=> and if not, wxScrollHelperEvtHandler::ProcessEvent() helps out and creates wxPaintDC (without rendering anything)
    Bind(wxEVT_SIZE,     [this](wxSizeEvent& event) { updateWindowSizes(); event.Skip(); });
    Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent&  event) { onKeyDown(event); });
}


void Grid::updateWindowSizes(bool updateScrollbar)
{
    /* We have to deal with TWO nasty circular dependencies:
    1.
        rowLabelWidth
            /|\
        mainWin::client width
            /|\
        SetScrollbars -> show/hide horizontal scrollbar depending on client width
            /|\
        mainWin::client height -> possibly trimmed by horizontal scrollbars
            /|\
        rowLabelWidth

    2.
        mainWin_->GetClientSize()
            /|\
        SetScrollbars -> show/hide scrollbars depending on whether client size is big enough
            /|\
        GetClientSize(); -> possibly trimmed by scrollbars
            /|\
        mainWin_->GetClientSize()  -> also trimmed, since it's a sub-window!
    */

    //break this vicious circle:

    //harmonize with Grid::GetSizeAvailableForScrollTarget()!

    //1. calculate row label width independent from scrollbars
    const int mainWinHeightGross = std::max(0, GetSize().GetHeight() - getColumnLabelHeight()); //independent from client sizes and scrollbars!
    const ptrdiff_t logicalHeight = rowLabelWin_->getLogicalHeight();                           //

    const int rowLabelWidth = [&]
    {
        if (drawRowLabel_ && logicalHeight > 0)
        {
            ptrdiff_t yFrom = CalcUnscrolledPosition(wxPoint(0, 0)).y;
            ptrdiff_t yTo   = CalcUnscrolledPosition(wxPoint(0, mainWinHeightGross - 1)).y ;
            yFrom = std::clamp<ptrdiff_t>(yFrom, 0, logicalHeight - 1);
            yTo   = std::clamp<ptrdiff_t>(yTo,   0, logicalHeight - 1);

            const ptrdiff_t rowFrom = rowLabelWin_->getRowAtPos(yFrom);
            const ptrdiff_t rowTo   = rowLabelWin_->getRowAtPos(yTo);
            if (rowFrom >= 0 && rowTo >= 0)
                return rowLabelWin_->getBestWidth(rowFrom, rowTo);
        }
        return 0;
    }();

    //2. update managed windows' sizes: just assume scrollbars are already set correctly, even if they may not be (yet)!
    //this ensures mainWin_->SetVirtualSize() and AdjustScrollbars() are working with the correct main window size, unless sb change later, which triggers a recalculation anyway!
    const wxSize mainWinSize(std::max(0, GetClientSize().GetWidth () - rowLabelWidth),
                             std::max(0, GetClientSize().GetHeight() - getColumnLabelHeight()));

    cornerWin_  ->SetSize(0, 0, rowLabelWidth, getColumnLabelHeight());
    rowLabelWin_->SetSize(0, getColumnLabelHeight(), rowLabelWidth, mainWinSize.GetHeight());
    colLabelWin_->SetSize(rowLabelWidth, 0, mainWinSize.GetWidth(), getColumnLabelHeight());
    mainWin_    ->SetSize(rowLabelWidth, getColumnLabelHeight(), mainWinSize.GetWidth(), mainWinSize.GetHeight());

    //avoid flicker in wxWindowMSW::HandleSize() when calling ::EndDeferWindowPos() where the sub-windows are moved only although they need to be redrawn!
    colLabelWin_->Refresh();
    mainWin_    ->Refresh();

    //3. update scrollbars: "guide wxScrolledHelper to not screw up too much"
    if (updateScrollbar)
    {
        auto setScrollbars2 = [&](int logWidth, int logHeight) //replace SetScrollbars, which loses precision of pixelsPerUnitX for some brain-dead reason
        {
            mainWin_->SetVirtualSize(logWidth, logHeight); //set before calling SetScrollRate():
            //else SetScrollRate() would fail to preserve scroll position when "new virtual pixel-pos > old virtual height"

            int ppsuX = 0; //pixel per scroll unit
            int ppsuY = 0;
            GetScrollPixelsPerUnit(&ppsuX, &ppsuY);

            const int ppsuNew = rowLabelWin_->getRowHeight();
            if (ppsuX != ppsuNew || ppsuY != ppsuNew) //support polling!
                SetScrollRate(ppsuNew, ppsuNew); //internally calls AdjustScrollbars() and GetVirtualSize()!

            AdjustScrollbars(); //lousy wxWidgets design decision: internally calls mainWin_->GetClientSize() without considering impact of scrollbars!
            //Attention: setting scrollbars triggers *synchronous* resize event if scrollbars are shown or hidden! => updateWindowSizes() recursion! (Windows)
        };

        const int mainWinWidthGross = std::max(0, GetSize().GetWidth() - rowLabelWidth);

        if (logicalHeight                      <= mainWinHeightGross &&
            getColWidthsSum(mainWinWidthGross) <= mainWinWidthGross &&
            //this special case needs to be considered *only* when both scrollbars are flexible:
            showScrollbarH_ == SB_SHOW_AUTOMATIC &&
            showScrollbarV_ == SB_SHOW_AUTOMATIC)
            setScrollbars2(0, 0); //no scrollbars required at all! -> wxScrolledWindow requires active help to detect this special case!
        else
        {
            const int logicalWidthTmp = getColWidthsSum(mainWinSize.GetWidth()); //assuming vertical scrollbar stays as it is...
            setScrollbars2(logicalWidthTmp, logicalHeight); //if scrollbars are shown or hidden a new resize event recurses into updateWindowSizes()
            /*
            is there a risk of endless recursion? No, 2-level recursion at most, consider the following 6 cases:

            <----------gw---------->
            <----------nw------>
            ------------------------  /|\   /|\
            |                   |  |   |     |
            |     main window   |  |   nh    |
            |                   |  |   |     gh
            ------------------------  \|/    |
            |                   |  |         |
            ------------------------        \|/
                gw := gross width
                nw := net width := gross width - sb size
                gh := gross height
                nh := net height := gross height - sb size

            There are 6 cases that can occur:
            ---------------------------------
                lw := logical width
                lh := logical height

            1. lw <= gw && lh <= gh  => no scrollbars needed

            2. lw > gw  && lh > gh   => need both scrollbars

            lh > gh
                3. lw <= nw         => need vertical scrollbar only
                4. nw < lw <= gw    => need both scrollbars

            lw > gw
                5. lh <= nh         => need horizontal scrollbar only
                6. nh < lh <= gh    => need both scrollbars
            */
        }
    }
}


wxSize Grid::GetSizeAvailableForScrollTarget(const wxSize& size)
{
    //1. "size == GetSize() == (0, 0)" happens temporarily during initialization
    //2. often it's even (0, 20)
    //3. fuck knows why, but we *temporarily* get "size == GetSize() == (1, 1)" when wxAUI panel containing Grid is dropped
    if (size.x <= 1 || size.y <= 1)
        return {}; //probably best considering calling code in generic/scrlwing.cpp: wxScrollHelper::AdjustScrollbars()

    //1. calculate row label width independent from scrollbars
    const int mainWinHeightGross = std::max(0, size.GetHeight() - getColumnLabelHeight()); //independent from client sizes and scrollbars!
    const ptrdiff_t logicalHeight = rowLabelWin_->getLogicalHeight();                      //

    const int rowLabelWidth = [&]
    {
        if (drawRowLabel_ && logicalHeight > 0)
        {
            ptrdiff_t yFrom = CalcUnscrolledPosition(wxPoint(0, 0)).y;
            ptrdiff_t yTo   = CalcUnscrolledPosition(wxPoint(0, mainWinHeightGross - 1)).y ;
            yFrom = std::clamp<ptrdiff_t>(yFrom, 0, logicalHeight - 1);
            yTo   = std::clamp<ptrdiff_t>(yTo,   0, logicalHeight - 1);

            const ptrdiff_t rowFrom = rowLabelWin_->getRowAtPos(yFrom);
            const ptrdiff_t rowTo   = rowLabelWin_->getRowAtPos(yTo);
            if (rowFrom >= 0 && rowTo >= 0)
                return rowLabelWin_->getBestWidth(rowFrom, rowTo);
        }
        return 0;
    }();

    //2. try(!) to determine scrollbar sizes:
#if GTK_MAJOR_VERSION == 2
    /* Ubuntu 19.10: "scrollbar-spacing" has a default value of 3: https://developer.gnome.org/gtk2/stable/GtkScrolledWindow.html#GtkScrolledWindow--s-scrollbar-spacing
        => the default Ubuntu theme (but also our Gtk2Styles.rc) set it to 0, but still the first call to gtk_widget_style_get() returns 3: why?
        => maybe styles are applied asynchronously? GetClientSize() is affected by this, so can't use!
        => always ignore spacing to get consistent scrollbar dimensions!  */
    GtkScrolledWindow* scrollWin = GTK_SCROLLED_WINDOW(wxWindow::m_widget);
    assert(scrollWin);
    GtkWidget* rangeH = ::gtk_scrolled_window_get_hscrollbar(scrollWin);
    GtkWidget* rangeV = ::gtk_scrolled_window_get_vscrollbar(scrollWin);

    GtkRequisition reqH = {};
    GtkRequisition reqV = {};
    if (rangeH) ::gtk_widget_size_request(rangeH, &reqH);
    if (rangeV) ::gtk_widget_size_request(rangeV, &reqV);
    assert(reqH.width > 0 && reqH.height > 0);
    assert(reqV.width > 0 && reqV.height > 0);

    const wxSize scrollBarSizeTmp(reqV.width, reqH.height);
    assert(scrollBarHeightH_ == 0 || scrollBarHeightH_ == scrollBarSizeTmp.y);
    assert(scrollBarWidthV_  == 0 || scrollBarWidthV_  == scrollBarSizeTmp.x);

#elif GTK_MAJOR_VERSION == 3
    //scrollbar size increases dynamically on mouse-hover!
    //see "overlay scrolling": https://developer.gnome.org/gtk3/stable/GtkScrolledWindow.html#gtk-scrolled-window-set-overlay-scrolling
    //luckily "scrollbar-spacing" is stable on GTK3
    const wxSize scrollBarSizeTmp = GetSize() - GetClientSize();

    //lame hard-coded numbers (from Ubuntu 19.10) and openSuse
    //=> let's have a *close* eye on scrollbar fluctuation!
    assert(scrollBarSizeTmp.x == 0 ||
           scrollBarSizeTmp.x == 6 || scrollBarSizeTmp.x == 13 || //Ubuntu 19.10
           scrollBarSizeTmp.x == 16); //openSuse
    assert(scrollBarSizeTmp.y == 0 ||
           scrollBarSizeTmp.y == 6 || scrollBarSizeTmp.y == 13 || //Ubuntu 19.10
           scrollBarSizeTmp.y == 16); //openSuse
#else
#error unknown GTK version!
#endif
    scrollBarHeightH_ = std::max(scrollBarHeightH_, scrollBarSizeTmp.y);
    scrollBarWidthV_  = std::max(scrollBarWidthV_,  scrollBarSizeTmp.x);
    //this function is called again by wxScrollHelper::AdjustScrollbars() if SB_SHOW_ALWAYS-scrollbars are not yet shown => scrollbar size > 0 eventually!

    //-----------------------------------------------------------------------------
    //harmonize with Grid::updateWindowSizes()!
    wxSize sizeAvail = size - wxSize(rowLabelWidth, getColumnLabelHeight());

    //EXCEPTION: space consumed by SB_SHOW_ALWAYS-scrollbars is *never* available for "scroll target"; see wxScrollHelper::AdjustScrollbars()
    if (showScrollbarH_ == SB_SHOW_ALWAYS)
        sizeAvail.y -= (scrollBarHeightH_ > 0 ? scrollBarHeightH_ : /*fallback:*/ scrollBarWidthV_);
    if (showScrollbarV_ == SB_SHOW_ALWAYS)
        sizeAvail.x -= (scrollBarWidthV_ > 0 ? scrollBarWidthV_ : /*fallback:*/ scrollBarHeightH_);

    return wxSize(std::max(0, sizeAvail.x),
                  std::max(0, sizeAvail.y));
}


void Grid::onKeyDown(wxKeyEvent& event)
{
    int keyCode = event.GetKeyCode();
    if (GetLayoutDirection() == wxLayout_RightToLeft)
    {
        if (keyCode == WXK_LEFT || keyCode == WXK_NUMPAD_LEFT)
            keyCode = WXK_RIGHT;
        else if (keyCode == WXK_RIGHT || keyCode == WXK_NUMPAD_RIGHT)
            keyCode = WXK_LEFT;
    }
    if (event.ShiftDown() && keyCode == WXK_F10) //== alias for menu key
        keyCode = WXK_WINDOWS_MENU;

    const ptrdiff_t rowCount  = getRowCount();
    const ptrdiff_t cursorRow = mainWin_->getCursor();

    auto moveCursorTo = [&](ptrdiff_t row)
    {
        if (rowCount > 0)
            setGridCursor(std::clamp<ptrdiff_t>(row, 0, rowCount - 1), GridEventPolicy::allow);
    };

    auto selectWithCursorTo = [&](ptrdiff_t row)
    {
        if (rowCount > 0)
        {
            row = std::clamp<ptrdiff_t>(row, 0, rowCount - 1);
            const ptrdiff_t anchorRow = mainWin_->getAnchor();

            mainWin_->setCursor(row, anchorRow);
            makeRowVisible(row);

            selection_.clear(); //clear selection, do NOT fire event

            const ptrdiff_t rowFirst = std::min(anchorRow, row);     //sort + convert to half-open range
            const ptrdiff_t rowLast  = std::max(anchorRow, row) + 1; //
            selectRange(rowFirst, rowLast, true /*positive*/, GridEventPolicy::allow); //set new selection + fire event
        }
    };

    switch (keyCode)
    {
        case WXK_MENU:         //simulate right mouse click at cursor row position (on lower edge)
        case WXK_WINDOWS_MENU: //(but truncate to window if cursor is out of view)
        {
            const size_t row = std::min(mainWin_->getCursor(), getRowCount());

            const int clientPosMainWinY = std::clamp(CalcScrolledPosition(wxPoint(0, rowLabelWin_->getRowHeight() * (row + 1))).y - 1, //logical -> window coordinates
                                                     0, mainWin_->GetClientSize().GetHeight() - 1);

            const wxPoint mousePos = mainWin_->GetPosition() + wxPoint(0, clientPosMainWinY); //mainWin_-relative to Grid-relative

            GridContextMenuEvent contextEvent(mousePos);
            GetEventHandler()->ProcessEvent(contextEvent);
        }
        return;

        //case WXK_TAB:
        //    if (Navigate(event.ShiftDown() ? wxNavigationKeyEvent::IsBackward : wxNavigationKeyEvent::IsForward))
        //        return;
        //    break;

        case WXK_UP:
        case WXK_NUMPAD_UP:
            if (event.ShiftDown())
                selectWithCursorTo(cursorRow - 1);
            else if (event.ControlDown())
                scrollDelta(0, -1);
            else
                moveCursorTo(cursorRow - 1);
            return; //swallow event: wxScrolledWindow, wxWidgets 2.9.3 on Kubuntu x64 processes arrow keys: prevent this!

        case WXK_DOWN:
        case WXK_NUMPAD_DOWN:
            if (event.ShiftDown())
                selectWithCursorTo(cursorRow + 1);
            else if (event.ControlDown())
                scrollDelta(0, 1);
            else
                moveCursorTo(cursorRow + 1);
            return; //swallow event

        case WXK_LEFT:
        case WXK_NUMPAD_LEFT:
            if (event.ControlDown())
                scrollDelta(-1, 0);
            else if (event.ShiftDown())
                ;
            else
                moveCursorTo(cursorRow);
            return;

        case WXK_RIGHT:
        case WXK_NUMPAD_RIGHT:
            if (event.ControlDown())
                scrollDelta(1, 0);
            else if (event.ShiftDown())
                ;
            else
                moveCursorTo(cursorRow);
            return;

        case WXK_HOME:
        case WXK_NUMPAD_HOME:
            if (event.ShiftDown())
                selectWithCursorTo(0);
            //else if (event.ControlDown())
            //    ;
            else
                moveCursorTo(0);
            return;

        case WXK_END:
        case WXK_NUMPAD_END:
            if (event.ShiftDown())
                selectWithCursorTo(rowCount - 1);
            //else if (event.ControlDown())
            //    ;
            else
                moveCursorTo(rowCount - 1);
            return;

        case WXK_PAGEUP:
        case WXK_NUMPAD_PAGEUP:
            if (event.ShiftDown())
                selectWithCursorTo(cursorRow - rowLabelWin_->GetClientSize().GetHeight() / rowLabelWin_->getRowHeight());
            //else if (event.ControlDown())
            //    ;
            else
                moveCursorTo(cursorRow - rowLabelWin_->GetClientSize().GetHeight() / rowLabelWin_->getRowHeight());
            return;

        case WXK_PAGEDOWN:
        case WXK_NUMPAD_PAGEDOWN:
            if (event.ShiftDown())
                selectWithCursorTo(cursorRow + rowLabelWin_->GetClientSize().GetHeight() / rowLabelWin_->getRowHeight());
            //else if (event.ControlDown())
            //    ;
            else
                moveCursorTo(cursorRow + rowLabelWin_->GetClientSize().GetHeight() / rowLabelWin_->getRowHeight());
            return;

        case 'A':  //Ctrl + A - select all
            if (event.ControlDown())
                selectRange(0, rowCount, true /*positive*/, GridEventPolicy::allow);
            break;

        case WXK_NUMPAD_ADD: //CTRL + '+' - auto-size all
            if (event.ControlDown())
                autoSizeColumns(GridEventPolicy::allow);
            return;
    }

    event.Skip();
}


void Grid::setColumnLabelHeight(int height)
{
    colLabelWin_->setColumnLabelHeight(height);
    updateWindowSizes();
}


int Grid::getColumnLabelHeight() const { return colLabelWin_->getColumnLabelHeight(); }


void Grid::showRowLabel(bool show)
{
    drawRowLabel_ = show;
    updateWindowSizes();
}


void Grid::selectRange(size_t rowFirst, size_t rowLast, bool positive, GridEventPolicy rangeEventPolicy)
{
    selectRange2(rowFirst, rowLast, positive, nullptr /*mouseClick*/, rangeEventPolicy);
}


void Grid::selectRange2(size_t rowFirst, size_t rowLast, bool positive, const GridClickEvent* mouseClick, GridEventPolicy rangeEventPolicy)
{
    assert(rowFirst <= rowLast);
    assert(getRowCount() == selection_.gridSize());
    rowFirst = std::clamp<size_t>(rowFirst, 0, selection_.gridSize());
    rowLast  = std::clamp<size_t>(rowLast,  0, selection_.gridSize());

    if (rowFirst < rowLast && !selection_.matchesRange(rowFirst, rowLast, positive))
    {
        selection_.selectRange(rowFirst, rowLast, positive);
        mainWin_->Refresh();
    }

    //issue event even for unchanged selection! e.g. MainWin::onMouseDown() temporarily clears range with GridEventPolicy::deny!
    if (rangeEventPolicy == GridEventPolicy::allow)
    {
        GridSelectEvent selEvent(rowFirst, rowLast, positive, mouseClick);
        [[maybe_unused]] const bool processed = GetEventHandler()->ProcessEvent(selEvent);
    }
}

void Grid::selectRow(size_t row, GridEventPolicy rangeEventPolicy) { selectRange(row, row + 1,             true  /*positive*/, rangeEventPolicy); }
void Grid::selectAllRows        (GridEventPolicy rangeEventPolicy) { selectRange(0, selection_.gridSize(), true  /*positive*/, rangeEventPolicy); }
void Grid::clearSelection       (GridEventPolicy rangeEventPolicy) { selectRange(0, selection_.gridSize(), false /*positive*/, rangeEventPolicy); }


void Grid::scrollDelta(int deltaX, int deltaY)
{
    const wxPoint scrollPosOld = GetViewStart();

    wxPoint scrollPosNew = scrollPosOld;
    scrollPosNew.x += deltaX;
    scrollPosNew.y += deltaY;

    scrollPosNew.x = std::max(0, scrollPosNew.x); //wxScrollHelper::Scroll() will exit prematurely if input happens to be "-1"!
    scrollPosNew.y = std::max(0, scrollPosNew.y); //

    if (scrollPosNew != scrollPosOld)
    {
        Scroll(scrollPosNew); //internally calls wxWindows::Update()!
        updateWindowSizes(); //may show horizontal scroll bar if row column gets wider
    }
}


size_t Grid::getRowCount() const
{
    return dataView_ ? dataView_->getRowCount() : 0;
}


void Grid::Refresh(bool eraseBackground, const wxRect* rect)
{
    const size_t rowCountNew = getRowCount();
    if (rowCountOld_ != rowCountNew)
    {
        rowCountOld_ = rowCountNew;
        updateWindowSizes();
    }

    if (selection_.gridSize() != rowCountNew)
    {
        const bool priorSelection = !selection_.matchesRange(0, selection_.gridSize(), false /*positive*/);

        selection_.resize(rowCountNew);

        if (priorSelection) //clear selection only when needed
        {
            //clearSelection(GridEventPolicy::allow); -> no, we need async event to make filegrid::refresh(*m_gridMainL, *m_gridMainC, *m_gridMainR) work
            selection_.clear();
            GetEventHandler()->AddPendingEvent(GridSelectEvent(0, rowCountNew, false /*positive*/, nullptr /*mouseClick*/));
        }
    }

    wxScrolledWindow::Refresh(eraseBackground, rect);
}


void Grid::setRowHeight(int height)
{
    rowLabelWin_->setRowHeight(height);
    updateWindowSizes();
    Refresh();
}


int Grid::getRowHeight() const { return rowLabelWin_->getRowHeight(); }


void Grid::setColumnConfig(const std::vector<Grid::ColAttributes>& attr)
{
    //hold ownership of non-visible columns
    oldColAttributes_ = attr;

    std::vector<VisibleColumn> visCols;
    for (const ColAttributes& ca : attr)
    {
        assert(ca.stretch >= 0);
        assert(ca.type != ColumnType::none);

        if (ca.visible)
            visCols.push_back({ca.type, ca.offset, std::max(ca.stretch, 0)});
    }

    //"ownership" of visible columns is now within Grid
    visibleCols_ = std::move(visCols);

    updateWindowSizes();
    Refresh();
}


std::vector<Grid::ColAttributes> Grid::getColumnConfig() const
{
    //get non-visible columns (+ outdated visible ones)
    std::vector<ColAttributes> output = oldColAttributes_;

    auto itVcols    = visibleCols_.begin();
    auto itVcolsend = visibleCols_.end();

    //update visible columns but keep order of non-visible ones!
    for (ColAttributes& ca : output)
        if (ca.visible)
        {
            if (itVcols != itVcolsend)
            {
                ca.type    = itVcols->type;
                ca.stretch = itVcols->stretch;
                ca.offset  = itVcols->offset;
                ++itVcols;
            }
            else
                assert(false);
        }
    assert(itVcols == itVcolsend);

    return output;
}


void Grid::showScrollBars(Grid::ScrollBarStatus horizontal, Grid::ScrollBarStatus vertical)
{
    if (showScrollbarH_ == horizontal &&
        showScrollbarV_ == vertical) return; //support polling!

    showScrollbarH_ = horizontal;
    showScrollbarV_ = vertical;

    //the following wxGTK approach is pretty much identical to wxWidgets 2.9 ShowScrollbars() code!

    auto mapStatus = [](ScrollBarStatus sbStatus) -> GtkPolicyType
    {
        switch (sbStatus)
        {
            case SB_SHOW_AUTOMATIC:
                return GTK_POLICY_AUTOMATIC;
            case SB_SHOW_ALWAYS:
                return GTK_POLICY_ALWAYS;
            case SB_SHOW_NEVER:
                return GTK_POLICY_NEVER;
        }
        assert(false);
        return GTK_POLICY_AUTOMATIC;
    };

    GtkScrolledWindow* scrollWin = GTK_SCROLLED_WINDOW(wxWindow::m_widget);
    assert(scrollWin);
    ::gtk_scrolled_window_set_policy(scrollWin,
                                     mapStatus(horizontal),
                                     mapStatus(vertical));

    updateWindowSizes();
}



wxWindow& Grid::getCornerWin  () { return *cornerWin_;   }
wxWindow& Grid::getRowLabelWin() { return *rowLabelWin_; }
wxWindow& Grid::getColLabelWin() { return *colLabelWin_; }
wxWindow& Grid::getMainWin    () { return *mainWin_;     }
const wxWindow& Grid::getMainWin() const { return *mainWin_; }


void Grid::moveColumn(size_t colFrom, size_t colTo)
{
    if (colFrom < visibleCols_.size() &&
        colTo   < visibleCols_.size() &&
        colTo != colFrom)
    {
        const VisibleColumn colAtt = visibleCols_[colFrom];
        visibleCols_.erase (visibleCols_.begin() + colFrom);
        visibleCols_.insert(visibleCols_.begin() + colTo, colAtt);
    }
}


ColumnType Grid::colToType(size_t col) const
{
    if (col < visibleCols_.size())
        return visibleCols_[col].type;
    return ColumnType::none;
}


Grid::ColumnPosInfo Grid::getColumnAtWinPos(int posX) const
{
    if (const int absX = CalcUnscrolledPosition(wxPoint(posX, 0)).x;
        absX >= 0)
    {
        int accWidth = 0;
        for (const ColumnWidth& cw : getColWidths())
        {
            accWidth += cw.width;
            if (absX < accWidth)
                return {cw.type, absX + cw.width - accWidth, cw.width};
        }
    }
    return {ColumnType::none, 0, 0};
}


ptrdiff_t Grid::getRowAtWinPos(int posY) const
{
    const int absY = CalcUnscrolledPosition(wxPoint(0, posY)).y;
    return rowLabelWin_->getRowAtPos(absY); //return -1 for invalid position, rowCount if past the end
}


std::pair<ptrdiff_t, ptrdiff_t> Grid::getVisibleRows(const wxRect& clientRect) const //returns range [begin, end)
{
    if (clientRect.height > 0)
    {
        const int rowFrom = getRowAtWinPos(clientRect.y);
        const int rowTo   = getRowAtWinPos(clientRect.GetBottom());

        return {std::max(rowFrom, 0),
                std::min<ptrdiff_t>((rowTo) + 1, getRowCount())};
    }
    return {};
}


wxRect Grid::getColumnLabelArea(ColumnType colType) const
{
    const std::vector<ColumnWidth>& absWidths = getColWidths(); //resolve negative/stretched widths

    //colType is not unique in general, but *this* function expects it!
    assert(std::count_if(absWidths.begin(), absWidths.end(), [&](const ColumnWidth& cw) { return cw.type == colType; }) <= 1);

    auto itCol = std::find_if(absWidths.begin(), absWidths.end(), [&](const ColumnWidth& cw) { return cw.type == colType; });
    if (itCol != absWidths.end())
    {
        ptrdiff_t posX = 0;
        std::for_each(absWidths.begin(), itCol,
        [&](const ColumnWidth& cw) { posX += cw.width; });

        return wxRect(wxPoint(posX, 0), wxSize(itCol->width, getColumnLabelHeight()));
    }
    return wxRect();
}


void Grid::refreshCell(size_t row, ColumnType colType)
{
    const wxRect& colArea = getColumnLabelArea(colType); //returns empty rect if column not found
    const wxRect& rowArea = rowLabelWin_->getRowLabelArea(row); //returns empty rect if row not found
    if (colArea.width > 0 && rowArea.height > 0)
    {
        const wxPoint topLeft = CalcScrolledPosition(wxPoint(colArea.x, rowArea.y)); //logical -> window coordinates
        const wxRect cellArea(topLeft, wxSize(colArea.width, rowArea.height));

        getMainWin().RefreshRect(cellArea);
    }
}


void Grid::setGridCursor(size_t row, GridEventPolicy rangeEventPolicy)
{
    mainWin_->setCursor(row, row);
    makeRowVisible(row);

    selection_.clear(); //clear selection, do NOT fire event
    selectRow(row, rangeEventPolicy); //set new selection + fire event
}


void Grid::makeRowVisible(size_t row)
{
    const wxRect labelRect = rowLabelWin_->getRowLabelArea(row); //returns empty rect if row not found
    if (labelRect.height > 0)
    {
        int pixelsPerUnitY = 0;
        GetScrollPixelsPerUnit(nullptr, &pixelsPerUnitY);
        if (pixelsPerUnitY > 0)
        {
            const wxPoint scrollPosOld = GetViewStart();

            const int clientPosY = CalcScrolledPosition(labelRect.GetTopLeft()).y;
            if (clientPosY < 0)
            {
                const int scrollPosNewY = labelRect.y / pixelsPerUnitY;
                Scroll(scrollPosOld.x, scrollPosNewY); //internally calls wxWindows::Update()!
                updateWindowSizes(); //may show horizontal scroll bar if row column gets wider
                Refresh();
            }
            else if (clientPosY + labelRect.height > rowLabelWin_->GetClientSize().GetHeight())
            {
                auto execScroll = [&](int clientHeight)
                {
                    const int scrollPosNewY = numeric::intDivCeil(labelRect.y + labelRect.height - clientHeight, pixelsPerUnitY);
                    Scroll(scrollPosOld.x, scrollPosNewY);
                    updateWindowSizes(); //may show horizontal scroll bar if row column gets wider
                    Refresh();
                };

                const int clientHeightBefore = rowLabelWin_->GetClientSize().GetHeight();
                execScroll(clientHeightBefore);

                //client height may decrease after scroll due to a new horizontal scrollbar, resulting in a partially visible last row
                const int clientHeightAfter = rowLabelWin_->GetClientSize().GetHeight();
                if (clientHeightAfter < clientHeightBefore)
                    execScroll(clientHeightAfter);
            }
        }
    }
}


void Grid::scrollTo(size_t row)
{
    const wxRect labelRect = rowLabelWin_->getRowLabelArea(row); //returns empty rect if row not found
    if (labelRect.height > 0)
    {
        int pixelsPerUnitY = 0;
        GetScrollPixelsPerUnit(nullptr, &pixelsPerUnitY);
        if (pixelsPerUnitY > 0)
        {
            const int scrollPosNewY = labelRect.y / pixelsPerUnitY;
            const wxPoint scrollPosOld = GetViewStart();

            if (scrollPosOld.y != scrollPosNewY) //support polling
            {
                Scroll(scrollPosOld.x, scrollPosNewY); //internally calls wxWindows::Update()!
                updateWindowSizes(); //may show horizontal scroll bar if row column gets wider
                Refresh();
            }
        }
    }
}


bool Grid::Enable(bool enable)
{
    Refresh();
    return wxScrolledWindow::Enable(enable);
}


size_t Grid::getGridCursor() const
{
    return mainWin_->getCursor();
}


int Grid::getBestColumnSize(size_t col) const
{
    if (dataView_ && col < visibleCols_.size())
    {
        const ColumnType type = visibleCols_[col].type;

        wxInfoDC dc(mainWin_);
        dc.SetFont(mainWin_->GetFont()); //harmonize with MainWin::render()

        const auto& [rowFirst, rowLast] = getVisibleRows(mainWin_->GetClientRect());

        int maxSize = 0;
        for (auto row = rowFirst; row < rowLast; ++row)
            maxSize = std::max(maxSize, dataView_->getBestSize(dc, row, type));

        return maxSize;
    }
    return -1;
}


void Grid::setColumnWidth(int width, size_t col, GridEventPolicy columnResizeEventPolicy, bool notifyAsync)
{
    if (col < visibleCols_.size())
    {
        VisibleColumn& vcRs = visibleCols_[col];

        const std::vector<int> stretchedWidths = getColStretchedWidths(mainWin_->GetClientSize().GetWidth());
        if (stretchedWidths.size() != visibleCols_.size())
        {
            assert(false);
            return;
        }
        //CAVEATS:
        //I. fixed-size columns: normalize offset so that resulting width is at least COLUMN_MIN_WIDTH_DIP: this is NOT enforced by getColWidths()!
        //II. stretched columns: do not allow user to set offsets so small that they result in negative (non-normalized) widths: this gives an
        //unusual delay when enlarging the column again later
        width = std::max(width, dipToWxsize(COLUMN_MIN_WIDTH_DIP));

        vcRs.offset = width - stretchedWidths[col]; //width := stretchedWidth + offset

        //III. resizing any column should normalize *all* other stretched columns' offsets considering current mainWinWidth!
        // test case:
        //1. have columns, both fixed-size and stretched, fit whole window width
        //2. shrink main window width so that horizontal scrollbars are shown despite the streched column
        //3. shrink a fixed-size column so that the scrollbars vanish and columns cover full width again
        //4. now verify that the stretched column is resizing immediately if main window is enlarged again
        for (size_t col2 = 0; col2 < visibleCols_.size(); ++col2)
            if (visibleCols_[col2].stretch > 0) //normalize stretched columns only
                visibleCols_[col2].offset = std::max(visibleCols_[col2].offset, dipToWxsize(COLUMN_MIN_WIDTH_DIP) - stretchedWidths[col2]);

        if (columnResizeEventPolicy == GridEventPolicy::allow)
        {
            GridColumnResizeEvent sizeEvent(vcRs.offset, vcRs.type);
            if (notifyAsync)
                GetEventHandler()->AddPendingEvent(sizeEvent);
            else
                GetEventHandler()->ProcessEvent(sizeEvent);
        }
    }
    else
        assert(false);
}


void Grid::autoSizeColumns(GridEventPolicy columnResizeEventPolicy)
{
    if (allowColumnResize_)
    {
        for (size_t col = 0; col < visibleCols_.size(); ++col)
        {
            const int bestWidth = getBestColumnSize(col); //return -1 on error
            if (bestWidth >= 0)
                setColumnWidth(bestWidth, col, columnResizeEventPolicy, true /*notifyAsync*/);
        }
        updateWindowSizes();
        Refresh();
    }
}


std::vector<int> Grid::getColStretchedWidths(int clientWidth) const //final width = (normalized) (stretchedWidth + offset)
{
    assert(clientWidth >= 0);
    clientWidth = std::max(clientWidth, 0);
    int stretchTotal = 0;
    for (const VisibleColumn& vc : visibleCols_)
    {
        assert(vc.stretch >= 0);
        stretchTotal += vc.stretch;
    }

    int remainingWidth = clientWidth;

    std::vector<int> output;

    if (stretchTotal <= 0)
        output.resize(visibleCols_.size()); //fill with zeros
    else
    {
        for (const VisibleColumn& vc : visibleCols_)
        {
            const int width = clientWidth * vc.stretch / stretchTotal; //rounds down!
            output.push_back(width);
            remainingWidth -= width;
        }

        //distribute *all* of clientWidth: should suffice to enlarge the first few stretched columns; no need to minimize total absolute error of distribution
        if (remainingWidth > 0)
            for (size_t col2 = 0; col2 < visibleCols_.size(); ++col2)
                if (visibleCols_[col2].stretch > 0)
                {
                    ++output[col2];
                    if (--remainingWidth == 0)
                        break;
                }
        assert(remainingWidth == 0);
    }
    return output;
}


std::vector<Grid::ColumnWidth> Grid::getColWidths() const
{
    return getColWidths(mainWin_->GetClientSize().GetWidth());
}


std::vector<Grid::ColumnWidth> Grid::getColWidths(int mainWinWidth) const //evaluate stretched columns
{
    const std::vector<int> stretchedWidths = getColStretchedWidths(mainWinWidth);
    assert(stretchedWidths.size() == visibleCols_.size());

    std::vector<ColumnWidth> output;
    for (size_t col2 = 0; col2 < visibleCols_.size(); ++col2)
    {
        const auto& vc = visibleCols_[col2];
        int width = stretchedWidths[col2] + vc.offset;

        if (vc.stretch > 0)
            width = std::max(width, dipToWxsize(COLUMN_MIN_WIDTH_DIP)); //normalization really needed here: e.g. smaller main window would result in negative width
        else
            width = std::max(width, 0); //support smaller width than COLUMN_MIN_WIDTH_DIP if set via configuration

        output.push_back({vc.type, width});
    }
    return output;
}


int Grid::getColWidthsSum(int mainWinWidth) const
{
    int sum = 0;
    for (const ColumnWidth& cw : getColWidths(mainWinWidth))
        sum += cw.width;
    return sum;
}
