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()
\$\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.

\$\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()
\$\endgroup\$

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.