Coverage for graphica/gui/shell/dataset_page.py: 100.0%
198 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""左パネルの「データセット」のページ。今の一覧・検索欄・操作のボタンを、中央の下から移して並べ直す。
3見本(docs/dev/mockups/gui_redesign/)の並び: 見出し(件数・フォルダ・+追加)、検索欄、一覧、選んでいる
4データセットの統計カード(2段目はアドバンスドだけ)、下に操作のボタンの行。
5"""
6import numpy as np
7from PySide6.QtCore import QSize, Qt, QTimer
8from PySide6.QtGui import QAction
9from PySide6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QSizePolicy, QTreeWidgetItemIterator,
10 QVBoxLayout, QWidget)
12from graphica.core.analysis import sample_standard_deviation
13from graphica.core.i18n import tr
14from graphica.gui.dataset_style_icon import make_dataset_style_icon
15from graphica.gui.shell.icons import themed_icon
16from graphica.gui.widgets import StatCard
18LEFT_PANEL_WIDTH = 280
19# ボタンの行に残すもの(左から)。曲線フィット・ピーク検出・多峰フィット・新規作成は「…」のメニューへ
20FOOT_BUTTONS = (
21 ("view_edit_data_button", "table"),
22 ("duplicate_dataset_button", "copy"),
23 ("auto_color_button", "palette"),
24)
25OVERFLOW_BUTTON_ACTIONS = (
26 ("new_dataset_button", "新規作成..."),
27 None,
28 ("fit_curve_button", "曲線フィット..."),
29 ("find_peaks_button", "ピーク検出..."),
30 ("multi_peak_fit_button", "多峰フィット..."),
31)
34def _ghost(button, icon_name, size=30):
35 """枠の無いアイコンのボタンにする(今のボタンをそのまま使う)。"""
36 button.setProperty("iconOnly", True)
37 button.setProperty("ghostButton", True)
38 button.setText("")
39 button.setFixedSize(size, size)
40 button.setIconSize(QSize(18, 18))
41 button.setIcon(themed_icon(icon_name))
42 button.setProperty("iconName", icon_name)
43 button.style().unpolish(button)
44 button.style().polish(button)
45 return button
48def _take_all(layout):
49 """レイアウトから部品を全部外す(部品は消さない。区切りの線は消す)。"""
50 while layout.count():
51 item = layout.takeAt(0)
52 widget = item.widget()
53 if widget is not None and widget.objectName() == "button_row_separator":
54 widget.deleteLater()
57def _dataset_items(tree):
58 iterator = QTreeWidgetItemIterator(tree)
59 while iterator.value():
60 item = iterator.value()
61 if item.data(0, Qt.ItemDataRole.UserRole) is not None:
62 yield item
63 iterator += 1
66class DatasetPage(QWidget):
67 def __init__(self, app, parent=None):
68 super().__init__(parent)
69 self.setObjectName("dataset_page")
70 self._app = app
71 tree = app.ui.dataset_list_widget
72 old_container = tree.parentWidget()
73 app.ui.gridLayout_2.removeWidget(old_container)
74 _take_all(old_container.layout())
75 old_container.deleteLater()
76 _take_all(app.ui.horizontalLayout_3)
77 app.ui.gridLayout_2.removeItem(app.ui.horizontalLayout_3)
79 layout = QVBoxLayout(self)
80 layout.setContentsMargins(0, 0, 0, 0)
81 layout.setSpacing(0)
83 # 見出し
84 head = QHBoxLayout()
85 head.setContentsMargins(14, 0, 10, 0)
86 head.setSpacing(6)
87 title = QLabel(tr("データセット"))
88 title.setObjectName("panel_title")
89 self.count_label = QLabel("0")
90 self.count_label.setObjectName("count_pill")
91 self.count_label.setFixedHeight(18)
92 self.count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
93 head.addWidget(title)
94 head.addWidget(self.count_label)
95 head.addStretch(1)
96 head.addWidget(_ghost(app.new_folder_button, "folder-plus", size=28))
97 add_button = app.ui.add_dataset_button
98 add_button.setProperty("iconOnly", False)
99 add_button.setProperty("primaryButton", True)
100 add_button.setText(tr("追加"))
101 add_button.setMinimumSize(0, 0)
102 add_button.setMaximumSize(16777215, 16777215)
103 add_button.setIconSize(QSize(14, 14))
104 add_button.setIcon(themed_icon("plus", size=14, token="on_accent"))
105 add_button.style().unpolish(add_button)
106 add_button.style().polish(add_button)
107 head.addWidget(add_button)
108 head_box = QWidget()
109 head_box.setFixedHeight(52)
110 head_box.setLayout(head)
111 layout.addWidget(head_box)
113 search_row = QHBoxLayout()
114 search_row.setContentsMargins(12, 0, 12, 8)
115 search_row.addWidget(app.dataset_search_edit)
116 layout.addLayout(search_row)
118 tree_row = QHBoxLayout()
119 tree_row.setContentsMargins(8, 0, 8, 4)
120 # 余った高さは一覧に回す(カードや見出しは中身の高さのまま)
121 tree.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
122 tree_row.addWidget(tree)
123 layout.addLayout(tree_row, 1)
125 # 選んでいるデータセットの統計
126 stats_head = QHBoxLayout()
127 stats_head.setContentsMargins(14, 6, 14, 6)
128 stats_head.setSpacing(6)
129 self.stats_swatch = QLabel()
130 self.stats_name = QLabel("-")
131 self.stats_name.setObjectName("stats_target_label")
132 stats_head.addWidget(self.stats_swatch)
133 stats_head.addWidget(self.stats_name, 1)
134 layout.addLayout(stats_head)
135 self.cards = {}
136 layout.addWidget(self._card_row((("n", "点の数"), ("mean", "平均"), ("sd", "標準偏差"))))
137 app.dataset_stats_advanced_row = self._card_row((("min", "最小"), ("max", "最大"), ("missing", "欠損")))
138 layout.addWidget(app.dataset_stats_advanced_row)
139 layout.addSpacing(6)
141 # 操作のボタンの行
142 foot = QFrame()
143 foot.setObjectName("panel_foot")
144 foot_layout = QHBoxLayout(foot)
145 foot_layout.setContentsMargins(8, 6, 8, 6)
146 foot_layout.setSpacing(2)
147 for attr, icon_name in FOOT_BUTTONS:
148 foot_layout.addWidget(_ghost(getattr(app, attr), icon_name))
149 foot_layout.addStretch(1)
150 overflow = app.dataset_overflow_button
151 overflow.setText("")
152 overflow.setProperty("ghostButton", True)
153 overflow.setFixedSize(30, 30)
154 overflow.setIconSize(QSize(18, 18))
155 overflow.setIcon(themed_icon("dots"))
156 overflow.setProperty("iconName", "dots")
157 overflow.setStyleSheet("QToolButton::menu-indicator { image: none; width: 0; }")
158 self._extend_overflow_menu(overflow.menu())
159 foot_layout.addWidget(overflow)
160 foot_layout.addWidget(_ghost(app.ui.remove_dataset_button, "trash"))
161 layout.addWidget(foot)
163 # 移さなかったボタン(…のメニューから押す)は見せないだけで残す(シグナルとテストはそのまま)
164 for entry in OVERFLOW_BUTTON_ACTIONS:
165 if entry is not None:
166 button = getattr(app, entry[0])
167 button.setParent(self)
168 button.hide()
170 # ダークモードの切り替えでアイコンを作り直す表(_refresh_custom_svg_icons)を、移したボタンの形に合わせる
171 icons = app._dataset_action_button_icons
172 for attr, icon_name in FOOT_BUTTONS:
173 button = getattr(app, attr)
174 icons[button] = (icon_name, icons.get(button, (icon_name, ""))[1])
175 icons.pop(add_button, None)
177 self.setFixedWidth(LEFT_PANEL_WIDTH)
178 self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
180 self._count_timer = QTimer(self)
181 self._count_timer.setSingleShot(True)
182 self._count_timer.timeout.connect(self.refresh_count)
183 model = tree.model()
184 for signal in (model.rowsInserted, model.rowsRemoved, model.modelReset):
185 signal.connect(lambda *args: self._count_timer.start(0))
186 self.refresh_count()
187 self.set_stats(None)
189 def _card_row(self, entries):
190 row = QWidget()
191 row.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
192 grid = QGridLayout(row)
193 grid.setContentsMargins(12, 0, 12, 6)
194 grid.setSpacing(6)
195 for column, (key, label) in enumerate(entries):
196 card = StatCard(tr(label))
197 self.cards[key] = card
198 grid.addWidget(card, 0, column)
199 return row
201 def _extend_overflow_menu(self, menu):
202 """今の「…」のメニューの頭に、ボタンの行から外した操作を足す(押すと元のボタンを押したのと同じ)。"""
203 first = menu.actions()[0] if menu.actions() else None
204 self._overflow_actions = []
205 for entry in OVERFLOW_BUTTON_ACTIONS:
206 if entry is None:
207 action = menu.insertSeparator(first)
208 else:
209 attr, text = entry
210 button = getattr(self._app, attr)
211 action = QAction(tr(text), menu)
212 menu.insertAction(first, action)
213 action.triggered.connect(button.click)
214 self._overflow_actions.append((action, button))
215 menu.insertSeparator(first)
216 # ボタンの有効・無効は選択で変わるので、開くたびに写す
217 menu.aboutToShow.connect(self._sync_overflow_enabled)
219 def _sync_overflow_enabled(self):
220 for action, button in self._overflow_actions:
221 action.setEnabled(button.isEnabled())
223 def refresh_count(self):
224 self.count_label.setText(str(sum(1 for _ in _dataset_items(self._app.ui.dataset_list_widget))))
226 def set_stats(self, dataset):
227 """選んでいるデータセットの Y の統計(NaN は数えない)。None なら全部「-」。"""
228 values = dict.fromkeys(("n", "mean", "sd", "min", "max", "missing"), "-")
229 if dataset is None:
230 self.stats_name.setText("-")
231 self.stats_swatch.clear()
232 else:
233 self.stats_name.setText(dataset.name)
234 self.stats_swatch.setPixmap(make_dataset_style_icon(dataset).pixmap(QSize(16, 10)))
235 try:
236 y = np.asarray(dataset.y_data, dtype=float)
237 valid = y[~np.isnan(y)]
238 values["missing"] = str(int(len(y) - len(valid)))
239 if len(valid):
240 std = sample_standard_deviation(valid)
241 values.update(n=str(len(valid)), mean=f"{np.mean(valid):.4g}",
242 sd="-" if np.isnan(std) else f"{std:.4g}",
243 min=f"{np.min(valid):.4g}", max=f"{np.max(valid):.4g}")
244 except (TypeError, ValueError):
245 pass
246 for key, card in self.cards.items():
247 card.set_value(values[key])
249 def refresh_icons(self):
250 app = self._app
251 for attr, icon_name in FOOT_BUTTONS:
252 getattr(app, attr).setIcon(themed_icon(icon_name))
253 app.new_folder_button.setIcon(themed_icon("folder-plus"))
254 app.ui.remove_dataset_button.setIcon(themed_icon("trash"))
255 app.dataset_overflow_button.setIcon(themed_icon("dots"))
256 app.ui.add_dataset_button.setIcon(themed_icon("plus", size=14, token="on_accent"))