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

2122 lines
82 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
设置窗口 - 提供现代化的配置界面
"""
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("<Delete>", self._handle_delete, "+")
self.bind_all("<Escape>", 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("<Delete>")
self.unbind_all("<Escape>")
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("<Return>", lambda e: on_ok())
entry.bind("<Escape>", 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("<Escape>", 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("<Button-3>", self._show_empty_area_menu)
# 在空白区域左键点击时取消选择
self.apps_scroll_frame.bind("<Button-1>", 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("<Button-1>", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame))
drag_handle.bind("<B1-Motion>", self._on_drag_motion)
drag_handle.bind("<ButtonRelease-1>", self._end_drag)
# 绑定左键点击和拖动事件到卡片本身
item_frame.bind("<Button-1>", lambda e, idx=index, frame=item_frame: self._on_item_click_or_drag_start(e, idx, frame))
item_frame.bind("<B1-Motion>", lambda e, idx=index, frame=item_frame: self._on_item_drag_motion(e, idx, frame))
item_frame.bind("<ButtonRelease-1>", self._on_item_drag_end)
# 绑定右键菜单(不阻止事件传播,这样空白区域也能触发)
item_frame.bind("<Button-3>", lambda e, idx=index: self._show_context_menu(e, idx), add="+")
# 绑定拖放事件到手柄背景框
drag_handle_bg.bind("<Button-1>", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame))
drag_handle_bg.bind("<B1-Motion>", self._on_drag_motion)
drag_handle_bg.bind("<ButtonRelease-1>", self._end_drag)
# 同时绑定拖放事件到手柄外层容器
handle_container.bind("<Button-1>", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame))
handle_container.bind("<B1-Motion>", self._on_drag_motion)
handle_container.bind("<ButtonRelease-1>", 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("<Button-3>", 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("<Button-3>", 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("<Button-3>", 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("<Button-1>", 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, "错误", "设置颜色失败")