PyQt QScrollArea doesn't display widgets - python-3.x

I am somewhat new to GUI programming and very new to PyQt, and I'm trying to build a GUI that displays a list of questions. I have created a QuestionBank class that subclasses QWidget and overrides the .show() method to display the list properly. I have tested this alone and it works correctly. However, the list of questions can be quite long, so I've been trying to make it scrollable. Rather than add a QScrollBar to the widget and then set up the event triggers by hand, I've been trying to my QuestionBank widget in a QScrollArea based on the syntax I've seen in examples online. While the scroll area shows up fine, it does not at all display the question bank but rather just shows a blank outline.
The QuestionBank class looks like this:
class QuestionBank(QWidget):
BUFFER = 10 # space between questions (can be modified)
def __init__(self, parent, questions):
# `parent` should be the QWidget that contains the QuestionBank, or None if
# QuestionBank is top level
# `questions` should be a list of MasterQuestion objects (not widgets)
QWidget.__init__(self, parent)
self.questions = [MasterQuestionWidget(self, q) for q in questions]
self.bottomEdge = 0
def show(self, y=BUFFER):
QWidget.show(self)
for q in self.questions:
# coordinates for each each question
q.move(QuestionBank.BUFFER, y)
q.show()
# update y-coordinate so that questions don't overlap
y += q.frameGeometry().height() + QuestionBank.BUFFER
self.bottomEdge = y + 3 * QuestionBank.BUFFER
# ... other methods down here
My code for showing the scroll bar looks like this:
app = QApplication(sys.argv)
frame = QScrollArea()
qs = QuestionBank(None, QFileManager.importQuestions())
qs.resize(350, 700)
frame.setGeometry(0, 0, 350, 300)
frame.setWidget(qs)
frame.show()
sys.exit(app.exec_())
I have tried many variants of this, including calling resize on frame instead of qs, getting rid of setGeometry, and setting the parent of qs to frame instead of None and I have no idea where I'm going wrong.
If it helps, I'm using PyQt5
Here is the question bank without the scroll area, to see what it is supposed to look like:
Here is the output of the code above with the scroll area:
This variation on the code is the only one that produces any output whatsoever, the rest just have blank windows. I'm convinced its something simple I'm missing, as the frame is obviously resizing correctly and it obviously knows what widget to display but its not showing the whole thing.
Any help is much appreciated, thank you in advance.

Related

ttk Treeview, get item's bbox after scrolling into view

I am working on an editable tkinter.ttk.Treeview subclass. For editing I need to place the edit widget on top of a choosen "cell" (list row/column). To get the proper coordinates, there is the Treeview.bbox() method.
If the row to be edited is not in view (collapsed or scrolled away), I cannot get its bbox obviously. Per the docs, the see() method is meant to bring an item into view in such a case.
Example Code:
from tkinter import Tk, Button
from tkinter.ttk import Treeview
root = Tk()
tv = Treeview(root)
tv.pack()
iids = [tv.insert("", "end", text=f"item {n}") for n in range(20)]
# can only get bbox once everything is on screen.
n = [0]
def show_bbox():
n[0] += 1
iid = iids[n[0]]
b = tv.bbox(iid)
if not b:
# If not visible, scroll into view and try again
tv.see(iid)
# ... but this still doesn't return a valid bbox!?
b = tv.bbox(iid)
print(f"bbox of item {n}", b)
btn = Button(root, text="bbox", command=show_bbox)
btn.pack(side="bottom")
root.mainloop()
(start, then click the button until you reach an invisible item)
The second tv.bbox() call ought to return a valid bbox, but still returns empty string. Apparently see doesnt work immediately, but enqeues the viewport change into the event queue somehow. So my code cannot just proceed synchronously as it seems.
How to solve this? Can see() be made to work immediately? If not, is there another workaround?
The problem is that even after calling see, the item isn't visible (and thus, doesn't have a bounding box) until it is literally drawn on the screen.
A simple solution is to call tv.update_idletasks() immediately after calling tv.see(), which should cause the display to refresh.
Another solution is to use tv.after to schedule the display of the box (or the overlaying of an entry widget) to happen after mainloop has a chance to refresh the window.
def print_bbox(iid):
bbox = tv.bbox(iid)
print(f"bbox of item {iid}", bbox)
def show_bbox():
n[0] += 1
iid = iids[n[0]]
tv.see(iid)
tv.after_idle(print_bbox, iid)

Adding GraphicsObject to two ViewBoxes or QGraphicsViews

Ultimately I want pyqtgraph to display a single GraphicsObject simultaneously in several ViewBoxes, sharing a single scene.
At the same time I want to have some other GraphicsObjects in a single ViewBox only.
Something like this:
vb0 = ViewBox()
vb1 = ViewBox()
# shown only in first ViewBox vb0
local_item = GraphicsObject()
# shown in all ViewBoxes
global_item = GraphicsObject()
vb0.addItem(local_item)
assert vb0.scene() is vb1.scene()
# the magic function i am looking for
vb0.scene().addItemGlobally(global_item)
So very naively I looked into the ViewBox sourcecode and reproduced the steps for addItem() like here:
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore as qc, QtGui as qg, QtWidgets as qw
class Polygon(pg.GraphicsObject):
"""Just a Triangle..."""
app = qw.QApplication([])
viewer = pg.GraphicsWindow()
vb0 = viewer.addViewBox(0, 0)
vb1 = viewer.addViewBox(0, 1)
viewer.show()
poly_yellow = Polygon((125, 125, 0, 255))
scene = vb0.scene()
added_items = vb1.addedItems = vb0.addedItems
child_group = vb1.childGroup = vb0.childGroup
child_group.setParent(scene)
child_group.itemsChangedListeners.append(vb1)
# here reproducing steps found in addItem()
if scene is not poly_yellow.scene():
scene.addItem(poly_yellow)
poly_yellow.setParentItem(child_group)
added_items.append(poly_yellow)
vb0.updateAutoRange()
# vb1.updateAutoRange()
# checking if ViewBoxes share relevant attributes
assert vb0.scene() is vb1.scene()
assert vb0.scene() is poly_yellow.scene()
assert vb0.addedItems is vb1.addedItems
assert vb0.childGroup is vb1.childGroup
app.exec_()
Running this gives me two ViewBoxes, but only vb0 showing the triangle. Also this approach would give me global only Items. Is there any way to get something like local/global items without re-implementing ViewBoxes completely?
EDIT: I think it is impossible to achieve what I want with pyqtgraph ViewBoxes. A transform of the global items must happen just before the painting.
I found it is not easily doable using ViewBox. However, it is possible to us pyqtgraphs GraphicsView, implementing a lot of functionality found in the ViewBox class already.
My approach now is to generate as many GraphicsView as I need, and set them all to one scene via GraphicsView.setScene()
The scene contains the 'global' items, displayed in every View. The Viewspecific, local Items are drawn using the GraphicsView.drawBackground() function.
I haven't tested it to much, but it seems quite good working, for the case, were several thousend items are added to the scene, but only few items are drawn to the background.

Splitting tkinter canvas and frame apart

Got a tkinter frame on the left being used for labels, checkbuttons, etc. On the right is a canvas displaying a map. I can scroll over the map and it will give the longitude/latitude coordinates of where the mouse pointer is located on the map at the time in question. I can click on the map and it will zoom in on the map. The problem is when I'm on the frame where I want to display underlying map data as I scroll the mouse across the frame the longitude/latitude changes, even though I'm not on the canvas. If I click on the frame, haven't put any checkbuttons on there yet to test it that way, it zooms right in just like it would over on the canvas.
Is there any way to split apart the action 'sensing' of the frame and canvas to keep them separate.
I would post up the code, a bit lengthy, but I got get out of here as I'm already running late.
Edit:
I'm back and thanks to Bryan's reply I think I understand what he was saying to do, just not sure how to do it. In a couple of attempts nothing seemed to work. Granted I'm still not fully sure of the (self,parent) 'addressing' method in the code below.
Also I see high probability coming up in the not to distant future of needing to be able to reference the mouse button to both t he canvas and the frame separately, aka have it do different things depending on where I have clicked on. Fortunately with the delay thanks to having to get out of here earlier and with Bryan's answer I have been able to shorten the code down even more and now have code that is doing exactly what I'm talking about. The delay in posting code worked to my benefit.
import tkinter as tk
from tkinter import *
class Example(tk.Frame):
def __init__(self, parent):
tk.Frame.__init__(self, parent)
self.frame = tk.Frame(self,bg='black', width=1366, height=714)
self.frame1 = tk.Frame(self,bg='gray', width=652, height=714)
self.frame.pack()
self.canvas = tk.Canvas(self, background="black", width=714, height=714)
self.canvas.pack_propagate(0)
self.canvas.place(x=652,y=0)
self.frame1.pack_propagate(0)
self.frame1.place(x=0,y=0)
self.longitudecenter = -95.9477127
self.latitudecenter = 36.989772
self.p = 57.935628
global v
s = Canvas(self, width=150, height=20)
s.pack_propagate(0)
s.place(x=0,y=695)
v = Label(s, bg='gray',fg='black',borderwidth=0,anchor='w')
v.pack()
parent.bind("<Motion>", self.on_motion)
self.canvas.focus_set()
self.canvas.configure(xscrollincrement=1, yscrollincrement=1)
def on_motion(self, event):
self.canvas.delete("sx")
self.startx, self.starty = self.canvas.canvasx(event.x),self.canvas.canvasy(event.y)
px = -(round((-self.longitudecenter + (self.p/2))- (self.startx * (self.p/714)),5))
py = round((self.latitudecenter + (self.p/2))-(self.starty * (self.p /714)),5)
v.config(text = "Longitude: " + str(px))
if __name__ == "__main__":
root = tk.Tk()
Example(root).pack(fill="both", expand=True)
root.mainloop()
This is part of what I've been using. How do I change it so I can bind to to the frame and to the canvas separately. Right now I only need, with the case of the mouse position, to be able to bind to the canvas, but in the future I will need to be able to use mouse clicks, maybe even mouse position separately on the canvas and frame.(who knows given how much this project has changed/advanced since I started it three weeks ago...the sky is the limit).
If you want a binding to only fire for a specific widget, but the binding on that widget rather than on a containing widget.
Change this:
parent.bind("<Motion>", self.on_motion)
To this:
self.canvas.bind("<Motion>", self.on_motion)

Tkinter Entry widget color fading

In my new application I want when the mouse is over the entry() widget to change color (this I know how to do it) but I want the color to change gradually, not immediately.
This is my code:
# User_Line Focus In/Out
def User_Line_Focus_In(self, event):
self.User_Line.configure(bg = "#DCDCDC")
def User_Line_Focus_Out(self, event):
self.User_Line.configure(bg = "#FFFFFF")
You need to create a method which increments the colour and you need to use tkinter's after which registers an alarm callback that is called after a given time. You then need to reference it recursively in order to get the fading effect you want.
def incrementHex(hex_str, increment): #with hex_str in format "#FFFFFF" or any colour
red = int(hex_str[1:3],16) #specifies base for integer conversion
green = int(hex_str[3:5],16)
blue = int(hex_str[5:],16)
red += increment #increment can be negative
green += increment
blue += increment
new_hex_str = "#" + str(hex(red)) + str(hex(blue)) + str(hex(green))
return new_hex_str
def Fade(self, start_hex, increment):
new_hex = self.incrementHex(start_hex, increment)
self.User_Line.configure(bg = new_hex)
#where self.master is the parent widget as defined in the __init__ method...
self.master.after(50,lambda: self.Fade(new_hex, increment)) #or any time interval in milliseconds
#you'll probably need some code to stop it fading here, but I'll let you tackle that one :)
def User_Line_Focus_In(self, event):
self.Fade("#FFFFFF",-1) #could be any colour and increment
I haven't been able to test it, but I think it should work in principle. An extension of this would be to have different increments for red, green and blue.
I think you are going to have to pull up your socks on this one and do some coding (tkinter doesn't have this built in)
So what you are looking for is :
An algorithm to go from color one to color two and get intermediate colors. (Hex values are just numbers in base 16 but they can be added or subtracted like normal numbers)
The simplest solution would be to just run the algorithm (color_difference here)
def fade_colors(event, new_color):
old_color = event.widget.cget('bg')
for color in color_difference(old_color, new_color):
event.widget.configure(color)
time.sleep(0.1)
widget.bind('<Enter>', lambda event: fade_colors(event, color))
You might also like to cancel the operation if the user leaves the widget. Take a look at the built in sched module.
If you find your gui becomes unresponsive during the fading you could consider using the after method, you can read this excellent blog post on non blocking gui techniques in python and tkinter. This may not be an issue if you cancel the callback as soon as the user leaves the widget (thus freeing up tkinter to handle his other actions)

PyQt/PySide How to access/move QGraphicsItem after having added it to QGraphicsScene

This might be a very uninformed question.
I've been trying to figure out QGraphics*, and have run into a problem when trying to move an item (a pixmap) relative to or inside of the QGraphicsView.
class MainWindow(QMainWindow,myProgram.Ui_MainWindow):
def __init__(self):
super().__init__()
self.setupUi(self)
self.scene = QGraphicsScene()
self.graphicsView.setScene(self.scene)
pic = QPixmap('myPic.png')
self.scene.addPixmap(pic)
print(self.scene.items())
This is the relevant part of the program with an arbitrary PNG
My goal, for example, would be to move the trash-can to the left-most part of the QGraphicsView.
I tried appending this to the code above:
pics = self.scene.items()
for i in pics:
i.setPos(100,100)
However, it doesn't make a difference, and even if it did, it would be awkward to have to search for it with a "for in".
So my questions are:
How do I access a QPixmap item once I have added it to a QGraphicsView via a QGraphicsScene. (I believe The QPixmap is at this point a QGraphicsItem, or more precisely a QGraphicsPixmapItem.)
Once accessed, how do I move it, or change any of its other attributes.
Would be very grateful for help, or if anyone has any good links to tutorials relevant to the question. :)
The problem is that you need to set the size of your scene and then set positions of the items, check this example:
from PyQt4 import QtGui as gui
app = gui.QApplication([])
pm = gui.QPixmap("icon.png")
scene = gui.QGraphicsScene()
scene.setSceneRect(0, 0, 200, 100) #set the size of the scene
view = gui.QGraphicsView()
view.setScene(scene)
item1 = scene.addPixmap(pm) #you get a reference of the item just added
item1.setPos(0,100-item1.boundingRect().height()) #now sets the position
view.show()
app.exec_()

Resources