Search code examples
pythontkintercanvastk-toolkit

tkinter - Infinite Canvas "world" / "view" - keeping track of items in view


I feel like this is a little bit complicated or at least I'm confused on it, so I'll try to explain it by rendering the issue. Let me know if the issue isn't clear.


I get the output from my viewing_box through the __init__ method and it shows:
(0, 0, 378, 265)
Which is equivalent to a width of 378 and a height of 265.

When failing, I track the output:

1 false
1 false
here ([0.0, -60.0], [100.0, 40.0]) (0, 60, 378, 325)

The tracking is done in _scan_view with the code:

            if not viewable:
                current = self.itemcget(item,'tags')
                if isinstance(current, tuple):
                    new = current-('viewable',)
                else:
                    print('here',points, (x1,y1,x2,y2))
                    new = ''
                    self.inview_items.discard(item)

So the rectangle stays with width and height of 100, the coords however failing to be the expected ones. While view width and height stays the same and moves correctly in my current understanding. Expected:
if x1 <= point[0] <= x2 and y1 <= point[1] <= y2: and it feels like I've created two coordinate systems but I don't get it. Is someone looking on it and see it immediately?

Full Code:

import tkinter as tk

class InfiniteCanvas(tk.Canvas):

    def __init__(self, master, **kwargs):
        super().__init__(master, **kwargs)
        self.inview_items   = set()  #in view
        self.niview_items   = set()  #not in view
        self._xshifted      = 0     #view moved in x direction
        self._yshifted      = 0     #view moved in y direction
        self._multi         = 0
        self.configure(confine=False,highlightthickness=0,bd=0)
        self.bind('<MouseWheel>',       self._vscroll)
        self.bind('<Shift-MouseWheel>', self._hscroll)
        root.bind('<Control-KeyPress>',lambda e:setattr(self,'_multi', 10))
        root.bind('<Control-KeyRelease>',lambda e:setattr(self,'_multi', 0))
        print(self.viewing_box())
        return None

    def viewing_box(self):
        'returns x1,y1,x2,y2 of the currently 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 _scan_view(self):
        x1,y1,x2,y2 = self.viewing_box()
        for item in self.find_withtag('viewable'):
            #check if one felt over the edge
            coords = self.coords(item)
            #https://www.geeksforgeeks.org/python-split-tuple-into-groups-of-n/
            points = tuple(
                coords[x:x + 2] for x in range(0, len(coords), 2))
            viewable = False
            for point in points:
                if x1 <= point[0] <= x2 and y1 <= point[1] <= y2:
                    #if any point is in viewing box
                    viewable = True
                    print(item, 'true')
                else:
                    print(item, 'false' )
            if not viewable:
                current = self.itemcget(item,'tags')
                if isinstance(current, tuple):
                    new = current-('viewable',)
                else:
                    print('here',points, (x1,y1,x2,y2))
                    new = ''
                    self.inview_items.discard(item)
                self.itemconfigure(item,tags=new)
        for item in self.find_overlapping(x1,y1,x2,y2):
            #check if item inside of viewing_box not in inview_items
            if item not in self.inview_items:
                self.inview_items.add(item)
                current = self.itemcget(item,'tags')
                if isinstance(current, tuple):
                    new = current+('viewable',)
                elif isinstance(current, str):
                    if str:
                        new = (current, 'viewable')
                    else:
                        new = 'viewable'
                    self.itemconfigure(item,tags=new)
        print(self.inview_items)

    def _create(self, *args):
        if (current:=args[-1].get('tags', False)):
            args[-1]['tags'] = current+('viewable',)
        else:
            args[-1]['tags'] = ('viewable',)
        ident = super()._create(*args)
        self._scan_view()
        return ident

    def _hscroll(self,event):
        offset = int(event.delta/120)
        if self._multi:
            offset = int(offset*self._multi)
        canvas.move('all', offset,0)
        self._xshifted += offset
        self._scan_view()

    def _vscroll(self,event):
        offset = int(event.delta/120)
        if self._multi:
            offset = int(offset*self._multi)
        canvas.move('all', 0,offset)
        self._yshifted += offset
        self._scan_view()

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()

PS: Before thinking this is over-complicated and using just find_overlapping isn't working, since it seems the item needs to be at least 51% in the view to get tracked with tkinters algorithm.

You can find an improved version now on CodeReview!


Solution

  • I still don't know what I have done wrong but it works with scan_dragto.

    import tkinter as tk
    
    class InfiniteCanvas(tk.Canvas):
    
        def __init__(self, master, **kwargs):
            super().__init__(master, **kwargs)
            self.inview_items   = set()  #in view
            self.niview_items   = set()  #not in view
            self._xshifted      = 0     #view moved in x direction
            self._yshifted      = 0     #view moved in y direction
            self._multi         = 0
            self.configure(confine=False,highlightthickness=0,bd=0)
            self.bind('<MouseWheel>',       self._vscroll)
            self.bind('<Shift-MouseWheel>', self._hscroll)
            root.bind('<Control-KeyPress>',lambda e:setattr(self,'_multi', 10))
            root.bind('<Control-KeyRelease>',lambda e:setattr(self,'_multi', 0))
            return None
    
        def viewing_box(self):
            'returns x1,y1,x2,y2 of the currently 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 _scan_view(self):
            x1,y1,x2,y2 = self.viewing_box()
            for item in self.find_withtag('viewable'):
                #check if one felt over the edge
                coords = self.coords(item)
                #https://www.geeksforgeeks.org/python-split-tuple-into-groups-of-n/
                points = tuple(
                    coords[x:x + 2] for x in range(0, len(coords), 2))
                viewable = False
                for point in points:
                    if x1 <= point[0] <= x2 and y1 <= point[1] <= y2:
                        #if any point is in viewing box
                        viewable = True
                if not viewable:
                    current = self.itemcget(item,'tags')
                    if isinstance(current, tuple):
                        new = current-('viewable',)
                    else:
                        print('here',points, (x1,y1,x2,y2))
                        new = ''
                        self.inview_items.discard(item)
                    self.itemconfigure(item,tags=new)
            for item in self.find_overlapping(x1,y1,x2,y2):
                #check if item inside of viewing_box not in inview_items
                if item not in self.inview_items:
                    self.inview_items.add(item)
                    current = self.itemcget(item,'tags')
                    if isinstance(current, tuple):
                        new = current+('viewable',)
                    elif isinstance(current, str):
                        if str:
                            new = (current, 'viewable')
                        else:
                            new = 'viewable'
                        self.itemconfigure(item,tags=new)
            print(self.inview_items)
    
        def _create(self, *args):
            if (current:=args[-1].get('tags', False)):
                args[-1]['tags'] = current+('viewable',)
            else:
                args[-1]['tags'] = ('viewable',)
            ident = super()._create(*args)
            self._scan_view()
            return ident
    
        def _hscroll(self,event):
            offset = int(event.delta/120)
            if self._multi:
                offset = int(offset*self._multi)
            cx,cy = self.winfo_rootx(), self.winfo_rooty()
            self.scan_mark(cx, cy)
            self.scan_dragto(cx+offset, cy, gain=1)
            self._xshifted += offset
            self._scan_view()
    
        def _vscroll(self,event):
            offset = int(event.delta/120)
            if self._multi:
                offset = int(offset*self._multi)
            cx,cy = self.winfo_rootx(), self.winfo_rooty()
            self.scan_mark(cx, cy)
            self.scan_dragto(cx, cy+offset, gain=1)
            self._yshifted += offset
            self._scan_view()
    
    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()