Files
NexusLauncher/ui/task/subfolder_editor.py
2025-11-23 20:41:50 +08:00

1696 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("<Button-1>", self.on_click)
self.bind("<B1-Motion>", self.on_drag)
self.bind("<ButtonRelease-1>", self.on_release)
self.bind("<Button-3>", self.on_right_button_press) # 右键按下
self.bind("<B3-Motion>", self.on_right_drag) # 右键拖动
self.bind("<ButtonRelease-3>", self.on_right_button_release) # 右键释放
self.bind("<MouseWheel>", self.on_zoom) # 滚轮缩放
self.bind("<Delete>", self.delete_selected)
self.bind("<Control-c>", self.copy_selected)
self.bind("<Control-v>", self.paste_node)
self.bind("<Control-d>", self.duplicate_selected)
self.bind("<Control-0>", self.fit_all_nodes) # Ctrl+0 适应所有节点
self.bind("<Home>", self.fit_all_nodes) # Home键也可以适应所有节点
self.bind("<F5>", self.emergency_reset_view) # F5紧急重置视图
self.bind("<F2>", self.rename_selected_node) # F2重命名选中节点
# 设置为可获得焦点
self.focus_set()
# 点击时获取焦点
self.bind("<FocusIn>", lambda e: self._log("[FOCUS] NodeEditor gained focus", "DEBUG"))
self.bind("<FocusOut>", 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("<Configure>", 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('<Return>', lambda e: on_ok())
dialog.bind('<Escape>', 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()