From 3a7f060d176a70b53484224290407de41a1189d4 Mon Sep 17 00:00:00 2001 From: Sanat Gupta Date: Thu, 23 Apr 2026 03:26:58 -0400 Subject: [PATCH 1/2] Preserve legend settings when regenerating in Qt figure options 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 #17775 --- .../backends/qt_editor/figureoptions.py | 25 +++- .../tests/test_figureoptions_legend.py | 128 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 lib/matplotlib/tests/test_figureoptions_legend.py diff --git a/lib/matplotlib/backends/qt_editor/figureoptions.py b/lib/matplotlib/backends/qt_editor/figureoptions.py index cd4e9583cce5..611081c7bd0d 100644 --- a/lib/matplotlib/backends/qt_editor/figureoptions.py +++ b/lib/matplotlib/backends/qt_editor/figureoptions.py @@ -248,11 +248,34 @@ def apply_callback(data): if generate_legend: draggable = None ncols = 1 + legend_kwargs = {} if axes.legend_ is not None: old_legend = axes.get_legend() draggable = old_legend._draggable is not None ncols = old_legend._ncols - new_legend = axes.legend(ncols=ncols) + # Preserve location and positioning + legend_kwargs['loc'] = old_legend._loc + if old_legend._bbox_to_anchor is not None: + legend_kwargs['bbox_to_anchor'] = ( + old_legend._bbox_to_anchor.bounds) + # Preserve styling + legend_kwargs['fontsize'] = old_legend._fontsize + legend_kwargs['frameon'] = old_legend.get_frame_on() + legend_kwargs['shadow'] = old_legend.shadow + legend_kwargs['framealpha'] = ( + old_legend.get_frame().get_alpha()) + legend_kwargs['title'] = ( + old_legend.get_title().get_text()) + # Preserve layout settings + if old_legend._mode is not None: + legend_kwargs['mode'] = old_legend._mode + legend_kwargs['columnspacing'] = ( + old_legend.columnspacing) + legend_kwargs['labelspacing'] = old_legend.labelspacing + legend_kwargs['handlelength'] = old_legend.handlelength + legend_kwargs['handletextpad'] = ( + old_legend.handletextpad) + new_legend = axes.legend(ncols=ncols, **legend_kwargs) if new_legend: new_legend.set_draggable(draggable) diff --git a/lib/matplotlib/tests/test_figureoptions_legend.py b/lib/matplotlib/tests/test_figureoptions_legend.py new file mode 100644 index 000000000000..1a2d960c4605 --- /dev/null +++ b/lib/matplotlib/tests/test_figureoptions_legend.py @@ -0,0 +1,128 @@ +"""Tests for legend preservation in Qt figure options dialog.""" +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt + + +def test_legend_properties_preserved(): + """ + Test that legend properties are preserved when the legend is + regenerated via the Qt figure options dialog. + + Regression test for https://github.com/matplotlib/matplotlib/issues/17775 + """ + fig, ax = plt.subplots() + ax.plot(range(5), label='a') + ax.plot(range(3)[::-1], label='b') + + 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, + ) + + old_legend = ax.get_legend() + old_loc = old_legend._loc + old_fontsize = old_legend._fontsize + old_frameon = old_legend.get_frame_on() + old_shadow = old_legend.shadow + old_title = old_legend.get_title().get_text() + old_ncols = old_legend._ncols + old_columnspacing = old_legend.columnspacing + old_labelspacing = old_legend.labelspacing + old_handlelength = old_legend.handlelength + old_handletextpad = old_legend.handletextpad + + # Simulate what the patched figureoptions code does + draggable = old_legend._draggable is not None + ncols = old_legend._ncols + legend_kwargs = {} + legend_kwargs['loc'] = old_legend._loc + if old_legend._bbox_to_anchor is not None: + legend_kwargs['bbox_to_anchor'] = ( + old_legend._bbox_to_anchor.bounds) + legend_kwargs['fontsize'] = old_legend._fontsize + legend_kwargs['frameon'] = old_legend.get_frame_on() + legend_kwargs['shadow'] = old_legend.shadow + legend_kwargs['framealpha'] = old_legend.get_frame().get_alpha() + legend_kwargs['title'] = old_legend.get_title().get_text() + if old_legend._mode is not None: + legend_kwargs['mode'] = old_legend._mode + legend_kwargs['columnspacing'] = old_legend.columnspacing + legend_kwargs['labelspacing'] = old_legend.labelspacing + legend_kwargs['handlelength'] = old_legend.handlelength + legend_kwargs['handletextpad'] = old_legend.handletextpad + + new_legend = ax.legend(ncols=ncols, **legend_kwargs) + if new_legend: + new_legend.set_draggable(draggable) + + assert new_legend._loc == old_loc + assert new_legend._fontsize == old_fontsize + assert new_legend.get_frame_on() == old_frameon + assert new_legend.shadow == old_shadow + assert new_legend.get_title().get_text() == old_title + assert new_legend._ncols == old_ncols + assert new_legend.columnspacing == old_columnspacing + assert new_legend.labelspacing == old_labelspacing + assert new_legend.handlelength == old_handlelength + assert new_legend.handletextpad == old_handletextpad + + plt.close(fig) + + +def test_legend_regeneration_no_existing_legend(): + """ + Test that regenerating a legend when none exists still works. + """ + fig, ax = plt.subplots() + ax.plot(range(5), label='a') + + assert ax.get_legend() is None + + new_legend = ax.legend(ncols=1) + assert new_legend is not None + assert len(new_legend.get_texts()) == 1 + assert new_legend.get_texts()[0].get_text() == 'a' + + plt.close(fig) + + +def test_legend_mode_preserved(): + """ + Test that mode='expand' is preserved on legend regeneration. + """ + fig, ax = plt.subplots() + ax.plot(range(5), label='a') + ax.plot(range(3)[::-1], label='b') + + ax.legend( + loc='lower left', + mode='expand', + ncols=2, + ) + + old_legend = ax.get_legend() + old_mode = old_legend._mode + old_loc = old_legend._loc + + legend_kwargs = {} + legend_kwargs['loc'] = old_legend._loc + legend_kwargs['fontsize'] = old_legend._fontsize + legend_kwargs['frameon'] = old_legend.get_frame_on() + if old_legend._mode is not None: + legend_kwargs['mode'] = old_legend._mode + + new_legend = ax.legend(ncols=old_legend._ncols, **legend_kwargs) + + assert new_legend._mode == old_mode + assert new_legend._loc == old_loc + + plt.close(fig) From da680ddf160cd6cb08455c9c78db92396e16a8e3 Mon Sep 17 00:00:00 2001 From: Sanat Gupta Date: Thu, 23 Apr 2026 12:47:57 -0400 Subject: [PATCH 2/2] Refactor: move legend property extraction to Legend._get_properties() 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 --- .../backends/qt_editor/figureoptions.py | 23 +-- lib/matplotlib/legend.py | 28 ++++ .../tests/test_figureoptions_legend.py | 146 +++++++++--------- 3 files changed, 101 insertions(+), 96 deletions(-) diff --git a/lib/matplotlib/backends/qt_editor/figureoptions.py b/lib/matplotlib/backends/qt_editor/figureoptions.py index 611081c7bd0d..30264bcf38a9 100644 --- a/lib/matplotlib/backends/qt_editor/figureoptions.py +++ b/lib/matplotlib/backends/qt_editor/figureoptions.py @@ -253,28 +253,7 @@ def apply_callback(data): old_legend = axes.get_legend() draggable = old_legend._draggable is not None ncols = old_legend._ncols - # Preserve location and positioning - legend_kwargs['loc'] = old_legend._loc - if old_legend._bbox_to_anchor is not None: - legend_kwargs['bbox_to_anchor'] = ( - old_legend._bbox_to_anchor.bounds) - # Preserve styling - legend_kwargs['fontsize'] = old_legend._fontsize - legend_kwargs['frameon'] = old_legend.get_frame_on() - legend_kwargs['shadow'] = old_legend.shadow - legend_kwargs['framealpha'] = ( - old_legend.get_frame().get_alpha()) - legend_kwargs['title'] = ( - old_legend.get_title().get_text()) - # Preserve layout settings - if old_legend._mode is not None: - legend_kwargs['mode'] = old_legend._mode - legend_kwargs['columnspacing'] = ( - old_legend.columnspacing) - legend_kwargs['labelspacing'] = old_legend.labelspacing - legend_kwargs['handlelength'] = old_legend.handlelength - legend_kwargs['handletextpad'] = ( - old_legend.handletextpad) + legend_kwargs = old_legend._get_properties() new_legend = axes.legend(ncols=ncols, **legend_kwargs) if new_legend: new_legend.set_draggable(draggable) diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 6bdef01ae9cf..f2d011e2d3fb 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -1098,6 +1098,34 @@ def set_frame_on(self, b): draw_frame = set_frame_on # Backcompat alias. + def _get_properties(self): + """ + Return a dictionary of legend properties for recreation. + + This is used by the Qt figure options dialog to preserve legend + settings when regenerating the legend. + """ + props = { + 'loc': self._loc, + 'fontsize': self._fontsize, + 'frameon': self.get_frame_on(), + 'shadow': self.shadow, + 'framealpha': self.get_frame().get_alpha(), + 'title': self.get_title().get_text(), + 'columnspacing': self.columnspacing, + 'labelspacing': self.labelspacing, + 'handlelength': self.handlelength, + 'handletextpad': self.handletextpad, + 'borderpad': self.borderpad, + 'borderaxespad': self.borderaxespad, + 'markerscale': self.markerscale, + } + 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 + return props + def get_bbox_to_anchor(self): """Return the bbox that the legend will be anchored to.""" if self._bbox_to_anchor is None: diff --git a/lib/matplotlib/tests/test_figureoptions_legend.py b/lib/matplotlib/tests/test_figureoptions_legend.py index 1a2d960c4605..846c3a8c2373 100644 --- a/lib/matplotlib/tests/test_figureoptions_legend.py +++ b/lib/matplotlib/tests/test_figureoptions_legend.py @@ -1,15 +1,12 @@ -"""Tests for legend preservation in Qt figure options dialog.""" +"""Tests for legend property preservation.""" import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt -def test_legend_properties_preserved(): +def test_get_properties_basic(): """ - Test that legend properties are preserved when the legend is - regenerated via the Qt figure options dialog. - - Regression test for https://github.com/matplotlib/matplotlib/issues/17775 + Test that Legend._get_properties() returns the correct properties. """ fig, ax = plt.subplots() ax.plot(range(5), label='a') @@ -28,101 +25,102 @@ def test_legend_properties_preserved(): handletextpad=1.2, ) - old_legend = ax.get_legend() - old_loc = old_legend._loc - old_fontsize = old_legend._fontsize - old_frameon = old_legend.get_frame_on() - old_shadow = old_legend.shadow - old_title = old_legend.get_title().get_text() - old_ncols = old_legend._ncols - old_columnspacing = old_legend.columnspacing - old_labelspacing = old_legend.labelspacing - old_handlelength = old_legend.handlelength - old_handletextpad = old_legend.handletextpad - - # Simulate what the patched figureoptions code does - draggable = old_legend._draggable is not None - ncols = old_legend._ncols - legend_kwargs = {} - legend_kwargs['loc'] = old_legend._loc - if old_legend._bbox_to_anchor is not None: - legend_kwargs['bbox_to_anchor'] = ( - old_legend._bbox_to_anchor.bounds) - legend_kwargs['fontsize'] = old_legend._fontsize - legend_kwargs['frameon'] = old_legend.get_frame_on() - legend_kwargs['shadow'] = old_legend.shadow - legend_kwargs['framealpha'] = old_legend.get_frame().get_alpha() - legend_kwargs['title'] = old_legend.get_title().get_text() - if old_legend._mode is not None: - legend_kwargs['mode'] = old_legend._mode - legend_kwargs['columnspacing'] = old_legend.columnspacing - legend_kwargs['labelspacing'] = old_legend.labelspacing - legend_kwargs['handlelength'] = old_legend.handlelength - legend_kwargs['handletextpad'] = old_legend.handletextpad - - new_legend = ax.legend(ncols=ncols, **legend_kwargs) - if new_legend: - new_legend.set_draggable(draggable) - - assert new_legend._loc == old_loc - assert new_legend._fontsize == old_fontsize - assert new_legend.get_frame_on() == old_frameon - assert new_legend.shadow == old_shadow - assert new_legend.get_title().get_text() == old_title - assert new_legend._ncols == old_ncols - assert new_legend.columnspacing == old_columnspacing - assert new_legend.labelspacing == old_labelspacing - assert new_legend.handlelength == old_handlelength - assert new_legend.handletextpad == old_handletextpad + legend = ax.get_legend() + props = legend._get_properties() + + 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 plt.close(fig) -def test_legend_regeneration_no_existing_legend(): +def test_get_properties_with_mode_and_bbox(): """ - Test that regenerating a legend when none exists still works. + Test that _get_properties() includes mode and bbox_to_anchor + when they are set. """ fig, ax = plt.subplots() ax.plot(range(5), label='a') - assert ax.get_legend() is None + ax.legend( + bbox_to_anchor=(0, 1.02, 1, 0.2), + loc='lower left', + mode='expand', + ncols=2, + ) - new_legend = ax.legend(ncols=1) - assert new_legend is not None - assert len(new_legend.get_texts()) == 1 - assert new_legend.get_texts()[0].get_text() == 'a' + legend = ax.get_legend() + props = legend._get_properties() + + assert props['mode'] == 'expand' + assert 'bbox_to_anchor' in props plt.close(fig) -def test_legend_mode_preserved(): +def test_get_properties_roundtrip(): """ - Test that mode='expand' is preserved on legend regeneration. + Test that properties from _get_properties() can be used to + recreate a legend with the same settings. """ fig, ax = plt.subplots() ax.plot(range(5), label='a') ax.plot(range(3)[::-1], label='b') ax.legend( - loc='lower left', - mode='expand', + 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, ) old_legend = ax.get_legend() - old_mode = old_legend._mode - old_loc = old_legend._loc + props = old_legend._get_properties() + ncols = old_legend._ncols + + # Recreate legend using extracted properties + new_legend = ax.legend(ncols=ncols, **props) + + assert new_legend._fontsize == 14 + assert new_legend.get_frame_on() is False + assert new_legend.shadow is True + assert new_legend.get_title().get_text() == 'My Legend' + assert new_legend._ncols == 2 + assert new_legend.columnspacing == 3.0 + assert new_legend.labelspacing == 1.5 + assert new_legend.handlelength == 4.0 + assert new_legend.handletextpad == 1.2 + + plt.close(fig) + - legend_kwargs = {} - legend_kwargs['loc'] = old_legend._loc - legend_kwargs['fontsize'] = old_legend._fontsize - legend_kwargs['frameon'] = old_legend.get_frame_on() - if old_legend._mode is not None: - legend_kwargs['mode'] = old_legend._mode +def test_legend_regeneration_no_existing_legend(): + """ + Test that regenerating a legend when none exists still works. + """ + fig, ax = plt.subplots() + ax.plot(range(5), label='a') - new_legend = ax.legend(ncols=old_legend._ncols, **legend_kwargs) + assert ax.get_legend() is None - assert new_legend._mode == old_mode - assert new_legend._loc == old_loc + new_legend = ax.legend(ncols=1) + assert new_legend is not None + assert len(new_legend.get_texts()) == 1 + assert new_legend.get_texts()[0].get_text() == 'a' plt.close(fig)