pythonpython-3.xtkinter

How to drag and drop items across frames in tkinter?


I have a tkinter gui which displays some nodes in levels. The levels are split into frames and on each frame the nodes are placed as per a level dictionary defined in the class Viewer. I want to drag and drop this nodes from one level to another and as I do that the levels should adjust themselves.

For example, if a level has only one node and that node is moved up then that level will cease to exist practically and the below levels should move up to fill the level hierarchy logic. However the drag and drop is not working as expected.
Below is my code :
Some notes about the code :

  1. level_dict in Viewer defines the levels. The DEPS key denotes the a nodes dependency on another.

  2. DragDrop class implements the drag and drop feature on clicking.

     import tkinter as tk
     from tkinter import ttk, filedialog
     import os
    
    
     class DragDrop(tk.Label):
         def __init__(self, parent, text, app, **kwargs):
             super().__init__(parent, text=text, **kwargs)
             self.parent = parent
             self.app = app
             self.text = text
             self.bind("<Button-1>", self.on_click)
             self.bind("<B1-Motion>", self.on_drag)
             self.bind("<ButtonRelease-1>", self.on_release)
             self._drag_data = {"x": 0, "y": 0, "item": None}
    
         def on_click(self, event):
             self._drag_data["item"] = self
             self._drag_data["x"] = event.x
             self._drag_data["y"] = event.y
    
         def on_drag(self, event):
             x = self.winfo_x() - self._drag_data["x"] + event.x
             y = self.winfo_y() - self._drag_data["y"] + event.y
             self.place(x=x, y=y)
    
         def on_release(self, event):
             self._drag_data = {"x": 0, "y": 0, "item": None}
             self.app.update_node_position(self)
    
     class Viewer:
         def __init__(self, root):
             self.root = root
             self.level_dict = {'JK': 0, 'pun': 1, 'utp': 1, 'pun utp': 0, 'utk': 1, 'gjr': 2, 'wbk': 3, 'nest': 4, 'mahm': 5, 'ksl': 6, 'krtk': 5}
             self.sections = {'JK': {'DEPS': None, 'TYPES': None, 'RATING': '0'}, 'pun': {'DEPS': 'JK', 'TYPES': None, 'RATING': '0'}, 'utp': {'DEPS': 'JK', 'TYPES': None, 'RATING': '0'}, 'utk': {'DEPS': 'pun utp', 'TYPES': None, 'RATING': '0'}, 'gjr': {'DEPS': 'utk', 'TYPES': None, 'RATING': '0'}, 'wbk': {'DEPS': 'gjr', 'TYPES': None, 'RATING': '0'}, 'nest': {'DEPS': 'wbk', 'TYPES': None, 'RATING': '0'}, 'mahm': {'DEPS': 'nest', 'TYPES': None, 'RATING': '0'}, 'ksl': {'DEPS': 'mahm', 'TYPES': None, 'RATING': '0'}, 'krtk': {'DEPS': 'nest', 'TYPES': None, 'RATING': '0'}}
    
             self.canvas = tk.Canvas(root, bg="white")
             self.h_scrollbar = tk.Scrollbar(root, orient=tk.HORIZONTAL, command=self.canvas.xview)
             self.v_scrollbar = tk.Scrollbar(root, orient=tk.VERTICAL, command=self.canvas.yview)
             self.scrollable_frame = tk.Frame(self.canvas)
    
             self.scrollable_frame.bind(
             "<Configure>",
             lambda e: self.canvas.configure(
                 scrollregion=self.canvas.bbox("all")
             )
         )
    
             self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
    
           self.canvas.configure(xscrollcommand=self.h_scrollbar.set, yscrollcommand=self.v_scrollbar.set)
    
             self.h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)
             self.v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
             self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
    
             self.event_var = tk.StringVar(value="All")
             self.create_widgets()
             self.draw_graph()
    
         def create_widgets(self):
             control_frame = tk.Frame(self.root)
             control_frame.pack(side=tk.TOP, fill=tk.X)
    
             event_label = tk.Label(control_frame, text="Select Types:")
             event_label.pack(side=tk.LEFT, padx=5, pady=5)
    
             event_options = ["All", "zones", "states"]
             event_menu = ttk.Combobox(control_frame, textvariable=self.event_var, values=event_options)
             event_menu.pack(side=tk.LEFT, padx=5, pady=5)
             event_menu.bind("<<ComboboxSelected>>", self.on_event_change)
    
             browse_button = tk.Button(control_frame, text="Browse data_file", command=self.browse_file)
             browse_button.pack(side=tk.LEFT, padx=5, pady=5)
    
             save_button = tk.Button(control_frame, text="Save", command=self.save_data_file_file)
             save_button.pack(side=tk.LEFT, padx=5, pady=5)
    
         def browse_file(self):
             data_file_file = filedialog.askopenfilename(filetypes=[("data_file files", "*.data_file")])
             if data_file_file:
                 self.data_file_file = data_file_file
                 self.main_func()
                 self.draw_graph()
    
         def save_data_file_file(self):
             save_file = filedialog.asksaveasfilename(defaultextension=".data_file", filetypes=[("data_file files", "*.data_file")])
             if save_file:
                 config = configparser.ConfigParser()
                 for section, attributes in self.sections.items():
                     config[section] = attributes
                     config[section]['LEVEL'] = str(self.level_dict[section])
                 with open(save_file, 'w') as configfile:
                     config.write(configfile)
    
         def on_event_change(self, event):
             self.event_filter = self.event_var.get() if self.event_var.get() != "All" else None
             self.main_func()
             self.draw_graph()
    
         def draw_graph(self):
             for widget in self.scrollable_frame.winfo_children():
                 widget.destroy()
    
             self.level_frames = {}
             levels = {}
             for section, level in self.level_dict.items():
                 if level not in levels:
                     levels[level] = []
                 levels[level].append(section)
    
             colors = ["lightblue", "lightgreen", "lightyellow", "lightpink", "lightgray"]
             for level, nodes in sorted(levels.items()):
                 level_frame = tk.Frame(self.scrollable_frame, bg=colors[level % len(colors)], bd=2, relief=tk.SOLID)
                 level_frame.pack(fill=tk.X, padx=10, pady=5)
                 self.level_frames[level] = level_frame
    
                 level_label = tk.Label(level_frame, text=f"Level {level}", bg=colors[level % len(colors)], font=("Arial", 12, "bold"), anchor="w")
                 level_label.pack(side=tk.TOP, fill=tk.X)
    
                 for node in nodes:
                     self.draw_node(level_frame, node)
    
         def draw_node(self, parent, node):
             level = self.level_dict.get(node, 0)
             label = f'{node}({level})'
             if node in self.sections:
                 if self.sections[node]['RATING'] == '1':
                     color = 'lightblue'
                 else:
                     color = 'skyblue'
                 fg_color = 'darkblue'
    
                 node_label = DragDrop(parent, text=label, app=self, bg=color, fg=fg_color, font=("Arial", 10), bd=1, relief=tk.SOLID, padx=5, pady=5)
                 node_label.pack(side=tk.LEFT, padx=5, pady=5)
    
         def update_node_position(self, node_label):
             node_text = node_label.cget("text")
             node_name = node_text.split('(')[0]
             old_level = self.level_dict[node_name]
    
             for level, frame in self.level_frames.items():
                 if node_label.winfo_y() >= frame.winfo_y() and node_label.winfo_y() < frame.winfo_y() + frame.winfo_height():
                     if old_level != level:
                         self.level_dict[node_name] = level
                         self.draw_graph()
                     break
    
             # Remove empty levels and adjust subsequent levels
             self.adjust_levels()
    
         def adjust_levels(self):
             levels = sorted(self.level_frames.keys())
             for i, level in enumerate(levels):
                 if not any(node in self.level_dict and self.level_dict[node] == level for node in self.sections):
                     del self.level_frames[level]
                     for node in self.level_dict:
                         if self.level_dict[node] > level:
                             self.level_dict[node] -= 1
             self.draw_graph()
    
     if __name__ == '__main__':
    
         root = tk.Tk()
         root.title("Order Viewer")
         app = Viewer(root)
         root.geometry("800x600")
         root.mainloop()
    

Solution

  • You cannot move a child label from its parent frame into another frame. A work-around is to create the label as child of self.scrollable_frame but pack it inside a level frame, then you can move the label over those level frames using .place().

    Below is the required changes:

    ...
    
    class DragDrop(tk.Label):
        ...
    
        def on_click(self, event):
             self._drag_data["item"] = self
             self._drag_data["x"] = event.x
             self._drag_data["y"] = event.y
             self.tkraise()   ### make it the top-most widget in parent container
    
        ...
    
    ...
    
    class Viewer:
        ...
    
        def draw_node(self, parent, node):
             level = self.level_dict.get(node, 0)
             label = f'{node}({level})'
             if node in self.sections:
                 if self.sections[node]['RATING'] == '1':
                     color = 'lightblue'
                 else:
                     color = 'skyblue'
                 fg_color = 'darkblue'
    
                 ### create as child of self.scrollable_frame
                 node_label = DragDrop(self.scrollable_frame, text=label, app=self, bg=color, fg=fg_color, font=("Arial", 10), bd=1, relief=tk.SOLID, padx=5, pady=5)
                 ### but pack it inside "parent"
                 node_label.pack(side=tk.LEFT, padx=5, pady=5, in_=parent)
    
         def update_node_position(self, node_label):
             node_text = node_label.cget("text")
             node_name = node_text.split('(')[0]
             old_level = self.level_dict[node_name]
    
             for level, frame in self.level_frames.items():
                 if node_label.winfo_y() >= frame.winfo_y() and node_label.winfo_y() < frame.winfo_y() + frame.winfo_height():
                     if old_level != level:
                         self.level_dict[node_name] = level
                         #self.draw_graph()   ### it is called inside self.adjust_levels() as well
                     break
    
             # Remove empty levels and adjust subsequent levels
             self.adjust_levels()
    
        ...
    
    ...