We have worked an idea out that seems to have a wide usecase and there seems to be no examples for it on the web for python tkinter. Having unlimited space to draw can be crucial and can be done wrong in many ways (trust me I tried I few before coming up with this example). However, I'm a hobby coder and just tried (the best of my knowledge) to solve this task for high performance and memory efficiency.
I would like you to review especially the performance and if possible provide solutions for it. Other constructive criticism of course is welcomed too.
It also would be interesting for what is a good use-case for this code or how you would improve the widget overall ?
Hope you have fun with it, like I had.
import tkinter as tk
class InfiniteCanvas(tk.Canvas):
'''
Initial idea by Nordine Lofti
https://stackoverflow.com/users/12349101/nordine-lotfi
written by Thingamabobs
https://stackoverflow.com/users/13629335/thingamabobs
The infinite canvas allows you to have infinite space to draw.
You can move around the world as follows:
- MouseWheel for Y movement.
- Shift-MouseWheel will perform X movement.
- Alt-Button-1-Motion will perform X and Y movement.
(pressing ctrl while moving will invoke a multiplier)
Additional features to the standard tk.Canvas:
- Keeps track of the viewable area
--> Acess via InfiniteCanvas().viewing_box()
- Keeps track of the visibile items
--> Acess via InfiniteCanvas().inview()
- Keeps track of the NOT visibile items
--> Acess via InfiniteCanvas().outofview()
Also a new standard tag is introduced to the Canvas.
All visible items will have the tag "inview"
'''
def __init__(self, master, **kwargs):
super().__init__(master, **kwargs)
self._xshifted = 0 #view moved in x direction
self._yshifted = 0 #view moved in y direction
self.configure(
confine=False, highlightthickness=0, bd=0)
self.bind('<MouseWheel>', self._vscroll)
self.bind('<Shift-MouseWheel>', self._hscroll)
self.winfo_toplevel().bind(
'<KeyPress-Alt_L>', self._alternate_cursor)
self.winfo_toplevel().bind(
'<KeyRelease-Alt_L>', self._alternate_cursor)
self.bind('<ButtonPress-1>', self._start_drag_scroll)
self.bind('<Alt-B1-Motion>', self._drag_scroll)
return None
def viewing_box(self) -> tuple:
'Returns a tuple of the form x1,y1,x2,y2 represents visible area'
x1 = 0 - self._xshifted
y1 = 0 - self._yshifted
x2 = self.winfo_reqwidth()-self._xshifted
y2 = self.winfo_reqheight()-self._yshifted
return x1,y1,x2,y2
def inview(self) -> set:
'Returns a set of identifiers that are currently viewed'
return set(self.find_overlapping(*self.viewing_box()))
def outofview(self) -> set:
'Returns a set of identifiers that are currently viewed'
all_ = set(self.find_all())
return all_ - self.inview()
def _alternate_cursor(self, event):
if (et:=event.type.name) == 'KeyPress':
self.configure(cursor='fleur')
elif et == 'KeyRelease':
self.configure(cursor='')
def _update_tags(self):
vbox = self.viewing_box()
self.addtag_overlapping('inview',*vbox)
inbox = set(self.find_overlapping(*vbox))
witag = set(self.find_withtag('inview'))
[self.dtag(i, 'inview') for i in witag-inbox]
self.viewing_box()
def _create(self, *args):
ident = super()._create(*args)
self._update_tags()
return ident
def _wheel_scroll(self, xy, amount):
cx,cy = self.winfo_rootx(), self.winfo_rooty()
self.scan_mark(cx, cy)
if xy == 'x': x,y = cx+amount, cy
elif xy == 'y': x,y = cx, cy+amount
name = f'_{xy}shifted'
setattr(self,name, getattr(self,name)+amount)
self.scan_dragto(x,y, gain=1)
self._update_tags()
def _drag_scroll(self,event):
xoff = event.x-self._start_drag_point_x
yoff = event.y-self._start_drag_point_y
self._xshifted += xoff
self._yshifted += yoff
gain = 1
if (event.state & 0x4) != 0: #if ctr/strg
gain = 2
self.scan_dragto(event.x, event.y, gain=gain)
self._start_drag_point_x = event.x
self._start_drag_point_y = event.y
self._update_tags()
def _start_drag_scroll(self,event):
self._start_drag_point_x = event.x
self._start_drag_point_y = event.y
self.scan_mark(event.x,event.y)
return
def _hscroll(self,event):
offset = int(event.delta/120)
if (event.state & 0x4) != 0: #if ctr/strg
offset = int(offset*10)
self._wheel_scroll('x', offset)
def _vscroll(self,event):
offset = int(event.delta/120)
if (event.state & 0x4) != 0:#if ctr/strg
offset = int(offset*10)
self._wheel_scroll('y', offset)
if __name__ == '__main__':
root = tk.Tk()
canvas = InfiniteCanvas(root)
canvas.pack(fill=tk.BOTH, expand=True)
size, offset, start = 100, 10, 0
canvas.create_rectangle(start,start, size,size, fill='green')
canvas.create_rectangle(
start+offset,start+offset, size+offset,size+offset, fill='darkgreen')
root.mainloop()
2 Answers 2
The code looks good and performance wise it is also pretty good, could handle about 1000 objects with relatively ease (of course depends on hardware and complexity of objects).
Some suggestions for improving the widget, avoid configuring the widget in __init__
, since the user might want to add border, only configure options that may destroy the functionality of the widget. If you want another default value use setdefault on kwargs. For example:
kwargs.setdefault("highlightthickness", 0)
kwargs.setdefault("confine", False)
kwargs.setdefault("bd", 0)
super().__init__(master, **kwargs)
If the user want different scroll step sizes, add those as arguments when creating and configuring the InfiniteCanvas.
I tried adding a zoom functionality which worked great with the code you already have, first bind as usual self.bind('<Alt-MouseWheel>', self._zoom)
then the function:
def _zoom(self, event: tk.Event):
zoom = self._zoom_step ** int(event.delta/120)
canvas.scale("all", self.canvasx(event.x), self.canvasy(event.y), zoom, zoom)
The self._zoom_step
is just a user defined zooming speed, which in my case I used the default value of 1.2.
I see you have used type hints for all public functions, I would also suggest using type hints for all functions, both arguments and return type, if someone want to extend the widget. You could also be more precise on what is returned, e.g., tuple[int, int, int, int]
.
The bind on toplevel is a bit dangerous, for example if you want multiple InfiniteCanvas. You could use add=True
to ensure it doesn't replace previous bindings, but then you need to remove them properly on destroy. Unfortunately I do not have any other ideas for handling this, but thought I could at least mention it.
I have before created map applications using e.g. https://www.openstreetmap.org/, and using the InfiniteCanvas with zoom would definitely be interesting.
-
1\$\begingroup\$ I do like the idea of zooming and you are right about the typehints, would have been more consistent. A Map-Application would be a great choice for it, indeed. However, I disagree with the options. The
confine = False
takes care of a eventual specifiedscrollregion
which would brake the functionality, similar forborderwidth
, since it wouldn't be considered inviewing_box
, that's an issue I didn't fixed yet. Also agree with thebind
, however I guess it's not that easy to fix without another tradeoff, but will think about it. Thanks for taking the time and response. \$\endgroup\$Thingamabobs– Thingamabobs2023年02月13日 17:42:59 +00:00Commented Feb 13, 2023 at 17:42 -
\$\begingroup\$ have provided an updated example based on your review and other things I have noticed. \$\endgroup\$Thingamabobs– Thingamabobs2023年02月19日 12:28:57 +00:00Commented Feb 19, 2023 at 12:28
Reworked code:
import tkinter as tk
class InfiniteCanvas(tk.Canvas):
'''
Initial idea by Nordine Lofti
https://stackoverflow.com/users/12349101/nordine-lotfi
written by Thingamabobs
https://stackoverflow.com/users/13629335/thingamabobs
with additional ideas by patrik-gustavsson
https://stackoverflow.com/users/4332183/patrik-gustavsson
The infinite canvas allows you to have infinite space to draw.
ALL BINDINGS ARE JUST AVAILABLE WHEN CANVAS HAS FOCUS!
FOCUS IS GIVEN WHEN YOU LEFT CLICK ONTO THE CANVAS!
You can move around the world as follows:
- MouseWheel for Y movement.
- Shift-MouseWheel will perform X movement.
- Alt-Button-1-Motion will perform X and Y movement.
(pressing ctrl while moving will invoke a multiplier)
You can zoom in and out with:
- Alt-MouseWheel
(pressing ctrl will invoke a multiplier)
Additional features to the standard tk.Canvas:
- Keeps track of the viewable area
--> Acess via InfiniteCanvas().viewing_box()
- Keeps track of the visibile items
--> Acess via InfiniteCanvas().inview()
- Keeps track of the NOT visibile items
--> Acess via InfiniteCanvas().outofview()
Also a new standard tag is introduced to the Canvas.
All visible items will have the tag "inview"
Notification bindings:
"<<ItemsDropped>>" = dropped items stored in self.dropped
"<<ItemsEntered>>" = entered items stored in self.entered
"<<VerticalScroll>>"
"<<HorizontalScroll>>"
"<<Zoom>>"
"<<DragView>>"
'''
def __init__(self, master, **kwargs):
super().__init__(master, **kwargs)
self._xshifted = 0 #view moved in x direction
self._yshifted = 0 #view moved in y direction
self._use_multi = False #Multiplier for View-manipulation
self.configure(confine=False) #confine=False ignores scrollregion
self.dropped = set() #storage
self.entered = set() #storage
#NotificationBindings
self.event_add('<<VerticalScroll>>', '<MouseWheel>')
self.event_add('<<HorizontalScroll>>', '<Shift-MouseWheel>')
self.event_add('<<Zoom>>', '<Alt-MouseWheel>')
self.event_add('<<DragView>>', '<Alt-B1-Motion>')
self.bind(#MouseWheel
'<<VerticalScroll>>', lambda e:self._wheel_scroll(e,'y'))
self.bind(#Shift+MouseWheel
'<<HorizontalScroll>>', lambda e:self._wheel_scroll(e,'x'))
self.bind(#Alt+MouseWheel
'<<Zoom>>', self._zoom)
self.bind(#Alt+LeftClick+MouseMovement
'<<DragView>>', self._drag_scroll)
self.event_generate('<<ItemsDropped>>') #invoked in _update_tags
self.event_generate('<<ItemsEntered>>') #invoked in _update_tags
## self.bind('<<ItemsDropped>>', lambda e:print('d',self.dropped))
## self.bind('<<ItemsEntered>>', lambda e:print('e',self.entered))
#Normal bindings
self.bind(#left click
'<ButtonPress-1>', lambda e:e.widget.focus_set())
self.bind(
'<KeyPress-Alt_L>', self._prepend_drag_scroll, add='+')
self.bind(
'<KeyRelease-Alt_L>', self._prepend_drag_scroll, add='+')
self.bind(
'<KeyPress-Control_L>', self._configure_multi)
self.bind(
'<KeyRelease-Control_L>', self._configure_multi)
return None
def viewing_box(self) -> tuple:
'Returns a tuple of the form x1,y1,x2,y2 represents visible area'
off = (int(self.cget('highlightthickness'))
+int(self.cget('borderwidth')))
x1 = 0 - self._xshifted+off
y1 = 0 - self._yshifted+off
x2 = self.winfo_width()-self._xshifted-off-1
y2 = self.winfo_height()-self._yshifted-off-1
return x1,y1,x2,y2
def inview(self) -> set:
'Returns a set of identifiers that are currently viewed'
return set(self.find_overlapping(*self.viewing_box()))
def outofview(self) -> set:
'Returns a set of identifiers that are currently NOT viewed'
all_ = set(self.find_all())
return all_ - self.inview()
def _configure_multi(self, event):
if (et:=event.type.name) == 'KeyPress':
self._use_multi = True
elif et == 'KeyRelease':
self._use_multi = False
def _zoom(self,event):
if str(self.focus_get()) == str(self):
x = canvas.canvasx(event.x)
y = canvas.canvasy(event.y)
multiplier = 1.005 if self._use_multi else 1.001
factor = multiplier ** event.delta
canvas.scale('all', x, y, factor, factor)
self._update_tags()
def _prepend_drag_scroll(self, event):
if (et:=event.type.name) == 'KeyPress':
self._recent_drag_point_x = event.x
self._recent_drag_point_y = event.y
self.scan_mark(event.x,event.y)
self.configure(cursor='fleur')
elif et == 'KeyRelease':
self.configure(cursor='')
self._recent_drag_point_x = None
self._recent_drag_point_y = None
def _update_tags(self):
vbox = self.viewing_box()
old = set(self.find_withtag('inview'))
self.addtag_overlapping('inview',*vbox)
inbox = set(self.find_overlapping(*vbox))
witag = set(self.find_withtag('inview'))
self.dropped = witag-inbox
if self.dropped:
[self.dtag(i, 'inview') for i in self.dropped]
self.event_generate('<<ItemsDropped>>')
new = set(self.find_withtag('inview'))
self.entered = new-old
if self.entered:
self.event_generate('<<ItemsEntered>>')
def _create(self, *args):
ident = super()._create(*args)
self._update_tags()
return ident
def _wheel_scroll(self, event, xy):
if str(self.focus_get()) == str(self):
parsed = int(event.delta/120)
amount = parsed*10 if self._use_multi else parsed
cx,cy = self.winfo_rootx(), self.winfo_rooty()
self.scan_mark(cx, cy)
if xy == 'x': x,y = cx+amount, cy
elif xy == 'y': x,y = cx, cy+amount
name = f'_{xy}shifted'
setattr(self,name, getattr(self,name)+amount)
self.scan_dragto(x,y, gain=1)
self._update_tags()
def _drag_scroll(self,event):
if str(self.focus_get()) == str(self):
self._xshifted += event.x-self._recent_drag_point_x
self._yshifted += event.y-self._recent_drag_point_y
gain = 2 if self._use_multi else 1
self.scan_dragto(event.x, event.y, gain=gain)
self._recent_drag_point_x = event.x
self._recent_drag_point_y = event.y
self.scan_mark(event.x,event.y)
self._update_tags()
if __name__ == '__main__':
root = tk.Tk()
canvas = InfiniteCanvas(root)
canvas.pack(fill=tk.BOTH, expand=True)
size, offset, start = 100, 10, 0
canvas.create_rectangle(start,start, size,size, fill='green')
canvas.create_rectangle(
start+offset,start+offset, size+offset,size+offset, fill='darkgreen')
root.after(100, lambda:print(canvas.viewing_box()))
root.mainloop()