8
\$\begingroup\$

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()
asked Feb 12, 2023 at 11:02
\$\endgroup\$

2 Answers 2

4
\$\begingroup\$

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.

answered Feb 13, 2023 at 17:32
\$\endgroup\$
2
  • 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 specified scrollregion which would brake the functionality, similar for borderwidth, since it wouldn't be considered in viewing_box, that's an issue I didn't fixed yet. Also agree with the bind, 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\$ Commented Feb 13, 2023 at 17:42
  • \$\begingroup\$ have provided an updated example based on your review and other things I have noticed. \$\endgroup\$ Commented Feb 19, 2023 at 12:28
1
\$\begingroup\$

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()
answered Feb 19, 2023 at 12:27
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.