# -*- coding: utf-8 -*- # Use PyQt6 imports import sys import yaml from pathlib import Path import copy import itertools import re # Change imports from PySide6 to PyQt6 from PyQt6 import QtWidgets, QtCore, QtGui from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QLabel, QPushButton, QComboBox, QLineEdit, QSplitter, QScrollArea, QFrame, QStatusBar, QMenu, QInputDialog, QMessageBox, QToolButton, QSizePolicy, QLayout, ) from PyQt6.QtCore import ( Qt, QSize, QPoint, pyqtSignal as Signal, # Use pyqtSignal directly QRect, QMargins, QTimer, ) from PyQt6.QtGui import ( QColor, QTextCharFormat, QFont, QAction, QIcon, QPalette, QTextCursor, QCloseEvent, ) # --- Constants --- INITIAL_RIGHT_COL_WIDTH = 510 YAML_DATA_FILE = "ds_patents_result.yml" TEXT_KEY_NAME = "摘要" ENTITY_KEY_NAME = "结果" # +++ Fixed Categories and Colors +++ FIXED_CATEGORIES = [ "材料类", "制造工艺类", "结构部件类", "技术特征类", "技术参数类", "特殊构造类", "功能属性类", "规范标准类", "性能指标类", "专利法律实体类", ] _color_pool_iter = itertools.cycle( [ QColor("#d50000"), QColor("#2962ff"), QColor("#00c853"), QColor("#ff6d00"), QColor("#aa00ff"), QColor("#00bfa5"), QColor("#c51162"), QColor("#0091ea"), QColor("#aeea00"), QColor("#dd2c00"), QColor("#304ffe"), QColor("#00b8d4"), QColor("#ffab00"), QColor("#6200ea"), QColor("#64dd17"), ] ) # Create a fixed mapping FIXED_CATEGORY_COLORS = {cat: next(_color_pool_iter) for cat in FIXED_CATEGORIES} DEFAULT_COLOR = QColor(Qt.GlobalColor.black) # +++ End Fixed Definitions +++ # --- Default Data Sets --- default_data_sets = [ { TEXT_KEY_NAME: "示例文本,机器学习。", ENTITY_KEY_NAME: {"技术特征类": ["机器学习"]}, }, {TEXT_KEY_NAME: "另一个例子。", ENTITY_KEY_NAME: {}}, ] # --- Data Loading/Saving Functions --- def load_data_from_yaml(filepath: str = YAML_DATA_FILE) -> list: """Loads and cleans data from the YAML file.""" data_path = Path(filepath) if not data_path.exists(): print(f"信息:未找到 {filepath}。使用默认数据。") return copy.deepcopy(default_data_sets) try: with open(data_path, "r", encoding="utf-8") as f: loaded_data = yaml.safe_load(f) if not isinstance(loaded_data, list) or not all( isinstance(item, dict) for item in loaded_data ): print(f"警告:{filepath} 包含无效格式。使用默认数据。") return copy.deepcopy(default_data_sets) print(f"成功从 {filepath} 加载数据。") cleaned_data = [] for i, item in enumerate(loaded_data): text = item.get(TEXT_KEY_NAME, "") vocab_raw = item.get(ENTITY_KEY_NAME, {}) vocab_cleaned = {} if isinstance(vocab_raw, dict): for key, value in vocab_raw.items(): str_key = str(key) if isinstance(value, list): vocab_cleaned[str_key] = [ str(v) for v in value if isinstance(v, (str, int, float)) ] elif value is None: vocab_cleaned[str_key] = [] else: print( f"警告: 在条目 {i} 中跳过无效的词汇类别 '{str_key}' " f"(值类型: {type(value)})" ) else: print( f"警告: 在条目 {i} 中发现无效的词汇结构 " f"(类型: {type(vocab_raw)}),将使用空词汇表。" ) cleaned_data.append( {TEXT_KEY_NAME: str(text), ENTITY_KEY_NAME: vocab_cleaned} ) # Ensure required keys exist & structure for i, item in enumerate(cleaned_data): if TEXT_KEY_NAME not in item: item[TEXT_KEY_NAME] = "" if ENTITY_KEY_NAME not in item: item[ENTITY_KEY_NAME] = {} if not isinstance(item[ENTITY_KEY_NAME], dict): print(f"警告: 条目 {i} 的 '{ENTITY_KEY_NAME}' 不是字典,已重置。") item[ENTITY_KEY_NAME] = {} else: for key, value in list(item[ENTITY_KEY_NAME].items()): if not isinstance(value, list): print(f"警告: 条目 {i}, 类别 '{key}' 的值不是列表,已重置。") item[ENTITY_KEY_NAME][key] = [] return cleaned_data except Exception as e: print(f"错误:无法加载或解析 {filepath}: {e}. 使用默认数据。") return copy.deepcopy(default_data_sets) def save_data_to_yaml(data_to_save: list, filepath: str = YAML_DATA_FILE): """Saves data to YAML, filtering for fixed categories.""" data_path = Path(filepath) if not isinstance(data_to_save, list): print("错误: 尝试保存的数据不是列表格式。保存中止。") return cleaned_data_to_save = [] for i, item in enumerate(data_to_save): if ( not isinstance(item, dict) or TEXT_KEY_NAME not in item or ENTITY_KEY_NAME not in item ): print(f"警告: 跳过保存格式不正确的条目 {i}。") continue text = str(item.get(TEXT_KEY_NAME, "")) vocab_raw = item.get(ENTITY_KEY_NAME, {}) cleaned_vocab = {} if isinstance(vocab_raw, dict): for key, value in vocab_raw.items(): str_key = str(key) if str_key in FIXED_CATEGORIES: # Only save fixed categories if isinstance(value, list): unique_strings = sorted( list(set(str(v) for v in value if v)) ) cleaned_vocab[str_key] = unique_strings else: cleaned_vocab[str_key] = [] cleaned_data_to_save.append( {TEXT_KEY_NAME: text, ENTITY_KEY_NAME: cleaned_vocab} ) try: with open(data_path, "w", encoding="utf-8") as f: yaml.dump( cleaned_data_to_save, f, allow_unicode=True, sort_keys=False, indent=2, default_flow_style=False, ) except Exception as e: print(f"错误:无法保存数据到 {filepath}: {e}") # --- FlowLayout Helper Class --- class FlowLayout(QLayout): """A layout that flows widgets horizontally, wrapping as needed.""" def __init__(self, parent=None, margin=-1, hspacing=-1, vspacing=-1): super().__init__(parent) if margin != -1: self.setContentsMargins(margin, margin, margin, margin) self._hspacing = hspacing self._vspacing = vspacing self._items = [] def __del__(self): item = self.takeAt(0) while item: item = self.takeAt(0) def addItem(self, item): self._items.append(item) def count(self): return len(self._items) def itemAt(self, index): if 0 <= index < len(self._items): return self._items[index] return None def takeAt(self, index): if 0 <= index < len(self._items): return self._items.pop(index) return None def expandingDirections(self): return Qt.Orientation(0) # Not vertically expanding def hasHeightForWidth(self): return True def heightForWidth(self, width): return self._do_layout(QRect(0, 0, width, 0), True) def setGeometry(self, rect): super().setGeometry(rect) self._do_layout(rect, False) def sizeHint(self): return self.minimumSize() def minimumSize(self): size = QSize() for item in self._items: size = size.expandedTo(item.minimumSize()) margin, _, _, _ = self.getContentsMargins() size += QSize(2 * margin, 2 * margin) return size def _do_layout(self, rect, test_only): left, top, right, bottom = self.getContentsMargins() effective_rect = rect.adjusted(+left, +top, -right, -bottom) x = effective_rect.x() y = effective_rect.y() line_height = 0 hspacing = self._hspacing if hspacing == -1: hspacing = self.spacing() vspacing = self._vspacing if vspacing == -1: vspacing = self.spacing() for item in self._items: wid = item.widget() space_x = hspacing if not wid: space_x = 0 # Simplified spacing calculation space_y = vspacing next_x = x + item.sizeHint().width() + space_x if next_x - space_x > effective_rect.right() and line_height > 0: x = effective_rect.x() y = y + line_height + space_y next_x = x + item.sizeHint().width() + space_x line_height = 0 if not test_only: item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) x = next_x line_height = max(line_height, item.sizeHint().height()) _, top, _, bottom = self.getContentsMargins() return y + line_height - rect.y() + bottom def clear(self): """Removes all items from the layout and deletes their widgets.""" while self.count(): item = self.takeAt(0) widget = item.widget() if widget: widget.deleteLater() # --- Main Application Window --- class VocabHighlighterApp(QMainWindow): """Main application window class.""" def __init__(self): super().__init__() self.setWindowTitle("中文文本词汇高亮工具 (PyQt6 - 固定分类)") self.setGeometry(100, 100, 1050, 700) # --- State --- self.data_sets = [] self.current_data_index = -1 self.current_text_content = "" self.current_vocab = {} self.original_text = "" self.original_vocab = {} self.is_modified = False # --- UI Elements --- self.text_display: QTextEdit = None self.text_editor: QTextEdit = None self.vocab_list_widget: QWidget = None self.vocab_list_layout: QVBoxLayout = None self.list_selector: QComboBox = None self.new_word_input: QLineEdit = None self.index_display_label: QLabel = None self.status_bar: QStatusBar = None self.splitter: QSplitter = None self.prev_button: QToolButton = None self.next_button: QToolButton = None self.add_word_button: QPushButton = None # --- Load Data --- self.data_sets = load_data_from_yaml() # --- Initialize UI --- self.initUI() # --- Load Initial Data --- print("正在加载初始数据...") initial_index = 0 if self.data_sets else -1 self.load_data_set(initial_index) def initUI(self): """Initializes the user interface elements and layout.""" # --- Central Widget and Main Splitter --- central_widget = QWidget(self) self.setCentralWidget(central_widget) main_layout = QHBoxLayout(central_widget) self.splitter = QSplitter(Qt.Orientation.Horizontal, self) main_layout.addWidget(self.splitter) # --- Left Panel --- left_panel = QWidget() left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(5, 5, 5, 5) left_layout.setSpacing(5) # Text Display left_layout.addWidget(QLabel("显示(高亮):")) self.text_display = QTextEdit() self.text_display.setReadOnly(True) self.text_display.setFixedHeight(200) self.text_display.setObjectName("TextDisplay") self.text_display.selectionChanged.connect( self.on_display_selection_changed ) left_layout.addWidget(self.text_display) # Navigation nav_layout = QHBoxLayout() self.prev_button = QToolButton() style = self.style() self.prev_button.setIcon( style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft) ) self.prev_button.setToolTip("上一条 (若有修改则保存)") self.prev_button.clicked.connect(self.go_previous) nav_layout.addWidget(self.prev_button) self.index_display_label = QLabel("? / ?") self.index_display_label.setAlignment(Qt.AlignmentFlag.AlignCenter) nav_layout.addWidget(self.index_display_label, 1) # Stretch self.next_button = QToolButton() self.next_button.setIcon( style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ArrowRight) ) self.next_button.setToolTip("下一条 (若有修改则保存)") self.next_button.clicked.connect(self.go_next) nav_layout.addWidget(self.next_button) left_layout.addLayout(nav_layout) # Text Editor left_layout.addWidget(QLabel("编辑中文文本:")) self.text_editor = QTextEdit() self.text_editor.setObjectName("TextEditor") self.text_editor.textChanged.connect(self.on_text_edited) self.text_editor.selectionChanged.connect( self.on_editor_selection_changed ) self.text_editor.setContextMenuPolicy( Qt.ContextMenuPolicy.CustomContextMenu ) self.text_editor.customContextMenuRequested.connect( self.show_editor_context_menu ) left_layout.addWidget(self.text_editor, 1) # Vertical stretch # Add Word Section divider = QFrame() divider.setFrameShape(QFrame.Shape.HLine) divider.setFrameShadow(QFrame.Shadow.Sunken) left_layout.addWidget(divider) left_layout.addWidget(QLabel("添加词汇:")) add_word_layout = QHBoxLayout() self.new_word_input = QLineEdit() self.new_word_input.setPlaceholderText("要添加的词汇") self.new_word_input.returnPressed.connect(self.add_word) add_word_layout.addWidget(self.new_word_input, 1) # Stretch self.list_selector = QComboBox() self.list_selector.addItems(FIXED_CATEGORIES) # Populate fixed list add_word_layout.addWidget(self.list_selector, 1) # Stretch self.add_word_button = QPushButton("添加") self.add_word_button.setToolTip("添加到所选列表") self.add_word_button.clicked.connect(self.add_word) add_word_layout.addWidget(self.add_word_button) left_layout.addLayout(add_word_layout) self.splitter.addWidget(left_panel) # --- Right Panel --- right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(5, 5, 5, 5) right_layout.setSpacing(10) right_layout.addWidget(QLabel("词汇列表 (固定分类):")) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setFrameShape(QFrame.Shape.StyledPanel) self.vocab_list_widget = QWidget() self.vocab_list_layout = QVBoxLayout(self.vocab_list_widget) self.vocab_list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) scroll_area.setWidget(self.vocab_list_widget) right_layout.addWidget(scroll_area, 1) # Vertical stretch self.splitter.addWidget(right_panel) # --- Splitter & Status Bar --- total_width = 1050 right_width = INITIAL_RIGHT_COL_WIDTH left_width = total_width - right_width - self.splitter.handleWidth() self.splitter.setSizes([max(150, left_width), max(150, right_width)]) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 0) self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) # --- Data Handling and State --- def load_data_set(self, index): """Loads a dataset into the UI and state.""" if not self.data_sets or not (0 <= index < len(self.data_sets)): print("无效索引或无数据,清空 UI。") self.current_data_index = -1 self.current_text_content = "" self.current_vocab = {} self.original_text = "" self.original_vocab = {} self.is_modified = False self.update_ui() return self.current_data_index = index data = self.data_sets[index] loaded_text = str(data.get(TEXT_KEY_NAME, "")) vocab_raw = data.get(ENTITY_KEY_NAME, {}) loaded_vocab = {} if isinstance(vocab_raw, dict): loaded_vocab = { str(k): [ str(v) for v in (val if isinstance(val, list) else []) if v ] for k, val in vocab_raw.items() } else: print(f"警告:条目 {index} 的词汇表格式无效,已重置。") self.current_text_content = loaded_text self.current_vocab = loaded_vocab self.original_text = loaded_text self.original_vocab = copy.deepcopy(loaded_vocab) self.is_modified = False print(f"已加载条目 {index + 1} / {len(self.data_sets)}") self.update_ui() self.show_status_message(f"已加载条目 {index + 1}") def check_if_modified(self) -> bool: """Checks if current data differs from the originally loaded data.""" if self.current_data_index < 0: return False editor_text = self.text_editor.toPlainText() text_modified = editor_text != self.original_text # Compare only the fixed categories for modification checks current_vocab_filtered = { k: sorted(list(set(str(w) for w in v if w))) for k, v in self.current_vocab.items() if k in FIXED_CATEGORIES and isinstance(v, list) } original_vocab_filtered = { k: sorted(list(set(str(w) for w in v if w))) for k, v in self.original_vocab.items() if k in FIXED_CATEGORIES and isinstance(v, list) } vocab_modified = current_vocab_filtered != original_vocab_filtered return self.is_modified or text_modified or vocab_modified def save_current_if_modified(self) -> bool: """Saves the current item (only fixed categories) if modified.""" editor_text = self.text_editor.toPlainText() if editor_text != self.current_text_content: self.current_text_content = editor_text self.is_modified = True # Mark if text changed if self.current_data_index < 0 or not self.check_if_modified(): return False idx = self.current_data_index print(f"检测到更改,正在保存条目 {idx + 1} (仅固定分类)...") # Filter vocabulary to save only fixed categories cleaned_vocab_save = {} for category, words in self.current_vocab.items(): str_key = str(category) if str_key in FIXED_CATEGORIES: if isinstance(words, list): cleaned_vocab_save[str_key] = sorted( list(set(str(w) for w in words if w)) ) else: cleaned_vocab_save[str_key] = [] # Save the filtered vocab back to the main data list self.data_sets[idx][TEXT_KEY_NAME] = self.current_text_content self.data_sets[idx][ENTITY_KEY_NAME] = cleaned_vocab_save # Call the actual file saving function save_data_to_yaml(self.data_sets) print(f"数据已成功保存到 {YAML_DATA_FILE}") self.show_status_message(f"条目 {idx + 1} 已保存", 3000) # Update original state *after* successful save self.original_text = self.current_text_content # Store deep copy of potentially modified current_vocab as new original self.original_vocab = copy.deepcopy(self.current_vocab) self.is_modified = False # Reset modification status self.setWindowTitle(f"中文文本词汇高亮工具 - 条目 {self.current_data_index + 1}") return True # --- UI Update Functions --- def update_ui(self): """Refreshes all relevant UI elements based on current state.""" if self.current_data_index < 0: self.text_display.setHtml("") self.text_editor.blockSignals(True) self.text_editor.setPlainText("") self.text_editor.blockSignals(False) self.index_display_label.setText("0 / 0") self.clear_vocab_display() self.prev_button.setEnabled(False) self.next_button.setEnabled(False) self.setWindowTitle("中文文本词汇高亮工具 (PyQt6 - 固定分类)") else: html_text = self.build_highlighted_html( self.current_text_content, self.current_vocab ) self.text_display.setHtml(html_text) current_editor_text = self.text_editor.toPlainText() if current_editor_text != self.current_text_content: self.text_editor.blockSignals(True) self.text_editor.setPlainText(self.current_text_content) self.text_editor.blockSignals(False) self.index_display_label.setText( f"{self.current_data_index + 1} / {len(self.data_sets)}" ) self.build_vocab_display() # Update right panel vocab list # list_selector already populated with fixed categories can_navigate = len(self.data_sets) > 1 self.prev_button.setEnabled(can_navigate) self.next_button.setEnabled(can_navigate) title = f"中文文本词汇高亮工具 - 条目 {self.current_data_index + 1}" if self.check_if_modified(): title += " *" self.setWindowTitle(title) def build_highlighted_html(self, text_content, vocab_dict) -> str: """Creates HTML string using fixed colors for known categories.""" if not isinstance(text_content, str): text_content = "" if not isinstance(vocab_dict, dict) or not text_content: return text_content escaped_text = ( text_content.replace("&", "&") .replace("<", "<") .replace(">", ">") ) matches = [] for list_name_orig, words in vocab_dict.items(): list_name = str(list_name_orig) color = FIXED_CATEGORY_COLORS.get(list_name, DEFAULT_COLOR) hex_color = color.name() if isinstance(words, list): sorted_words = sorted( [str(w) for w in words if w], key=len, reverse=True ) for word in sorted_words: start_index = 0 while True: try: index = text_content.find(word, start_index) except ValueError: break if index == -1: break end_index = index + len(word) matches.append( (index, end_index, word, list_name, hex_color) ) start_index = index + 1 matches.sort(key=lambda x: (x[0], -(x[1] - x[0]))) result_html = "" current_pos = 0 processed_indices = set() for start, end, word, category_name, hex_color in matches: is_overlapped = any(i in processed_indices for i in range(start, end)) if is_overlapped: continue if start > current_pos: result_html += escaped_text[current_pos:start] escaped_word = ( word.replace("&", "&") .replace("<", "<") .replace(">", ">") ) result_html += ( f'' f"{escaped_word}" ) current_pos = end processed_indices.update(range(start, end)) if current_pos < len(escaped_text): result_html += escaped_text[current_pos:] return result_html def clear_vocab_display(self): """Clears the vocab display area.""" if not self.vocab_list_layout: return while self.vocab_list_layout.count(): layout_item = self.vocab_list_layout.takeAt(0) if layout_item is None: continue widget = layout_item.widget() if widget: widget.deleteLater() else: # Handle layouts within the main layout (e.g., FlowLayout) layout_to_clear = layout_item.layout() if layout_to_clear and isinstance(layout_to_clear, FlowLayout): layout_to_clear.clear() def build_vocab_display(self): """Populates the right panel based on FIXED categories.""" self.clear_vocab_display() if not isinstance(self.current_vocab, dict): return # Iterate through the FIXED list to control display order for list_name in FIXED_CATEGORIES: words = self.current_vocab.get(list_name, []) color = FIXED_CATEGORY_COLORS.get(list_name, DEFAULT_COLOR) title_label = QLabel(list_name) title_font = QFont() title_font.setBold(True) title_font.setPointSize(12) title_label.setFont(title_font) title_label.setStyleSheet(f"color: {color.name()};") self.vocab_list_layout.addWidget(title_label) words_container = QWidget() flow_layout = FlowLayout( words_container, margin=0, hspacing=4, vspacing=2 ) words_list = words if isinstance(words, list) else [] unique_words = sorted(list(set(str(w) for w in words_list if w))) if not unique_words: empty_label = QLabel("(空列表)") empty_label.setStyleSheet("color: gray; font-style: italic;") flow_layout.addWidget(empty_label) else: for word in unique_words: word_label = QLabel(word) word_label.setStyleSheet(f"color: {color.name()};") word_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) # Add Context Menu Policy and Connection word_label.setContextMenuPolicy( Qt.ContextMenuPolicy.CustomContextMenu ) word_label.customContextMenuRequested.connect( lambda pos, w=word, cat=list_name, lbl=word_label: self.show_word_context_menu( pos, w, cat, lbl ) ) flow_layout.addWidget(word_label) self.vocab_list_layout.addWidget(words_container) spacer = QFrame() spacer.setFrameShape(QFrame.Shape.HLine) spacer.setFrameShadow(QFrame.Shadow.Sunken) spacer.setFixedHeight(5) self.vocab_list_layout.addWidget(spacer) # --- Event Handlers (Slots) --- def on_text_edited(self): """Flags modification when text editor content changes.""" self.is_modified = True if self.current_data_index >= 0 and not self.windowTitle().endswith(" *"): self.setWindowTitle(self.windowTitle() + " *") def on_editor_selection_changed(self): """Copies selected editor text to input.""" if not self.text_editor or not self.new_word_input: return cursor = self.text_editor.textCursor() selected_text = cursor.selectedText().strip() self.new_word_input.blockSignals(True) self.new_word_input.setText(selected_text) self.new_word_input.blockSignals(False) def on_display_selection_changed(self): """Copies selected display text to input.""" if not self.text_display or not self.new_word_input: return cursor = self.text_display.textCursor() selected_text = cursor.selectedText().strip() self.new_word_input.blockSignals(True) self.new_word_input.setText(selected_text) self.new_word_input.blockSignals(False) def go_next(self): """Navigates to the next item, saving if modified.""" if not self.data_sets: return if self.save_current_if_modified(): QTimer.singleShot(10, self._navigate_next) else: self._navigate_next() def _navigate_next(self): """Helper to perform the actual next navigation.""" if not self.data_sets: return next_index = (self.current_data_index + 1) % len(self.data_sets) self.load_data_set(next_index) def go_previous(self): """Navigates to the previous item, saving if modified.""" if not self.data_sets: return if self.save_current_if_modified(): QTimer.singleShot(10, self._navigate_previous) else: self._navigate_previous() def _navigate_previous(self): """Helper to perform the actual previous navigation.""" if not self.data_sets: return prev_index = (self.current_data_index - 1 + len(self.data_sets)) % len( self.data_sets ) self.load_data_set(prev_index) def add_word(self, word_to_add=None, selected_list=None) -> bool: """Adds a word to the selected FIXED category.""" if self.current_data_index < 0: self.show_status_message("错误:没有加载的条目可添加词汇。", is_error=True) return False word = ( word_to_add if isinstance(word_to_add, str) else self.new_word_input.text().strip() ) target_list_key = ( selected_list if isinstance(selected_list, str) else self.list_selector.currentText() ) if not word: self.show_status_message("错误:要添加的词汇不能为空!", is_error=True) if not isinstance(word_to_add, str): self.new_word_input.setFocus() return False if not target_list_key or target_list_key not in FIXED_CATEGORIES: self.show_status_message( f"错误:无效的目标列表 '{target_list_key}'!", is_error=True ) if not isinstance(selected_list, str): self.list_selector.setFocus() return False # Ensure the category exists in current_vocab and is a list if target_list_key not in self.current_vocab or not isinstance( self.current_vocab.get(target_list_key), list ): self.current_vocab[target_list_key] = [] word_str = str(word) existing_words_str = { str(w) for w in self.current_vocab[target_list_key] } if word_str not in existing_words_str: self.current_vocab[target_list_key].append(word_str) self.current_vocab[target_list_key].sort(key=str) if not isinstance(word_to_add, str): self.new_word_input.clear() self.is_modified = True self.update_ui() self.show_status_message( f"'{word_str}' 已添加到 '{target_list_key}' (未保存)" ) if not isinstance(word_to_add, str): self.new_word_input.setFocus() return True else: self.show_status_message(f"'{word_str}' 已存在于 '{target_list_key}'") if not isinstance(word_to_add, str): self.new_word_input.selectAll() self.new_word_input.setFocus() return False def show_status_message( self, message: str, timeout: int = 5000, is_error: bool = False ): """Displays a message in the status bar.""" if self.status_bar: style_sheet = "color: red;" if is_error else "" self.status_bar.setStyleSheet(style_sheet) self.status_bar.showMessage(message, timeout) # --- Context Menu Handlers --- def show_editor_context_menu(self, position: QPoint): """Shows the right-click context menu (Create New List removed).""" menu = QMenu(self) selected_text = self.text_editor.textCursor().selectedText() has_selection = bool(selected_text) add_to_menu = QMenu("添加到列表...", menu) add_to_menu.setEnabled(has_selection) if has_selection: for list_name in FIXED_CATEGORIES: action = QtGui.QAction(list_name, self) action.triggered.connect( lambda checked=False, text=selected_text, name=list_name: self.add_word( word_to_add=text, selected_list=name ) ) add_to_menu.addAction(action) menu.addMenu(add_to_menu) # Create New List action removed menu.addSeparator() copy_action = QtGui.QAction("复制", self) copy_action.setEnabled(has_selection) copy_action.triggered.connect(self.text_editor.copy) menu.addAction(copy_action) paste_action = QtGui.QAction("粘贴", self) paste_action.triggered.connect(self.text_editor.paste) menu.addAction(paste_action) global_pos = self.text_editor.mapToGlobal(position) menu.exec(global_pos) def show_word_context_menu( self, position: QPoint, word_to_delete: str, category_name: str, word_label_widget: QLabel ): """Shows context menu for a specific word label in the vocab list.""" menu = QMenu(self) delete_action = QtGui.QAction(f"删除词汇 '{word_to_delete}'", self) delete_action.triggered.connect( lambda checked=False, w=word_to_delete, cat=category_name: self.delete_vocab_word( w, cat ) ) menu.addAction(delete_action) global_pos = word_label_widget.mapToGlobal(position) menu.exec(global_pos) def delete_vocab_word(self, word_to_delete: str, category_name: str): """Removes a specific word from a category list.""" if self.current_data_index < 0: self.show_status_message("错误:没有加载的条目可删除词汇。", is_error=True) return if not isinstance(self.current_vocab, dict): print("错误:当前词汇数据无效,无法删除。") return if category_name in self.current_vocab and isinstance( self.current_vocab[category_name], list ): try: word_str_to_delete = str(word_to_delete) item_to_remove = next( item for item in self.current_vocab[category_name] if str(item) == word_str_to_delete ) self.current_vocab[category_name].remove(item_to_remove) self.is_modified = True self.update_ui() self.show_status_message( f"词汇 '{word_to_delete}' 已从 '{category_name}' 删除 (未保存)" ) except (ValueError, StopIteration): print( f"警告:尝试删除时未在列表 '{category_name}' " f"中找到词汇 '{word_to_delete}'。" ) self.show_status_message( f"错误:未找到词汇 '{word_to_delete}'", is_error=True ) else: print(f"警告:尝试删除词汇时未找到分类列表 '{category_name}'。") self.show_status_message( f"错误:未找到分类 '{category_name}'", is_error=True ) # create_new_list method removed # --- Window Close Event --- def closeEvent(self, event: QCloseEvent): """Handles the window close request.""" print("窗口关闭事件触发。正在检查最终保存...") if self.check_if_modified(): if self.save_current_if_modified(): print("更改已保存。") event.accept() else: # Save failed or check_if_modified still true after attempt reply = QMessageBox.question( self, "退出确认", "当前条目有未保存的更改。确定要退出吗?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: print("未保存更改,用户选择退出。") event.accept() else: print("退出操作已取消。") event.ignore() else: print("无未保存更改,正常退出。") event.accept() # --- Run Application --- if __name__ == "__main__": app = QApplication(sys.argv) # app.setStyle('Fusion') # Optional styling main_window = VocabHighlighterApp() main_window.show() sys.exit(app.exec())