data_process_ui/app/widget_display.py

764 lines
21 KiB
Python
Raw Normal View History

2026-01-12 09:21:42 +08:00
# -*- 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()