| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033 |
- # -*- 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'<span style="color: {hex_color}; font-weight: bold;">'
- f"{escaped_word}</span>"
- )
- 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())
|