-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Fix axis label clipping with PlotItem content margins (issue #3375) #3384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d1f5e2a
f2fc5aa
e8e5786
b75a071
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,9 +67,14 @@ | |
| self.label.setRotation(-90) | ||
| # allow labels on vertical axis to extend above and below the length of the axis | ||
| hide_overlapping_labels = False | ||
| # For vertical axes, prefer not reducing text space to maintain readability | ||
| auto_reduce_text_space = False | ||
| elif orientation in {'top', 'bottom'}: | ||
| # stop labels on horizontal axis from overlapping so vertical axis labels have room | ||
| hide_overlapping_labels = True | ||
| # For horizontal axes, allow text to extend to prevent clipping | ||
| # This addresses the issue where rightmost labels get clipped | ||
| hide_overlapping_labels = False ## Changed from True - allow extension | ||
| # For horizontal axes, be more conservative about reducing space | ||
| auto_reduce_text_space = False | ||
| else: | ||
| raise ValueError( | ||
| "Orientation argument must be one of 'left', 'right', 'top', or 'bottom'." | ||
|
|
@@ -79,7 +84,7 @@ | |
| 'tickTextWidth': 30, ## space reserved for tick text | ||
| 'tickTextHeight': 18, | ||
| 'autoExpandTextSpace': True, ## automatically expand text space if needed | ||
| 'autoReduceTextSpace': True, | ||
| 'autoReduceTextSpace': auto_reduce_text_space, ## improved default based on orientation | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change makes sense to me, since the horizontal axis text space should not usually expand with label length, and therefore have less need to recover from temporary expansion. |
||
| 'hideOverlappingLabels': hide_overlapping_labels, | ||
| 'tickFont': None, | ||
| 'stopAxisAtTick': (False, False), ## whether axis is drawn to edge of box or to last tick | ||
|
|
@@ -571,8 +576,231 @@ | |
| if mx > self.textHeight or mx < self.textHeight - 10: | ||
| self.textHeight = mx | ||
| if self.style['autoExpandTextSpace']: | ||
| # Enhanced text space handling for horizontal axes | ||
| if hasattr(self, '_tickSpacing') and self._tickSpacing: | ||
| self._checkAndRequestLayoutExpansion(x) | ||
| self._updateHeight() | ||
|
|
||
| def _checkAndRequestLayoutExpansion(self, current_text_size): | ||
| """ | ||
| Check if text would be clipped and request layout expansion if needed. | ||
| This addresses the issue where autoExpandTextSpace doesn't work properly | ||
| with PlotItem content margins. | ||
| """ | ||
| if not self.style.get('autoExpandTextSpace', False): | ||
| return | ||
|
|
||
| # Calculate required space for rightmost tick labels | ||
| required_space = self._calculateRequiredTextSpace() | ||
| if required_space <= 0: | ||
| return | ||
|
|
||
| # Get available space considering parent margins | ||
| available_space = self._getAvailableTextSpace() | ||
|
|
||
| # If we need more space, request layout expansion | ||
| if required_space > available_space: | ||
| self._requestLayoutExpansion(required_space) | ||
|
|
||
| def _calculateRequiredTextSpace(self): | ||
| """ | ||
| Calculate the actual space needed for all tick labels, | ||
| including potential overflow of the rightmost label. | ||
| Returns: | ||
| float: Required space in pixels, or 0 if calculation not possible | ||
| """ | ||
| try: | ||
| # Need tick spacing and values to calculate required space | ||
| if not hasattr(self, '_tickSpacing') or not self._tickSpacing: | ||
| return 0 | ||
|
|
||
| # Get the current tick values | ||
| if hasattr(self, 'range') and self.range is not None: | ||
| range_size = abs(self.range[1] - self.range[0]) | ||
| if range_size == 0: | ||
| return 0 | ||
|
|
||
| # Get ticks for current range and spacing | ||
| ticks = self.tickValues(self.range, range_size, self._tickSpacing) | ||
| if not ticks: | ||
| return 0 | ||
|
|
||
| # Get tick strings | ||
| tick_strings = self.tickStrings(ticks, self.autoSIPrefix, self.autoSIPrefixScale) | ||
| if not tick_strings: | ||
| return 0 | ||
|
|
||
| # Calculate font metrics | ||
| font = self.style.get('tickFont', QtGui.QFont()) | ||
| fm = QtGui.QFontMetrics(font) | ||
|
|
||
| max_required_width = 0 | ||
|
|
||
| # Check each tick label position | ||
| for i, (tick_val, tick_str) in enumerate(zip(ticks, tick_strings)): | ||
| # Get text dimensions | ||
| if hasattr(fm, 'horizontalAdvance'): # Qt 5.11+ | ||
| text_width = fm.horizontalAdvance(tick_str) | ||
| else: # Older Qt versions | ||
| text_width = fm.width(tick_str) | ||
|
|
||
| if self.orientation in ['bottom', 'top']: | ||
| # For horizontal axes, check rightmost label overflow | ||
| # Calculate where this tick would be positioned | ||
| axis_length = self.geometry().width() | ||
| if axis_length > 0: | ||
| # Normalize tick position (0 to 1) | ||
| if len(ticks) > 1: | ||
| tick_pos_norm = i / (len(ticks) - 1) | ||
| else: | ||
| tick_pos_norm = 0.5 | ||
|
|
||
| # Convert to pixel position | ||
| tick_pixel_pos = tick_pos_norm * axis_length | ||
|
|
||
| # Check if text extends beyond axis | ||
| text_right_edge = tick_pixel_pos + (text_width / 2) | ||
| max_required_width = max(max_required_width, text_right_edge) | ||
|
|
||
| return max_required_width | ||
|
|
||
| except (AttributeError, TypeError, ValueError): | ||
| # If anything goes wrong, fall back to current behavior | ||
| pass | ||
|
|
||
| return 0 | ||
|
|
||
| def _getAvailableTextSpace(self): | ||
| """ | ||
| Get available space for text considering parent layout margins. | ||
| Returns: | ||
| float: Available space in pixels | ||
| """ | ||
| try: | ||
| # Get current geometry | ||
| geom = self.geometry() | ||
| available_width = geom.width() | ||
|
|
||
| # Try to get parent margins | ||
| parent_margins = self._getParentLayoutMargins() | ||
| if parent_margins and hasattr(parent_margins, 'right'): | ||
| # Account for right margin that might limit available space | ||
| available_width -= parent_margins.right() / 2 # Conservative adjustment | ||
|
|
||
| return max(0, available_width) | ||
|
|
||
| except (AttributeError, TypeError): | ||
| # Fallback to geometry width | ||
| return self.geometry().width() | ||
|
|
||
| def _getParentLayoutMargins(self): | ||
| """ | ||
| Get layout margins from parent PlotItem. | ||
| Returns: | ||
| QtCore.QMarginsF or None: The content margins, or None if unavailable | ||
| """ | ||
| try: | ||
| # Walk up the parent hierarchy to find PlotItem | ||
| parent = self.parentItem() | ||
| while parent: | ||
| # Check if this parent has a layout with margins | ||
| if hasattr(parent, 'layout'): | ||
| # Try different methods for getting margins | ||
| margins = None | ||
| if hasattr(parent.layout, 'getContentsMargins'): | ||
| # QGraphicsGridLayout uses getContentsMargins() | ||
| margins_tuple = parent.layout.getContentsMargins() | ||
| if isinstance(margins_tuple, (tuple, list)) and len(margins_tuple) == 4: | ||
| # Convert tuple to QMarginsF-like object | ||
| if hasattr(QtCore, 'QMarginsF'): | ||
| margins = QtCore.QMarginsF( | ||
| float(margins_tuple[0]), | ||
| float(margins_tuple[1]), | ||
| float(margins_tuple[2]), | ||
| float(margins_tuple[3]) | ||
| ) | ||
| else: | ||
| # Create a simple margins object for older Qt | ||
| class SimpleMargins: | ||
| def __init__(self, left, top, right, bottom): | ||
| self._left, self._top, self._right, self._bottom = left, top, right, bottom | ||
| def left(self): return self._left | ||
| def top(self): return self._top | ||
| def right(self): return self._right | ||
| def bottom(self): return self._bottom | ||
| margins = SimpleMargins(*margins_tuple) | ||
| elif hasattr(parent.layout, 'contentsMargins'): | ||
| # Regular Qt layouts use contentsMargins() | ||
| margins = parent.layout.contentsMargins() | ||
|
|
||
| if margins: | ||
| return margins | ||
| parent = parent.parentItem() | ||
| except (AttributeError, RuntimeError): | ||
Check noticeCode scanning / CodeQL Empty except Note
'except' clause does nothing but pass and there is no explanatory comment.
|
||
| pass | ||
|
|
||
| return None | ||
|
|
||
| def _requestLayoutExpansion(self, required_space): | ||
| """ | ||
| Request the parent PlotItem to expand layout to accommodate text. | ||
| Parameters: | ||
| required_space (float): The space needed in pixels | ||
| """ | ||
| try: | ||
| # Find parent PlotItem | ||
| parent = self.parentItem() | ||
| while parent: | ||
| # Check if parent has our expansion method | ||
| if hasattr(parent, '_expandForAxisText'): | ||
| parent._expandForAxisText(self.orientation, required_space) | ||
| break | ||
| # Also check if parent is a PlotItem directly | ||
| elif hasattr(parent, 'layout') and hasattr(parent.layout, 'setContentsMargins'): | ||
| # Direct expansion for PlotItem-like objects | ||
| self._directLayoutExpansion(parent, required_space) | ||
| break | ||
| parent = parent.parentItem() | ||
| except (AttributeError, RuntimeError): | ||
Check noticeCode scanning / CodeQL Empty except Note
'except' clause does nothing but pass and there is no explanatory comment.
|
||
| pass | ||
|
|
||
| def _directLayoutExpansion(self, plot_parent, required_space): | ||
| """ | ||
| Directly expand PlotItem layout margins when needed. | ||
| Parameters: | ||
| plot_parent: The PlotItem parent | ||
| required_space (float): Space needed in pixels | ||
| """ | ||
| try: | ||
| if not hasattr(plot_parent.layout, 'contentsMargins'): | ||
| return | ||
|
|
||
| current_margins = plot_parent.layout.contentsMargins() | ||
|
|
||
| if self.orientation in ['bottom', 'top']: | ||
| # For horizontal axes, we may need to expand right margin | ||
| axis_width = self.geometry().width() | ||
| if required_space > axis_width and axis_width > 0: | ||
| extra_needed = required_space - axis_width | ||
|
|
||
| # Increase right margin to accommodate overflow | ||
| new_right_margin = current_margins.right() + extra_needed * 0.5 # Conservative | ||
|
|
||
| plot_parent.layout.setContentsMargins( | ||
| current_margins.left(), | ||
| current_margins.top(), | ||
| new_right_margin, | ||
| current_margins.bottom() | ||
| ) | ||
|
|
||
| except (AttributeError, TypeError): | ||
Check noticeCode scanning / CodeQL Empty except Note
'except' clause does nothing but pass and there is no explanatory comment.
|
||
| pass | ||
|
|
||
| def _adjustSize(self): | ||
| if self.orientation in ['left', 'right']: | ||
| self._updateWidth() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1643,3 +1643,124 @@ | |
| self.fileDialog.setAcceptMode(QtWidgets.QFileDialog.AcceptMode.AcceptSave) | ||
| self.fileDialog.show() | ||
| self.fileDialog.fileSelected.connect(handler) | ||
|
|
||
| def _expandForAxisText(self, axis_orientation, required_space): | ||
| """ | ||
| Expand layout when axes request more space for text. | ||
| Called by AxisItem when autoExpandTextSpace is enabled and text would be clipped. | ||
| This addresses the issue where autoExpandTextSpace doesn't work properly | ||
| with PlotItem content margins (GitHub issue #3375). | ||
| Parameters: | ||
| axis_orientation (str): The orientation of the requesting axis ('left', 'right', 'top', 'bottom') | ||
| required_space (float): The total space needed in pixels | ||
| """ | ||
| try: | ||
| if not hasattr(self.layout, 'setContentsMargins'): | ||
| return | ||
|
|
||
| # Get current margins using the appropriate method | ||
| if hasattr(self.layout, 'getContentsMargins'): | ||
| current_margins_tuple = self.layout.getContentsMargins() | ||
| # Convert tuple to margins object for easier manipulation | ||
| class MarginsHelper: | ||
| def __init__(self, left, top, right, bottom): | ||
| self._left, self._top, self._right, self._bottom = left, top, right, bottom | ||
| def left(self): return self._left | ||
| def top(self): return self._top | ||
| def right(self): return self._right | ||
| def bottom(self): return self._bottom | ||
| current_margins = MarginsHelper(*current_margins_tuple) | ||
| elif hasattr(self.layout, 'contentsMargins'): | ||
| current_margins = self.layout.contentsMargins() | ||
| else: | ||
| return | ||
| margins_changed = False | ||
|
|
||
| if axis_orientation in ['bottom', 'top']: | ||
| # For horizontal axes, check if we need to expand right margin | ||
| current_width = self.geometry().width() | ||
| if current_width > 0 and required_space > current_width: | ||
| extra_needed = required_space - current_width | ||
|
|
||
| # Calculate new right margin (conservative approach) | ||
| additional_margin = min(extra_needed * 0.3, 50) # Cap at 50px max | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we consider making the magic numbers into adjustable parameters? Especially if this might end up in a release without much testing period on master, it might break some application layouts in unexpected ways. Having a tweakable parameter would help with damage control in that case. We can keep them as undocumented additions until we have more experience with the outcome and the urgency to make them accessible. Maybe |
||
| new_right_margin = current_margins.right() + additional_margin | ||
|
|
||
| self.layout.setContentsMargins( | ||
| current_margins.left(), | ||
| current_margins.top(), | ||
| new_right_margin, | ||
| current_margins.bottom() | ||
| ) | ||
| margins_changed = True | ||
|
|
||
| elif axis_orientation in ['left', 'right']: | ||
| # For vertical axes, check if we need to expand top/bottom margins | ||
| current_height = self.geometry().height() | ||
| if current_height > 0 and required_space > current_height: | ||
| extra_needed = required_space - current_height | ||
|
|
||
| # Calculate new bottom margin (conservative approach) | ||
| additional_margin = min(extra_needed * 0.3, 30) # Cap at 30px max | ||
| new_bottom_margin = current_margins.bottom() + additional_margin | ||
|
|
||
| self.layout.setContentsMargins( | ||
| current_margins.left(), | ||
| current_margins.top(), | ||
| current_margins.right(), | ||
| new_bottom_margin | ||
| ) | ||
| margins_changed = True | ||
|
|
||
| # Notify all axes of margin changes if we modified them | ||
| if margins_changed: | ||
| self._notifyAxesOfMarginChange() | ||
|
|
||
| except (AttributeError, TypeError, RuntimeError): | ||
| # Gracefully handle any issues | ||
| pass | ||
|
|
||
| def _notifyAxesOfMarginChange(self): | ||
| """ | ||
| Notify all axes when layout margins change. | ||
| This helps axes adjust their calculations accordingly. | ||
| """ | ||
| try: | ||
| # Get margins using the appropriate method | ||
| if hasattr(self.layout, 'getContentsMargins'): | ||
| new_margins = self.layout.getContentsMargins() | ||
| elif hasattr(self.layout, 'contentsMargins'): | ||
| new_margins = self.layout.contentsMargins() | ||
| else: | ||
| return | ||
|
|
||
| for axis_name in ['left', 'right', 'top', 'bottom']: | ||
| if axis_name in self.axes: | ||
| axis = self.axes[axis_name]['item'] | ||
| if axis and hasattr(axis, '_onParentMarginsChanged'): | ||
| axis._onParentMarginsChanged(new_margins) | ||
| except (AttributeError, KeyError, RuntimeError): | ||
Check noticeCode scanning / CodeQL Empty except Note
'except' clause does nothing but pass and there is no explanatory comment.
|
||
| pass | ||
|
|
||
| def setContentsMargins(self, left, top, right, bottom): | ||
| """ | ||
| Set the content margins for the plot layout. | ||
| This method overrides the base implementation to ensure that | ||
| axes are properly notified of margin changes, which helps | ||
| with text space calculations. | ||
| Parameters: | ||
| left, top, right, bottom (float): Margin values in pixels | ||
| """ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have nothing to contribute to a discussion on how to best implement this... |
||
| try: | ||
| # Set the margins on the layout | ||
| if hasattr(self.layout, 'setContentsMargins'): | ||
| self.layout.setContentsMargins(left, top, right, bottom) | ||
|
|
||
| # Notify axes of the change for better text space handling | ||
| self._notifyAxesOfMarginChange() | ||
| except (AttributeError, RuntimeError): | ||
Check noticeCode scanning / CodeQL Empty except Note
'except' clause does nothing but pass and there is no explanatory comment.
|
||
| pass | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would suggest to leave
hide_overlapping_labelsunchanged. The justification for it being on for the horizontal axis is the following:In the common application of an updating real-time chart, long timestamp labels move along the bottom axis. These have a tendency to stick out on the left and overlap the zero of the vertical axis. In a competition between the specific 0 label and an arbitrary timestamp, the 0 should take priority. That is not a strong reason, but enough to suggest not introducing a change in behavior.
A smart solution might want to distinguish between a side with potentially colliding axis labels ("left"), and a side where that is not the case ("right"), but I think we do not currently have the necessary functionality to either implement that in AxisItem, or to detect the best approach.
Until we find that smart solution to handle this automatically, the next step of progressive improvement might be to add more hints to the docs for how and why to manually set this flag?