Skip to content

[FEATURE] Remember the image compare splitter position #3591

Closed

Description

@Ikkoru

Description

Description

The image compare splitter always lays out as an equal split. A dragged position is discarded by:

  • resizing the window
  • maximizing or restoring
  • toggling View > Split Vertically
  • opening the next image comparison
  • restarting WinMerge

Expected: the position is kept.

Use case: Dragging Mode > Vertical Wipe with one pane reduced to a narrow strip (would also be nice if it could be made smaller than 32px).

Opened WinMerge/winimerge#39
Have a WinMerge PR ready and waiting for your approval.

Examples or references (optional)

WinIMerge, at 57ef929:

Location Relevance
ImgMergeWindow.hpp CalcChildImgWindowRect() :1264 Computes the equal split. Stateless
OnSize() :1406 Calls it on every WM_SIZE
MoveSplitter() :1308 Applies a drag to the child rects only
SetHorizontalSplit() :213 Re-lays out through the same function
WinIMergeLib.h No splitter property. GetPaneWindowRect() has no setter counterpart

WinMerge:

Location Relevance
Src/ImgMergeFrm.cpp LoadOptions() Runs in OnCreateClient(), before OpenImages()
Src/ImgMergeFrm.cpp SaveOptions() Runs from DestroyWindow()
Src/OptionsDef.h, Src/OptionsInit.cpp Existing Settings/Image... options

No existing request found in either repository.

Additional context

The value must live in CImgMergeWindow and be read by CalcChildImgWindowRect(). Storing it only on the WinMerge side does not survive, because OnSize() rebuilds the layout from scratch.

Two repositories: a WinIMerge change, then a submodule bump and the options wiring in WinMerge.

The interface is declared in three files. Src/ImgMergeFrm.h includes "WinIMergeLib.h", which resolves to WinMerge's tracked copy at Src/WinIMergeLib.h, not the one in Externals/winimerge. Patching only the submodule copy builds WinIMergeLib and then fails in Merge.vcxproj with C2039: 'SetSplitterPosition': is not a member of 'IImgMergeWindow'.

Design decisions

Decision Options
Unit Pixels, or a fraction of the window. Pixels keep a narrow strip narrow when the window grows; a fraction scales it up
Which pane the value sizes The first pane, or whichever pane is smaller. Anchoring the first pane only preserves a narrow strip on the left; a narrow right-hand pane grows on resize. Sizing the smaller pane needs a sign to say which pane it refers to
Orientation change Reset the value in SetHorizontalSplit(), or keep a per-orientation pair of options. A single unsigned axis-agnostic value would apply a width as a height. Note Settings/SplitHorizontally is shared with the text compare and is saved in OnViewSplitVertically(), not SaveOptions()
Three-pane compare Leave on the equal split, or store a position per splitter

The sketch below implements: pixels, smaller pane with a sign, reset on orientation change, two panes only.

Status

Implemented and built against 57ef929 plus current WinMerge master, on Windows 11 x64 with VS Build Tools 2026. Verified in the standalone WinIMerge app and in WinMergeU.exe: position kept across resize, maximize/restore, reopening a comparison and restart, with the narrow pane on either side; default centred layout unchanged when never dragged; 32px minimum kept; three-pane unaffected.

Implementation sketch

WinIMergeLib.h, and Src/WinIMergeLib.h in the WinMerge repo

Appended, so the vtable order is unchanged:

virtual int  GetSplitterPosition() const = 0;
virtual void SetSplitterPosition(int position) = 0;

ImgMergeWindow.hpp

Member, initialised to 0:

// Size along the split axis of whichever pane is smaller, in pixels:
// positive for the first pane, negative for the second. 0 = equal split.
int m_splitterPosition;

Bounds match MoveSplitter(), so a restored layout equals the dragged one:

int CalcSplitterPosition(int nImages, LONG splitSize) const
{
    const int minSize = 32;
    const int total = static_cast<int>(splitSize);
    const int maxPos = total - minSize;
    if (nImages != 2 || m_splitterPosition == 0 || maxPos < minSize)
        return 0;
    const int pos = (m_splitterPosition > 0)
        ? m_splitterPosition
        : total - 2 * 2 + m_splitterPosition;
    return (std::min)((std::max)(pos, minSize), maxPos);
}

The clamp is returned, not written back to m_splitterPosition: the window is laid out once during OnCreateClient() before it has its final size. (std::min) / (std::max) are parenthesised as elsewhere in this file, which does not define NOMINMAX.

CalcChildImgWindowRect() needs a separate branch rather than a substituted width, because the existing tail derives the last pane's left edge from width (rc.left = rc.right - width - cx;), which holds only for an equal split:

if (!bHorizontalSplit)
{
    const int splitPos = CalcSplitterPosition(nImages, rcParent.right);
    if (splitPos > 0)
    {
        rc.left = 0;
        rc.right = splitPos;
        childrects.push_back(rc);
        rc.left = rc.right + 2 * 2;
        rc.right = rcParent.right;
        childrects.push_back(rc);
    }
    else
    {
        // existing equal-split code, unchanged
    }
}

Mirrored on rcParent.bottom / rc.top / rc.bottom for the horizontal case.

MoveSplitter(), after the existing clamping, before the closing SetWindowRect() loop:

if (m_nImages == 2 && m_nDraggingSplitter == 0)
{
    const int first = m_bHorizontalSplit
        ? rc[0].bottom - rc[0].top
        : rc[0].right - rc[0].left;
    const int second = m_bHorizontalSplit
        ? rc[1].bottom - rc[1].top
        : rc[1].right - rc[1].left;
    m_splitterPosition = (second < first) ? -second : first;
}

OnLButtonUp() resets m_nDraggingSplitter only after MoveSplitter() returns. Sizes rather than rc[0].right, because ImgWindow::GetWindowRect() is relative to the parent's window origin, not its client origin.

Accessors:

int GetSplitterPosition() const override
{
    return m_splitterPosition;
}

void SetSplitterPosition(int position) override
{
    m_splitterPosition = position;
    if (m_hWnd && m_nImages == 2)
    {
        std::vector<RECT> rects = CalcChildImgWindowRect(m_hWnd, m_nImages, m_bHorizontalSplit);
        for (int i = 0; i < m_nImages; ++i)
            m_imgWindow[i].SetWindowRect(rects[i]);
    }
}

Stored before the m_hWnd check, unlike SetHorizontalSplit(), which returns early and drops its argument. LoadOptions() calls this while m_nImages is 0; the value is applied by the OpenImages() / NewImages() call to CalcChildImgWindowRect().

SetHorizontalSplit(), before m_bHorizontalSplit is assigned:

if (m_bHorizontalSplit != horizontalSplit)
    m_splitterPosition = 0;

WinMerge

// OptionsDef.h
inline const String OPT_CMP_IMG_SPLITTER_POS {_T("Settings/ImageSplitterPosition"s)};
// OptionsInit.cpp
pOptions->InitOption(OPT_CMP_IMG_SPLITTER_POS, 0, -65535, 65535);
// ImgMergeFrm.cpp, LoadOptions(), after SetHorizontalSplit()
m_pImgMergeWindow->SetSplitterPosition(GetOptionsMgr()->GetInt(OPT_CMP_IMG_SPLITTER_POS));
// ImgMergeFrm.cpp, SaveOptions()
GetOptionsMgr()->SaveOption(OPT_CMP_IMG_SPLITTER_POS, m_pImgMergeWindow->GetSplitterPosition());

GetPaneWindowRect(0) would return the current clamped rectangle rather than the stored preference, so a getter is needed.

No new UI.


Unrelated: the horizontal branch of CalcChildImgWindowRect() uses GetSystemMetrics(SM_CXVSCROLL) as a height; SM_CYHSCROLL appears to be intended. Both metrics are equal by default, so there is no visible effect. Untouched by the sketch.

Activity

  1. sdottaka commented on Aug 29, 2026

    @sdottaka
    Member

    I was wondering if what you really want might be a single-pane view rather than persistent splitter position.

    If we persist the splitter position, I think we would also need a way to restore the default 50:50 split. As I understand it, the current implementation doesn't provide a way to do that. Is my understanding correct?

  2. Ikkoru commented on Aug 29, 2026

    @Ikkoru
    ContributorAuthor

    I did indeed want a single pane view :3
    However this way introduced less settings / UI options while giving pretty much the same effect, so I went with it.

    Currently the 50:50 split is returned on horizontal <-> vertical swap. And you can always add a Reset splitter position somewhere if you want. Or a double-click event on the splitter that resets it.

  3. sdottaka commented on Aug 30, 2026

    @sdottaka
    Member

    I’m planning to merge the PR WinMerge/winimerge#39 for now.

    My current plan is:

    • Add this functionality to all comparison windows, rather than only the image comparison window.
    • Add Window → Keep Splitter Position. When checked, the current splitter position will be locked. When unchecked, it will return to 50:50.
  4. Ikkoru commented on Aug 30, 2026

    @Ikkoru
    ContributorAuthor

    I have a PR ready for WinMerge too. Should I post it? You might be able to reuse code from it.

  5. sdottaka commented on Aug 30, 2026

    @sdottaka
    Member

    I’ve merged the PR WinMerge/winimerge#39. Please go ahead and post the WinMerge PR!

  6. sdottaka commented on Aug 30, 2026

    @sdottaka
    Member

    I’ve merged Remember the image compare splitter position #3593.

    Please note that I plan to make the splitter position persistent only when Window → Keep Splitter Position is checked.

  7. added this to the v2.16.59 milestone on Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions