Named colors for dynamically applied palettes#1625
Named colors for dynamically applied palettes#1625NilsNemitz wants to merge 32 commits intopyqtgraph:masterfrom
Conversation
pyqtgraph/functions.py
Outdated
|
|
||
| pen = QtGui.QPen(QtGui.QBrush(color), width) | ||
| pen.setCosmetic(cosmetic) | ||
| assert qpen is not None |
There was a problem hiding this comment.
probably don't want an assert in functional code; as they can be squashed/suppressed when you run python with the -O argument (which I would expect a fair chunk of our user-base to actually do).
Probably to something like:
if qpen is None:
raise RuntimeError("some message")There was a problem hiding this comment.
qpen being None is really not supposed to ever happen here; it means I missed a possible execution path. This is an accidentally surviving debug statement.
But I guess throwing a recognizable error would be more helpful. Replaced by
if qpen is None:
raise ValueError('Failed to construct QPen from arguments '+str(args)+','+str(kargs) )
|
Dear @j9ac9k , thank you for the comments. I have updated the code accordingly. The NamedPen and NamedBrush objects now take a manual reference to the NamedColorManager on init, that should remove the main cyclic imports. |
|
|
||
| ## Start Qt event loop | ||
| if __name__ == '__main__': | ||
| QtWidgets.QApplication.instance().exec_() |
There was a problem hiding this comment.
I think we updated these since you created this PR, but put this line here instead: pg.mkQApp().exec_() (this handles the case there is no QApplication instance, which I know you declare earlier, but makes it easier)
pyqtgraph/colormap.py
Outdated
| return cm | ||
|
|
||
|
|
||
| def make_monochrome(color='green'): |
pyqtgraph/functions.py
Outdated
| args = args[0] | ||
| # now args is either a non-list entity, or a multi-element tuple | ||
| # short-circuits: | ||
| if isinstance(args, NamedBrush): |
There was a problem hiding this comment.
no code change requested here, just thinking of how nice the pattern matching functionality in python 3.10 will be able to serve us, ...too bad we won't get access to it for another ~4 years in this library.
pyqtgraph/namedBrush.py
Outdated
| return not self.__eq__(other) | ||
|
|
||
| def __hash__(self): | ||
| return id(self) |
There was a problem hiding this comment.
We got burned really bad by this and had to fix that here: #1564
At the end of the day, QBrushs/QPens are mutable, and implementing __hash__ in such a way is our attempt to make them not mutable, which I think we're going to run into issues in general if we try and enforce that criteria.
There was a problem hiding this comment.
This is only implemented here to allow the NamedColorManager to keep a list of weak references to all defined NamedColor objects. When a NamedColor object is deleted, the reference should be purged from he list immediately. A new object created with the same id would not be part of the list (or set).
But as discussed on Slack, this logic might be more straightforward to implement as a WeakValueDictionary, with id as the key. That should avoid providing a weak, limited-use hash function here.
pyqtgraph/namedBrush.py
Outdated
| if DEBUG: print(' NamedBrush '+name+' updated to QColor ('+str(qcol.name())+', alpha='+str(alpha)+')') | ||
| super().setColor( qcol ) | ||
|
|
||
| def paletteChange(self, color_dict): |
There was a problem hiding this comment.
named very similarly to the Signal paletteChanged, could we overwrite setPalette ... but it's not really a palette object coming in, maybe updatePalette?
There was a problem hiding this comment.
I am undecided on this. This takes the place of what was originally intended to be a signal alerting all NamedColor objects of a palette change. Unfortunately QColor does not handle signals. I like that the naming still seems to indicate this function.
pyqtgraph/namedPen.py
Outdated
| return not self.__eq__(other) | ||
|
|
||
| def __hash__(self): | ||
| return id(self) |
There was a problem hiding this comment.
see earlier comment about using the id() for __hash__
|
Hi @NilsNemitz Sorry it's taken me a while to follow up on this. We've been busy 😈 First, I want to reiterate, this looks amazing, and I believe every effort getting this into pyqtgraph is very worthwhile. In terms of code suggestion changes, I put most of my comments in-line, but I also would suggest changing some of the names of the signals and slots to be more in line with the Qt API in general, which we try and follow: In this case this would mean:
One of the things that is bugging me a bit is it seems like we (and this isn't a we as in you, a few of us have fallen into this trap) are trying to treat the You have looked into the color-space here more than most of us by this point @NilsNemitz; do you see a solution of updating the existing QPen/QBrush/QColor values with new values to reflect the new colors we want to display less feasible than creating new instances of those objects? |
|
Dear @j9ac9k, thank you very much for the review. Is there some Qt convention on names for functions that handle signals? updateStyle might work, but this is very close to the names of functions that are already in use, I think. |
|
"trying to treat the QPen, QBrush and QColor objects as if they're immutable. " I was originally hoping to use that feature to implement adjustable colors. However, that boat has already sailed: This treatment is supported by the behavior of the Python Qt bindings (or at least by PyQt5, which I am using): In the Qt c code, a QColor object can be passed to create e.g. a QPen object. This QColor object is kept intact, and the same object is returned by the getter function. So in principle, changing the original QColor object should affect the derived QPen and all colors that reference this QColor or QPen. In PyQt, this connection does not exist, as the getter function returns a copy of the original object (which I suspect is created on initializing the QPen). I don't think the user can have any expectation of assigning a QColor and then achieving a useful response to changing its properties. Adding a NamedColor would provide precisely this functionality in a way that could be supported throughout the library. The NamedColor / NamedPen / NamedBrush code is the thinnest wrapper I could come up with to extend the functionality of QColor / QPen / QBrush to locate and update them after their initial creation. If someone has a good idea how to make this even thinner, I'd be happy to pursue that. If we can agree to ask the user to treat colors as immutable, then only one We could consider disabling the related functions of |
Yeah, this one is just via approximating; what I generally do is I look at the most equivalent/nearest Qt object, look at all the methods/signals and try to use a name that is in line with those. Here is a list of all the methods for QGraphicsItem: https://doc.qt.io/qt-5/qgraphicsitem-members.html Signals are a bit tougher to track down, but I just know that
Blerg, I got myself crossed up with your implementation of
I think this is a pretty tall order; and is in conflict with the upstream framework, and may be in conflict with an application that pyqtgraph is being integrated into. Also, there is some president for making wrappers for Qt objects like this in PyQtGraph; |
|
Hi @NilsNemitz I gave this one a second look-over and I don't have any real concerns other than some naming mechanisms to get more in-line with the Qt API, which I can certainly help with. I am good with merging this once those are straightened out. This is a big feature; and I suspect there are likely unintentional side-effects along the way; but I'm willing to sort those out as we go. We may need to add some test code for this (test changing the global config option, check the colors of existing plots, that sort of thing). |
pyqtgraph/functions.py
Outdated
|
|
||
| err = 'Could not create a color from {:s}(type: {:s})'.format(str(args), str(type(args))) | ||
| result = COLOR_REGISTRY.getRegisteredColor(args) | ||
| if result is not None: # make a NamedPen |
There was a problem hiding this comment.
No idea! --> changed
| name = identifier | ||
| if color_dict is None or name not in color_dict: | ||
| if name[0] != '#': | ||
| raise ValueError('Undefined color name '+str(identifier)) |
There was a problem hiding this comment.
would again recommend a f-string here f"Undefined color name {identifier}"
There was a problem hiding this comment.
I will grudgingly admit that these seem quite useful. --> changed
| # 's': QtGui.QColor(100,100,150,255) | ||
| # } | ||
| # self.addDefaultRamp() | ||
| self.dark = None # is initially set when assigning system palette |
There was a problem hiding this comment.
instead of setting to None, we can set default to what the current setup is:
https://github.com/pyqtgraph/pyqtgraph/blob/master/pyqtgraph/Qt.py#L417-L420
app = QtWidgets.QApplication.instance()
self.dark = app.property('darkMode')This way you shouldn't have to worry about the case of None
There was a problem hiding this comment.
For the moment, each palette has a dark property, which currently simply represents that the background color is darker than (127,127,127). Since we are looking only at the plot palette here, this is not strongly linked to the system palette. PyQtGraph has been plotting dark plots in a light window for a long time.
As the comment says, this gets initialized immediately, so there is nothing to worry about. But not setting it makes the linter complain...
So I think this is okay here. We might want some method somewhere that sets the initial plot palette based on the system mode... But that would happen somewhere else. And for continuity, I think PyQtGraph should always start up with a dark plotting palette until instructed otherwise.
| self.dark = bool( sum( bg_tuple[:3] ) < 3 * 127 ) # dark mode? | ||
| self.palette[key] = col | ||
|
|
||
| def addDefaultRamp(self): |
There was a problem hiding this comment.
should there be something in this function, or should it be removed?
There was a problem hiding this comment.
For now, all custom gray colors are defined manually, but this function should replace that eventually.
Sorry, I forgot that this was left as a stub for now.
pyqtgraph/palette.py
Outdated
| avail = ('m3','m4','m5','m6','m7','m8') | ||
| colors = {} | ||
| for idx, (value, key) in enumerate(brightness): | ||
| colors[key] = avail[idx] |
There was a problem hiding this comment.
dict comprehension time!
colors = {key: avail[idx] for idx, (_, key) in enumerate(brightness}Actually, relooking at this, probably better off... (was guessing on variable names, please make better choices than I did 😆 )
colors = {key: avai for _, key, avai in zip(brightness, avail)There was a problem hiding this comment.
I think I'll change this to
choice = ('m3','m4','m5','m6','m7','m8') # 6 candidates for 6 needed colors
colors = {
key: choice[idx] # assign in order of dark to bright
for idx, (_, key) in enumerate(brightness)
}It's not quite as compact, but I personally find it easier to see the ordering when the index is explicitly present.
|
Hi @NilsNemitz I did another look through on the code; most of my changes now involve using list comprehensions instead, f-strings, things of that nature. I didn't make comments, but I think at this point it's ok to start deleting commented out code. One thing I did want to discuss was naming of signals/slots. Generally "somethingChanged" refers to a signal in Qt land; and you have "styleHasChanged" as a slot. I would recommend having |
|
Dear @j9ac9k , thanks for looking over this (too long) PR! I tried to make some improvements based on your comments. But this is more quality of life stuff and shouldn't change API or functionality much. I renamed the signal |
| loadUiType = uic.loadUiType | ||
|
|
||
| QtCore.Signal = QtCore.pyqtSignal | ||
| # QtCore.Slot = QtCore.pyqtSlot # The current policy is to avoid slot decorators. |
There was a problem hiding this comment.
This line can be deleted, yeah?
| loadUiType = uic.loadUiType | ||
|
|
||
| QtCore.Signal = QtCore.pyqtSignal | ||
| # QtCore.Slot = QtCore.pyqtSlot # The current policy is to avoid slot decorators. |
There was a problem hiding this comment.
This line can be deleted.
| # Each keyword argument sets one global option. | ||
| # """ | ||
| # for k,v in opts.items(): | ||
| # setConfigOption(k, v) |
There was a problem hiding this comment.
All this commented code can just be deleted.
|
|
||
| del key, col # let the linter know that we are done with these | ||
|
|
||
| def _expand_rgba_hex_string(hex): |
There was a problem hiding this comment.
This behavior (or very similar) is already implemented inside pyqtgraph.functions.mkColor, so we should try to refactor that out and share it.
|
Sorry, I believe this is the active version of this feature: |
pyqtgraph currently does not handle changes to the overall palette of colors really well (#1407 ).
#1588 points out that there is a useful paletteChange() signal that would allow an application to react to a switch between light and dark mode. However, any color changes beyond the simple background/foreground options would need to be applied manually.
This PR attempts to simplify this.
The example user-side code (in
examples/PaletteApplicationExample.py) demonstrates a full palette replacement by a single line command likepg.palette.get('monogreen').apply()This can be applied at any time, and automatically updates the displayed plots, even if no new data is sent.
The implementation exploits that basically all color requests go through functions
mkColor()/mkPen()/mkBrush(). These can now returnNamedPenorNamedBrushobjects, subclassed fromQPenandQBrush. These are assigned functional names such as 'gr_fg' for the graphical foreground color, or 'p0' to 'pB' for a set of twelve predefined plot colors. Such color names can be used directly in thepen=orbrush=keyword arguments of the plotting functions.When a new palette is assigned,
NamedPen/NamedBrushobjects automatically update the super-classQPen/QBrushobject according to the new palette. As the named objects otherwise function in the same way, no large changes to the codebase are required. However, the subclasses ofGraphicsObject,GraphicsWidgetandGraphicsViewnow have a functionstyleHasChanged(), which is called on palette updates and can be used to perform additional tasks only when required. For example,ScatterPlotItem's SymbolAtlas needs to be cleared to rebuild in the new color set.This PR demonstrates changes for most functionality in PlotCurveItem and ScatterPlotItem. If this method is adopted to improve color handling, there should be no major problems with including the remaining items. Some more cleanup is required.
I do not see any noticable impact on performance, since only minimal overhead is incurred from working with subclasses of QPen / QBrush. No additional code is executed except during the actual palette change.
The included palettes need some improvement, and would ultimately be user-definable and probably loaded from disk on request.
Comments and ideas are very welcome!