1696 lines
66 KiB
Python
1696 lines
66 KiB
Python
#!/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()
|