#!/usr/bin/env python # -*- coding: utf-8 -*- """ 设置窗口 - 提供现代化的配置界面 """ import customtkinter as ctk import os import glob from tkinter import filedialog from typing import Callable from PIL import Image from config import ConfigManager from config.constants import ( # 颜色常量 PRESET_COLORS, BORDER_COLOR, BG_COLOR_DARK, LINE_COLOR_GRAY, BORDER_COLOR_WHITE, DIALOG_BG_COLOR, DIALOG_TEXT_COLOR, # 按钮颜色 BUTTON_GRAY, BUTTON_GRAY_HOVER, BUTTON_RED, BUTTON_RED_HOVER, BUTTON_BLUE, BUTTON_BLUE_HOVER, BUTTON_GREEN, BUTTON_GREEN_HOVER, SAVE_BUTTON_COLOR, SAVE_BUTTON_HOVER, # 状态颜色 COLOR_SUCCESS, COLOR_SUCCESS_HOVER, # 拖拽和选择颜色 DRAG_HIGHLIGHT_COLOR, DRAG_HIGHLIGHT_BG, SELECTION_BORDER, SELECTION_BG, # 滚动条颜色 SCROLLBAR_COLOR, SCROLLBAR_HOVER_COLOR, # 窗口尺寸 SETTINGS_WINDOW_SIZE, DIALOG_INPUT_SIZE, DIALOG_CONFIRM_SIZE, DIALOG_APP_EDIT_SIZE, DIALOG_ICON_SELECT_SIZE ) from .utilities import custom_dialogs from .utilities.icon_utils import get_icon_path, set_window_icon, get_icons_dir import ctypes class SettingsWindow(ctk.CTkToplevel): """设置窗口类""" def __init__(self, parent, config_manager: ConfigManager, on_update: Callable): super().__init__(parent) self.config_manager = config_manager self.on_update = on_update self.debug_mode = False # 调试模式控制 # 拖放相关变量 self.drag_source = None self.drag_target = None self.drag_data = None self.drag_widget = None # 多选和复制粘贴相关变量 self.selected_items = [] # 存储选中的应用索引 self.clipboard_apps = [] # 剪贴板中的应用数据 self.last_selected_index = None # 上次选中的索引,用于Shift多选 # 性能优化相关变量 self._update_timer = None # 延迟更新定时器 self._cached_projects = None # 缓存的项目列表 self._last_project_update = 0 # 上次项目更新时间 # 窗口配置 self.title("NexusLauncher - Settings") self.geometry(SETTINGS_WINDOW_SIZE) self.resizable(True, True) # 先隐藏窗口,避免加载时闪烁 self.withdraw() # 设置窗口图标(在transient之前设置) set_window_icon(self) # 先设置为瞬态窗口 self.transient(parent) # 等待窗口创建完成 self.update_idletasks() # 再次尝试设置图标(确保生效) def set_icon_delayed(): set_window_icon(self) self.after(50, set_icon_delayed) self.after(200, set_icon_delayed) # 创建界面 self._create_widgets() self._load_data() # 绑定基本键盘快捷键 self.bind_all("", self._handle_delete, "+") self.bind_all("", self._handle_escape, "+") # 延迟绑定全局点击事件,确保所有控件都已创建 self.after(100, self._setup_global_click_binding) # 所有内容加载完成后,居中显示窗口并抓取焦点 self._center_window() self.deiconify() self.grab_set() self.focus_set() # 设置深色标题栏 self.after(10, lambda: self._set_dark_title_bar(self)) 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 _ensure_project_selected(self) -> str: """确保已选择项目,返回项目名称或None""" current_project = self.project_combo.get() if not current_project: custom_dialogs.show_warning(self, "警告", "请先选择一个项目") return None return current_project def _create_button(self, parent, text: str, command, width: int = 120, height: int = 35, fg_color=None, hover_color=None, font_size: int = 12): """创建标准按钮的工厂方法""" if fg_color is None: fg_color = BUTTON_BLUE if hover_color is None: hover_color = BUTTON_BLUE_HOVER return ctk.CTkButton( parent, text=text, command=command, width=width, height=height, font=ctk.CTkFont(size=font_size), fg_color=fg_color, hover_color=hover_color ) def _batch_operation(self, operation_func, items, operation_name: str = "操作"): """批量操作的通用方法""" if not items: custom_dialogs.show_warning(self, "警告", f"没有选中的项目进行{operation_name}") return False success_count = 0 failed_count = 0 for item in items: try: if operation_func(item): success_count += 1 else: failed_count += 1 except Exception as e: self._log(f"Batch operation error: {e}", "DEBUG") failed_count += 1 # 显示结果 if failed_count == 0: custom_dialogs.show_info(self, "成功", f"{operation_name}完成,共处理 {success_count} 项") else: custom_dialogs.show_warning( self, "部分成功", f"{operation_name}完成,成功 {success_count} 项,失败 {failed_count} 项" ) return success_count > 0 def _center_window(self): """将窗口居中显示在屏幕中央""" self.update_idletasks() # 获取窗口大小 window_width = self.winfo_width() window_height = self.winfo_height() # 获取屏幕大小 screen_width = self.winfo_screenwidth() screen_height = self.winfo_screenheight() # 计算居中位置 x = (screen_width - window_width) // 2 y = (screen_height - window_height) // 2 # 设置窗口位置 self.geometry(f"{window_width}x{window_height}+{x}+{y}") def _set_dark_title_bar(self, window): """设置窗口深色标题栏(Windows 10/11)""" try: window.update() hwnd = ctypes.windll.user32.GetParent(window.winfo_id()) # DWMWA_USE_IMMERSIVE_DARK_MODE = 20 (Windows 11) # DWMWA_USE_IMMERSIVE_DARK_MODE = 19 (Windows 10 1903+) DWMWA_USE_IMMERSIVE_DARK_MODE = 20 value = ctypes.c_int(1) # 1 = 深色模式 ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value) ) # 如果 Windows 11 方式失败,尝试 Windows 10 方式 if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value)) != 0: DWMWA_USE_IMMERSIVE_DARK_MODE = 19 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 destroy(self): """销毁设置窗口前解除全局绑定,避免潜在泄漏""" try: # 解除全局快捷键绑定 self.unbind_all("") self.unbind_all("") except Exception as e: self._log(f"Failed to unbind global events on destroy: {e}", "DEBUG") # 调用父类销毁 return super().destroy() def _create_custom_input_dialog(self, title: str, text: str, default_value: str = "") -> str: """Create custom input dialog (with icon and dark title bar)""" dialog = ctk.CTkToplevel(self) dialog.title(title) dialog.geometry(DIALOG_INPUT_SIZE) dialog.resizable(False, False) # 设置图标路径 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) - (220 // 2) dialog.geometry(f"{DIALOG_INPUT_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) # 存储结果 result = {"value": None} # 提示文本 label = ctk.CTkLabel(dialog, text=text, font=ctk.CTkFont(size=13)) label.pack(pady=(30, 10), padx=20) # 输入框 entry = ctk.CTkEntry(dialog, width=350, height=35) entry.pack(pady=10, padx=20) if default_value: entry.insert(0, default_value) entry.focus_set() # 按钮容器 btn_frame = ctk.CTkFrame(dialog, fg_color="transparent") btn_frame.pack(pady=20) def on_ok(): result["value"] = entry.get() dialog.destroy() def on_cancel(): result["value"] = None dialog.destroy() # OK 按钮 ok_btn = ctk.CTkButton( btn_frame, text="确定", command=on_ok, width=150, height=50, font=ctk.CTkFont(size=14) ) ok_btn.pack(side="left", padx=10) # Cancel 按钮 cancel_btn = ctk.CTkButton( btn_frame, text="取消", command=on_cancel, width=150, height=50, font=ctk.CTkFont(size=14), fg_color=BUTTON_GRAY, hover_color=BUTTON_GRAY_HOVER ) cancel_btn.pack(side="left", padx=10) # 绑定回车键 entry.bind("", lambda e: on_ok()) entry.bind("", lambda e: on_cancel()) # 等待对话框关闭 dialog.wait_window() return result["value"] def _create_custom_confirm_dialog(self, title: str, message: str) -> bool: """创建自定义确认对话框(带图标和深色标题栏)""" dialog = ctk.CTkToplevel(self) dialog.title(title) dialog.geometry(DIALOG_CONFIRM_SIZE) dialog.resizable(False, False) # 设置图标路径 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) - (450 // 2) y = (dialog.winfo_screenheight() // 2) - (200 // 2) dialog.geometry(f"{DIALOG_CONFIRM_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) # 存储结果 result = {"value": False} # 消息文本 label = ctk.CTkLabel( dialog, text=message, font=ctk.CTkFont(size=13), wraplength=400 ) label.pack(pady=(40, 20), padx=20) # 按钮容器 btn_frame = ctk.CTkFrame(dialog, fg_color="transparent") btn_frame.pack(pady=20) def on_yes(): result["value"] = True dialog.destroy() def on_no(): result["value"] = False dialog.destroy() # 是 按钮 yes_btn = ctk.CTkButton( btn_frame, text="是(Y)", command=on_yes, width=150, height=50, font=ctk.CTkFont(size=14), fg_color=BUTTON_RED, hover_color=BUTTON_RED_HOVER ) yes_btn.pack(side="left", padx=10) # 否 按钮 no_btn = ctk.CTkButton( btn_frame, text="否(N)", command=on_no, width=150, height=50, font=ctk.CTkFont(size=14), fg_color=BUTTON_GRAY, hover_color=BUTTON_GRAY_HOVER ) no_btn.pack(side="left", padx=10) # 绑定键盘快捷键 dialog.bind("y", lambda e: on_yes()) dialog.bind("Y", lambda e: on_yes()) dialog.bind("n", lambda e: on_no()) dialog.bind("N", lambda e: on_no()) dialog.bind("", lambda e: on_no()) # 等待对话框关闭 dialog.wait_window() return result["value"] def _create_widgets(self): """创建界面组件""" # 主容器 self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(1, weight=1) # 标题 title_label = ctk.CTkLabel( self, text="配置管理", font=ctk.CTkFont(size=20, weight="bold") ) title_label.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="w") # 主内容区域 content_frame = ctk.CTkFrame(self) content_frame.grid(row=1, column=0, padx=20, pady=(0, 20), sticky="nsew") content_frame.grid_columnconfigure(0, weight=1) content_frame.grid_rowconfigure(1, weight=1) # 项目选择区域 project_frame = ctk.CTkFrame(content_frame) project_frame.grid(row=0, column=0, padx=10, pady=(10, 0), sticky="ew") project_frame.grid_columnconfigure(1, weight=1) # 第一行:项目选择 ctk.CTkLabel( project_frame, text="选择项目:", font=ctk.CTkFont(size=14, weight="bold") ).grid(row=0, column=0, padx=10, pady=(10, 5), sticky="w") self.project_combo = ctk.CTkComboBox( project_frame, command=self._on_project_changed, width=200, state="readonly" # 设置为只读,禁止输入 ) self.project_combo.grid(row=0, column=1, padx=10, pady=(10, 5), sticky="ew") # 第二行:项目管理按钮 project_buttons_frame = ctk.CTkFrame(project_frame, fg_color="transparent") project_buttons_frame.grid(row=1, column=0, columnspan=2, padx=10, pady=(5, 5), sticky="ew") # 项目管理按钮 project_buttons = [ ("新建项目", self._add_project, BUTTON_BLUE, BUTTON_BLUE_HOVER), ("复制项目", self._copy_project, BUTTON_BLUE, BUTTON_BLUE_HOVER), ("重命名项目", self._rename_project, BUTTON_BLUE, BUTTON_BLUE_HOVER), ("删除项目", self._delete_project, BUTTON_RED, BUTTON_RED_HOVER) ] for text, command, fg_color, hover_color in project_buttons: self._create_button( project_buttons_frame, text, command, fg_color=fg_color, hover_color=hover_color ).pack(side="left", padx=5) # 第三行:项目图标和颜色设置 settings_frame = ctk.CTkFrame(project_frame, fg_color="transparent") settings_frame.grid(row=2, column=0, columnspan=2, padx=10, pady=(5, 10), sticky="ew") # 项目设置按钮 settings_buttons = [ ("设置图标", self._set_project_icon), ("设置背景颜色", self._set_project_color) ] for text, command in settings_buttons: self._create_button(settings_frame, text, command).pack(side="left", padx=5) # 应用列表区域 - 直接在框架内显示标题 apps_frame = ctk.CTkFrame(content_frame, fg_color="transparent") apps_frame.grid(row=1, column=0, padx=10, pady=(0, 0), sticky="nsew") apps_frame.grid_columnconfigure(0, weight=1) apps_frame.grid_rowconfigure(1, weight=1) apps_label = ctk.CTkLabel( apps_frame, text="应用列表:", font=ctk.CTkFont(size=14, weight="bold") ) apps_label.grid(row=0, column=0, padx=0, pady=(0, 0), sticky="nw") # 滚动框架 - 底部留出空间以显示拖动指示器 self.apps_scroll_frame = ctk.CTkScrollableFrame(apps_frame) self.apps_scroll_frame.grid(row=1, column=0, padx=0, pady=(0, 20), sticky="nsew") # 延迟设置滚动条颜色,确保滚动条已创建 def set_scrollbar_colors(): try: # 尝试不同的滚动条属性名称 for attr_name in ['_scrollbar', '_parent_canvas', '_scrollbar_vertical']: if hasattr(self.apps_scroll_frame, attr_name): scrollbar = getattr(self.apps_scroll_frame, attr_name) if scrollbar and hasattr(scrollbar, 'configure'): scrollbar.configure( button_color=SCROLLBAR_COLOR, button_hover_color=SCROLLBAR_HOVER_COLOR, fg_color=SCROLLBAR_COLOR ) self._log(f"Successfully set scrollbar color via {attr_name}", "DEBUG") break except Exception as e: self._log(f"Failed to set scrollbar color: {e}", "DEBUG") # 延迟执行 self.after(100, set_scrollbar_colors) # 在空白区域右键点击时显示粘贴菜单 self.apps_scroll_frame.bind("", self._show_empty_area_menu) # 在空白区域左键点击时取消选择 self.apps_scroll_frame.bind("", self._handle_empty_area_click) # 添加应用按钮 add_app_btn = ctk.CTkButton( content_frame, text="+ 添加应用", command=self._add_app, height=40, font=ctk.CTkFont(size=14, weight="bold"), fg_color=BUTTON_GREEN, hover_color=BUTTON_GREEN_HOVER ) add_app_btn.grid(row=2, column=0, padx=10, pady=(5, 10), sticky="ew") def _load_data(self): """加载数据""" # 加载项目列表 projects = self.config_manager.get_projects() if projects: self.project_combo.configure(values=projects) current_project = self.config_manager.get_current_project() if current_project in projects: self.project_combo.set(current_project) else: self.project_combo.set(projects[0]) self._load_apps() else: self.project_combo.configure(values=[""]) self.project_combo.set("") def _on_project_changed(self, choice): """项目切换事件""" self._load_apps() def _load_apps(self): """加载应用列表""" # 清空选择 self.selected_items = [] self.last_selected_index = None # 清空现有应用 for widget in self.apps_scroll_frame.winfo_children(): widget.destroy() current_project = self.project_combo.get() if not current_project: return apps = self.config_manager.get_apps(current_project) for idx, app in enumerate(apps): self._create_app_item(idx, app) def _create_app_item(self, index: int, app: dict): """创建应用项""" item_frame = ctk.CTkFrame( self.apps_scroll_frame, corner_radius=12, fg_color=SCROLLBAR_COLOR # 设置与滑块一致的背景色 ) item_frame.pack(fill="x", padx=5, pady=3) item_frame.grid_columnconfigure(1, weight=1) # 存储索引信息 item_frame.app_index = index # 设置应用项边框 item_frame.configure(border_width=1, border_color=BORDER_COLOR) # 创建一个容器框架来定位拖动手柄,覆盖整个卡片高度 handle_container = ctk.CTkFrame(item_frame, fg_color="transparent") handle_container.grid(row=0, column=0, rowspan=4, padx=(5, 8), pady=8, sticky="ns") # 增加rowspan为4覆盖按钮行 handle_container.grid_rowconfigure(0, weight=1) # 上方留出空间 handle_container.grid_rowconfigure(1, weight=0) # 手柄行 handle_container.grid_rowconfigure(2, weight=1) # 下方留出空间 # 添加拖放手柄 - 使用更协调的设计并垂直居中 # 创建一个背景框作为手柄的容器 drag_handle_bg = ctk.CTkFrame( handle_container, width=30, height=100, fg_color="transparent", # 透明背景,使用Canvas绘制 corner_radius=15 # 增大圆角 ) drag_handle_bg.grid(row=1, column=0, sticky="ns") drag_handle_bg.grid_propagate(False) # 防止内容影响容器大小 drag_handle_bg.grid_rowconfigure(0, weight=1) drag_handle_bg.grid_columnconfigure(0, weight=1) # 使用Canvas而不是Label来显示拖动符号,以解决偏移问题 drag_handle = ctk.CTkCanvas( drag_handle_bg, width=30, height=100, bg=SCROLLBAR_COLOR, # 与滚动条颜色统一 highlightthickness=0, # 移除边框 cursor="hand2" ) drag_handle.grid(row=0, column=0, sticky="nsew") # 在Canvas中心绘制三条水平线 line_color = LINE_COLOR_GRAY line_width = 18 # 线条长度 line_height = 3 # 线条高度 line_spacing = 7 # 线条间距 # 计算中心位置 center_x = 15 center_y = 50 # 计算第一条线的位置(三条线的中心位置) start_y = center_y - line_spacing # 绘制三条线并保存线条ID line_ids = [] for i in range(3): y_pos = start_y + i * line_spacing line_id = drag_handle.create_line( center_x - line_width/2, y_pos, center_x + line_width/2, y_pos, fill=line_color, width=line_height, capstyle="round" # 圆形线条端点 ) line_ids.append(line_id) # 在item_frame上保存拖动手柄的引用,用于后续更新颜色 item_frame.drag_handle = drag_handle item_frame.drag_handle_line_ids = line_ids # 绑定拖放事件到手柄文本 drag_handle.bind("", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame)) drag_handle.bind("", self._on_drag_motion) drag_handle.bind("", self._end_drag) # 绑定左键点击和拖动事件到卡片本身 item_frame.bind("", lambda e, idx=index, frame=item_frame: self._on_item_click_or_drag_start(e, idx, frame)) item_frame.bind("", lambda e, idx=index, frame=item_frame: self._on_item_drag_motion(e, idx, frame)) item_frame.bind("", self._on_item_drag_end) # 绑定右键菜单(不阻止事件传播,这样空白区域也能触发) item_frame.bind("", lambda e, idx=index: self._show_context_menu(e, idx), add="+") # 绑定拖放事件到手柄背景框 drag_handle_bg.bind("", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame)) drag_handle_bg.bind("", self._on_drag_motion) drag_handle_bg.bind("", self._end_drag) # 同时绑定拖放事件到手柄外层容器 handle_container.bind("", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame)) handle_container.bind("", self._on_drag_motion) handle_container.bind("", self._end_drag) # 应用名称 name_label = ctk.CTkLabel( item_frame, text=f"名称: {app['name']}", font=ctk.CTkFont(size=12, weight="bold") ) name_label.grid(row=0, column=1, columnspan=3, padx=10, pady=(8, 2), sticky="w") name_label.bind("", lambda e, idx=index: self._show_context_menu(e, idx)) # 路径 path_label = ctk.CTkLabel( item_frame, text=f"路径: {app['path']}", font=ctk.CTkFont(size=11) ) path_label.grid(row=1, column=1, columnspan=3, padx=10, pady=1, sticky="w") path_label.bind("", lambda e, idx=index: self._show_context_menu(e, idx)) # 版本 version_label = ctk.CTkLabel( item_frame, text=f"版本: {app['version']}", font=ctk.CTkFont(size=11) ) version_label.grid(row=2, column=1, columnspan=3, padx=10, pady=(1, 6), sticky="w") version_label.bind("", lambda e, idx=index: self._show_context_menu(e, idx)) # 按钮行容器 button_frame = ctk.CTkFrame(item_frame, fg_color="transparent") button_frame.grid(row=3, column=1, columnspan=3, padx=10, pady=(0, 8), sticky="e") # 编辑按钮 ctk.CTkButton( button_frame, text="编辑", command=lambda: self._edit_app(index, app), width=80, font=ctk.CTkFont(size=12), fg_color=BUTTON_BLUE, hover_color=BUTTON_BLUE_HOVER ).pack(side="left", padx=5) # 删除按钮 ctk.CTkButton( button_frame, text="删除", command=lambda: self._delete_app(index), width=80, font=ctk.CTkFont(size=12), fg_color=BUTTON_RED, hover_color=BUTTON_RED_HOVER ).pack(side="left", padx=5) def _add_project(self): """添加项目""" # 使用自定义输入对话框 project_name = self._create_custom_input_dialog( title="新建项目", text="请输入项目名称:" ) if project_name: # 设置默认图标为 NexusLauncher.ico(只保存文件名,不保存完整路径) default_icon = "NexusLauncher.ico" if self.config_manager.add_project(project_name, default_icon): # 先通知主窗口更新(刷新项目列表) self.on_update() # 再刷新设置窗口的数据 self._load_data() # 自动切换到新建的项目 self.project_combo.set(project_name) self._load_apps() # 刷新应用列表 # 检查新项目的应用数量 apps = self.config_manager.get_apps(project_name) # 确保键盘事件在新项目创建后仍然有效 self.focus_set() self.focus_force() # 显示成功消息 custom_dialogs.show_info(self, "成功", f"项目 '{project_name}' 已创建") else: custom_dialogs.show_error(self, "错误", "项目已存在或创建失败") def _delete_project(self): """删除项目""" current_project = self._ensure_project_selected() if not current_project: return # 使用自定义确认对话框 result = self._create_custom_confirm_dialog( title="确认删除", message=f"确定要删除项目 '{current_project}' 吗?\n这将删除该项目下的所有应用配置。" ) if result: if self.config_manager.delete_project(current_project): custom_dialogs.show_info(self, "成功", "项目删除成功") self._load_data() self.on_update() else: custom_dialogs.show_error(self, "错误", "删除项目失败") def _rename_project(self): """重命名项目""" current_project = self._ensure_project_selected() if not current_project: return # 使用自定义输入对话框 new_name = self._create_custom_input_dialog( title="重命名项目", text=f"请输入项目 '{current_project}' 的新名称:", default_value=current_project ) if new_name: if self.config_manager.rename_project(current_project, new_name): # 先通知主窗口更新(刷新项目列表) self.on_update() # 再刷新设置窗口的数据 self._load_data() # 自动切换到重命名后的项目 self.project_combo.set(new_name) self._load_apps() # 刷新应用列表 # 显示成功消息 custom_dialogs.show_info(self, "成功", f"项目重命名为 '{new_name}'") else: custom_dialogs.show_error(self, "错误", "重命名项目失败,可能名称重复") elif new_name == current_project: custom_dialogs.show_info(self, "成功", "新名称与原名称相同,无需修改") def _copy_project(self): """复制项目""" current_project = self._ensure_project_selected() if not current_project: return # 生成默认新项目名称(自动添加 _01, _02 等后缀) base_name = current_project counter = 1 default_name = f"{base_name}_01" # 检查名称是否已存在,如果存在则递增数字 existing_projects = self.config_manager.get_projects() while default_name in existing_projects: counter += 1 default_name = f"{base_name}_{counter:02d}" # 弹出对话框让用户输入新项目名称 new_project_name = custom_dialogs.ask_string( self, "复制项目", f"请输入新项目名称:", default_name ) if not new_project_name: return # 用户取消 if new_project_name in existing_projects: custom_dialogs.show_error(self, "错误", "项目名称已存在,请使用其他名称") return # 创建新项目(设置默认图标,只保存文件名) default_icon = "NexusLauncher.ico" if self.config_manager.add_project(new_project_name, default_icon): # 复制源项目的图标和颜色(如果有) source_icon = self.config_manager.get_project_icon(current_project) if source_icon: self.config_manager.set_project_icon(new_project_name, source_icon) source_color = self.config_manager.get_project_color(current_project) if source_color: self.config_manager.set_project_color(new_project_name, source_color) # 获取源项目的所有应用 source_apps = self.config_manager.get_apps(current_project) # 复制所有应用到新项目 for app in source_apps: # 添加应用 self.config_manager.add_app( new_project_name, app['name'], app['path'], app['version'] ) # 复制图标设置 icon = self.config_manager.get_app_icon(app['path']) if icon: self.config_manager.set_app_icon(app['path'], icon) # 复制颜色设置 color = self.config_manager.get_app_color(app['path']) if color: self.config_manager.set_app_color(app['path'], color) # 复制 task_settings(直接从 config_data 复制原始数据,避免格式转换) if (current_project in self.config_manager.config_data.get("projects", {}) and "task_settings" in self.config_manager.config_data["projects"][current_project]): # 直接复制原始的 task_settings(保持 JSON 中的正斜杠格式) source_task_settings = self.config_manager.config_data["projects"][current_project]["task_settings"] # 确保新项目结构存在 if not self.config_manager.config_data.get("projects"): self.config_manager.config_data["projects"] = {} if new_project_name not in self.config_manager.config_data["projects"]: self.config_manager.config_data["projects"][new_project_name] = {} # 深拷贝 task_settings 到新项目 import copy self.config_manager.config_data["projects"][new_project_name]["task_settings"] = copy.deepcopy(source_task_settings) # 保存配置 self.config_manager.save_config() # 先通知主窗口更新(刷新项目列表) self.on_update() # 再刷新设置窗口的数据 self._load_data() # 自动切换到新复制的项目 self.project_combo.set(new_project_name) self._load_apps() # 刷新应用列表 # 显示成功消息 custom_dialogs.show_info( self, "成功", f"已复制项目 '{current_project}' 到 '{new_project_name}'\n共复制了 {len(source_apps)} 个应用" ) else: custom_dialogs.show_error(self, "错误", "复制项目失败") def _add_app(self): """添加应用""" current_project = self._ensure_project_selected() if not current_project: return self._show_app_dialog(current_project) def _edit_app(self, index: int, app: dict): """编辑应用""" current_project = self.project_combo.get() if not current_project: return self._show_app_dialog(current_project, index, app) def _delete_app(self, index: int): """删除应用""" current_project = self.project_combo.get() if not current_project: return result = custom_dialogs.ask_yes_no(self, "确认删除", "确定要删除这个应用吗?") if result: if self.config_manager.delete_app(current_project, index): custom_dialogs.show_info(self, "成功", "应用已删除") self._load_apps() self.on_update() else: custom_dialogs.show_error(self, "错误", "删除应用失败") def _start_drag(self, event, index, frame): """开始拖放""" self.drag_source = index self.drag_widget = frame # 记录鼠标相对于组件的位置 self.drag_data = (event.x_root, event.y_root, frame.winfo_y()) # 改变组件外观,表示正在拖动 frame.configure(border_width=2, border_color=DRAG_HIGHLIGHT_COLOR, fg_color=DRAG_HIGHLIGHT_BG) # 更新拖动手柄的颜色 if hasattr(frame, 'drag_handle') and hasattr(frame, 'drag_handle_line_ids'): # 更改Canvas背景色为与卡片一致的高亮色 frame.drag_handle.configure(bg=DRAG_HIGHLIGHT_BG) # 更改线条颜色为高亮色 for line_id in frame.drag_handle_line_ids: frame.drag_handle.itemconfig(line_id, fill=DRAG_HIGHLIGHT_COLOR) # 创建拖动时的光标指示器 if not hasattr(self, 'insert_indicator') or not self.insert_indicator.winfo_exists(): self.insert_indicator = ctk.CTkFrame(self.apps_scroll_frame, height=4, fg_color=DRAG_HIGHLIGHT_COLOR, corner_radius=2) # 阻止事件传播到item_frame,避免触发多选逻辑 return "break" def _on_drag_motion(self, event): """拖放过程中""" if not self.drag_data or not self.drag_widget: return # 计算移动距离 x_origin, y_origin, y_start = self.drag_data y_offset = event.y_root - y_origin new_y = y_start + y_offset # 获取所有应用项,排除插入指示器 all_children = self.apps_scroll_frame.winfo_children() app_frames = [f for f in all_children if f != getattr(self, 'insert_indicator', None)] if not app_frames: return # 简化的拖动逻辑 target_index = self.drag_source insert_y = 0 insert_at_end = False found = False # 遍历所有卡片,找到鼠标位置对应的目标 for i, frame in enumerate(app_frames): if i == self.drag_source: continue frame_y = frame.winfo_y() frame_h = frame.winfo_height() frame_mid = frame_y + frame_h / 2 # 如果鼠标在这张卡片的上半部分,插入到它之前 if new_y < frame_mid: target_index = i # 确保指示器不会超出可视区域 insert_y = max(10, frame_y - 4) found = True break # 如果没有找到插入位置,说明要插入到末尾 if not found: # 找到最后一张非拖动卡片 last_frame = None for i in range(len(app_frames) - 1, -1, -1): if i != self.drag_source: last_frame = app_frames[i] break if last_frame: target_index = len(app_frames) # 将指示器放在最后一张卡片内部的底部,而不是下方 frame_bottom = last_frame.winfo_y() + last_frame.winfo_height() # 指示器显示在卡片底部边缘 insert_y = frame_bottom - 2 insert_at_end = True if target_index != self.drag_target: self.drag_target = target_index # 重置所有应用项的外观 for i, frame in enumerate(app_frames): if i != self.drag_source: frame.configure(border_width=1, border_color=BORDER_COLOR) # 显示插入指示器 try: indicator_width = self.apps_scroll_frame.winfo_width() - 20 self.insert_indicator.configure(width=indicator_width) self.insert_indicator.place(x=10, y=insert_y - 4) self.insert_indicator.lift() self.insert_indicator.configure(fg_color=DRAG_HIGHLIGHT_COLOR, border_width=1, border_color=BORDER_COLOR_WHITE) except Exception: pass def _end_drag(self, event): """结束拖放""" if self.drag_source is not None and self.drag_target is not None and self.drag_source != self.drag_target: current_project = self.project_combo.get() if current_project: # 重新排序应用 if self.config_manager.reorder_apps(current_project, self.drag_source, self.drag_target): self._load_apps() self.on_update() # 隐藏插入指示器 try: if hasattr(self, 'insert_indicator') and self.insert_indicator.winfo_exists(): if self.insert_indicator.winfo_ismapped(): self.insert_indicator.place_forget() except Exception as e: self._log(f"Error hiding indicator on end drag: {e}", "DEBUG") # 重置所有应用项的外观 for frame in self.apps_scroll_frame.winfo_children(): if frame != getattr(self, 'insert_indicator', None): try: frame.configure(border_width=1, border_color=BORDER_COLOR, fg_color="transparent") # 恢复拖动手柄的颜色 if hasattr(frame, 'drag_handle') and hasattr(frame, 'drag_handle_line_ids'): frame.drag_handle.configure(bg=SCROLLBAR_COLOR) # 与滚动条统一 for line_id in frame.drag_handle_line_ids: frame.drag_handle.itemconfig(line_id, fill=LINE_COLOR_GRAY) except Exception: pass # 重置拖放状态 self.drag_source = None self.drag_target = None self.drag_data = None self.drag_widget = None def _show_app_dialog(self, project_name: str, index: int = -1, app: dict = None): """显示应用编辑对话框""" dialog = ctk.CTkToplevel(self) dialog.title("编辑应用" if app else "添加应用") dialog.geometry(DIALOG_APP_EDIT_SIZE) # 设置对话框背景颜色(与设置窗口一致) dialog.configure(fg_color=DIALOG_BG_COLOR) # 设置窗口图标(在transient之前) icon_path = get_icon_path() if os.path.exists(icon_path): try: dialog.iconbitmap(icon_path) dialog.wm_iconbitmap(icon_path) except Exception: pass dialog.transient(self) dialog.grab_set() # 设置深色标题栏(Windows 10/11) dialog.after(10, lambda: self._set_dark_title_bar(dialog)) # 居中显示 dialog.update_idletasks() x = (dialog.winfo_screenwidth() // 2) - (650 // 2) y = (dialog.winfo_screenheight() // 2) - (700 // 2) dialog.geometry(f"{DIALOG_APP_EDIT_SIZE}+{x}+{y}") # 再次尝试设置图标(确保生效) if os.path.exists(icon_path): def set_icon_delayed(): try: dialog.iconbitmap(icon_path) dialog.wm_iconbitmap(icon_path) except Exception: pass dialog.after(50, set_icon_delayed) dialog.after(200, set_icon_delayed) # 表单 form_frame = ctk.CTkFrame(dialog) form_frame.pack(fill="both", expand=True, padx=20, pady=20) # 获取图标文件夹路径 icons_dir = get_icons_dir() # 应用名称 ctk.CTkLabel(form_frame, text="应用名称:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) name_entry = ctk.CTkEntry(form_frame, placeholder_text="例如: 记事本") name_entry.pack(fill="x", pady=(0, 10)) if app: name_entry.insert(0, app['name']) # 应用路径 ctk.CTkLabel(form_frame, text="应用路径:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) path_frame = ctk.CTkFrame(form_frame) path_frame.pack(fill="x", pady=(0, 10)) path_entry = ctk.CTkEntry(path_frame, placeholder_text="例如: C:\\Program Files\\app.exe") path_entry.pack(side="left", fill="x", expand=True, padx=(0, 10)) if app: # 显示时将正斜杠转换为反斜杠(UI 显示格式) display_path = app['path'].replace("/", "\\") path_entry.insert(0, display_path) def browse_file(): filename = filedialog.askopenfilename( title="选择应用程序", filetypes=[("可执行文件", "*.exe"), ("所有文件", "*.*")] ) if filename: # 将选择的路径标准化为反斜杠(UI 显示格式) normalized_path = filename.replace("/", "\\") path_entry.delete(0, "end") path_entry.insert(0, normalized_path) ctk.CTkButton(path_frame, text="浏览", command=browse_file, width=80).pack(side="right") # 版本号 ctk.CTkLabel(form_frame, text="版本号:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) version_entry = ctk.CTkEntry(form_frame, placeholder_text="例如: 1.0.0") version_entry.pack(fill="x", pady=(0, 10)) if app: version_entry.insert(0, app['version']) # 图标选择 ctk.CTkLabel(form_frame, text="选择图标:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) icon_frame = ctk.CTkFrame(form_frame) icon_frame.pack(fill="x", pady=(0, 10)) # 默认图标路径(使用 NexusLauncher.ico 作为默认图标) default_icon_path = get_icon_path() selected_icon = ctk.StringVar(value=default_icon_path if os.path.exists(default_icon_path) else "") # 如果已有图标设置,则加载 if app and app['path']: custom_icon = self.config_manager.get_app_icon(app['path']) if custom_icon and os.path.exists(custom_icon): selected_icon.set(custom_icon) # 显示当前选择的图标 icon_preview_frame = ctk.CTkFrame(icon_frame) icon_preview_frame.pack(side="left", padx=(0, 10)) icon_preview_label = ctk.CTkLabel(icon_preview_frame, text="无图标", image=None) icon_preview_label.pack(padx=10, pady=10) # 更新图标预览 def update_icon_preview(icon_path=None): if not icon_path and selected_icon.get(): icon_path = selected_icon.get() if icon_path and os.path.exists(icon_path): try: # 加载图标并调整大小 img = Image.open(icon_path) img = img.resize((48, 48), Image.Resampling.LANCZOS) ctk_img = ctk.CTkImage(light_image=img, dark_image=img, size=(48, 48)) icon_preview_label.configure(image=ctk_img, text="") # 清除文字 icon_preview_label._image = ctk_img # 保持引用以防止垃圾回收 except Exception as e: self._log(f"Failed to load icon preview: {e}", "DEBUG") icon_preview_label.configure(image=None, text="Preview failed") else: icon_preview_label.configure(image=None, text="No icon") # 初始化预览 update_icon_preview() # 图标选择按钮 icon_buttons_frame = ctk.CTkFrame(icon_frame, fg_color="transparent") icon_buttons_frame.pack(side="left", fill="x", expand=True) def browse_icon(): filename = filedialog.askopenfilename( title="选择图标文件", filetypes=[("图标文件", "*.png;*.ico;*.jpg;*.jpeg"), ("所有文件", "*.*")] ) if filename: selected_icon.set(filename) update_icon_preview(filename) def select_preset_icon(): # 创建预设图标选择对话框 icon_dialog = ctk.CTkToplevel(dialog) icon_dialog.title("选择预设图标") icon_dialog.geometry(DIALOG_ICON_SELECT_SIZE) # 设置窗口图标(在transient之前) icon_path = get_icon_path() if os.path.exists(icon_path): try: icon_dialog.iconbitmap(icon_path) icon_dialog.wm_iconbitmap(icon_path) except Exception: pass icon_dialog.transient(dialog) icon_dialog.grab_set() # 再次尝试设置图标(确保生效) if os.path.exists(icon_path): def set_icon_delayed(): try: icon_dialog.iconbitmap(icon_path) icon_dialog.wm_iconbitmap(icon_path) except Exception: pass icon_dialog.after(50, set_icon_delayed) icon_dialog.after(200, set_icon_delayed) # 获取所有预设图标 preset_icons = glob.glob(os.path.join(icons_dir, "*.png")) preset_icons.extend(glob.glob(os.path.join(icons_dir, "*.ico"))) # 创建滚动区域 scroll_frame = ctk.CTkScrollableFrame( icon_dialog, scrollbar_button_color=SCROLLBAR_COLOR, scrollbar_button_hover_color=SCROLLBAR_HOVER_COLOR ) scroll_frame.pack(fill="both", expand=True, padx=20, pady=20) # 创建网格布局 row, col = 0, 0 max_cols = 5 for icon_path in preset_icons: try: # 创建图标项 icon_item = ctk.CTkFrame(scroll_frame) icon_item.grid(row=row, column=col, padx=5, pady=5) # 加载图标 img = Image.open(icon_path) img = img.resize((48, 48), Image.Resampling.LANCZOS) ctk_img = ctk.CTkImage(light_image=img, dark_image=img, size=(48, 48)) # 创建按钮 def make_select_cmd(path): return lambda: select_icon(path) icon_btn = ctk.CTkButton( icon_item, text="", image=ctk_img, command=make_select_cmd(icon_path), width=60, height=60 ) icon_btn.pack(padx=5, pady=5) icon_btn._image = ctk_img # 保持引用 # 显示图标名称 icon_name = os.path.splitext(os.path.basename(icon_path))[0] ctk.CTkLabel( icon_item, text=icon_name, font=ctk.CTkFont(size=10) ).pack(pady=(0, 5)) # 更新行列位置 col += 1 if col >= max_cols: col = 0 row += 1 except Exception as e: self._log(f"Failed to load preset icon {icon_path}: {e}", "DEBUG") def select_icon(path): selected_icon.set(path) update_icon_preview(path) icon_dialog.destroy() # 取消按钮 ctk.CTkButton( icon_dialog, text="取消", command=icon_dialog.destroy, width=100 ).pack(pady=10) # 图标选择按钮 ctk.CTkButton( icon_buttons_frame, text="选择预设图标", command=select_preset_icon ).pack(side="left", padx=5, pady=5) ctk.CTkButton( icon_buttons_frame, text="浏览本地图标", command=browse_icon ).pack(side="left", padx=5, pady=5) ctk.CTkButton( icon_buttons_frame, text="清除图标", command=lambda: [selected_icon.set(""), update_icon_preview()] ).pack(side="left", padx=5, pady=5) # 按钮颜色选择 ctk.CTkLabel(form_frame, text="按钮颜色:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) color_frame = ctk.CTkFrame(form_frame) color_frame.pack(fill="x", pady=(0, 10)) # 默认颜色(使用预设颜色列表中的第一个) default_color = PRESET_COLORS[0] # 蓝灰色 selected_color = ctk.StringVar(value=default_color) # 如果已有颜色设置,则加载 if app and app['path']: custom_color = self.config_manager.get_app_color(app['path']) if custom_color: selected_color.set(custom_color) # 颜色预览框 color_preview_frame = ctk.CTkFrame(color_frame, width=60, height=60, corner_radius=10) color_preview_frame.pack(side="left", padx=(0, 10)) color_preview_frame.pack_propagate(False) color_preview_label = ctk.CTkLabel(color_preview_frame, text="", width=60, height=60, corner_radius=10) color_preview_label.pack(fill="both", expand=True) # 更新颜色预览 def update_color_preview(color=None): if not color and selected_color.get(): color = selected_color.get() if color: color_preview_label.configure(fg_color=color, text="") else: color_preview_label.configure(fg_color="transparent", text="无颜色") # 初始化预览 update_color_preview() # 使用配置文件中的预设颜色列表 preset_colors = PRESET_COLORS # 颜色选择按钮容器 color_buttons_frame = ctk.CTkFrame(color_frame, fg_color="transparent") color_buttons_frame.pack(side="left", fill="x", expand=True) # 创建颜色按钮网格 color_grid_frame = ctk.CTkFrame(color_buttons_frame, fg_color="transparent") color_grid_frame.pack(fill="x", pady=5) for idx, color in enumerate(preset_colors): row = idx // 6 col = idx % 6 def make_color_cmd(c): return lambda: [selected_color.set(c), update_color_preview(c)] color_btn = ctk.CTkButton( color_grid_frame, text="", width=35, height=35, fg_color=color, hover_color=color, command=make_color_cmd(color), corner_radius=8 ) color_btn.grid(row=row, column=col, padx=3, pady=3) # 自定义颜色和清除按钮 custom_color_frame = ctk.CTkFrame(color_buttons_frame, fg_color="transparent") custom_color_frame.pack(fill="x", pady=5) def choose_custom_color(): from tkinter import colorchooser color = colorchooser.askcolor(title="选择颜色") if color and color[1]: selected_color.set(color[1]) update_color_preview(color[1]) ctk.CTkButton( custom_color_frame, text="自定义颜色", command=choose_custom_color, width=120 ).pack(side="left", padx=5) ctk.CTkButton( custom_color_frame, text="清除颜色", command=lambda: [selected_color.set(""), update_color_preview()], width=100 ).pack(side="left", padx=5) # 按钮 btn_frame = ctk.CTkFrame(form_frame) btn_frame.pack(fill="x", pady=(10, 0)) def save_app(): name = name_entry.get().strip() path = path_entry.get().strip() version = version_entry.get().strip() if not name or not path or not version: custom_dialogs.show_warning(dialog, "警告", "请填写所有字段") return # 将路径转换为正斜杠格式(JSON 存储格式) path = path.replace("\\", "/") # 保存图标设置 icon_path = selected_icon.get() # 保存颜色设置 color = selected_color.get() if index >= 0: # 更新 if self.config_manager.update_app(project_name, index, name, path, version): # 设置图标 if icon_path: self.config_manager.set_app_icon(path, icon_path) else: self.config_manager.remove_app_icon(path) # 设置颜色 if color: self.config_manager.set_app_color(path, color) else: self.config_manager.remove_app_color(path) custom_dialogs.show_info(dialog, "成功", "应用已更新") dialog.destroy() self._load_apps() self.on_update() else: custom_dialogs.show_error(dialog, "错误", "更新应用失败") else: # 添加 if self.config_manager.add_app(project_name, name, path, version): # 设置图标 if icon_path: self.config_manager.set_app_icon(path, icon_path) # 设置颜色 if color: self.config_manager.set_app_color(path, color) custom_dialogs.show_info(dialog, "成功", "应用已添加") dialog.destroy() self._load_apps() self.on_update() else: custom_dialogs.show_error(dialog, "错误", "添加应用失败") ctk.CTkButton( btn_frame, text="保存", command=save_app, fg_color=SAVE_BUTTON_COLOR, hover_color=SAVE_BUTTON_HOVER ).pack(side="right", padx=5) ctk.CTkButton( btn_frame, text="取消", command=dialog.destroy, fg_color=BUTTON_GRAY, hover_color=BUTTON_GRAY_HOVER ).pack(side="right", padx=5) def _on_item_click_or_drag_start(self, event, index: int, frame): """处理卡片点击或拖动开始(智能判断)""" # 记录初始点击位置和时间,用于判断是点击还是拖动 self.click_start_pos = (event.x_root, event.y_root) self.click_start_time = event.time self.potential_drag_index = index self.potential_drag_frame = frame self.is_dragging = False # 如果点击的是已选中的卡片,暂不处理选择逻辑,等待判断是拖动还是点击 if index in self.selected_items: self.pending_selection = None else: # 如果点击的是未选中的卡片,记录待处理的选择操作 self.pending_selection = (event, index, frame) def _on_item_drag_motion(self, event, index: int, frame): """处理卡片拖动移动""" if not hasattr(self, 'click_start_pos'): return # 计算移动距离 dx = abs(event.x_root - self.click_start_pos[0]) dy = abs(event.y_root - self.click_start_pos[1]) # 如果移动距离超过阈值(5像素),认为是拖动操作 if dx > 5 or dy > 5: if not self.is_dragging: # 开始拖动 self.is_dragging = True # 如果拖动的是未选中的卡片,先选中它 if hasattr(self, 'pending_selection') and self.pending_selection: evt, idx, frm = self.pending_selection self._on_item_click(evt, idx, frm) self.pending_selection = None # 开始拖动选中的卡片 self._start_drag(event, self.potential_drag_index, self.potential_drag_frame) else: # 继续拖动 self._on_drag_motion(event) def _on_item_drag_end(self, event): """处理卡片拖动结束""" if hasattr(self, 'is_dragging') and self.is_dragging: # 结束拖动 self._end_drag(event) self.is_dragging = False elif hasattr(self, 'pending_selection') and self.pending_selection: # 没有发生拖动,执行点击选择 evt, idx, frm = self.pending_selection self._on_item_click(evt, idx, frm) self.pending_selection = None # 清理临时变量 if hasattr(self, 'click_start_pos'): delattr(self, 'click_start_pos') if hasattr(self, 'potential_drag_index'): delattr(self, 'potential_drag_index') if hasattr(self, 'potential_drag_frame'): delattr(self, 'potential_drag_frame') def _on_item_click(self, event, index: int, frame): """处理应用项点击事件(用于多选)""" # 检查是否按住Shift键 if event.state & 0x0001: # Shift键 if self.last_selected_index is not None: # Shift多选:选择从上次选中到当前点击之间的所有项 start = min(self.last_selected_index, index) end = max(self.last_selected_index, index) self.selected_items = list(range(start, end + 1)) else: # 如果没有上次选中的项,只选中当前项 self.selected_items = [index] elif event.state & 0x0004: # Ctrl键 # Ctrl多选:切换当前项的选中状态 if index in self.selected_items: self.selected_items.remove(index) else: self.selected_items.append(index) else: # 普通点击:只选中当前项 self.selected_items = [index] self.last_selected_index = index self._update_selection_display() # 阻止事件冒泡到空白区域处理 return "break" def _update_selection_display(self): """更新选中项的显示效果(优化版)""" # 取消之前的延迟更新 if hasattr(self, '_update_timer') and self._update_timer is not None: self.after_cancel(self._update_timer) # 延迟更新以减少频繁刷新 self._update_timer = self.after(10, self._do_update_selection_display) def _do_update_selection_display(self): """执行选择显示更新""" selected_set = set(self.selected_items) # 使用集合提高查找效率 # 遍历所有应用项,更新选中状态 for widget in self.apps_scroll_frame.winfo_children(): if hasattr(widget, 'app_index'): is_selected = widget.app_index in selected_set if is_selected: # 选中状态 - 使用边框和背景色高亮 widget.configure(border_width=2, border_color=SELECTION_BORDER, fg_color=SELECTION_BG) # 更新拖动手柄颜色为选中状态 if hasattr(widget, 'drag_handle') and hasattr(widget, 'drag_handle_line_ids'): for line_id in widget.drag_handle_line_ids: widget.drag_handle.itemconfig(line_id, fill=SELECTION_BORDER) # 手柄背景与选中背景一致 widget.drag_handle.configure(bg=SELECTION_BG) else: # 未选中状态 - 使用与滑块一致的背景色 widget.configure(border_width=1, border_color=BORDER_COLOR, fg_color=SCROLLBAR_COLOR) # 恢复拖动手柄颜色为默认状态 if hasattr(widget, 'drag_handle') and hasattr(widget, 'drag_handle_line_ids'): for line_id in widget.drag_handle_line_ids: widget.drag_handle.itemconfig(line_id, fill=LINE_COLOR_GRAY) widget.drag_handle.configure(bg=SCROLLBAR_COLOR) # 与滚动条统一 def _handle_empty_area_click(self, event): """处理滚动框架内空白区域左键点击,取消选择""" # 这个方法现在由全局点击处理方法统一处理 self._handle_global_click(event) def _handle_global_click(self, event): """处理全局左键点击事件,在空白区域取消选择""" # 检查点击的控件是否是应用卡片或其子控件 clicked_widget = event.widget # 向上遍历控件树,检查是否点击在应用卡片上 current_widget = clicked_widget is_app_item = False for _ in range(10): # 增加检查层数,确保能找到应用卡片 if hasattr(current_widget, 'app_index'): is_app_item = True break try: current_widget = current_widget.master if not current_widget: break except: break # 检查是否点击在交互控件上(按钮、输入框等) widget_class = clicked_widget.__class__.__name__ is_interactive = any(control_type in widget_class for control_type in ['Button', 'Entry', 'Textbox', 'Combobox', 'Checkbutton', 'Radiobutton', 'Scale', 'Scrollbar']) # 如果不是点击在应用卡片上,也不是交互控件,且有选中项,则取消选择 if not is_app_item and not is_interactive and self.selected_items: self._log("Global click - clearing selection of {len(self.selected_items)} apps", "DEBUG") self.selected_items.clear() self.last_selected_index = None self._update_selection_display() def _setup_global_click_binding(self): """设置全局点击事件绑定""" def bind_recursive(widget): """递归绑定所有子控件的点击事件""" try: # 跳过应用卡片,它们有自己的点击处理 if hasattr(widget, 'app_index'): return # 跳过已经有特定点击处理的控件 widget_class = widget.__class__.__name__ if any(control_type in widget_class for control_type in ['Button', 'Entry', 'Textbox', 'Combobox']): return # 绑定点击事件 widget.bind("", self._handle_global_click, "+") # 递归绑定子控件 for child in widget.winfo_children(): bind_recursive(child) except Exception: pass # 忽略绑定错误 # 从根窗口开始递归绑定 bind_recursive(self) def _show_empty_area_menu(self, event): """在空白区域显示右键菜单""" from tkinter import Menu # 创建现代化的右键菜单 menu = Menu(self, tearoff=0, bg="#2d2d30", fg="#ffffff", activebackground="#0e639c", activeforeground="#ffffff", selectcolor="#ffffff", relief="flat", borderwidth=1, bd=1, font=("Segoe UI", 11, "normal")) # 全选选项 current_project = self.project_combo.get() if current_project: apps = self.config_manager.get_apps(current_project) if apps: menu.add_command( label=f"全选 ({len(apps)} 项)", command=self._select_all_apps_direct ) menu.add_separator() # 粘贴选项 if self.clipboard_apps: menu.add_command( label=f"粘贴 ({len(self.clipboard_apps)} 项)", command=self._paste_apps ) # 如果菜单为空,添加提示 if menu.index("end") is None: menu.add_command(label="无可用操作", state="disabled") # 显示菜单 try: menu.tk_popup(event.x_root, event.y_root) finally: menu.grab_release() def _show_context_menu(self, event, index: int): """显示应用项右键菜单""" from tkinter import Menu # 如果右键点击的项不在选中列表中,则只选中当前项 if index not in self.selected_items: self.selected_items = [index] self.last_selected_index = index self._update_selection_display() # 创建现代化的右键菜单 menu = Menu(self, tearoff=0, bg="#2d2d30", fg="#ffffff", activebackground="#0e639c", activeforeground="#ffffff", selectcolor="#ffffff", relief="flat", borderwidth=1, bd=1, font=("Segoe UI", 11, "normal")) # 选择相关操作 current_project = self.project_combo.get() if current_project: apps = self.config_manager.get_apps(current_project) if len(apps) > len(self.selected_items): menu.add_command( label=f"全选 ({len(apps)} 项)", command=self._select_all_apps_direct ) menu.add_separator() # 编辑操作 menu.add_command( label=f"复制 ({len(self.selected_items)} 项)", command=self._copy_apps ) menu.add_command( label=f"剪切 ({len(self.selected_items)} 项)", command=self._cut_apps ) # 粘贴操作 if self.clipboard_apps: menu.add_command( label=f"粘贴 ({len(self.clipboard_apps)} 项)", command=self._paste_apps ) # 分隔符 menu.add_separator() # 删除操作 menu.add_command( label=f"删除 ({len(self.selected_items)} 项)", command=self._delete_selected_apps ) # 显示菜单 try: menu.tk_popup(event.x_root, event.y_root) finally: menu.grab_release() def _copy_apps(self, menu=None): """复制选中的应用到剪贴板""" if menu: menu.destroy() # 获取界面上当前选择的项目 current_project = self.project_combo.get() if not current_project: return apps = self.config_manager.get_apps(current_project) # 复制选中的应用数据 self.clipboard_apps = [] for idx in sorted(self.selected_items): if idx < len(apps): # 深拷贝应用数据 app_copy = apps[idx].copy() self.clipboard_apps.append(app_copy) self._log("Copied {len(self.clipboard_apps)} apps to clipboard", "DEBUG") def _cut_apps(self, menu=None): """剪切选中的应用到剪贴板""" if menu: menu.destroy() # 先复制 self._copy_apps() # 然后删除选中的应用 # 获取界面上当前选择的项目 current_project = self.project_combo.get() if not current_project: return apps = self.config_manager.get_apps(current_project) # 从后往前删除,避免索引变化 for idx in sorted(self.selected_items, reverse=True): if idx < len(apps): self.config_manager.delete_app(current_project, idx) # 清空选中列表 self.selected_items = [] self.last_selected_index = None # 重新加载 self._load_apps() self.on_update() self._log(f"Cut {len(self.clipboard_apps)} apps", "DEBUG") def _paste_apps(self, menu=None): """粘贴剪贴板中的应用到当前项目""" if menu: menu.destroy() if not self.clipboard_apps: custom_dialogs.show_warning(self, "警告", "剪贴板为空") return # 获取界面上当前选择的项目,而不是配置文件中的当前项目 current_project = self.project_combo.get() if not current_project: custom_dialogs.show_warning(self, "警告", "请先选择一个项目") return # 获取目标项目的现有应用 existing_apps = self.config_manager.get_apps(current_project) # 粘贴所有应用(智能去重) success_count = 0 skipped_count = 0 for app in self.clipboard_apps: # 检查是否已存在完全相同的应用(路径、版本都相同) is_duplicate = False for existing_app in existing_apps: if (existing_app['path'] == app['path'] and existing_app['version'] == app['version']): # 完全相同的应用,跳过 is_duplicate = True skipped_count += 1 self._log(f"Skipping duplicate app: {app['name']} (same path and version)", "DEBUG") break if is_duplicate: continue # 检查是否存在相同路径但版本不同的应用 has_same_path = any(existing_app['path'] == app['path'] for existing_app in existing_apps) if has_same_path: # 路径相同但版本不同,添加版本后缀 app['name'] = f"{app['name']} (v{app['version']})" self._log(f"Same path but different version, renamed to: {app['name']}", "DEBUG") # 检查名称是否重复,如果重复则添加数字后缀 base_name = app['name'] counter = 1 while any(existing_app['name'] == app['name'] for existing_app in existing_apps): app['name'] = f"{base_name} ({counter})" counter += 1 # 添加应用 if self.config_manager.add_app(current_project, app['name'], app['path'], app['version']): # 复制图标设置 icon = self.config_manager.get_app_icon(app['path']) if icon: self.config_manager.set_app_icon(app['path'], icon) # 复制颜色设置 color = self.config_manager.get_app_color(app['path']) if color: self.config_manager.set_app_color(app['path'], color) success_count += 1 # 更新现有应用列表,以便后续检查 existing_apps = self.config_manager.get_apps(current_project) # 重新加载 self._load_apps() self.on_update() # 显示结果消息 if skipped_count > 0: message = f"已粘贴 {success_count} 个应用到 {current_project}\n跳过 {skipped_count} 个重复应用" else: message = f"已粘贴 {success_count} 个应用到 {current_project}" custom_dialogs.show_info(self, "成功", message) self._log(f"Pasted {success_count} apps, skipped {skipped_count} duplicates", "DEBUG") def _delete_selected_apps(self): """删除选中的应用""" if not self.selected_items: return # 确认删除 result = custom_dialogs.ask_yes_no( self, "Confirm Delete", f"Are you sure you want to delete the selected {len(self.selected_items)} applications?" ) if not result: return # 获取界面上当前选择的项目 current_project = self.project_combo.get() if not current_project: return apps = self.config_manager.get_apps(current_project) # 从后往前删除,避免索引变化 for idx in sorted(self.selected_items, reverse=True): if idx < len(apps): self.config_manager.delete_app(current_project, idx) # 清空选中列表 self.selected_items = [] self.last_selected_index = None # 重新加载 self._load_apps() self.on_update() self._log("Deleted selected apps", "DEBUG") def _clear_selection(self): """清除所有选择""" self.selected_items = [] self.last_selected_index = None self._update_selection_display() def _select_all_apps(self): """全选当前项目的所有应用""" current_project = self.project_combo.get() if not current_project: return # 获取当前项目的所有应用 apps = self.config_manager.get_apps(current_project) if not apps: # 清空选择状态,确保UI一致性 self.selected_items = [] self.last_selected_index = None self._update_selection_display() self._log("No apps to select in empty project", "DEBUG") return # 选择所有应用 self.selected_items = list(range(len(apps))) self.last_selected_index = len(apps) - 1 if apps else None self._update_selection_display() self._log(f"Selected all {len(apps)} apps", "DEBUG") # 为了兼容性,保留别名 def _select_all_apps_direct(self): """直接全选所有应用(别名方法)""" return self._select_all_apps() # 基本快捷键处理函数 def _handle_delete(self, event): """处理 Delete""" if self.winfo_exists() and self.state() == 'normal': focused = self.focus_get() if focused and isinstance(focused, (ctk.CTkEntry, ctk.CTkTextbox)): return # 让文本框处理自己的 Delete self._delete_selected_apps() return "break" def _handle_escape(self, event): """处理 Escape""" if self.winfo_exists() and self.state() == 'normal': self._clear_selection() return "break" def _set_project_icon(self): """设置项目图标""" current_project = self._ensure_project_selected() if not current_project: return # 打开文件选择对话框 icon_path = filedialog.askopenfilename( title="选择项目图标", filetypes=[ ("图片文件", "*.png *.jpg *.jpeg *.ico *.bmp *.gif"), ("所有文件", "*.*") ] ) if icon_path: # 保存图标路径 if self.config_manager.set_project_icon(current_project, icon_path): custom_dialogs.show_info(self, "成功", f"项目 '{current_project}' 的图标已设置") # 通知主窗口更新 self.on_update() else: custom_dialogs.show_error(self, "错误", "设置图标失败") def _set_project_color(self): """设置项目背景颜色""" current_project = self._ensure_project_selected() if not current_project: return # 打开颜色选择对话框 from tkinter import colorchooser color = colorchooser.askcolor( title="选择项目背景颜色", initialcolor=self.config_manager.get_project_color(current_project) ) if color and color[1]: # color[1] 是十六进制颜色值 # 保存颜色 if self.config_manager.set_project_color(current_project, color[1]): custom_dialogs.show_info(self, "成功", f"项目 '{current_project}' 的背景颜色已设置") # 通知主窗口更新 self.on_update() else: custom_dialogs.show_error(self, "错误", "设置颜色失败")