Creating scrollable list with python and PyQt - python-3.x

I have a QScrollArea with a QWidget (content_widget) inside in my application. I want to fill it with images while the application is running. I would like to have the scroll behavior if the number of images exceeds the number which can be shown without scrolling. I came this far:
children_occupied_width = self.ui.content_widget.childrenRect().width()
image_view = QtGui.QGraphicsView(self.ui.content_widget)
image_view.setGeometry(QtCore.QRect( children_occupied_width, 0, 210, 210 ))
image_view.setObjectName("New Image")
placeholder_image = QtGui.QGraphicsScene()
placeholder_image.addPixmap(QtGui.QPixmap('placeholder.png'))
image_view.setScene(placeholder_image)
image_view.show()
Although the images appear in the list at the right position there is no scrolling if the images start to be placed outside of the visible area. The size of content_widget seems not to change even with
self.ui.content_widget.resize(...)
or
self.ui.content_widget.adjustSize()
How to make it grow/resize?

This first part turned out to not be what you really needed. I am leaving it for informational purposes, but see the update at the bottom
The problem is that the QGraphicsView is itself a type of scroll widget, representing a slice of the scene it is viewing. Ideally you would just use the view by itself and make use of its own scrollbars.
But if you have specific need to forward the content size of the view up to a normal QWidget scroll, then what you would need to do is make your QGraphicsView always resize itself when the contents of the scene change. The QScrollArea is only going to respond to size changes of the widget it is set to. The view needs to change size. The process would be that the view needs to listen to signals from the scene for added or removed items, and then resize itself to completely enclose all of those children.
Here is an example of how the QGraphicsView widget, on its own, is perfectly capable of serving as the scroll functionality:
from PyQt4 import QtCore, QtGui
app = QtGui.QApplication([])
scene = QtGui.QGraphicsScene()
view = QtGui.QGraphicsView()
view.setScene(scene)
view.resize(600,300)
pix = QtGui.QPixmap("image.png")
w,h = pix.width(), pix.height()
x = y = 0
pad = 5
col = 3
for i in xrange(1,20):
item = scene.addPixmap(pix)
item.setPos(x,y)
if i % col == 0:
x = 0
y += h+pad
else:
x+=w+pad
view.show()
view.raise_()
app.exec_()
You can see that when the images overflow the current size of the view, you get scrollbars.
If you really need to have some parent scroll area acting as the scroll for the view (for reasons I do not really understand), then here is a more complex example showing how you would need to watch for some event on the scene and then constantly update the size of the view to force scrollbars on the parent scrollarea. I have chosen to watch a signal on the scene for when its rect changes (more children are added)
from PyQt4 import QtCore, QtGui
app = QtGui.QApplication([])
win = QtGui.QDialog()
win.resize(600,300)
layout = QtGui.QVBoxLayout(win)
scroll = QtGui.QScrollArea()
scroll.setWidgetResizable(True)
layout.addWidget(scroll)
view = QtGui.QGraphicsView(scroll)
view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
scene = QtGui.QGraphicsScene(scroll)
view.setScene(scene)
scroll.setWidget(view)
pix = QtGui.QPixmap("image.png")
w,h = pix.width(), pix.height()
x = y = i = 0
pad = 5
col = 3
def createImage():
global x,y,i
item = scene.addPixmap(pix)
item.setPos(x,y)
i+=1
if i % col == 0:
x = 0
y += h+pad
else:
x+=w+pad
def changed():
size = scene.itemsBoundingRect().size()
view.setMinimumSize(size.width()+pad, size.height()+pad)
scene.changed.connect(changed)
t = QtCore.QTimer(win)
t.timeout.connect(createImage)
t.start(500)
win.show()
win.raise_()
app.exec_()
The scroll area is always looking at the size of the widget that has set as the child. The view must be resized.
Update
As it turns out from your comments, you didn't really need to use the QGraphics objects for this approach. It only made your task more complicated. Simply use a vertical layout and add QLabel widgets to it:
from PyQt4 import QtCore, QtGui
app = QtGui.QApplication([])
win = QtGui.QDialog()
win.resize(300,300)
layout = QtGui.QVBoxLayout(win)
scroll = QtGui.QScrollArea()
scroll.setWidgetResizable(True)
layout.addWidget(scroll)
scrollContents = QtGui.QWidget()
layout = QtGui.QVBoxLayout(scrollContents)
scroll.setWidget(scrollContents)
pix = QtGui.QPixmap("image.png")
def createImage():
label = QtGui.QLabel()
label.setPixmap(pix)
layout.addWidget(label)
t = QtCore.QTimer(win)
t.timeout.connect(createImage)
t.start(500)
win.show()
win.raise_()
app.exec_()

I found at least a reason why resize did not worked in my code. Setting widgetResizable property of QScroll area to false (or disabling it in the Qt Designer) made it work.

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)

Making parts of canvas transparent while still detecting and blocking mouse clicks in transparent areas in tkinter?

I'm trying to make a program where the user can paint on the screen. So I want to make an invisible canvas window in fullscreen where only the user's pen marks on the canvas will be visible. The closest thing I found is this function: root.attributes("-transparentcolor","color code here"), which will make all the parts of the window that's in the color you give transparent. So if I give the second parameter the background color of the canvas, then only the pen strokes on the canvas will be visible. This is so close to what I want, except for one thing, the transparent areas can't detect or block mouse clicks! Any mouse clicks will just go through to whatever is behind the tkinter window. Is there a way to make it so the transparent areas will still block mouse clicks? I really need help on this!
Here is a much better way to do this using only tkinter. Explanation is in code comments. Basically uses two windows, one for "blocking" the mouse and being transparent using the "-alpha" attribute and the other window for "hosting" canvas and having one completely transparent color while keeping others opaque using "-transparentcolor" attribute. That also means that this is cross-platform solution too (except I think the -transparentcolor attribute differs a little bit on other OS like Linux where I think it is -splash or sth and maybe something different on MacOS):
from tkinter import Tk, Toplevel, Canvas
# setting the starting coordinate of the line so that
# on motion it is possible to immediately draw it
def set_first(event):
points.extend([event.x, event.y])
# on motion append new coordinates to the list and if there are
# 4 (the minimum), create a new line and save the id
# otherwise update the existing line
def append_and_draw(event):
global line
points.extend([event.x, event.y])
if len(points) == 4:
line = canvas.create_line(points, **line_options)
else:
canvas.coords(line, points)
# when released clear the list to not waste space
# and not necessarily but also set "id" to None
def clear_list(event=None):
global line
points.clear()
line = None
line = None # this is a reference to the current line (id)
points = [] # list to keep track of current line coordinates
line_options = {} # dictionary to allow easier change of line options
# just a variable to more easily store the transparent color
transparent_color = 'grey15'
# creating the root window which will help with drawing the line
# because it will "block" mouse because `-alpha` (0.01 seems to be the lowest value)
# attribute is used, however it makes everything transparent on the window
# so need another window to "host" the canvas
root = Tk()
root.attributes('-alpha', 0.01)
root.attributes('-topmost', True)
root.attributes('-fullscreen', True)
# just press Esc key to close the whole thing, otherwise
# it is only doable by pressing Alt + F4 or turning off
# the computer
root.bind('<Escape>', lambda e: root.quit())
# create the host window, because it allows to have only
# one transparent color while keeping the other opaque and
# visible
top = Toplevel(root)
top.attributes('-transparentcolor', transparent_color)
top.attributes('-topmost', True)
top.attributes('-fullscreen', True)
# set the focus to root because that is where events are bound
root.focus_set()
# create the canvas to draw on
canvas = Canvas(top, bg=transparent_color, highlightthickness=0)
canvas.pack(fill='both', expand=True)
# bind all the events to `root` which "blocks" mouse
# but is also almost (because it has a very small alpha value
# it is not entirely invisible but human eye won't notice much)
# invisible
root.bind('<Button-1>', set_first)
root.bind('<B1-Motion>', append_and_draw)
root.bind('<ButtonRelease-1>', clear_list)
root.mainloop()
Here is an improvable example (you may need to pip install pyautogui, ctypes is a built-in library), it is also Windows only as far as I know:
Note: The other answer using two windows, however, is a lot better but I will keep this too just for the information.
from tkinter import Tk, Canvas
import pyautogui as pag
import ctypes
data = {
'draw': True,
'cur_line_points': [],
'cur_line_id': None
}
# function taken mainly from here: https://stackoverflow.com/a/46596592/14531062
def is_pressed(btn: str = 'left') -> bool:
if btn == 'left':
btn = 0x01
elif btn == 'right':
btn = 0x02
else:
raise Warning("incorrect argument, should be 'left' or 'right'")
return ctypes.windll.user32.GetKeyState(btn) not in (0, 1)
def draw_line(canvas_):
if not data['draw']:
root.after(10, draw_line, canvas_)
return
pressed = is_pressed('left')
cur_line_points = data['cur_line_points']
cur_line_id = data['cur_line_id']
if not pressed:
if cur_line_id is not None:
canvas_.coords(cur_line_id, cur_line_points)
data['cur_line_id'] = None
cur_line_points.clear()
else:
mouse_x, mouse_y = pag.position()
cur_line_points.extend((mouse_x, mouse_y))
len_points = len(cur_line_points)
if len_points == 4:
data['cur_line_id'] = canvas_.create_line(cur_line_points)
elif len_points > 4:
canvas_.coords(cur_line_id, cur_line_points)
root.after(10, draw_line, canvas_)
transparent_color = 'grey15'
root = Tk()
root.config(bg=transparent_color)
root.attributes('-transparentcolor', transparent_color)
root.attributes('-topmost', True)
root.attributes('-fullscreen', True)
canvas = Canvas(root, bg=transparent_color, highlightthickness=0)
canvas.pack(fill='both', expand=True)
draw_line(canvas)
root.mainloop()
Basically detects if mouse button is pressed using the built-in library ctypes and if it is adds the current mouse coordinates (does that using pyautogui library which may need be installed) to a list and then draws a line based on that list (it also keeps the reference of the currently drawn line and simply changes its coordinates instead of drawing a new line each time it loops), the only slight issue is that while drawing the mouse is also interacting with the window below, highlighting text and stuff, couldn't really figure out how to remove that yet but at least you get to draw a line.

Inherit subframes in widget creation auto sizing (tkinter, python 3)

What I am trying to do is:
Create a main frame (pc_gui) and two sub frames (video_frame) and (side_frame) respectively.
Allow the user to be able to resize the application window to suit and have all frames, widgets, images, etc. resize accordingly.
Reference frames elsewhere in the gui.
In attempt to keep things tidy, possibly in error, I have an Application class, as well as functions: init, app_size, create_frames, create_widgets, and an updater function. There is quite a bit more, but if I can get this core part to work right I should be able to keep making progress.
This begins with the relatively standard tkinter UI initialization which includes the starting size of the application window.
# Initialize Application Window
pc_gui = tk.Tk()
pc_gui.geometry("1280x720")
# Initialize Updater Variables
UPDATE_RATE = 1000
# Run application
app = Application(pc_gui)
pc_gui.mainloop()
Then I create the class, main frame (pc_gui), and I call the various sub frame, application size, and images functions.
class Application(tk.Frame):
""" GUI """
def __init__(self, pc_gui):
""" Initialize the frame """
tk.Frame.__init__(self, pc_gui)
self.app_size()
self.grid()
self.create_frames()
self.create_images()
self.updater()
This is the app_size function. It's purpose is to parse the current size of the application window and the display monitor assuming the user will change these to suit while the program is running.
def app_size(self):
pc_gui.update_idletasks() # Get Current Application Values
app_width = pc_gui.winfo_width() # Current Application Width
app_height = pc_gui.winfo_height() # Current Application Height
disp_width = pc_gui.winfo_screenwidth() # Monitor (Screen) Width
disp_height = pc_gui.winfo_screenheight() # Monitor (Screen) Height
return app_width, app_height, disp_width, disp_height
Then I create the sub frames using the app_size values. The colors are simply there to help me keep track of things during development.
def create_frames(self):
""" Create Frames """
app_size = self.app_size() # Get size wxh of application window and display
app_width = app_size[0]
app_height = app_size[1]
disp_width = app_size[2]
disp_height = app_size[3]
geometry = "%dx%d" % (app_width, app_height) # Create text value for geometry
pc_gui.geometry(geometry) # Set Application Window Size
# Section of Application window dedicated to source video
video_frame = tk.Frame(
master = pc_gui,
width = app_width*.75,
height = app_height,
bg = "blue"
)
video_frame.place(
x = 0,
y = 0
)
# Section of Application window dedicated to calculations
side_frame = tk.Frame(
master = pc_gui,
width = app_width*.25,
height = app_height,
bg = "red"
)
side_frame.place(
x = app_width*.75,
y = 0
)
pc_gui.update_idletasks()
sf_x = side_frame.winfo_x()
sf_y = side_frame.winfo_y()
sf_w = side_frame.winfo_width()
sf_h = side_frame.winfo_height()
return sf_x, sf_y, sf_w, sf_h
def updater(self):
#self.create_frames() # Update Application Window
self.create_images() # Update Images Widget
self.after(UPDATE_RATE, self.updater) # Call "updater" function after 1 second
Then I start creating widgets. For the image (Label) widgets I would like to use grid, but I am having the hardest time figuring out how to reference the sub frame (side_frame) in the create_images function. I have truncated the important bits to make this question more to the point.
def create_images(self):
sf_dims = self.create_frames()
sf_x = sf_dims[0]
sf_y = sf_dims[1]
sf_w = sf_dims[2]
sf_h = sf_dims[3]
...
fp1_lbl = tk.Label(
master = pc_gui,
image = fp1
)
fp1_lbl.image = fp1
fp1_lbl.place(
x=sf_x + img_padding,
y=sf_y + 20,
anchor='nw'
)
Everything up to this point "works" admittedly rather inefficiently. What I would like to do is not have those sf_x, _y, _w, and _h hanging out there in the wind and it follows that I would also not have to call the frame function from widget function in order to get them.
The reason is that I feel it's not in the right spirit of python frames as I would like to use Grid on the side frame only but create (or use) Grid from the widget function (the point of creating the side_frame in the first place) and I would prefer to only refresh the sub parts of the application window that need to be refreshed, and not the whole smash every 1 second.
What ends up happening is the images flicker every 1 second, even when nothing needs to be updated and while the images do resize according to the application window I am doing this with the Place functionality and effectively ignoring the existence of side_frame.
My current attempts have been around the following
fp1_lbl = tk.Label(
master = self.side_frame,
image = fp1
)
fp1_lbl.image = fp1
fp1_lbl.place(
x=sf_x + img_padding,
y=sf_y + 20,
anchor='nw'
)
I get versions of the following error:
AttributeError: 'Application' object has no attribute 'side_frame'
You need to name things with the "self" prefix in order to use them in other methods. So when you create the subframes:
# Section of Application window dedicated to calculations
self.side_frame = tk.Frame(
master = pc_gui,
width = app_width*.25,
height = app_height,
bg = "red"
)
self.side_frame.place(
x = app_width*.75,
y = 0
)
Now you can use them anywhere in your class as you have tried:
fp1_lbl = tk.Label(
master = self.side_frame,
image = fp1
)
Remember there is no implied relationship between a variable named foo and self.foo. They are 2 completely unrelated names.
As for your resizing, tkinter will do that for you. You can use the relwidth and relheight arguments to set the width and height to a fraction between 0 and 1. Here's a demo:
import tkinter as tk
class Application(tk.Frame):
""" GUI """
def __init__(self, pc_gui):
""" Initialize the frame """
tk.Frame.__init__(self, pc_gui)
self.create_frames()
def create_frames(self):
# insert the subframes in *this* Frame (self), not in the master Frame
self.video_frame = tk.Frame(self, bg = "blue")
self.video_frame.place(relheight=1.0, relwidth=.25)
self.side_frame = tk.Frame(self, bg = "red")
self.side_frame.place(relheight=1.0, relwidth=.75, relx=1.0, anchor='ne')
pc_gui = tk.Tk()
pc_gui.geometry("1280x720")
win = Application(pc_gui)
win.pack(fill=tk.BOTH, expand=True)
tk.Button(pc_gui, text = "Don't click me!").pack()
pc_gui.mainloop()

Is it possible to make Tkinter scrollbars move independently of each other in different Toplevel windows?

Imagine there are two Tkinter.Toplevel() windows, called Window_1 and Window_2, which can be opened by clicking the same button (lets called Button_0).
Button_0 is pressed and Window_1 pops up. In Window_1, I can scroll up and down using a mouse pad (MAC OS). After that, I left Window_1 open.
Button_0 is pressed again and Window_2 pops up, while Window_1 stays open. In Window_2, I can again scroll up and down.
Now, I go back to Window_1 and try to scroll using mouse pad, contents in Window_1 DO NOT MOVE, but contents in Window_2 DO MOVE.
Then I close Window_2, and try to scroll on Window_1, this time I got error messages asking for a canvas on Window_2.
I did bind function,
def on_vertical(canvas,event):
canvas.yview_scroll(-3 * event.delta, 'units')
to a canvas inside each windows. As far as I know about the error, it seems that this function could not be used twice at the same time (both windows are opened).
I would like the way that when both Windows stay open. While on each window, I can scroll up-down while the another one do not move. Is it possible to code that?
This is the code example (please do noted that the Window name is not corrected label.)
from tkinter import *
######################## FUNCTIONS (DEF) ########################
def on_vertical(canvas,event):
canvas.yview_scroll(-3 * event.delta, 'units')
######################## FUNCTIONS (CLASS) ########################
class Window(Frame):
def __init__(self, master=None):
Frame.__init__(self, master)
self.master = master
self.init_window()
#INITIAL WINDOW
def init_window(self):
self.master.title("Main Window")
self.pack(fill=BOTH, expand=1)
Button(self, text="Button_0",command = self.load_and_print).place(x = 7, y = 95)
# creating a button instance
Button(self, text="EXIT PROGRAM", command=self.client_exit).place(x=500, y=250)
#OPEN A NEW WINDOW CONTAINING STOCK LISTS
def load_and_print(self):
new_window = Toplevel(self)
new_window.title("Window")
canvas = Canvas(new_window, width = 800, height = 500, scrollregion = (0, 0, 0, 2500))
frame = Frame(canvas)
vbar = Scrollbar(new_window, orient = VERTICAL, command = canvas.yview)
vbar.pack(side = RIGHT,fill = Y)
canvas.create_window(0,0, window = frame, anchor = NW)
canvas.config(yscrollcommand = vbar.set)
canvas.pack(side = TOP,expand = True,fill = BOTH)
canvas.bind_all('<MouseWheel>', lambda event, canvas=canvas: on_vertical(canvas,event))
#MAKE PROGRAM EXIT
def client_exit(self):
exit()
######################## MAIN PROGRAMME ########################
#call window
root = Tk()
#size of the window
root.geometry("700x300")
app = Window(root)
root.mainloop()
root.update()
The problem is that you are using bind_all instead of bind for the mousewheel event.
Because you're using bind_all, each time you create a new window it replaces the old binding with a new binding. No matter which window gets the event, your function will always only work for the last window to be created. And, of course, when that window is destroyed then the mouse binding will throw an error since the canvas no longer exists.
Using bind
One solution is simple: use bind instead of bind_all.
canvas.bind_all('<MouseWheel>', lambda event, canvas=canvas: on_vertical(canvas,event))
Using bind_all
If you want the benefits of bind_all -- namely, that the scrolling works even if the mouse is over some other widget, you need to modify on_vertical to figure out which canvas to scroll at the time that it is running instead of having the canvas being passed in.
You can do that with a little bit of introspection. For example, the event object knows which widget received the event. From that you can figure out which window the mouse is in, and from that you can figure out which canvas to scroll.
For example, move the binding up to the __init__ and change it like this:
self.bind_all('<MouseWheel>', on_vertical)
Next, change on_vertical to figure out which canvas to scroll. In the following example I assume each toplevel has exactly one canvas and that you always want to scroll that canvas (ie: you lose the ability to scroll text widgets and listboxes)
If that's not the case, you can add whatever logic you want to figure out which widget to scroll.
def on_vertical(event):
top = event.widget.winfo_toplevel()
for child in top.winfo_children():
if child.winfo_class() == "Canvas":
child.yview_scroll(-3 * event.delta, 'units')
break

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