# -*- encoding: utf-8 -*- import os import copy import math from functools import partial import numpy as np from PyQt6.QtCore import ( Qt, QSize, QPoint, QPointF, QMimeData, ) from PyQt6.QtWidgets import ( QWidget, QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QMenu, QSizePolicy, QHeaderView, QApplication, QStyledItemDelegate, ) from PyQt6.QtGui import ( QIcon, QPainter, QColor, QPen, QBrush, QPalette, QFont, QPainterPath, QImage, QAction, QValidator, QIntValidator, QDoubleValidator, ) from PyQt6.QtCharts import QChartView, QChart, QLineSeries, QScatterSeries, QValueAxis from config import config, data_path, script_path import util font = QFont("consolas", 14) font.setFamilies(["consolas", "黑体"]) def str2data(s, delimiter = None, line_end = "\n"): if delimiter is None: delimiter = [",", "\t", ";"] data = s.split(line_end) data = [line for line in data] for i, line in enumerate(data): ss = "" l = [] for c in line: if c in delimiter: l.append(ss) ss = "" else: ss += c l.append(ss) data[i] = l return data def data2str(data, delimiter = ",", line_end = "\n"): return line_end.join([delimiter.join(line) for line in data]) class optional_int_validator(QIntValidator): def validate(self, text, pos): if not text: return (QValidator.State.Acceptable, text, pos) return super().validate(text, pos) class optional_doublle_validator(QDoubleValidator): def validate(self, text, pos): if not text: return (QValidator.State.Acceptable, text, pos) return super().validate(text, pos) def get_step(span): base = math.log10(span) base += 0.1 b = int(base) c = int((b - base) * 3) b = 10.0 ** b if c == 0: step = b / 10 elif c == 1: step = b / 5 else: step = b / 2 return step def get_tick(a, b): step = get_step(b - a) list_tick = [] tick = a - a % step if tick < a: tick += step while tick <= b: list_tick.append(tick) tick += step return list_tick class header_view(QHeaderView): def __init__(self, *arg, **kwarg): super().__init__(*arg, **kwarg) self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu) def contextMenuEvent(self, event): # Get the clicked header position pos = event.pos() # Position relative to the header # Get the logical index of the clicked section orient = self.orientation() index = self.logicalIndexAt(pos) parent = self.parent() menu_table = QMenu() act_insert_above = QAction("在上方插入行", menu_table, triggered = partial(parent.insert_row, index)) act_insert_below = QAction("在下方插入行", menu_table, triggered = partial(parent.insert_row, index + 1)) act_remove_row = QAction("删除行", menu_table, triggered = partial(parent.remove_row, index)) act_insert_left = QAction("在左侧插入列", menu_table, triggered = partial(parent.insert_col, index)) act_insert_right = QAction("在右侧插入列", menu_table, triggered = partial(parent.insert_col, index + 1)) act_remove_col = QAction("删除列", menu_table, triggered = partial(parent.remove_col, index)) if orient == Qt.Orientation.Horizontal: menu_table.addActions([ act_insert_left, act_insert_right, act_remove_col, ]) else: menu_table.addActions([ act_insert_above, act_insert_below, act_remove_row, ]) menu_table.exec(event.globalPos()) class table_item(QTableWidgetItem): def __init__(self, text = "", value = None, formatter = None, *arg, **kwarg): super().__init__(*arg, **kwarg) self.data = None self.formatter = formatter if text: self.text = text elif value: self.value = value @property def text(self): return super().text() @text.setter def text(self, s): if self.formatter: self.data = self.formatter.str2val(s) if self.data is None: s = "" else: s = self.formatter.val2str(self.data) super().setText(s) @property def value(self): if not (self.data is None): return self.data if self.formatter: value = self.formatter.str2val(super().text()) return value return super().text() @value.setter def value(self, v): if v is None: super().setText("") elif self.formatter: super().setText(self.formatter.val2str(v)) else: super().setText(str(v)) self.data = v def empty(self): return self.data is None class item_delegate(QStyledItemDelegate): def createEditor(self, parent, option, index): # Create the editor widget (e.g., QLineEdit) editor = super().createEditor(parent, option, index) row = index.row() col = index.column() item = self.parent().at(row, col) if item.formatter is None: pass elif item.data is None: item.setText("") else: item.setText(str(item.data)) if item.formatter: if item.formatter.dtype is int: editor.setValidator(optional_int_validator()) if item.formatter.dtype is float: editor.setValidator(optional_doublle_validator()) width = self.parent().horizontalHeader().sectionSize(col) editor.setFixedWidth(width) #editor.setMinimumWidth(editor.width()) return editor def setModelData(self, editor, model, index): # Process data before saving to the model text = editor.text() row = index.row() col = index.column() item = self.parent().at(row, col) if text == "": item.value = None else: if item.formatter: item.value = item.formatter.dtype(text) else: item.text = text # def displayText(self, value, locale): # return super().displayText(value, locale) # def updateEditorGeometry(self, editor, option, index): # row = index.row() # col = index.column() # item = self.parent().at(row, col) # editor.setWidth(item.width()) # editor.setGeometry(option.rect) # # editor.setGeometry(option.rect.adjusted(0, 0, 0, 0)) class widget_table(QTableWidget): def __init__(self, window, id, config_tab, *arg, **kwarg): super().__init__(*arg, **kwarg) self.window = window self.app = QApplication.instance() horizontal_header = header_view(Qt.Orientation.Horizontal, self) horizontal_header.setMinimumSectionSize(100) self.setHorizontalHeader(horizontal_header) self.setVerticalHeader(header_view(Qt.Orientation.Vertical, self)) self.setItemDelegate(item_delegate(self)) self.id = id self.config_tab = config_tab self.setFont(font) self.formatter = None @property def row(self): return super().rowCount() @row.setter def row(self, row): super().setRowCount(row) @property def col(self): return super().columnCount() @col.setter def col(self, col): super().setColumnCount(col) def at(self, row, col): if row >= super().rowCount(): self.setRowCount(row + 1) if col >= super().columnCount(): self.setColumnCount(col + 1) item = super().item(row, col) if item is None: formatter = None if self.formatter: formatter = self.formatter(row, col) item = table_item(formatter = formatter) super().setItem(row, col, item) return item def clear_content(self): super().clearContents() def load_data(self, data, row_begin = 0, col_begin = 0, row_end = None, col_end = None, as_value = False): row = row_begin while (not row_end or row < row_end) and row < len(data): line = data[row] col = col_begin while (not col_end or col < col_end) and col < len(line): if as_value: self.at(row, col).value = line[col] else: self.at(row, col).text = line[col] col += 1 row += 1 def dump_data(self, row_begin = 0, col_begin = 0, row_end = None, col_end = None, as_value = False): if row_end is None: row_end = self.row if col_end is None: col_end = self.col if row_end <= row_begin or col_end <= col_begin: return [] data = [] for row in range(row_begin, row_end): line = [] for col in range(col_begin, col_end): if as_value: line.append(self.at(row, col).value) else: line.append(self.at(row, col).text) data.append(line) return data def load_str(self, s, delimiter = None, line_end = "\n", *arg, **kwarg): data = str2data(s, delimiter, line_end) self.load_data(data, *arg, **kwarg) def dump_str(self, delimiter = ",", line_end = "\n", *arg, **kwarg): data = self.dump_data(*arg, **kwarg) s = data2str(data, delimiter, line_end) return s def load_file(self, filename, *arg, **kwarg): with open(filename, "r", encoding="utf-8") as fobj: content = fobj.read() self.load_str(content, *arg, **kwarg) def dump_file(self, filename, *arg, **kwarg): s = self.dump_str(*arg, **kwarg) with open(filename, "w", encoding="utf-8") as fobj: fobj.write(s) def initial(self): config_tab = self.config_tab if "row" in config_tab: self.row = config_tab["row"] if "col" in config_tab: self.col = config_tab["col"] if "column_header" in config_tab: self.setHorizontalHeaderLabels(config_tab["column_header"]) if "format_script" in config_tab: filename = os.path.join(script_path, config_tab["format_script"]) module = util.load_module(filename) self.formatter = module.table_formatter if "data" in config_tab: data = config_tab["data"] self.load_data(data) if "datafile" in config_tab: datafile = os.path.join(data_path, config_tab["datafile"]) self.load_file(datafile) if "span" in config_tab: for span in config_tab["span"]: self.setSpan(*span) self.fit_content() def reset_content(self): self.clear_content() self.initial() def fit_content(self): header = self.horizontalHeader() header.resizeSections(QHeaderView.ResizeMode.ResizeToContents) def calc_index(self): list_index = self.selectedIndexes() if not list_index: return min_r = min(list_index, key = lambda x:x.row()).row() max_r = max(list_index, key = lambda x:x.row()).row() + 1 min_c = min(list_index, key = lambda x:x.column()).column() max_c = max(list_index, key = lambda x:x.column()).column() + 1 flag_rect = (max_r - min_r) * (max_c - min_c) != len(list_index) flag_row = flag_rect and min_c == 0 and max_c == self.columnCount() flag_col = flag_rect and min_r == 0 and max_r == self.rowCount() return list_index, min_r, max_r, min_c, max_c, flag_rect, flag_row, flag_col def load_clipboard(self): clipboard = self.app.clipboard() mimedata = clipboard.mimeData() # if mimedata.hasHtml(): # # Handle HTML with explicit UTF-16 decoding # raw_html = bytes(mimedata.data("text/html")) # try: # html = raw_html.decode("utf-16") # except UnicodeDecodeError: # html = raw_html.decode("utf-8", errors="replace") # self.parse_html_table(html) text = None if mimedata.hasText(): # Handle plain text with encoding detection raw_text = bytes(mimedata.data("text/plain")) # Check for UTF-16 BOM if raw_text.startswith(b"\xff\xfe") or raw_text.startswith(b"\xfe\xff"): text = raw_text.decode("utf-16") else: try: text = raw_text.decode("utf-8") except UnicodeDecodeError: text = raw_text.decode(locale.getpreferredencoding(), errors="replace") if text is None: return data = str2data(text, delimiter=["\t"]) return data def clear_select(self): list_index = self.selectedIndexes() for index in list_index: row = index.row() col = index.column() self.at(row, col).value = None def copy_select(self, as_value = False): data = self.calc_index() if not data: return list_index, min_r, max_r, min_c, max_c, flag_rect, flag_row, flag_col = data data = [[""] * (max_c - min_c) for row in range(max_r - min_r)] for index in list_index: row = index.row() col = index.column() item = self.at(row, col) if as_value: if item.value is None: data[row - min_r][col - min_c] = "" else: data[row - min_r][col - min_c] = str(self.at(row, col).value) else: data[row - min_r][col - min_c] = self.at(row, col).text text = data2str(data, delimiter = "\t") clipboard = self.app.clipboard() mimedata = QMimeData() mimedata.setText(text) clipboard.setMimeData(mimedata) def paste_select(self, as_value = False): data = self.calc_index() if not data: return list_index, min_r, max_r, min_c, max_c, flag_rect, flag_row, flag_col = data data = self.load_clipboard() for index in list_index: row = index.row() col = index.column() r = row - min_r if r >= len(data): continue c = col - min_c line = data[r] if c >= len(line): continue item = self.at(row, col) if as_value and item.formatter: if line[c] == "": item.value = None else: dtype = item.formatter.dtype try: try: item.value = dtype(line[c]) except: if dtype is int: item.value = int(float(line[c])) except: raise RuntimeError(f"无法将\"{line[c]}\"转化为{dype}类型") else: item.text = line[c] def insert_row(self, row): self.insertRow(row) def remove_row(self, row): self.removeRow(row) def insert_col(self, col): self.insertColumn(col) def remove_col(self, col): self.removeColumn(col) def contextMenuEvent(self, event): data = self.calc_index() if not data: return list_index, min_r, max_r, min_c, max_c, flag_rect, flag_row, flag_col = data menu_table = QMenu() act_clear_select = QAction("清空所选区域", menu_table, triggered = self.clear_select) act_copy_text = QAction("复制所选区域文本", menu_table, triggered = self.copy_select) act_copy_value = QAction("复制所选区域数据", menu_table, triggered = partial(self.copy_select, True)) act_paste_text = QAction("粘贴文本到所选区域", menu_table, triggered = self.paste_select) act_paste_value = QAction("粘贴数据到所选区域", menu_table, triggered = partial(self.paste_select, True)) menu_table.addActions([ act_clear_select, act_copy_text, act_copy_value, act_paste_text, act_paste_value, ]) menu_table.exec(event.globalPos()) def keyPressEvent(self, event): if ( event.key() == Qt.Key.Key_Delete ): self.clear_select() event.accept() elif ( event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_C ): self.copy_select() event.accept() elif ( (event.modifiers() == Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier) and event.key() == Qt.Key.Key_C ): self.copy_select(as_value = True) event.accept() elif ( event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_V ): self.paste_select() event.accept() elif ( (event.modifiers() == Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier) and event.key() == Qt.Key.Key_V ): self.paste_select(as_value = True) event.accept() else: super().keyPressEvent(event) class chart_plot(QChart): def __init__(self, window, config_tab, *arg): super().__init__(*arg) self.window = window self.setBackgroundBrush(QBrush(QColor(0, 0, 0))) self.setAnimationOptions(QChart.AnimationOption.SeriesAnimations) self.legend().setLabelColor(QColor(220, 220, 220)) self.axis_x = QValueAxis() self.axis_y = QValueAxis() if "axis_x_title" in config_tab: title = config_tab["axis_x_title"] self.axis_x.setTitleText(title) if "axis_y_title" in config_tab: title = config_tab["axis_y_title"] self.axis_y.setTitleText(title) axis_list = [self.axis_x, self.axis_y] for axis in axis_list: axis.setTitleBrush(QBrush(QColor(220, 220, 220))) axis.setLabelsColor(QColor(220, 220, 220)) axis.setLinePenColor(QColor(200, 200, 200)) axis.setGridLineColor(QColor(100, 100, 100)) self.addAxis(self.axis_x, Qt.AlignmentFlag.AlignBottom) self.addAxis(self.axis_y, Qt.AlignmentFlag.AlignLeft) self.list_name = [] def plot(self, *arg, **kwarg): self.removeAllSeries() self.list_name.clear() min_x, max_x = None, None min_y, max_y = None, None for i in range((len(arg) + 2) // 3): x = arg[i * 3] y = arg[i * 3 + 1] line = QLineSeries() if i * 3 + 2 < len(arg): name = arg[i * 3 + 2] else: name = f"数据{i}" line.setName(name) self.list_name.append(name) self.addSeries(line) scatter = QScatterSeries() scatter.setColor(line.color()) scatter.setMarkerSize(10) scatter.setPointLabelsColor(line.color()) #scatter.setPointLabelsVisible() self.addSeries(scatter) line.attachAxis(self.axis_x) line.attachAxis(self.axis_y) scatter.attachAxis(self.axis_x) scatter.attachAxis(self.axis_y) marker = self.legend().markers(scatter) for i in marker: i.setVisible(False) for i in range(len(x)): xx = x[i] yy = y[i] point = QPointF(xx, yy) line.append(point) scatter.append(point) # Calculate and set axis ranges x1, x2 = min(x), max(x) y1, y2 = min(y), max(y) if min_x is None: min_x, max_x = x1, x2 min_y, max_y = y1, y2 else: min_x, max_x = min(min_x, x1), max(max_x, x2) min_y, max_y = min(min_y, y1), max(max_y, y2) # Add 10% padding to axis ranges min_x, max_x = min_x - 0.1 * (max_x - min_x), max_x + 0.1 * (max_x - min_x) min_y, max_y = min_y - 0.1 * (max_y - min_y), max_y + 0.1 * (max_y - min_y) self.axis_x.setRange(min_x, max_x) self.axis_y.setRange(min_y, max_y) step = get_step(max_x - min_x) self.axis_x.setTickType(QValueAxis.TickType.TicksDynamic); self.axis_x.setTickAnchor(min_x - min_x % step); self.axis_x.setTickInterval(step); step = get_step(max_y - min_y) self.axis_y.setTickType(QValueAxis.TickType.TicksDynamic); self.axis_y.setTickAnchor(min_y - min_y % step); self.axis_y.setTickInterval(step); def contextMenuEvent(self, event): if not self.list_name: return menu = QMenu() for index, name in enumerate(self.list_name): act_toggle_series = QAction(f"显示/隐藏{name}", menu, triggered = partial(self.toggle_series, index)) menu.addAction(act_toggle_series) menu.exec(event.screenPos()) def toggle_series(self, index): line = self.series()[index * 2] scatter = self.series()[index * 2 + 1] flag = line.isVisible() flag = not flag line.setVisible(flag) scatter.setVisible(flag) marker = self.legend().markers(scatter) for i in marker: i.setVisible(False) class chart_view(QChartView): def __init__(self, window, id, config_tab, *arg): super().__init__(*arg) self.window = window self.id = id self.config_tab = config_tab self.setRenderHint(QPainter.RenderHint.Antialiasing) self.chart_plot = chart_plot(window, config_tab) self.setChart(self.chart_plot) def plot(self, *arg, **kwarg): self.chart_plot.plot(*arg, **kwarg) class tab_widget(QTabWidget): def __init__(self, window, *arg, **kwarg): super().__init__(*arg, **kwarg) self.window = window self.currentChanged.connect(self.on_current_changed) self.setFont(font) def on_current_changed(self, index): list_id = self.window.widget_display.list_id if index < 0 or not list_id: return table_id = list_id[index] self.window.widget_interact.load_interact(table_id) class widget_display(QWidget): def __init__(self, window, *arg, **kwarg): super().__init__(*arg, **kwarg) self.window = window self.setFont(font) layout_base = QVBoxLayout() self.setLayout(layout_base) self.tab_widget = tab_widget(window) layout_base.addWidget(self.tab_widget) self.map_table = {} self.map_graph = {} self.list_id = [] if not ("display" in config): return config_display = config["display"] for project, config_project in config_display.items(): for config_tab in config_project: if not "type" in config_tab: continue if config_tab["type"] == "table": id = config_tab["id"] label = config_tab["label"] widget = widget_table(self.window, id, config_tab) self.map_table[id] = widget elif config_tab["type"] == "graph": id = config_tab["id"] label = config_tab["label"] widget = chart_view(self.window, id, config_tab) self.map_graph[id] = widget def initial_table(self): for widget in self.map_table.values(): widget.initial() def load_project(self, project): self.list_id.clear() self.tab_widget.clear() if not ("display" in config): return config_display = config["display"] if not (project in config_display): return config_project = config_display[project] for config_tab in config_project: if not "type" in config_tab: continue id = config_tab["id"] label = config_tab["label"] widget = None if config_tab["type"] == "table": widget = self.map_table[id] elif config_tab["type"] == "graph": widget = self.map_graph[id] self.list_id.append(id) self.tab_widget.addTab(widget, label) self.tab_widget.setTabToolTip(self.tab_widget.count() - 1, f"id:{id}") def get_current_widget(self): widget = self.tab_widget.currentWidget() return widget def get_current_id(self): widget = self.get_current_widget() if widget is None: return return self.get_current_widget().id def load_table(self, data): widget = self.get_current_widget() if widget is None: return widget.load_table(data) def dump_table(self): widget = self.get_current_widget() if widget is None: return return widget.dump_table()