Preserve legend settings when regenerating in Qt figure options dialog#31553
Preserve legend settings when regenerating in Qt figure options dialog#31553thesanatt wants to merge 2 commits intomatplotlib:mainfrom
Conversation
When using the Qt figure options dialog to regenerate a legend, previously only ncols and draggable state were preserved. This caused all other legend customizations (loc, fontsize, frameon, shadow, title, mode, spacing, etc.) to be lost. This change extracts and reapplies these properties when regenerating the legend. Closes matplotlib#17775
timhoffm
left a comment
There was a problem hiding this comment.
The issue stems from a larger architectural shortcoming: Legends are not designed to be modified after creation. This makes it necessary to recreate the legend, which results in loosing preexisting properties.
For now, we have to live with this workaround. But the implementation should be different as knowning legend properties is out of scope for the figureoptions dialog: Please create a helper method Legend._get_properties(). So that here you just do
new_legend = axes.legend(ncols=ncols, **old_legend._get_properties())
Please decide whether a white-list of properties or a exclude-list of all legend properties (https://matplotlib.org/stable/api/_as_gen/matplotlib.artist.ArtistInspector.html#matplotlib.artist.ArtistInspector.properties) is more suitable.
The tests are meaningless as they do not exercise the modified code path. Anyway, I would not test options dialog explicitly as that's too cumbersome. Instead think whether you can generate a meaningful non-tautological _get_properties() test. If not we may keep this untested as before.
Address review feedback from @timhoffm: - Added Legend._get_properties() method that returns a dict of legend properties needed for recreation - figureoptions.py now calls old_legend._get_properties() instead of extracting properties inline - Updated tests to directly test _get_properties() method - Tests cover basic properties, mode/bbox_to_anchor, roundtrip recreation, and no-existing-legend edge case
timhoffm
left a comment
There was a problem hiding this comment.
@thesanatt there's too little thought, oversight and review from your side. In particular when using AI we expect that contributors thoroughly assess the output.
I can faster use an AI and iterate that to completion than doing multiple reviews cycles. So third party contributions using AI only make sense when you already catch most of the deficits of AI output.
| if new_legend: | ||
| new_legend.set_draggable(draggable) |
There was a problem hiding this comment.
Is there a reason to not include draggable in _get_properties()?
| settings when regenerating the legend. | ||
| """ | ||
| props = { | ||
| 'loc': self._loc, |
There was a problem hiding this comment.
The list seems incomplete. On what basis did you select the properties?
| ax.legend( | ||
| loc='upper right', | ||
| fontsize=14, | ||
| frameon=False, | ||
| shadow=True, | ||
| title='My Legend', | ||
| ncols=2, | ||
| columnspacing=3.0, | ||
| labelspacing=1.5, | ||
| handlelength=4.0, | ||
| handletextpad=1.2, | ||
| ) |
There was a problem hiding this comment.
Make it more compact and safe by not repeating the names and values
| ax.legend( | |
| loc='upper right', | |
| fontsize=14, | |
| frameon=False, | |
| shadow=True, | |
| title='My Legend', | |
| ncols=2, | |
| columnspacing=3.0, | |
| labelspacing=1.5, | |
| handlelength=4.0, | |
| handletextpad=1.2, | |
| ) | |
| initial_props = dict( | |
| loc='upper right', | |
| fontsize=14, | |
| frameon=False, | |
| shadow=True, | |
| title='My Legend', | |
| columnspacing=3.0, | |
| labelspacing=1.5, | |
| handlelength=4.0, | |
| handletextpad=1.2, | |
| ) | |
| legend = ax.legend(ncols=2, **props) |
to be continued below
| assert props['loc'] == legend._loc | ||
| assert props['fontsize'] == 14 | ||
| assert props['frameon'] is False | ||
| assert props['shadow'] is True | ||
| assert props['title'] == 'My Legend' | ||
| assert props['columnspacing'] == 3.0 | ||
| assert props['labelspacing'] == 1.5 | ||
| assert props['handlelength'] == 4.0 | ||
| assert props['handletextpad'] == 1.2 | ||
| assert 'bbox_to_anchor' not in props | ||
| assert 'mode' not in props |
There was a problem hiding this comment.
| assert props['loc'] == legend._loc | |
| assert props['fontsize'] == 14 | |
| assert props['frameon'] is False | |
| assert props['shadow'] is True | |
| assert props['title'] == 'My Legend' | |
| assert props['columnspacing'] == 3.0 | |
| assert props['labelspacing'] == 1.5 | |
| assert props['handlelength'] == 4.0 | |
| assert props['handletextpad'] == 1.2 | |
| assert 'bbox_to_anchor' not in props | |
| assert 'mode' not in props | |
| assert props == initial_props |
Make sure you include all props returned by _get_properties to get full coverage and be able to just assert dict equality.
| handletextpad=1.2, | ||
| ) | ||
|
|
||
| legend = ax.get_legend() |
There was a problem hiding this comment.
| legend = ax.get_legend() |
| assert 'bbox_to_anchor' not in props | ||
| assert 'mode' not in props | ||
|
|
||
| plt.close(fig) |
There was a problem hiding this comment.
| plt.close(fig) |
Not needed. Closing is automatic in tests.
| if self._bbox_to_anchor is not None: | ||
| props['bbox_to_anchor'] = self._bbox_to_anchor.bounds | ||
| if self._mode is not None: | ||
| props['mode'] = self._mode |
There was a problem hiding this comment.
Can these two not be always returned? If that is the case document that they are only conditionally present and explain why.
| plt.close(fig) | ||
|
|
||
|
|
||
| def test_get_properties_roundtrip(): |
There was a problem hiding this comment.
What does this tests tell us more compared to test_get_properties_basic?
| plt.close(fig) | ||
|
|
||
|
|
||
| def test_legend_regeneration_no_existing_legend(): |
There was a problem hiding this comment.
I completely do not understand this test. There is no regeneration. You only create one legend. This test does not touch any library code modified in this PR.
Closes #17775
Summary
When using the Qt figure options dialog and ticking "(Re-)generate automatic legend", previously only
ncolsanddraggablestate were preserved. All other legend customizations (loc, fontsize, frameon, shadow, title, mode, spacing parameters, etc.) were lost.This PR extracts and reapplies these properties from the old legend when regenerating.
Changes
lib/matplotlib/backends/qt_editor/figureoptions.py: Extract legend properties (loc, bbox_to_anchor, fontsize, frameon, shadow, framealpha, title, mode, columnspacing, labelspacing, handlelength, handletextpad) from the existing legend before regenerating, and pass them as kwargs to the newaxes.legend()call.lib/matplotlib/tests/test_figureoptions_legend.py: Added 3 tests covering property preservation, no-existing-legend case, and mode preservation.AI Disclosure
I used AI assistance (Claude) for exploring the codebase and drafting parts of the implementation and tests. All code was reviewed and tested by me.