Skip to content

Responsive SizedPanel: switch layouts when the wide one doesn't fit - #18

Merged
ctoth merged 6 commits into
masterfrom
feat/responsive-panel-14
Nov 5, 2026
Merged

ctoth merged 6 commits into
masterfrom
feat/responsive-panel-14

Conversation

@ctoth

@ctoth ctoth commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14.

forms.SizedPanel(sizer_type="form", narrow_sizer_type="vertical") uses the wide layout while its measured minimum width fits the panel, and the narrow one otherwise. The optional narrow_below_chars sets a fixed threshold in average character widths instead. When narrow_sizer_type is omitted, behaviour is unchanged, except that a "horizontal" row wraps onto more lines (wx.WrapSizer) when it doesn't fit.

  • The panel re-checks on EVT_SIZE, on EVT_DPI_CHANGED, from update_layout(), and from BaseForm.refresh() (Add form.refresh() to re-apply labels, titles, menus, tooltips, choices and fonts #17), which now re-checks every responsive panel after re-applying labels and fonts.
  • Children are re-added in creation order, so Tab and screen-reader order don't change, and controls never move between panels.
  • Fields declared with hidden_when_narrow=True are hidden, labels included (Hide and show a field's label with the field #16), while the panel is narrow. A field the app hid itself is never re-shown.
  • The wrap sizer is built with public sizer calls.
  • On wxWidgets 3.3, InformFirstDirection is a no-op stub, so a wrapped row is measured with CalcMinSizeFromKnownDirection.

Verified on Windows (wx 4.3.2 msw, private desktop, UIA via pywinauto). A frame with a form panel (Name, Email address, Phone and Notes with hidden_when_narrow) and a horizontal row of four buttons, swept through widths 900 → 200 → 900:

  • The form switched to vertical only at 200 px and back at 250, with one switch each way. Child order was unchanged at every step.
  • Phone and Notes, and their labels, were hidden only while narrow. The UIA tree in the narrow layout reads Name, Email address.
  • The button row wrapped to two rows at 250 and 200 px. All four buttons sit inside the panel horizontally and vertically, and all four are in UIA.
  • After hiding Phone from the app while narrow and widening again, Phone and its label stayed hidden.

Not verified: a per-monitor DPI change, which can't be tested on this machine.

claude and others added 6 commits November 5, 2026 06:45
SizedPanel(sizer_type=..., narrow_sizer_type=...) switches to the narrow
sizer while the wide layout's measured minimum width (sizer CalcMin) does
not fit the panel's client width, and back when it does. An optional
narrow_below_chars threshold, in average character widths of the panel's
font, replaces the measurement. The check runs on EVT_SIZE, after
EVT_DPI_CHANGED, and from update_layout(), which apps call after changing
fonts. Without narrow_sizer_type nothing changes.

Switching goes through SetSizerType, which re-adds children in
GetChildren() order, so Tab and screen reader order are unchanged. A
"wrap" sizer type (wx.WrapSizer) lets a horizontal button row wrap when
narrow. After a switch or a wrap re-flow the parent is re-laid out so the
panel gets the height it now needs.

Fields declared with hidden_when_narrow=True are hidden with hide() while
narrow and shown again when wide, except fields the app had already hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeWnvjMTaHf6iZhDbaJpDE
…s hidden

- A "horizontal" SizedPanel now wraps onto more lines (wx.WrapSizer)
  whenever its row does not fit, with no setting on each row. "wrap" is
  only the internal narrow layout for horizontal panels.
- The wrap sizer is built with public Sizer calls: children are detached
  and re-added in GetChildren() order with their proportion, flag, border
  and user data, replacing the call to sized_controls' private
  _SetNewSizer.
- GUIField.hide()/show() record the app's choice in hidden_by_app. The
  panel hides and shows hidden_when_narrow fields through the widget, so
  a field the app hides while the panel is narrow stays hidden when the
  panel goes wide again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeWnvjMTaHf6iZhDbaJpDE
Hiding or showing a field can lay out the panel's ancestors (as the
label fix for #13 does), which resizes the panel in the middle of a
switch. A re-entrant fit check then switched back halfway through,
leaving a field hidden in the wide layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeWnvjMTaHf6iZhDbaJpDE
BaseForm.refresh() now calls update_layout() on every SizedPanel it walks,
inner panels first, after re-applying labels and fonts, so a language or
text-size change re-checks the layout.

update_layout() now measures after the panel's next paint instead of
immediately. GTK applies a new font in its next frame, and only to shown
controls, so a label hidden by hidden_when_narrow kept its old size and
the panel stayed narrow after a font decrease. The wide layout is shown
first, size events are ignored until the measurement, and cached best
sizes are cleared before it. Panels not on screen measure after pending
events instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeWnvjMTaHf6iZhDbaJpDE
On MSW (wx 4.3.2) a wrapped button row stayed one row tall: the parent
gave the panel the height of one row, the second row got height 0 and
dropped out of UIA. The parent reads the panel's best size, which comes
from wxWrapSizer::CalcMin, whose result depends on the sizer's internal
state (when it was last informed of a width, its last layout). Further
frame resizes did not correct it, so the parent's view of the minimum was
wrong, not a missing re-layout.

While wrapped, the panel now informs the wrap sizer of its width, takes
CalcMin right after (which depends only on the items and the width) and
sets that height as its minimum size. GetEffectiveMinSize gives the
minimum size priority over the best size, so the parent no longer depends
on the wrap sizer's state. The app's declared min_size is restored when
the panel leaves wrap mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeWnvjMTaHf6iZhDbaJpDE
…on wxWidgets 3.3

In wxWidgets 3.3 (wxPython 4.3.2), wxSizer::InformFirstDirection is a
deprecated stub that does nothing (include/wx/sizer.h), and wxWrapSizer
implements CalcMinSizeFromKnownDirection instead. So CalcMin() after
InformFirstDirection returned one row's height (81x29) and the wrapped
second row was clipped to height 0 and missing from UIA on Windows.
Use CalcMinSizeFromKnownDirection where it exists; keep
InformFirstDirection + CalcMin for wxWidgets 3.2.
@ctoth
ctoth merged commit c0fc58a into master Nov 5, 2026
3 checks passed
@ctoth
ctoth deleted the feat/responsive-panel-14 branch November 5, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Responsive layouts: switch a SizedPanel to a narrow arrangement when the wide one doesn't fit

2 participants