#!/usr/bin/env python # -*- coding: utf-8 -*- """ Node Editor Module ----------------- A canvas-based node editor for creating and managing folder structures. """ import tkinter as tk import customtkinter as ctk import time from typing import Dict, List, Tuple, Optional, Any, Callable from .node import Node from ..utilities.icon_utils import get_icon_path from config.constants import ( NODE_CANVAS_BG, NODE_GRID_COLOR, NODE_BG_COLOR, NODE_BORDER_COLOR, NODE_SELECTED_BORDER, NODE_ID_TEXT_COLOR, NODE_INPUT_COLOR, NODE_OUTPUT_COLOR, NODE_CONNECTION_COLOR, NODE_CONNECTION_SELECTED, NODE_COLOR_PALETTE, DIALOG_BG_COLOR, DIALOG_TEXT_COLOR, COLOR_SUCCESS, COLOR_SUCCESS_HOVER, BUTTON_GRAY, BUTTON_GRAY_HOVER, DIALOG_NODE_RENAME_SIZE ) class NodeEditor(tk.Canvas): """A canvas-based node editor for creating and managing folder structures. This class provides a visual interface for creating, editing, and managing nodes that represent folder structures. It supports drag-and-drop, parent-child relationships, and various node operations. """ # 类级别的剪贴板,支持跨面板粘贴 _clipboard = None # 调试模式控制 debug_mode = False def _log(self, message: str, level: str = "INFO"): """统一的日志方法 Args: message: 日志消息 level: 日志级别 (DEBUG, INFO, WARNING, ERROR) """ # DEBUG级别的日志只在调试模式下输出 if level == "DEBUG" and not self.debug_mode: return prefix = f"[{level}]" full_message = f"{prefix} {message}" print(full_message) def _find_node_at_position(self, x: float, y: float) -> Optional[Node]: """查找指定位置的节点""" for node in self.nodes: if (node.x <= x <= node.x + node.width and node.y <= y <= node.y + node.height): return node return None def _find_node_by_id(self, node_id: str) -> Optional[Node]: """根据ID查找节点""" return next((node for node in self.nodes if node.id == node_id), None) def __init__(self, parent, **kwargs): """Initialize the NodeEditor as a plain Canvas. Scrolling is handled by the outer TaskPanel; this widget only draws nodes and connections on its own canvas. Args: parent: The parent widget **kwargs: Additional keyword arguments for the Canvas """ # Give the canvas a reasonably large logical area so nodes are not # squeezed into a very small region. The outer CTkCanvas will handle # the actual visible viewport. default_kwargs = { "bg": NODE_CANVAS_BG, # 更深的背景色 "highlightthickness": 0, "width": 3000, # 增大画布尺寸 "height": 3000, } default_kwargs.update(kwargs) super().__init__(parent, **default_kwargs) # Node management self.nodes: List[Node] = [] self.connections: List[Tuple[str, str, int]] = [] # (parent_id, child_id, canvas_id) self.selected_nodes: List[Node] = [] # 多选节点列表 self.selected_node: Optional[Node] = None # 保留用于兼容性 self.selected_connections: List[Tuple[str, str, int]] = [] # 多选连接线列表 self.selected_connection: Optional[Tuple[str, str, int]] = None # 保留用于兼容性 self.drag_data = {"x": 0, "y": 0, "item": None} self.connection_start: Optional[str] = None self._center_after_id = None # 用于跟踪待处理的居中任务 self.context_menu: Optional[tk.Menu] = None self.clipboard: Optional[Node] = None # 框选 self.selection_rect = None self.selection_start = None self.is_box_selecting = False # 右键长按检测 self.right_click_start_time = 0 self.right_click_threshold = 0.3 # 300ms # 缩放和平移 self.scale = 1.0 # 缩放比例 self.pan_start_x = 0 self.pan_start_y = 0 self.is_panning = False # 临时连接线 self.temp_connection_line = None self.is_dragging_connection = False # Bind events self.bind("", self.on_click) self.bind("", self.on_drag) self.bind("", self.on_release) self.bind("", self.on_right_button_press) # 右键按下 self.bind("", self.on_right_drag) # 右键拖动 self.bind("", self.on_right_button_release) # 右键释放 self.bind("", self.on_zoom) # 滚轮缩放 self.bind("", self.delete_selected) self.bind("", self.copy_selected) self.bind("", self.paste_node) self.bind("", self.duplicate_selected) self.bind("", self.fit_all_nodes) # Ctrl+0 适应所有节点 self.bind("", self.fit_all_nodes) # Home键也可以适应所有节点 self.bind("", self.emergency_reset_view) # F5紧急重置视图 self.bind("", self.rename_selected_node) # F2重命名选中节点 # 设置为可获得焦点 self.focus_set() # 点击时获取焦点 self.bind("", lambda e: self._log("[FOCUS] NodeEditor gained focus", "DEBUG")) self.bind("", lambda e: self._log("[FOCUS] NodeEditor lost focus", "DEBUG")) # Ensure scrollregion always covers all drawn items. The outer canvas # reads this scrollregion to determine how much area can be scrolled. self.bind("", self._on_configure) # 绘制网格背景 self._draw_grid() def _canvas_coords(self, event_x, event_y): """将事件坐标转换为画布坐标 Args: event_x: 事件的 x 坐标 event_y: 事件的 y 坐标 Returns: (canvas_x, canvas_y): 画布坐标 """ return (self.canvasx(event_x), self.canvasy(event_y)) def _draw_grid(self): """绘制网格背景""" grid_size = 50 width = 3000 height = 3000 # 绘制垂直线 for x in range(0, width, grid_size): self.create_line(x, 0, x, height, fill=NODE_GRID_COLOR, width=1, tags='grid') # 绘制水平线 for y in range(0, height, grid_size): self.create_line(0, y, width, y, fill=NODE_GRID_COLOR, width=1, tags='grid') # 将网格移到最底层 self.tag_lower('grid') def _on_configure(self, event=None): """Update scrollregion to include all items on the canvas. 注意:此方法被禁用以避免干扰居中逻辑。 滚动区域现在由 _simple_center_view 方法管理。 """ # 禁用自动调整滚动区域,避免破坏居中效果 # bbox = self.bbox("all") # if bbox: # self.configure(scrollregion=bbox) pass def add_node(self, name: str, x: float, y: float, parent_id: Optional[str] = None) -> Node: """Add a new node to the editor. Args: name: The name of the node x: X coordinate y: Y coordinate parent_id: Optional parent node ID Returns: The created node """ node = Node(name, parent_id=parent_id) node.x = x node.y = y self.nodes.append(node) self.draw_node(node) # If we have a parent, create a connection if parent_id: parent = self._find_node_by_id(parent_id) if parent: parent.children.append(node) self.draw_connection(parent, node) return node def _create_rounded_rect(self, x1, y1, x2, y2, radius=10, **kwargs): """创建圆角矩形 Args: x1, y1: 左上角坐标 x2, y2: 右下角坐标 radius: 圆角半径 **kwargs: 其他 canvas 参数 Returns: 创建的图形 ID """ points = [ x1 + radius, y1, x1 + radius, y1, x2 - radius, y1, x2 - radius, y1, x2, y1, x2, y1 + radius, x2, y1 + radius, x2, y2 - radius, x2, y2 - radius, x2, y2, x2 - radius, y2, x2 - radius, y2, x1 + radius, y2, x1 + radius, y2, x1, y2, x1, y2 - radius, x1, y2 - radius, x1, y1 + radius, x1, y1 + radius, x1, y1 ] return self.create_polygon(points, smooth=True, **kwargs) def _get_node_color(self, name: str) -> str: """根据节点名称生成一致的颜色 Args: name: 节点名称 Returns: 十六进制颜色代码 """ # 预定义的颜色调色板(饱和度和亮度适中) color_palette = NODE_COLOR_PALETTE # 特殊节点使用固定颜色 if name == 'TaskFolder': return NODE_COLOR_PALETTE[0] # 蓝色 # 使用名称的哈希值选择颜色 hash_value = hash(name) color_index = abs(hash_value) % len(color_palette) return color_palette[color_index] def draw_node(self, node: Node): """Draw a node with flat modern design. Args: node: The node to draw """ x, y = node.x, node.y # 判断节点是否被选中(支持多选) is_selected = node in self.selected_nodes # 根据节点名称生成一致的颜色 header_color = self._get_node_color(node.name) # Node background (主体背景) bg_color = NODE_BG_COLOR # 选中时使用更明显的边框 if is_selected: border_color = NODE_SELECTED_BORDER # 青色高亮 border_width = 3 else: border_color = NODE_BORDER_COLOR border_width = 1 node_rect = self._create_rounded_rect( x, y, x + node.width, y + node.height, radius=12, # 增大圆角 fill=bg_color, outline=border_color, width=border_width, tags=('node', f'node_{node.id}', f'node_rect_{node.id}') ) # Node header bar (顶部标题栏) self._create_rounded_rect( x, y, x + node.width, y + 24, radius=12, # 增大圆角 fill=header_color, outline='', tags=('node_header', f'node_{node.id}', f'node_header_{node.id}') ) # Node name (节点名称) self.create_text( x + node.width // 2, y + 12, text=node.name, fill='white', font=('Segoe UI', 9, 'bold'), tags=('node_text', f'node_{node.id}', f'node_text_{node.id}') ) # Node ID (简短ID显示) self.create_text( x + node.width // 2, y + 40, text=f"ID: {node.id[:6]}", fill=NODE_ID_TEXT_COLOR, font=('Consolas', 7), tags=('node_id', f'node_{node.id}', f'node_id_{node.id}') ) # Input connection point (顶部输入点) input_x = x + node.width / 2.0 # 使用浮点数确保精度 input_y = y self.create_oval( input_x - 6, input_y - 6, input_x + 6, input_y + 6, fill=NODE_INPUT_COLOR, outline='white', width=2, tags=('input_point', f'node_{node.id}', f'input_point_{node.id}') ) # Output connection point (底部输出点) output_x = x + node.width / 2.0 # 使用浮点数确保精度 output_y = y + node.height self.create_oval( output_x - 6, output_y - 6, output_x + 6, output_y + 6, fill=NODE_OUTPUT_COLOR, outline='white', width=2, tags=('connection_point', f'node_{node.id}', f'connection_point_{node.id}') ) def draw_connection(self, parent: Node, child: Node): """Draw a smooth bezier connection (vertical). Args: parent: The parent node child: The child node """ # 精确计算连接点位置(从底部到顶部) start_x = parent.x + parent.width / 2.0 # 使用浮点数确保精度 start_y = parent.y + parent.height # 父节点底部 end_x = child.x + child.width / 2.0 end_y = child.y # 子节点顶部 # 计算贝塞尔曲线控制点(垂直方向) distance = abs(end_y - start_y) control_offset = min(distance * 0.5, 100) # 使用多个点模拟垂直贝塞尔曲线 points = [ start_x, start_y, start_x, start_y + control_offset, end_x, end_y - control_offset, end_x, end_y ] # 检查连接线是否被选中 is_selected = any( conn[0] == parent.id and conn[1] == child.id for conn in self.selected_connections ) # 根据选中状态设置颜色和宽度 line_color = NODE_CONNECTION_SELECTED if is_selected else NODE_CONNECTION_COLOR line_width = 3 if is_selected else 2 # 主连接线 - 使用更高的 splinesteps 使线条更流畅 connection = self.create_line( *points, fill=line_color, width=line_width, smooth=True, splinesteps=50, # 增加到 50 使线条更流畅 capstyle=tk.ROUND, # 圆形端点 joinstyle=tk.ROUND, # 圆形连接 arrow=tk.LAST, # 在末端添加箭头 arrowshape=(10, 12, 4), # 箭头形状 (长度, 宽度, 厚度) tags=('connection', f'connection_{parent.id}_{child.id}') ) self.connections.append((parent.id, child.id, connection)) self.tag_lower(connection) def on_click(self, event): """Handle mouse click events.""" self.focus_set() canvas_x, canvas_y = self._canvas_coords(event.x, event.y) item = self.find_withtag(tk.CURRENT) if not item: self._handle_empty_click(canvas_x, canvas_y) return tags = self.gettags(item) # 处理不同类型的点击 if 'connection' in tags: self._handle_connection_click(tags, event.state) elif 'connection_point' in tags or 'input_point' in tags: self._handle_connection_point_click(tags) elif any(tag.startswith('node_') and not tag.startswith(('node_text', 'node_header')) for tag in tags): self._handle_node_click(tags, event.state, canvas_x, canvas_y) else: self._handle_empty_click(canvas_x, canvas_y) def _handle_connection_click(self, tags, state): """处理连接线点击""" for tag in tags: if not tag.startswith('connection_'): continue parts = tag.split('_') if len(parts) < 3: continue parent_id, child_id = parts[1], parts[2] conn = self._find_connection(parent_id, child_id) if not conn: continue is_ctrl = bool(state & 0x4) if is_ctrl: # 切换连接选择状态 if conn in self.selected_connections: self.selected_connections.remove(conn) else: self.selected_connections.append(conn) else: # 只选择指定连接 self.selected_connections = [conn] self.selected_connection = conn self.redraw_all() return def _handle_connection_point_click(self, tags): """处理连接点点击""" # 从标签中提取节点ID node_id = None for tag in tags: if tag.startswith('connection_point_') or tag.startswith('input_point_'): node_id = tag.split('_')[-1] break if node_id: self.connection_start = node_id self.is_dragging_connection = True self.itemconfig(tk.CURRENT, fill=NODE_CONNECTION_SELECTED, outline=NODE_CONNECTION_SELECTED) def _handle_node_click(self, tags, state, canvas_x, canvas_y): """处理节点点击""" node_tag = next((tag for tag in tags if tag.startswith('node_') and not tag.startswith(('node_text', 'node_header'))), None) if not node_tag: return node_id = node_tag.split('_', 1)[1] clicked_node = self._find_node_by_id(node_id) if not clicked_node: return # 处理修饰键 if state & 0x1: # Shift # 添加节点到选择 if clicked_node not in self.selected_nodes: self.selected_nodes.append(clicked_node) self.selected_node = clicked_node self._log(f"[SELECT] Shift+click: Added node {clicked_node.name} to selection, total {len(self.selected_nodes)}", "DEBUG") elif state & 0x4: # Ctrl # 切换节点选择状态 if clicked_node in self.selected_nodes: self.selected_nodes.remove(clicked_node) if clicked_node == self.selected_node: self.selected_node = self.selected_nodes[0] if self.selected_nodes else None else: self.selected_nodes.append(clicked_node) self.selected_node = clicked_node self._log(f"[SELECT] Ctrl+click: Toggled node {clicked_node.name} selection, total {len(self.selected_nodes)}", "DEBUG") else: # 普通点击:如果点击的是已选中的节点,保持多选状态;否则单选 if clicked_node in self.selected_nodes and len(self.selected_nodes) > 1: # 点击已选中的节点,保持多选状态,只更新主选择节点 self.selected_node = clicked_node self._log(f"[SELECT] Normal click on selected node: {clicked_node.name}, maintaining multi-selection ({len(self.selected_nodes)})", "DEBUG") else: # 点击未选中的节点,或者只有一个选中节点,进行单选 self.selected_nodes = [clicked_node] self.selected_node = clicked_node self._log(f"[SELECT] Normal click: Selected node {clicked_node.name}", "DEBUG") # 使用正确的点击位置设置拖动数据 self.drag_data = {"x": canvas_x, "y": canvas_y, "item": f'node_{node_id}'} self.redraw_all() def _handle_empty_click(self, canvas_x, canvas_y): """处理空白区域点击""" self.selected_nodes = [] self.selected_node = None self.selected_connections = [] self.selected_connection = None # 清理拖动数据 self.drag_data = {"x": 0, "y": 0, "item": None} self.selection_start = (canvas_x, canvas_y) self.is_box_selecting = True self._log(f"[BOX_SELECT] Start selection: ({canvas_x}, {canvas_y})", "DEBUG") def _find_connection(self, parent_id, child_id): """查找连接""" for conn in self.connections: if conn[0] == parent_id and conn[1] == child_id: return conn return None def on_drag(self, event): """Handle node dragging, connection dragging, and box selection.""" # 转换为画布坐标 canvas_x, canvas_y = self._canvas_coords(event.x, event.y) # 处理框选 if self.is_box_selecting and self.selection_start: # 删除旧的选择框 if self.selection_rect: self.delete(self.selection_rect) # 绘制新的选择框 x1, y1 = self.selection_start self.selection_rect = self.create_rectangle( x1, y1, canvas_x, canvas_y, outline=NODE_CONNECTION_COLOR, width=2, dash=(5, 3), tags='selection_rect' ) return # 处理连接线拖动 if self.is_dragging_connection and self.connection_start: if self.temp_connection_line: self.delete(self.temp_connection_line) start_node = next((n for n in self.nodes if n.id == self.connection_start), None) if start_node: # 使用浮点数确保精度(从底部输出点开始) start_x = start_node.x + start_node.width / 2.0 start_y = start_node.y + start_node.height distance = abs(canvas_y - start_y) control_offset = min(distance * 0.5, 100) points = [ start_x, start_y, start_x, start_y + control_offset, canvas_x, canvas_y - control_offset, canvas_x, canvas_y ] self.temp_connection_line = self.create_line( *points, fill=NODE_INPUT_COLOR, width=2, smooth=True, splinesteps=50, # 与正式连接线保持一致 dash=(5, 3), capstyle=tk.ROUND, joinstyle=tk.ROUND, arrow=tk.LAST, # 在末端添加箭头 arrowshape=(10, 12, 4), # 箭头形状 (长度, 宽度, 厚度) tags='temp_connection' ) return # 处理多节点拖动 if self.selected_nodes and self.drag_data.get("item"): dx = canvas_x - self.drag_data["x"] dy = canvas_y - self.drag_data["y"] # 移动所有选中的节点 for node in self.selected_nodes: node.x += dx node.y += dy # 更新拖动数据 self.drag_data = {"x": canvas_x, "y": canvas_y, "item": self.drag_data["item"]} # 重绘所有内容以保持同步 self.redraw_all() self._log(f"[DRAG] Batch moved {len(self.selected_nodes)} nodes: dx={dx:.1f}, dy={dy:.1f}", "DEBUG") def update_connections(self, node: Node): """Update all connections for a node. Args: node: The node whose connections need updating """ # Update connections where this node is the parent for child in node.children: self.delete_connection(node.id, child.id) self.draw_connection(node, child) # Update connections where this node is the child if node.parent_id: parent = self._find_node_by_id(node.parent_id) if parent: self.delete_connection(parent.id, node.id) self.draw_connection(parent, node) def delete_connection(self, parent_id: str, child_id: str): """Delete a connection between nodes. Args: parent_id: ID of the parent node child_id: ID of the child node """ for i, (p_id, c_id, conn_id) in enumerate(self.connections[:]): if p_id == parent_id and c_id == child_id: self.delete(conn_id) self.connections.pop(i) break def on_release(self, event): """Handle mouse release.""" # 处理框选释放 if self.is_box_selecting: if self.selection_rect: # 获取选择框范围 coords = self.coords(self.selection_rect) if len(coords) == 4: x1, y1, x2, y2 = coords # 确保 x1 < x2, y1 < y2 if x1 > x2: x1, x2 = x2, x1 if y1 > y2: y1, y2 = y2, y1 # 选中框内的所有节点 self.selected_nodes = [] for node in self.nodes: node_center_x = node.x + node.width // 2 node_center_y = node.y + node.height // 2 if x1 <= node_center_x <= x2 and y1 <= node_center_y <= y2: self.selected_nodes.append(node) # 选中框内的所有连接线 self.selected_connections = [] for conn in self.connections: parent_id, child_id, conn_id = conn parent = next((n for n in self.nodes if n.id == parent_id), None) child = next((n for n in self.nodes if n.id == child_id), None) if parent and child: # 检查连接线路径上的多个采样点是否在框内(垂直贝塞尔曲线) start_x = parent.x + parent.width / 2 start_y = parent.y + parent.height end_x = child.x + child.width / 2 end_y = child.y # 计算贝塞尔曲线控制点 distance = abs(end_y - start_y) control_offset = min(distance * 0.5, 100) # 控制点坐标 cp1_x = start_x cp1_y = start_y + control_offset cp2_x = end_x cp2_y = end_y - control_offset # 在贝塞尔曲线上采样多个点 sample_points = [] for t in [i * 0.1 for i in range(11)]: # 0.0, 0.1, 0.2, ..., 1.0 # 三次贝塞尔曲线公式 # B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3 t2 = t * t t3 = t2 * t mt = 1 - t mt2 = mt * mt mt3 = mt2 * mt sample_x = (mt3 * start_x + 3 * mt2 * t * cp1_x + 3 * mt * t2 * cp2_x + t3 * end_x) sample_y = (mt3 * start_y + 3 * mt2 * t * cp1_y + 3 * mt * t2 * cp2_y + t3 * end_y) sample_points.append((sample_x, sample_y)) # 如果任意采样点在框内,则选中该连接线 for px, py in sample_points: if x1 <= px <= x2 and y1 <= py <= y2: self.selected_connections.append(conn) break if self.selected_nodes: self.selected_node = self.selected_nodes[0] self._log(f"[BOX_SELECT] Selected {len(self.selected_nodes)} nodes, {len(self.selected_connections)} connections", "DEBUG") # 删除选择框 self.delete(self.selection_rect) self.selection_rect = None self.is_box_selecting = False self.selection_start = None self.redraw_all() return # 处理连接线释放 if self.is_dragging_connection and self.connection_start: self._log("Connector release", "DEBUG") self._log(f"Starting Node ID: {self.connection_start}", "DEBUG") # 转换为画布坐标 canvas_x, canvas_y = self._canvas_coords(event.x, event.y) self._log(f"Release position: canvas({canvas_x:.0f}, {canvas_y:.0f})", "DEBUG") # 删除临时连接线 if self.temp_connection_line: self.delete(self.temp_connection_line) self.temp_connection_line = None # 使用画布坐标查找最近的元素 items = self.find_overlapping(canvas_x-5, canvas_y-5, canvas_x+5, canvas_y+5) self._log(f"Nearby elements: {items}", "DEBUG") # 查找连接点 target_node_id = None for item in items: tags = self.gettags(item) self._log(f"Tag of item {item}: {tags}", "DEBUG") if 'input_point' in tags or 'connection_point' in tags: # 提取目标节点 ID for tag in tags: if tag.startswith('input_point_') or tag.startswith('connection_point_'): target_node_id = tag.split('_')[-1] self._log(f"[CONNECTION] Found target node ID: {target_node_id}", "DEBUG") break if target_node_id: break # 找到连接点就退出循环 if target_node_id and target_node_id != self.connection_start: parent = next((n for n in self.nodes if n.id == self.connection_start), None) child = next((n for n in self.nodes if n.id == target_node_id), None) if parent and child: # 防止自己连接自己 if parent.id == child.id: self._log("[CONNECTION] Cannot connect to self", "DEBUG") else: # 如果子节点已经有父节点,先移除旧的连接 if child.parent_id: old_parent = next((n for n in self.nodes if n.id == child.parent_id), None) if old_parent: if child in old_parent.children: old_parent.children.remove(child) # 删除旧连接线 self.delete_connection(old_parent.id, child.id) self._log(f"[CONNECTION] Removed old connection: {old_parent.name} -> {child.name}", "DEBUG") # 创建新连接(替换旧连接) if child not in parent.children: parent.children.append(child) child.parent_id = parent.id self._log(f"[CONNECTION] Created new connection: {parent.name} -> {child.name}", "DEBUG") # 重置连接状态 self.connection_start = None self.is_dragging_connection = False # 重绘所有节点以恢复连接点颜色 self.redraw_all() return self.drag_data = {"x": 0, "y": 0, "item": None} def on_right_button_press(self, event): """右键按下 - 记录时间用于检测长按""" canvas_x, canvas_y = self._canvas_coords(event.x, event.y) self.right_click_start_time = time.time() self.right_click_pos = (canvas_x, canvas_y) self.right_click_event = event # 保存原始事件用于菜单显示 def on_right_drag(self, event): """右键拖动 - 如果移动距离超过阈值,开始拖动画布""" # 计算移动距离 if hasattr(self, 'right_click_pos'): dx = abs(event.x - self.right_click_pos[0]) dy = abs(event.y - self.right_click_pos[1]) # 如果移动距离超过 5 像素,开始拖动 if dx > 5 or dy > 5: if not self.is_panning: self.is_panning = True self.pan_start_x = event.x self.pan_start_y = event.y self.config(cursor="fleur") # 拖动画布 if self.is_panning: dx = event.x - self.pan_start_x dy = event.y - self.pan_start_y for node in self.nodes: node.x += dx node.y += dy self.pan_start_x = event.x self.pan_start_y = event.y self.redraw_all() def on_right_button_release(self, event): """右键释放 - 判断是短按(菜单)还是长按(拖动)""" if self.is_panning: # 如果正在拖动,停止拖动 self.is_panning = False self.config(cursor="") else: # 检查是否是短按(没有移动且时间短) elapsed = time.time() - self.right_click_start_time if elapsed < self.right_click_threshold: # 短按:显示右键菜单 self.show_context_menu(event) def on_zoom(self, event): """滚轮缩放""" # 转换为画布坐标 canvas_x, canvas_y = self._canvas_coords(event.x, event.y) # 计算缩放因子 if event.delta > 0: scale_factor = 1.1 # 放大 else: scale_factor = 0.9 # 缩小 # 限制缩放范围 new_scale = self.scale * scale_factor if 0.5 <= new_scale <= 3.0: # 调整缩放范围,避免过小或过大 old_scale = self.scale self.scale = new_scale # 以鼠标位置为中心缩放节点位置 for node in self.nodes: # 计算节点相对于鼠标的位置 rel_x = node.x - canvas_x rel_y = node.y - canvas_y # 缩放后的新位置 node.x = canvas_x + rel_x * (self.scale / old_scale) node.y = canvas_y + rel_y * (self.scale / old_scale) # 节点大小始终基于原始尺寸 * 当前总缩放比例 node.width = int(120 * self.scale) node.height = int(60 * self.scale) # 重新绘制 self.redraw_all() def redraw_all(self): """重新绘制所有节点和连接""" # 删除所有非网格和非临时连接线的元素 for tag in ['node', 'node_header', 'node_text', 'node_id', 'connection_point', 'input_point', 'connection']: self.delete(tag) # 清空连接列表 self.connections.clear() # 先绘制所有连接(在底层) for node in self.nodes: if node.parent_id: parent = next((n for n in self.nodes if n.id == node.parent_id), None) if parent: self.draw_connection(parent, node) # 再绘制所有节点(在顶层) for node in self.nodes: self.draw_node(node) # 确保网格在最底层 self.tag_lower('grid') def show_context_menu(self, event): """Show context menu for node operations.""" # 转换为画布坐标 canvas_x, canvas_y = self._canvas_coords(event.x, event.y) # 使用画布坐标查找最近的元素 item = self.find_closest(canvas_x, canvas_y) if item: tags = self.gettags(item) node_tag = next((tag for tag in tags if tag.startswith('node_') and not tag.startswith('node_text') and not tag.startswith('node_header')), None) if node_tag: node_id = node_tag.split('_', 1)[1] clicked_node = next((n for n in self.nodes if n.id == node_id), None) if clicked_node: # 检查点击位置是否在节点范围内 if (clicked_node.x <= canvas_x <= clicked_node.x + clicked_node.width and clicked_node.y <= canvas_y <= clicked_node.y + clicked_node.height): self.selected_node = clicked_node self.selected_nodes = [clicked_node] # Create context menu if self.context_menu: self.context_menu.destroy() self.context_menu = tk.Menu(self, tearoff=0) self.context_menu.add_command(label="Add Child", command=self.add_child_node) # TaskFolder 根节点不允许重命名和删除 if self.selected_node.name != "TaskFolder": self.context_menu.add_command(label="Rename", command=self.rename_node) self.context_menu.add_separator() self.context_menu.add_command(label="Delete", command=self.delete_selected) self.context_menu.add_command(label="Duplicate", command=lambda: self.duplicate_selected(None)) # 使用屏幕坐标显示菜单 self.context_menu.post(event.x_root, event.y_root) self._log(f"[MENU] Right-click menu: {clicked_node.name} at canvas({canvas_x:.0f}, {canvas_y:.0f})", "DEBUG") return # 右键点击空白区域 - 显示创建节点菜单 if self.context_menu: self.context_menu.destroy() self.context_menu = tk.Menu(self, tearoff=0) self.context_menu.add_command( label="Create Node Here", command=lambda: self.create_node_at_position(canvas_x, canvas_y) ) self.context_menu.post(event.x_root, event.y_root) self._log(f"[MENU] Empty area right-click menu at canvas({canvas_x:.0f}, {canvas_y:.0f})", "DEBUG") def create_node_at_position(self, x: float, y: float): """在指定位置创建新节点(无父节点)""" new_node = self.add_node("New Folder", x, y, parent_id=None) self.selected_node = new_node self.selected_nodes = [new_node] self.redraw_all() self._log(f"[NODE] Created new node at ({x:.0f}, {y:.0f})", "DEBUG") def add_child_node(self): """Add a child node to the selected node.""" if self.selected_node: x = self.selected_node.x + 150 y = self.selected_node.y + 100 child = self.add_node("New Folder", x, y, self.selected_node.id) self.selected_node.children.append(child) self.draw_connection(self.selected_node, child) def rename_node(self): """Rename the selected node with unified dialog style.""" if not self.selected_node: return # TaskFolder 根节点不允许重命名 if self.selected_node.name == "TaskFolder": self._log("The root node of TaskFolder cannot be renamed.", "WARNING") return # 创建统一样式的对话框 dialog = ctk.CTkToplevel(self) dialog.title("重命名节点") dialog.geometry(DIALOG_NODE_RENAME_SIZE) dialog.resizable(False, False) # 设置对话框背景颜色(与其他对话框一致) dialog.configure(fg_color=DIALOG_BG_COLOR) # 设置图标路径 icon_path = get_icon_path() # 第一次设置图标 if os.path.exists(icon_path): try: dialog.iconbitmap(icon_path) dialog.wm_iconbitmap(icon_path) except: pass # 设置为模态对话框 dialog.transient(self) dialog.grab_set() # 居中显示 dialog.update_idletasks() x = (dialog.winfo_screenwidth() // 2) - (400 // 2) y = (dialog.winfo_screenheight() // 2) - (180 // 2) dialog.geometry(f"{DIALOG_NODE_RENAME_SIZE}+{x}+{y}") # 设置深色标题栏和图标的组合函数 def apply_title_bar_and_icon(): # 先设置深色标题栏 self._set_dark_title_bar(dialog) # 再次设置图标(确保不被覆盖) if os.path.exists(icon_path): try: dialog.iconbitmap(icon_path) dialog.wm_iconbitmap(icon_path) except: pass # 延迟执行 dialog.after(10, apply_title_bar_and_icon) # 再次确保图标设置(多次尝试) dialog.after(100, lambda: dialog.iconbitmap(icon_path) if os.path.exists(icon_path) else None) dialog.after(200, lambda: dialog.iconbitmap(icon_path) if os.path.exists(icon_path) else None) # 主框架 main_frame = ctk.CTkFrame(dialog, fg_color=DIALOG_BG_COLOR) main_frame.pack(fill="both", expand=True, padx=0, pady=0) # 内容框架 content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") content_frame.pack(fill="both", expand=True, padx=20, pady=20) # 标签 label = ctk.CTkLabel( content_frame, text="新名称:", font=("Segoe UI", 12), text_color=DIALOG_TEXT_COLOR ) label.pack(pady=(10, 5)) # 输入框 name_var = tk.StringVar(value=self.selected_node.name) entry = ctk.CTkEntry( content_frame, textvariable=name_var, font=("Segoe UI", 12), width=350, height=35 ) entry.pack(pady=5) entry.select_range(0, 'end') entry.focus_set() # 按钮框架 button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") button_frame.pack(pady=(0, 15)) def on_ok(): new_name = name_var.get().strip() if new_name: self.selected_node.name = new_name self.itemconfig(f'node_text_{self.selected_node.id}', text=new_name) self._log(f"[NODE] Node renamed to: {new_name}", "DEBUG") dialog.destroy() def on_cancel(): dialog.destroy() # 确定按钮 ok_button = ctk.CTkButton( button_frame, text="确定", command=on_ok, fg_color=COLOR_SUCCESS, hover_color=COLOR_SUCCESS_HOVER, font=("Segoe UI", 12, "bold"), width=100, height=35, corner_radius=8 ) ok_button.pack(side="left", padx=5) # 取消按钮 cancel_button = ctk.CTkButton( button_frame, text="取消", command=on_cancel, fg_color=BUTTON_GRAY, hover_color=BUTTON_GRAY_HOVER, font=("Segoe UI", 12, "bold"), width=100, height=35, corner_radius=8 ) cancel_button.pack(side="left", padx=5) # 绑定快捷键 dialog.bind('', lambda e: on_ok()) dialog.bind('', lambda e: on_cancel()) def delete_selected(self, event=None): """Delete the selected nodes or connections.""" self._log("Press the Delete key", "DEBUG") self._log(f"Selected connections: {len(self.selected_connections)}", "DEBUG") self._log(f"Selected nodes: {len(self.selected_nodes)}", "DEBUG") # Delete selected connections (supports batch deletion) if self.selected_connections: deleted_count = 0 for conn in self.selected_connections[:]: # 复制列表以避免修改时出错 parent_id, child_id, conn_id = conn # 查找父节点和子节点 parent = next((n for n in self.nodes if n.id == parent_id), None) child = next((n for n in self.nodes if n.id == child_id), None) if parent and child: # 移除子节点的父节点引用 if child in parent.children: parent.children.remove(child) child.parent_id = None # 删除连接线 self.delete_connection(parent_id, child_id) deleted_count += 1 self._log(f"[CONNECTION] Deleted connection: {parent.name} -> {child.name}", "DEBUG") # 清空选择 self.selected_connections = [] self.selected_connection = None self._log(f"[CONNECTION] Deleted {deleted_count} connections total", "DEBUG") self.redraw_all() return # 删除所有选中的节点 if not self.selected_nodes: return # 递归删除节点及其子节点 def delete_node_recursive(node): # 先删除所有子节点 for child in node.children[:]: delete_node_recursive(child) # 从父节点的子节点列表中移除 if node.parent_id: parent = next((n for n in self.nodes if n.id == node.parent_id), None) if parent and node in parent.children: parent.children.remove(node) # 从节点列表中移除 if node in self.nodes: self.nodes.remove(node) # 删除所有选中的节点(跳过 TaskFolder) nodes_to_delete = [n for n in self.selected_nodes if n.name != "TaskFolder"] for node in nodes_to_delete: delete_node_recursive(node) self.selected_nodes = [] self.selected_node = None # 取消连接线高亮 for conn in self.selected_connections: self.itemconfig(conn[2], fill=NODE_INPUT_COLOR, width=2) self.selected_connections = [] self.selected_connection = None self.redraw_all() def redraw_connections(self): """Redraw all connections between nodes.""" # Remove all connections for _, _, conn_id in self.connections: self.delete(conn_id) self.connections = [] # Redraw connections for node in self.nodes: for child in node.children: self.draw_connection(node, child) def get_structure(self) -> List[Dict[str, Any]]: """Get the folder structure as a list of dictionaries. Returns: A list of root nodes and their children as dictionaries """ # Only return root nodes, their children will be included recursively return [node.to_dict() for node in self.nodes if node.parent_id is None] def load_structure(self, structure_data: List[Dict[str, Any]]): """Load folder structure from a list of dictionaries. Args: structure_data: List of node dictionaries to load """ self._log(f"Loading structure with {len(structure_data)} root nodes", "DEBUG") # Clear existing nodes self.nodes = [] self.delete("all") # Rebuild the node tree node_dict = {} # id -> node mapping # First pass: create all nodes def create_nodes(node_data, parent_id=None, depth=0): node = Node( node_data["name"], node_id=node_data["id"], parent_id=parent_id ) # 使用传入的位置坐标 node.x = node_data.get("x", 100) node.y = node_data.get("y", 100) node.width = node_data.get("width", 120) node.height = node_data.get("height", 60) node_dict[node.id] = node self._log(f"Creating node: {node.name} at ({node.x}, {node.y})", "DEBUG") # Create children recursively for child_data in node_data.get("children", []): child_node = create_nodes(child_data, node.id, depth + 1) node.children.append(child_node) return node # Create all nodes and build the tree root_nodes = [] for node_data in structure_data: node = create_nodes(node_data) root_nodes.append(node) # 收集所有节点(包括子节点)到 self.nodes self.nodes = [] def collect_all_nodes(node): self.nodes.append(node) for child in node.children: collect_all_nodes(child) for root_node in root_nodes: collect_all_nodes(root_node) # 绘制所有节点 for node in self.nodes: self.draw_node(node) # 绘制所有连接 for node in self.nodes: if node.parent_id: parent = next((n for n in self.nodes if n.id == node.parent_id), None) if parent: self.draw_connection(parent, node) self._log(f"Structure loaded: {len(self.nodes)} total nodes created and drawn", "DEBUG") # 取消之前的居中任务 self._cancel_pending_center() # 自动调整视图以显示所有节点 - 使用简化方法 self._center_after_id = self.after(300, self._ultra_simple_center) def _cancel_pending_center(self): """取消待处理的居中任务""" if hasattr(self, '_center_after_id') and self._center_after_id: self.after_cancel(self._center_after_id) self._center_after_id = None def force_recenter(self): """强制重新居中(用于外部调用)""" self._log("🎯 Manually force centering", "DEBUG") self._ultra_simple_center() def _ultra_simple_center(self): """动态适配窗口的居中方法 - 根据窗口大小计算中轴线""" if not self.nodes: return self._log(" Use dynamic adaptation centering method", "DEBUG") # 获取实际可用的画布尺寸 self.update_idletasks() canvas_width = self.winfo_width() canvas_height = self.winfo_height() # 如果画布尺寸异常,从父窗口估算 if canvas_width <= 1 or canvas_height <= 1 or canvas_height > 2000: try: parent = self.master if parent: parent_width = parent.winfo_width() parent_height = parent.winfo_height() canvas_width = parent_width - 60 # 减去边距和滚动条 canvas_height = parent_height - 170 # 减去信息区和按钮 self._log(f" Use parent window to estimate canvas: {canvas_width}x{canvas_height}", "DEBUG") else: # 最后的回退方案 canvas_width, canvas_height = 1140, 730 self._log(f" Use default canvas size: {canvas_width}x{canvas_height}", "DEBUG") except: canvas_width, canvas_height = 1140, 730 self._log(f" Use default canvas size: {canvas_width}x{canvas_height}", "DEBUG") else: # 实际画布尺寸需要考虑滚动条的影响 # 垂直滚动条通常占用约15-20像素宽度 effective_width = canvas_width - 20 # 减去垂直滚动条宽度 effective_height = canvas_height - 20 # 减去水平滚动条高度 self._log(f" Original canvas size: {canvas_width}x{canvas_height}", "DEBUG") self._log(f" Effective canvas size: {effective_width}x{effective_height} (subtracting scrollbar)", "DEBUG") canvas_width = effective_width canvas_height = effective_height # 计算画布中轴线 - 垂直方向稍微靠上,水平方向微调 canvas_center_x = canvas_width / 2 + 10 # 稍微向右偏移10像素补正 canvas_center_y = canvas_height * 0.45 # 从0.5改为0.45,整体靠上一些 self._log(f" Canvas center: ({canvas_center_x:.0f}, {canvas_center_y:.0f}) [vertical upper, horizontal adjustment]", "DEBUG") # 找到根节点 root_node = None for node in self.nodes: if node.name == "TaskFolder" or node.parent_id is None: root_node = node break if not root_node: root_node = self.nodes[0] # 计算根节点当前中心 root_center_x = root_node.x + root_node.width / 2 root_center_y = root_node.y + root_node.height / 2 self._log(f" Root node center: ({root_center_x:.0f}, {root_center_y:.0f})", "DEBUG") # 计算需要的偏移量,让根节点对齐到画布中轴线 offset_x = canvas_center_x - root_center_x offset_y = canvas_center_y - root_center_y self._log(f" Need offset: ({offset_x:.0f}, {offset_y:.0f})", "DEBUG") # 如果偏移量较大,实际移动所有节点位置 if abs(offset_x) > 5 or abs(offset_y) > 5: self._log(" Move all nodes to adapt to the center line...", "DEBUG") for node in self.nodes: node.x += offset_x node.y += offset_y # 重新计算根节点中心 root_center_x = root_node.x + root_node.width / 2 root_center_y = root_node.y + root_node.height / 2 self._log(f" Move after root node center: ({root_center_x:.0f}, {root_center_y:.0f})", "DEBUG") # 重新计算所有节点的边界 min_x = min(node.x for node in self.nodes) max_x = max(node.x + node.width for node in self.nodes) min_y = min(node.y for node in self.nodes) max_y = max(node.y + node.height for node in self.nodes) # 设置滚动区域包含所有节点,但不以根节点为中心 margin = 200 scroll_region = (min_x - margin, min_y - margin, max_x + margin, max_y + margin) self.configure(scrollregion=scroll_region) self._log(f" Scroll region: ({scroll_region[0]:.0f}, {scroll_region[1]:.0f}) to ({scroll_region[2]:.0f}, {scroll_region[3]:.0f})", "DEBUG") # 简化滚动逻辑:直接让根节点在画布中显示在正确位置 # 计算根节点在滚动区域中的相对位置 scroll_width = (max_x + margin) - (min_x - margin) scroll_height = (max_y + margin) - (min_y - margin) if scroll_width > 0: # 让根节点的X位置对应到画布中心X target_scroll_left = root_center_x - canvas_width / 2 scroll_x = (target_scroll_left - (min_x - margin)) / scroll_width scroll_x = max(0.0, min(1.0, scroll_x)) else: scroll_x = 0.5 if scroll_height > 0: # 让根节点的Y位置对应到画布的45%位置(靠上) target_scroll_top = root_center_y - canvas_height * 0.45 scroll_y = (target_scroll_top - (min_y - margin)) / scroll_height scroll_y = max(0.0, min(1.0, scroll_y)) else: scroll_y = 0.5 self._log(f" Target scroll position to display root node at canvas ({canvas_width/2:.0f}, {canvas_height*0.45:.0f})", "DEBUG") self._log(f" Scroll ratio: ({scroll_x:.3f}, {scroll_y:.3f})", "DEBUG") # 应用滚动位置 self.xview_moveto(scroll_x) self.yview_moveto(scroll_y) # 重绘所有节点到新位置 self.redraw_all() self._log("[LAYOUT] Dynamic adaptation completed - nodes moved to center axis", "DEBUG") def fit_all_nodes(self, event=None): """手动触发适应所有节点功能(快捷键:Ctrl+0 或 Home)""" self._ultra_simple_center() return "break" def emergency_reset_view(self, event=None): """紧急重置视图到原点(快捷键:F5)""" self._log("[EMERGENCY] Resetting view to origin", "DEBUG") # 重置滚动位置到 (0, 0) self.xview_moveto(0.0) self.yview_moveto(0.0) # 重新计算滚动区域 if self.nodes: min_x = min(node.x for node in self.nodes) - 100 max_x = max(node.x + node.width for node in self.nodes) + 100 min_y = min(node.y for node in self.nodes) - 100 max_y = max(node.y + node.height for node in self.nodes) + 100 self.configure(scrollregion=(min_x, min_y, max_x, max_y)) self._log(f"[SCROLL] Reset scroll region: ({min_x:.0f}, {min_y:.0f}) to ({max_x:.0f}, {max_y:.0f})", "DEBUG") return "break" def _set_dark_title_bar(self, window): """设置窗口的深色标题栏(Windows 10/11)""" try: import ctypes from ctypes import wintypes # 获取窗口句柄 hwnd = window.winfo_id() # Windows 10/11 深色标题栏设置 DWMWA_USE_IMMERSIVE_DARK_MODE = 20 value = wintypes.DWORD(1) # 1 表示启用深色模式 ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value) ) except Exception as e: self._log(f"Failed to set dark title bar: {e}", "DEBUG") def rename_selected_node(self, event=None): """F2快捷键重命名选中节点""" if self.selected_node: # TaskFolder 根节点不允许重命名 if self.selected_node.name == "TaskFolder": self._log("TaskFolder root node cannot be renamed", "WARNING") return "break" self.rename_node() return "break" def _auto_layout_nodes(self): """自动布局节点,使其排列整齐""" if not self.nodes: return # 从根节点开始布局 start_x = 50 start_y = 50 x_spacing = 180 # 水平间距 y_spacing = 100 # 垂直间距 for root_node in self.nodes: if root_node.parent_id is None: root_node.x = start_x root_node.y = start_y # 递归布局子节点 self._layout_children(root_node, start_x, start_y + y_spacing, x_spacing, y_spacing) def _layout_children(self, parent_node, start_x, start_y, x_spacing, y_spacing): """递归布局子节点""" if not parent_node.children: return # 计算子节点的总宽度 total_width = len(parent_node.children) * x_spacing # 起始 x 位置(居中对齐父节点) current_x = parent_node.x - (total_width - x_spacing) / 2 for i, child in enumerate(parent_node.children): child.x = current_x + i * x_spacing child.y = start_y # 检查并避免重叠 self._avoid_overlap(child) # 递归布局子节点的子节点 self._layout_children(child, child.x, start_y + y_spacing, x_spacing, y_spacing) def _avoid_overlap(self, node: Node, max_attempts: int = 10): """检查并避免节点重叠 Args: node: 要检查的节点 max_attempts: 最大尝试次数 """ margin = 20 # 节点之间的最小间距 for attempt in range(max_attempts): overlapping = False for other in self.nodes: if other.id == node.id: continue # 检查是否重叠(包含边距) if (abs(node.x - other.x) < node.width + margin and abs(node.y - other.y) < node.height + margin): overlapping = True # 计算避让方向 dx = node.x - other.x dy = node.y - other.y # 根据重叠方向移动节点 if abs(dx) > abs(dy): # 水平方向重叠更多 if dx > 0: node.x = other.x + other.width + margin else: node.x = other.x - node.width - margin else: # 垂直方向重叠更多 if dy > 0: node.y = other.y + other.height + margin else: node.y = other.y - node.height - margin break if not overlapping: break def _draw_node_children_connections(self, node): """递归绘制节点及其子节点的连接""" for child in node.children: self.draw_connection(node, child) self._draw_node_children_connections(child) def copy_selected(self, event=None): """Copy the selected nodes to the clipboard.""" if not self.selected_nodes: self._log("No nodes selected", "WARNING") return # 只复制节点本身的数据,不包括子节点 def copy_node_data(node): """复制节点数据(不包括子节点)""" node_data = { 'name': node.name, 'width': node.width, 'height': node.height, 'children': [] # 不复制子节点 } return node_data # 复制所有选中的节点 NodeEditor._clipboard = [copy_node_data(node) for node in self.selected_nodes] self._log(f"[CLIPBOARD] Copied {len(NodeEditor._clipboard)} nodes (excluding children)", "DEBUG") def paste_node(self, event=None): """Paste nodes from the clipboard.""" if not NodeEditor._clipboard: self._log("Clipboard is empty", "WARNING") return # 计算粘贴位置(在当前视口中心或鼠标位置) paste_x = 400 paste_y = 400 # 如果有选中的节点,在其旁边粘贴 if self.selected_node: paste_x = self.selected_node.x + 150 paste_y = self.selected_node.y + 50 # 递归创建节点 def create_node_from_data(node_data, parent_id=None, offset_x=0, offset_y=0): """从数据创建节点""" new_node = self.add_node( node_data['name'], paste_x + offset_x, paste_y + offset_y, parent_id ) # 如果有父节点,建立连接 if parent_id: parent = next((n for n in self.nodes if n.id == parent_id), None) if parent: parent.children.append(new_node) self.draw_connection(parent, new_node) # 递归创建子节点 for i, child_data in enumerate(node_data['children']): create_node_from_data( child_data, new_node.id, offset_x + 150, offset_y + i * 80 ) return new_node # 清空当前选择 self.selected_nodes = [] # 粘贴所有节点 for i, node_data in enumerate(NodeEditor._clipboard): new_node = create_node_from_data(node_data, None, 0, i * 80) self.selected_nodes.append(new_node) self.selected_node = self.selected_nodes[0] if self.selected_nodes else None self.redraw_all() self._log(f"[CLIPBOARD] Pasted {len(self.selected_nodes)} nodes", "DEBUG") def duplicate_selected(self, event=None): """Duplicate the selected node.""" if not self.selected_node: return # 创建新节点 new_x = self.selected_node.x + 150 new_y = self.selected_node.y + 50 # 复制节点 new_node = self.add_node( self.selected_node.name + " (Copy)", new_x, new_y, self.selected_node.parent_id ) # 如果有父节点,添加到父节点的子节点列表 if self.selected_node.parent_id: parent = next((n for n in self.nodes if n.id == self.selected_node.parent_id), None) if parent: parent.children.append(new_node) self.draw_connection(parent, new_node) # 递归复制子节点 def duplicate_children(original_node, new_parent_node): for child in original_node.children: child_x = new_parent_node.x + 150 child_y = new_parent_node.y + 100 new_child = self.add_node( child.name, child_x, child_y, new_parent_node.id ) new_parent_node.children.append(new_child) self.draw_connection(new_parent_node, new_child) duplicate_children(child, new_child) duplicate_children(self.selected_node, new_node) # 选中新节点 self.selected_nodes = [new_node] self.selected_node = new_node self.redraw_all()