Related
Basically, this is an interactive heatmap but the twist is that the source is updated by reading values from a file that gets updated regularly.
dont bother about the class "generator", it is just for keeping data and it runs regularly threaded
make sure a file named "Server_dump.txt" exists in the same directory of the script with a single number greater than 0 inside before u execute the bokeh script.
what basically happens is i change a number inside the file named "Server_dump.txt" by using echo 4 > Server_dump.txt on bash,
u can put any number other than 4 and the script automatically checks the file and plots the new point.
if u don't use bash, u could use a text editor , replace the number and save, and all will be the same.
the run function inside the generator class is the one which checks if this file was modified , reads the number, transforms it into x& y coords and increments the number of taps associated with these coords and gives the source x,y,taps values based on that number.
well that function works fine and each time i echo a number , the correct rectangle is plotted but,
now I want to add the functionality of that clicking on a certain rectangle triggers a callback to plot a second graph based on the coords of the clicked rectangle but i can't even get it to trigger even though i have tried other examples with selected.on_change in them and they worked fine.
*if i increase self.taps for a certain rect by writing the number to the file multiple times, color gets updated but if i hover over the rect it shows me the past values and not the latest value only .
my bokeh version is 1.0.4
from functools import partial
from random import random,randint
import threading
import time
from tornado import gen
from os.path import getmtime
from math import pi
import pandas as pd
from random import randint, random
from bokeh.io import show
from bokeh.models import LinearColorMapper, BasicTicker, widgets, PrintfTickFormatter, ColorBar, ColumnDataSource, FactorRange
from bokeh.plotting import figure, curdoc
from bokeh.layouts import row, column, gridplot
source = ColumnDataSource(data=dict(x=[], y=[], taps=[]))
doc = curdoc()
#sloppy data receiving function to change data to a plottable shape
class generator(threading.Thread):
def __init__(self):
super(generator, self).__init__()
self.chart_coords = {'x':[],'y':[],'taps':[]}
self.Pi_coords = {}
self.coord = 0
self.pos = 0
self.col = 0
self.row = 0
self.s = 0
self.t = 0
def chart_dict_gen(self,row, col):
self.col = col
self.row = row+1
self.chart_coords['x'] = [i for i in range(1,cla.row)]
self.chart_coords['y'] = [i for i in range(cla.col, 0, -1)] #reversed list because chart requires that
self.chart_coords['taps']= [0]*(row * col)
self.taps = [[0 for y in range(col)] for x in range(row)]
def Pi_dict_gen(self,row,col):
key = 1
for x in range(1,row):
for y in range(1,col):
self.Pi_coords[key] = (x,y)
key = key + 1
def Pi_to_chart(self,N):
x,y = self.Pi_coords[N][0], self.Pi_coords[N][1]
return x,y
def run(self):
while True:
if(self.t == 0):
self.t=1
continue
time.sleep(0.1)
h = getmtime("Server_dump.txt")
if self.s != h:
self.s = h
with open('Server_dump.txt') as f:
m = next(f)
y,x = self.Pi_to_chart(int(m))
self.taps[x][y] += 1
# but update the document from callback
doc.add_next_tick_callback(partial(update, x=x, y=y, taps=self.taps[x][y]))
cla = generator()
cla.chart_dict_gen(15,15)
cla.Pi_dict_gen(15, 15)
x = cla.chart_coords['x']
y = cla.chart_coords['y']
taps = cla.chart_coords['taps']
#gen.coroutine
def update(x, y, taps):
taps += taps
print(x,y,taps)
source.stream(dict(x=[x], y=[y], taps=[taps]))
colors = ["#CCEBFF","#B2E0FF","#99D6FF","#80CCFF","#66c2FF","#4DB8FF","#33ADFF","#19A3FF", "#0099FF", "#008AE6", "#007ACC","#006BB2", "#005C99", "#004C80", "#003D66", "#002E4C", "#001F33", "#000F1A", "#000000"]
mapper = LinearColorMapper(palette=colors, low= 0, high= 15) #low = min(cla.chart_coords['taps']) high = max(cla.chart_coords['taps'])
TOOLS = "hover,save,pan,box_zoom,reset,wheel_zoom"
p = figure(title="Tou",
x_range=list(map(str,x)),
y_range=list(map(str,reversed(y))),
x_axis_location="above",
plot_width=900, plot_height=400,
tools=TOOLS, toolbar_location='below',
tooltips=[('coords', '#y #x'), ('taps', '#taps%')])
p.grid.grid_line_color = "#ffffff"
p.axis.axis_line_color = "#ef4723"
p.axis.major_tick_line_color = "#af0a36"
p.axis.major_label_text_font_size = "7pt"
p.xgrid.grid_line_color = None
p.ygrid.grid_line_color = None
p.rect(x="x", y="y",
width=0.9, height=0.9,
source=source,
fill_color={'field': 'taps', 'transform': mapper},
line_color = "#ffffff",
)
color_bar = ColorBar(color_mapper=mapper,
major_label_text_font_size="7pt",
ticker=BasicTicker(desired_num_ticks=len(colors)),
formatter=PrintfTickFormatter(format="%d%%"),
label_standoff=6, border_line_color=None, location=(0, 0))
curdoc().theme = 'dark_minimal'
def ck(attr, old, new):
print('here') #doesn't even print hi in the terminal if i click anywhere
source.selected.on_change('indices', ck)
p.add_layout(color_bar, 'right')
doc.add_root(p)
thread = cla
thread.start()
i wanted even to get a printed hi in the terminal but nothing
You have not actually added any selection tool at all to your plot, so no selection is ever made. You have specified:
TOOLS = "hover,save,pan,box_zoom,reset,wheel_zoom"
Those are the only tools that will be added, and none of them make selections, there for nothing will cause source.selection.indices to ever be updated. If you are looking for selections based on tap, you must add a TapTool, e.g. with
TOOLS = "hover,save,pan,box_zoom,reset,wheel_zoom,tap"
Note that there will not be repeated callbacks if you tap the same rect multiple times. The callback only fires when the selection changes and clicking the same glyph twice in a row results in an identical selection.
I'm trying to do something that I'd normally consider trivial but seems to be very difficult in bokeh: Adding a vertical colorbar to a plot and then having the title of the colorbar (a.k.a. the variable behind the colormapping) appear to one side of the colorbar but rotated 90 degrees clockwise from horizontal.
From what I can tell of the bokeh ColorBar() interface (looking at both documentation and using the python interpreter's help() function for this element), this is not possible. In desperation I have added my own Label()-based annotation. This works but is klunky and displays odd behavior when deployed in a bokeh serve situation--that the width of the data window on the plot varies inversely with the length of the title colorbar's title string.
Below I've included a modified version of the bokeh server mpg example. Apologies for its complexity, but I felt this was the best way to illustrate the problem using infrastructure/data that ships with bokeh. For those unfamiliar with bokeh serve, this code snippet needs to saved to a file named main.py that resides in a directory--for the sake of argument let's say CrossFilter2--and in the parent directory of CrossFilter2 one needs to invoke the command
bokeh serve --show CrossFilter2
this will then display in a browser window (localhost:5006/CrossFilter2) and if you play with the color selection widget you will see what I mean, namely that short variable names such as 'hp' or 'mpg' result in a wider data display windows than longer variable names such as 'accel' or 'weight'. I suspect that there may be a bug in how label elements are sized--that their x and y dimensions are swapped--and that bokeh has not understood that the label element has been rotated.
My questions are:
Must I really have to go to this kind of trouble to get a simple colorbar label feature that I can get with little-to-no trouble in matplotlib/plotly?
If I must go through the hassle you can see in my sample code, is there some other way I can do this that avoids the data window width problem?
import numpy as np
import pandas as pd
from bokeh.layouts import row, widgetbox
from bokeh.models import Select
from bokeh.models import HoverTool, ColorBar, LinearColorMapper, Label
from bokeh.palettes import Spectral5
from bokeh.plotting import curdoc, figure, ColumnDataSource
from bokeh.sampledata.autompg import autompg_clean as df
df = df.copy()
SIZES = list(range(6, 22, 3))
COLORS = Spectral5
# data cleanup
df.cyl = df.cyl.astype(str)
df.yr = df.yr.astype(str)
columns = sorted(df.columns)
discrete = [x for x in columns if df[x].dtype == object]
continuous = [x for x in columns if x not in discrete]
quantileable = [x for x in continuous if len(df[x].unique()) > 20]
def create_figure():
xs = df[x.value].tolist()
ys = df[y.value].tolist()
x_title = x.value.title()
y_title = y.value.title()
name = df['name'].tolist()
kw = dict()
if x.value in discrete:
kw['x_range'] = sorted(set(xs))
if y.value in discrete:
kw['y_range'] = sorted(set(ys))
kw['title'] = "%s vs %s" % (y_title, x_title)
p = figure(plot_height=600, plot_width=800,
tools='pan,box_zoom,wheel_zoom,lasso_select,reset,save',
toolbar_location='above', **kw)
p.xaxis.axis_label = x_title
p.yaxis.axis_label = y_title
if x.value in discrete:
p.xaxis.major_label_orientation = pd.np.pi / 4
if size.value != 'None':
groups = pd.qcut(df[size.value].values, len(SIZES))
sz = [SIZES[xx] for xx in groups.codes]
else:
sz = [9] * len(xs)
if color.value != 'None':
coloring = df[color.value].tolist()
cv_95 = np.percentile(np.asarray(coloring), 95)
mapper = LinearColorMapper(palette=Spectral5,
low=cv_min, high=cv_95)
mapper.low_color = 'blue'
mapper.high_color = 'red'
add_color_bar = True
ninety_degrees = pd.np.pi / 2.
color_bar = ColorBar(color_mapper=mapper, title='',
#title=color.value.title(),
title_text_font_style='bold',
title_text_font_size='20px',
title_text_align='center',
orientation='vertical',
major_label_text_font_size='16px',
major_label_text_font_style='bold',
label_standoff=8,
major_tick_line_color='black',
major_tick_line_width=3,
major_tick_in=12,
location=(0,0))
else:
c = ['#31AADE'] * len(xs)
add_color_bar = False
if add_color_bar:
source = ColumnDataSource(data=dict(x=xs, y=ys,
c=coloring, size=sz, name=name))
else:
source = ColumnDataSource(data=dict(x=xs, y=ys, color=c,
size=sz, name=name))
if add_color_bar:
p.circle('x', 'y', fill_color={'field': 'c',
'transform': mapper},
line_color=None, size='size', source=source)
else:
p.circle('x', 'y', color='color', size='size', source=source)
p.add_tools(HoverTool(tooltips=[('x', '#x'), ('y', '#y'),
('desc', '#name')]))
if add_color_bar:
color_bar_label = Label(text=color.value.title(),
angle=ninety_degrees,
text_color='black',
text_font_style='bold',
text_font_size='20px',
x=25, y=300,
x_units='screen', y_units='screen')
p.add_layout(color_bar, 'right')
p.add_layout(color_bar_label, 'right')
return p
def update(attr, old, new):
layout.children[1] = create_figure()
x = Select(title='X-Axis', value='mpg', options=columns)
x.on_change('value', update)
y = Select(title='Y-Axis', value='hp', options=columns)
y.on_change('value', update)
size = Select(title='Size', value='None',
options=['None'] + quantileable)
size.on_change('value', update)
color = Select(title='Color', value='None',
options=['None'] + quantileable)
color.on_change('value', update)
controls = widgetbox([x, y, color, size], width=200)
layout = row(controls, create_figure())
curdoc().add_root(layout)
curdoc().title = "Crossfilter"
You can add a vertical label to the Colorbar by plotting it on a separate axis and adding a title to this axis. To illustrate this, here's a modified version of Bokeh's standard Colorbar example (found here):
import numpy as np
from bokeh.plotting import figure, output_file, show
from bokeh.models import LogColorMapper, LogTicker, ColorBar
from bokeh.layouts import row
plot_height = 500
plot_width = 500
color_bar_height = plot_height + 11
color_bar_width = 180
output_file('color_bar.html')
def normal2d(X, Y, sigx=1.0, sigy=1.0, mux=0.0, muy=0.0):
z = (X-mux)**2 / sigx**2 + (Y-muy)**2 / sigy**2
return np.exp(-z/2) / (2 * np.pi * sigx * sigy)
X, Y = np.mgrid[-3:3:100j, -2:2:100j]
Z = normal2d(X, Y, 0.1, 0.2, 1.0, 1.0) + 0.1*normal2d(X, Y, 1.0, 1.0)
image = Z * 1e6
color_mapper = LogColorMapper(palette="Viridis256", low=1, high=1e7)
plot = figure(x_range=(0,1), y_range=(0,1), toolbar_location=None,
width=plot_width, height=plot_height)
plot.image(image=[image], color_mapper=color_mapper,
dh=[1.0], dw=[1.0], x=[0], y=[0])
Now, to make the Colorbar, create a separate dummy plot, add the Colorbar to the dummy plot and place it next to the main plot. Add the Colorbar label as the title of the dummy plot and center it appropriately.
color_bar = ColorBar(color_mapper=color_mapper, ticker=LogTicker(),
label_standoff=12, border_line_color=None, location=(0,0))
color_bar_plot = figure(title="My color bar title", title_location="right",
height=color_bar_height, width=color_bar_width,
toolbar_location=None, min_border=0,
outline_line_color=None)
color_bar_plot.add_layout(color_bar, 'right')
color_bar_plot.title.align="center"
color_bar_plot.title.text_font_size = '12pt'
layout = row(plot, color_bar_plot)
show(layout)
This gives the following output image:
One thing to look out for is that color_bar_width is set wide enough to incorporate both the Colorbar, its axes labels and the Colorbar label. If the width is set too small, you will get an error and the plot won't render.
As of Bokeh 0.12.10 there is no built in label available for colorbars. In addition to your approach or something like it, another possibility would be a custom extension, though that is similarly not trivial.
Offhand, a colobar label certainly seems like a reasonable thing to consider. Regarding the notion that it ought to be trivially available, if you polled all users about what they consider should be trivially available, there will be thousands of different suggestions for what to prioritize. As is very often the case in the OSS world, there are far more possible things to do, than there are people to do them (less than 3 in this case). So, would first suggest a GitHub Issue to request the feature, and second, if you have the ability, volunteering to help implement it. Your contribution would be valuable and appreciated by many.
I have modified the ImageView example by adding the statement data[:, ::10, :] = 0, which sets every tenth element of the middle dimension to 0. The program now shows horizontal lines. This is consistent with the documentation of the ImageView.setImage function: the default axes dictionary is {'t':0, 'x':1, 'y':2, 'c':3}. However, when I change this to {'t':0, 'x':2, 'y':1, 'c':3}, nothing changes where I would expect to get vertical rows.
So my question is: how can I give the row dimension a higher precedence in PyQtGraph? Of course I can transpose all my arrays myself before passing them to the setImage function but I prefer not to. Especially since both Numpy and Qt use the row/column convention and not X before Y. I don't see why PyQtGraph chooses the latter.
For completeness, find my modified ImageView example below.
import numpy as np
from pyqtgraph.Qt import QtCore, QtGui
import pyqtgraph as pg
app = QtGui.QApplication([])
## Create window with ImageView widget
win = QtGui.QMainWindow()
win.resize(800,800)
imv = pg.ImageView()
win.setCentralWidget(imv)
win.show()
win.setWindowTitle('pyqtgraph example: ImageView')
## Create random 3D data set with noisy signals
img = pg.gaussianFilter(np.random.normal(size=(200, 200)), (5, 5)) * 20 + 100
img = img[np.newaxis,:,:]
decay = np.exp(-np.linspace(0,0.3,100))[:,np.newaxis,np.newaxis]
data = np.random.normal(size=(100, 200, 200))
data += img * decay
data += 2
## Add time-varying signal
sig = np.zeros(data.shape[0])
sig[30:] += np.exp(-np.linspace(1,10, 70))
sig[40:] += np.exp(-np.linspace(1,10, 60))
sig[70:] += np.exp(-np.linspace(1,10, 30))
sig = sig[:,np.newaxis,np.newaxis] * 3
data[:,50:60,50:60] += sig
data[:, ::10, :] = 0 # Make image a-symmetrical
## Display the data and assign each frame a time value from 1.0 to 3.0
imv.setImage(data, xvals=np.linspace(1., 3., data.shape[0]),
axes={'t':0, 'x':2, 'y':1, 'c':3}) # doesn't help
## Start Qt event loop unless running in interactive mode.
if __name__ == '__main__':
import sys
if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
QtGui.QApplication.instance().exec_()
Looking through ImageView.py, setImage() parses the axes dictionary and based on presence of 't' it builds the z-axis/frame slider, and that's it. Rearranging the axes seems unimplemented yet.
In my game after pressing the start button I wanted to hide the button to stop the user from pressing the start button again and making more bubbles appear. I tried to make a variable equal to 0. Then every time the start button was clicked the value would go up by 1. If the value was 2 then the start button would disappear or become inactive. By trying to add this 'hiding button code' its only showing one bubble on my screen when the start button is clicked. Oh and the started button doesn't hide... ._.
from tkinter import *
import random
import tkinter as tk
# Message box for score to tell user if they have won or lost
from tkinter.messagebox import showinfo
class BubbleFrame:
def __init__(self, root, name):
self.name = name
root.title("Math Bubbles")
self.bubbles = {} # this will hold bubbles ids, positions and velocities
self.score = 0
self.buttonclick = 0 # Newly added in attempt to hide button
Button(root, text="Start", width=8, bg="Pink", command=self.make_bubbles).pack() # This button starts the game, making the bubbles move across the screen
Button(root, text="Quit", width=8, bg="Yellow",command=quit).pack()
self.canvas = Canvas(root, width=800, height=650, bg='#afeeee')
self.canvas.create_text(400, 30, fill="darkblue", font="Times 20 italic bold", text="Click the bubbles that are answers in the two times tables.")
#Shows score at beinging of the game
self.current_score = self.canvas.create_text(200, 60, fill="darkblue", font="Times 15 italic bold", text="Your score is: 0")
self.canvas.pack()
def make_bubbles(self):
for each_no in range(1, 21):
self.buttonclick += 1 #Newly added in attempt to hide button
xval = random.randint(5, 765)
yval = random.randint(5, 615)
COLOURS = ('#00ff7f', '#ffff00', '#ee82ee', '#ff69b4', '#fff0f5') # CAPS represents a constant variable
colour = random.choice(COLOURS) # This picks a colour randomly
oval_id = self.canvas.create_oval(xval, yval, xval + 60, yval + 60,fill=colour, outline="#000000", width=5, tags="bubble")
text_id = self.canvas.create_text(xval + 30, yval + 30, text=each_no, tags="bubble")
self.canvas.tag_bind("bubble", "<Button-1>", lambda x: self.click(x))
self.bubbles[oval_id] = (xval, yval, 0, 0, each_no, text_id) # add bubbles to dictionary
if buttonclick == 2: #Newly added in attempt to hide button
showinfo("Oh No", "Sorry %s, but the game is already started" % self.name)
def click(self, event):
if self.canvas.find_withtag(CURRENT):
item_uid = event.widget.find_closest(event.x, event.y)[0]
is_even = False
try: # clicked oval
self.bubbles[item_uid]
except KeyError: # clicked oval
for key, value in self.bubbles.iteritems():
if item_uid == value[5]: # comparing to text_id
if value[4] % 2 == 0:
is_even = True
self.canvas.delete(key) # deleting oval
self.canvas.delete(item_uid) # deleting text
else:
if self.bubbles[item_uid][4] % 2 == 0:
is_even = True
self.canvas.delete(item_uid) # deleting oval
self.canvas.delete(self.bubbles[item_uid][5]) # deleting text
if is_even:
self.score += 1
else:
self.score -= 1
showinfo("Oh no!", "%s! You clicked the wrong bubble, please start again." % self.name)
if self.score == 10:
#Tells user You won! if score is 10
showinfo("Winner", "You won %s!" % self.name)
self.canvas.delete(self.current_score)
#Shows updated score on canvas
self.current_score = self.canvas.create_text(200, 60, fill="darkblue", font="Times 15 italic bold", text="Your score is: %s"%self.score)
def bubble_move(self, root):
for oval_id, (x, y, dx, dy, each_no, text_id) in self.bubbles.items():
# update velocities and positions
dx += random.randint(-1, 1)
dy += random.randint(-1, 1)
# dx and dy should not be too large
dx, dy = max(-5, min(dx, 5)), max(-5, min(dy, 5))
# bounce off walls
if not 0 < x < 770:
dx = -dx
if not 0 < y < 620:
dy = -dy
# apply new velocities
self.canvas.move(oval_id, dx, dy)
self.canvas.move(text_id, dx, dy)
self.bubbles[oval_id] = (x + dx, y + dy, dx, dy, each_no, text_id)
# have mainloop repeat this after 100 ms
root.after(100, self.bubble_move, root)
if __name__ == "__main__":
root = Tk()
root.title("Welcome")
Label(root, text="Welcome to Math bubbles, what is your name?").pack()
name = Entry(root)
name.pack()
def submit(name, root):
root.destroy()
root = Tk()
Label(root, text="Hello %s, press the Start button to begin.\n" % name).pack()
BubbleFrame(root, name).bubble_move(root)
Button(root, text="Ok", command=lambda: submit(name.get(), root)).pack()
root.mainloop()
I faced IndentationError in line 103 and NameError in 39 (it is self.buttonclick not buttonclick). Next time check your code before posting.
Well, you invoke 20 buttonclicks each time you click on Start button and the value checking happens in the middle of the process.
I fixed the make_bubbles method for you abit.
def make_bubbles(self):
self.buttonclick += 1 #Newly added in attempt to hide button
if self.buttonclick == 2: #Newly added in attempt to hide button
self.startbutton.configure(state="disabled")
showinfo("Oh No", "Sorry %s, but the game is already started" % self.name)
return
for each_no in range(1, 21):
...
The logic to enable the button at the end of game is missing or perhaps no need at all. Maybe I would rather clear all bubbles and generate 20 new ones each time I click the Start button. Anyway, I recommend this site for tkinter programming.
There is some more things to fix here too. No more iteritems() in Python 3. Use self.bubbles.items() instead. Good luck!
I am wondering how to best truncate text in a QLabel based on it's maximum width/height.
The incoming text could be any length, but in order to keep a tidy layout I'd like to truncate long strings to fill a maximum amount of space (widget's maximum width/height).
E.g.:
'A very long string where there should only be a short one, but I can't control input to the widget as it's a user given value'
would become:
'A very long string where there should only be a short one, but ...'
based on the required space the current font needs.
How can I achieve this best?
Here is a simple example of what I'm after, though this is based on word count, not available space:
import sys
from PySide.QtGui import *
from PySide.QtCore import *
def truncateText(text):
maxWords = 10
words = text.split(' ')
return ' '.join(words[:maxWords]) + ' ...'
app = QApplication(sys.argv)
mainWindow = QWidget()
layout = QHBoxLayout()
mainWindow.setLayout(layout)
text = 'this is a very long string, '*10
label = QLabel(truncateText(text))
label.setWordWrap(True)
label.setFixedWidth(200)
layout.addWidget(label)
mainWindow.show()
sys.exit(app.exec_())
Even easier - use the QFontMetrics.elidedText method and overload the paintEvent, here's an example:
from PyQt4.QtCore import Qt
from PyQt4.QtGui import QApplication,\
QLabel,\
QFontMetrics,\
QPainter
class MyLabel(QLabel):
def paintEvent( self, event ):
painter = QPainter(self)
metrics = QFontMetrics(self.font())
elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
painter.drawText(self.rect(), self.alignment(), elided)
if ( __name__ == '__main__' ):
app = None
if ( not QApplication.instance() ):
app = QApplication([])
label = MyLabel()
label.setText('This is a really, long and poorly formatted runon sentence used to illustrate a point')
label.setWindowFlags(Qt.Dialog)
label.show()
if ( app ):
app.exec_()
I found that #Eric Hulser's answer, while great, didn't work when the label was put into another widget.
I came up with this by hacking together Eric's response with the Qt Elided Label Example. It should behave just like a regular label, yet elide horizontally when the text width exceeds the widget width. It has an extra argument for different elide modes. I also wrote some tests for fun :)
If you want to use PyQt5...
Change "PySide2" to "PyQt5"
Change "Signal" to "pyqtSignal"
Enjoy!
Eliding Label
# eliding_label.py
from PySide2 import QtCore, QtWidgets, QtGui
class ElidingLabel(QtWidgets.QLabel):
"""Label with text elision.
QLabel which will elide text too long to fit the widget. Based on:
https://doc-snapshots.qt.io/qtforpython-5.15/overviews/qtwidgets-widgets-elidedlabel-example.html
Parameters
----------
text : str
Label text.
mode : QtCore.Qt.TextElideMode
Specify where ellipsis should appear when displaying texts that
don’t fit.
Default is QtCore.Qt.ElideMiddle.
Possible modes:
QtCore.Qt.ElideLeft
QtCore.Qt.ElideMiddle
QtCore.Qt.ElideRight
parent : QWidget
Parent widget. Default is None.
f : Qt.WindowFlags()
https://doc-snapshots.qt.io/qtforpython-5.15/PySide2/QtCore/Qt.html#PySide2.QtCore.PySide2.QtCore.Qt.WindowType
"""
elision_changed = QtCore.Signal(bool)
def __init__(self, text='', mode=QtCore.Qt.ElideMiddle, **kwargs):
super().__init__(**kwargs)
self._mode = mode
self.is_elided = False
self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
self.setText(text)
def setText(self, text):
self._contents = text
# This line set for testing. Its value is the return value of
# QFontMetrics.elidedText, set in paintEvent. The variable
# must be initialized for testing. The value should always be
# the same as contents when not elided.
self._elided_line = text
self.update()
def text(self):
return self._contents
def paintEvent(self, event):
super().paintEvent(event)
did_elide = False
painter = QtGui.QPainter(self)
font_metrics = painter.fontMetrics()
text_width = font_metrics.horizontalAdvance(self.text())
# layout phase
text_layout = QtGui.QTextLayout(self._contents, painter.font())
text_layout.beginLayout()
while True:
line = text_layout.createLine()
if not line.isValid():
break
line.setLineWidth(self.width())
if text_width >= self.width():
self._elided_line = font_metrics.elidedText(self._contents, self._mode, self.width())
painter.drawText(QtCore.QPoint(0, font_metrics.ascent()), self._elided_line)
did_elide = line.isValid()
break
else:
line.draw(painter, QtCore.QPoint(0, 0))
text_layout.endLayout()
if did_elide != self.is_elided:
self.is_elided = did_elide
self.elision_changed.emit(did_elide)
if __name__ == '__main__':
app = QtWidgets.QApplication([])
long_text = "this is some long text, wouldn't you say?"
elabel = ElidingLabel(long_text)
elabel.show()
app.exec_()
Test Eliding Label
# test_eliding_label.py.py
#
# Run tests with
#
# python3 -m unittest test_eliding_label.py --failfast --quiet
import unittest
import unittest.mock
from PySide2 import QtCore, QtWidgets, QtGui, QtTest
import eliding_label
if not QtWidgets.QApplication.instance():
APP = QtWidgets.QApplication([]) # pragma: no cover
class TestElidingLabelArguments(unittest.TestCase):
def test_optional_text_argument(self):
elabel = eliding_label.ElidingLabel()
self.assertEqual(elabel.text(), "")
def test_text_argument_sets_label_text(self):
elabel = eliding_label.ElidingLabel(text="Test text")
self.assertEqual(elabel.text(), "Test text")
def test_optional_elision_mode_argument(self):
elabel = eliding_label.ElidingLabel()
self.assertEqual(elabel._mode, QtCore.Qt.ElideMiddle)
class TestElidingLabel(unittest.TestCase):
def setUp(self):
self.elabel = eliding_label.ElidingLabel()
def test_elabel_is_a_label(self):
self.assertIsInstance(self.elabel, QtWidgets.QLabel)
def test_has_elision_predicate(self):
self.assertEqual(self.elabel.is_elided, False)
def test_elision_predicate_changes_when_text_width_exceeds_widget_width(self):
# NOTE: This is a bit of a stretch, inducing a paint event
# when the event loop isn't running. Throws a bunch of C++
# sourced text which can't be (easily) caught.
self.elabel.setFixedWidth(25)
self.assertEqual(self.elabel.width(), 25)
long_text = "This is line is definely longer than 25 pixels."
painter = QtGui.QPainter()
font_metrics = painter.fontMetrics()
long_text_width = font_metrics.horizontalAdvance(long_text)
self.assertGreater(long_text_width, 25)
self.elabel.setText(long_text)
x = self.elabel.x()
y = self.elabel.y()
w = self.elabel.width()
h = self.elabel.height()
paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
self.elabel.paintEvent(paint_event)
self.assertEqual(self.elabel.is_elided, True)
def test_text_is_elided_when_text_width_exceeds_widget_width(self):
# NOTE: This is a bit of a stretch, inducing a paint event
# when the event loop isn't running. Throws a bunch of C++
# sourced text which can't be (easily) caught.
self.elabel.setFixedWidth(25)
self.assertEqual(self.elabel.width(), 25)
long_text = "This is line is definely longer than 25 pixels."
painter = QtGui.QPainter()
font_metrics = painter.fontMetrics()
long_text_width = font_metrics.horizontalAdvance(long_text)
self.assertGreater(long_text_width, 25)
self.elabel.setText(long_text)
x = self.elabel.x()
y = self.elabel.y()
w = self.elabel.width()
h = self.elabel.height()
paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
self.elabel.paintEvent(paint_event)
# PySide2.QtGui.QFontMetrics.elidedText states, "If the string
# text is wider than width , returns an elided version of the
# string (i.e., a string with '…' in it). Otherwise, returns
# the original string."
self.assertEqual(self.elabel._elided_line, '…')
def test_text_is_not_elided_when_text_width_is_less_than_widget_width(self):
# NOTE: This is a bit of a stretch, inducing a paint event
# when the event loop isn't running. Throws a bunch of C++
# sourced text which can't be (easily) caught.
self.elabel.setFixedWidth(500)
self.assertEqual(self.elabel.width(), 500)
short_text = "Less than 500"
painter = QtGui.QPainter()
font_metrics = painter.fontMetrics()
short_text_width = font_metrics.horizontalAdvance(short_text)
self.assertLess(short_text_width, 500)
self.elabel.setText(short_text)
x = self.elabel.x()
y = self.elabel.y()
w = self.elabel.width()
h = self.elabel.height()
paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
self.elabel.paintEvent(paint_event)
# PySide2.QtGui.QFontMetrics.elidedText states, "If the string
# text is wider than width , returns an elided version of the
# string (i.e., a string with '…' in it). Otherwise, returns
# the original string."
self.assertEqual(self.elabel._elided_line, short_text)
def test_stores_full_text_even_when_elided(self):
# NOTE: This is a bit of a stretch, inducing a paint event
# when the event loop isn't running. Throws a bunch of C++
# sourced text which can't be (easily) caught.
self.elabel.setFixedWidth(25)
self.assertEqual(self.elabel.width(), 25)
long_text = "This is line is definely longer than 25 pixels."
painter = QtGui.QPainter()
font_metrics = painter.fontMetrics()
long_text_width = font_metrics.horizontalAdvance(long_text)
self.assertGreater(long_text_width, 25)
self.elabel.setText(long_text)
x = self.elabel.x()
y = self.elabel.y()
w = self.elabel.width()
h = self.elabel.height()
paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
self.elabel.paintEvent(paint_event)
# PySide2.QtGui.QFontMetrics.elidedText states, "If the string
# text is wider than width , returns an elided version of the
# string (i.e., a string with '…' in it). Otherwise, returns
# the original string."
self.assertEqual(self.elabel._elided_line, '…')
self.assertEqual(self.elabel.text(), long_text)
def test_has_elision_changed_signal(self):
self.assertIsInstance(self.elabel.elision_changed, QtCore.Signal)
def test_elision_changed_signal_emits_on_change_to_is_elided_predicate(self):
mock = unittest.mock.Mock()
self.elabel.elision_changed.connect(mock.method)
# NOTE: This is a bit of a stretch, inducing a paint event
# when the event loop isn't running. Throws a bunch of C++
# sourced text which can't be (easily) caught.
# Induce elision
self.elabel.setFixedWidth(150)
self.assertEqual(self.elabel.width(), 150)
long_text = "This line is definitely going to be more than 150 pixels"
painter = QtGui.QPainter()
font_metrics = painter.fontMetrics()
long_text_width = font_metrics.horizontalAdvance(long_text)
self.assertGreater(long_text_width, 150)
self.elabel.setText(long_text)
self.assertEqual(self.elabel.is_elided, False) # no elide until painting
x = self.elabel.x()
y = self.elabel.y()
w = self.elabel.width()
h = self.elabel.height()
paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
self.elabel.paintEvent(paint_event)
self.assertEqual(self.elabel.is_elided, True)
mock.method.assert_called_once()
# Remove elision
short_text = "Less than 150"
painter = QtGui.QPainter()
font_metrics = painter.fontMetrics()
short_text_width = font_metrics.horizontalAdvance(short_text)
self.assertLess(short_text_width, 150)
self.elabel.setText(short_text)
self.assertEqual(self.elabel.is_elided, True) # still elided until painting
x = self.elabel.x()
y = self.elabel.y()
w = self.elabel.width()
h = self.elabel.height()
paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
self.elabel.paintEvent(paint_event)
self.assertEqual(self.elabel.is_elided, False)
self.assertEqual(mock.method.call_count, 2)
You can achieves this through determining the width with QFontMetrics, see this answer.
You would probably want to use or create some algorithm which finds the place to cut quickly, unless doing it in a simple for loop would be sufficient.
simpler solution if you want show QLabel in center in provided area
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.minimumSizeHint = lambda self=label: QSize(0, QLabel.minimumSizeHint(self).height() )