Coverage for graphica/gui/panels/dataset_tree.py: 95.8%
267 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"""
2データセットの一覧(ツリー)の管理: 追加・削除・複製、フォルダ、表示/非表示、並べ替え。
3データセットに対する個々の操作は graphica.gui.datasets の機能ごとのクラスにある。
5ツリーの項目の UserRole には、データセットなら Dataset そのもの、フォルダなら None が入る。
6選択を読むときは行番号ではなく _get_current_dataset() / _get_selected_datasets() を使う
7(フォルダが入れ子になっていてもデータセットだけを扱えるように)。
8"""
9import copy
10import logging
11import uuid
12import numpy as np
13import pandas as pd
14from PySide6.QtCore import Qt, QTimer
15from PySide6.QtWidgets import (QDialog, QMenu)
17from graphica.gui import notify
18from graphica.gui import dialog_dirs
19from graphica.core.commands import (SetDatasetPropertiesCommand, ReorderDatasetsCommand)
20from graphica.core.dataset import Dataset
21from graphica.gui.datasets.order import DatasetOrder
22from graphica.gui.workers import BUILTIN_DATA_FILE_EXTENSIONS
23from graphica.core.plugin_api import get_registered_importer_extensions
24from graphica.gui.data_editor import DataEditorDialog
25from graphica.gui.datasets.actions_menu import populate_dataset_actions_menu
26from graphica.gui.dialogs import (NewDatasetDialog)
27from graphica.gui.dataset_style_icon import (
28 make_dataset_style_icon, make_dataset_visibility_icon, apply_dataset_visibility_text_style,
29 DATASET_TREE_VISIBILITY_COLUMN,
30)
32logger = logging.getLogger(__name__)
35class DatasetTreePanel:
36 """状態はタブが持ち、この部品はメソッドと信号の配線だけを持つ。PlotterApp の同じ名前は窓口(gui/panels/__init__.py)。"""
38 # PlotterApp から同じ名前で使えるもの(テスト・メニュー・ほかの部品が使う)
39 EXPOSED_NAMES = (
40 '_connect_dataset_signals', '_find_dataset_row', '_on_add_dataset', '_on_create_new_dataset',
41 '_on_dataset_order_applied', '_on_dataset_rows_moved', '_on_dataset_search_changed',
42 '_on_dataset_tree_context_menu', '_on_dataset_tree_item_clicked', '_on_duplicate_dataset',
43 '_on_hide_all_datasets', '_on_hide_all_in_folder', '_on_new_folder', '_on_remove_dataset',
44 '_on_rename_dataset_folder', '_on_show_all_datasets', '_on_show_all_in_folder', '_on_show_data_editor',
45 '_push_dataset_property_command', '_refresh_after_dataset_property_change', '_set_all_datasets_visibility',
46 '_set_folder_datasets_visibility', '_sync_dataset_list_and_replot', '_top_level_selected_items',
47 )
49 def __init__(self, app):
50 self._app = app
52 def _on_add_dataset(self):
53 # プラグインが登録した拡張子も選べるようにする
54 plugin_extensions = get_registered_importer_extensions()
55 plugin_pattern = ''.join(f' *{ext}' for ext in plugin_extensions)
56 builtin_pattern = ' '.join(f'*{ext}' for ext in BUILTIN_DATA_FILE_EXTENSIONS)
57 file_path, _ = dialog_dirs.get_open_file_name(
58 self._app, dialog_dirs.DATA, "データファイルを選択",
59 f"Data Files ({builtin_pattern}{plugin_pattern});;All Files (*)"
60 )
61 if file_path:
62 self._app.load_data(file_path)
64 def _on_create_new_dataset(self):
65 """名前・列・行数を指定した空のデータセットを作り、そのままデータエディタで手入力させる。"""
66 dialog = NewDatasetDialog(self._app)
67 if dialog.exec() != QDialog.DialogCode.Accepted:
68 return
70 name = dialog.get_dataset_name()
71 column_names = dialog.get_column_names()
72 row_count = dialog.get_row_count()
74 df = pd.DataFrame({col: [np.nan] * row_count for col in column_names})
75 x_col_name = column_names[0]
76 y_col_name = column_names[1] if len(column_names) > 1 else column_names[0]
78 new_dataset = Dataset(name=name, df=df, x_col_name=x_col_name, y_col_name=y_col_name)
79 self._app._add_dataset_with_undo(new_dataset, self._app._get_target_folder_for_new_dataset(),
80 description="新規データセットの作成")
82 self._on_show_data_editor()
84 def _on_dataset_search_changed(self, text):
85 """名前に検索文字列を含むデータセットだけを出す。フォルダは中に1つでも当たりがあれば出す。"""
86 self._app.dataset_order.filter_by_name(text)
87 # 隠れた項目は選択に数えないので、削除などのボタンを決め直す(選択の通知は出ない)
88 self._app.property_panel.update_ui_state()
90 def _on_new_folder(self):
91 """選択中がフォルダならその中に、そうでなければ一番上に作る。"""
92 name, ok = notify.get_text(self._app, "新しいフォルダ", "フォルダ名:", text="新しいフォルダ")
93 if not ok or not name:
94 return
96 current_item = self._app.ui.dataset_list_widget.currentItem()
97 parent_item = None
98 if current_item is not None and current_item.data(0, Qt.ItemDataRole.UserRole) is None:
99 parent_item = current_item
101 self._app._add_dataset_folder_item(name, parent_item)
103 def _on_rename_dataset_folder(self):
104 """
105 フォルダの構造は Undo の対象外なので、名前の変更も Undo しない。
106 保存時はツリーの表示文字列を読むので、setText だけで保存にも反映される。
107 """
108 current_item = self._app.ui.dataset_list_widget.currentItem()
109 if current_item is None or current_item.data(0, Qt.ItemDataRole.UserRole) is not None:
110 return
111 old_name = current_item.text(0)
112 new_name, ok = notify.get_text(self._app, "フォルダ名を変更", "新しいフォルダ名:", text=old_name)
113 if not ok or not new_name:
114 return
115 current_item.setText(0, new_name)
117 def _set_folder_datasets_visibility(self, folder_item, visible):
118 """フォルダの中(サブフォルダも)のデータセットをまとめて表示/非表示にする(Undo 1回分)。"""
119 dataset_items = self._app._flatten_dataset_tree(folder_item)
120 datasets = [item.data(0, Qt.ItemDataRole.UserRole) for item in dataset_items]
121 if not datasets:
122 return
123 is_batch = len(datasets) > 1
124 if is_batch:
125 self._app.undo_stack.beginMacro(f"フォルダ内の表示/非表示切替 ({len(datasets)}件)")
126 for ds in datasets:
127 self._push_dataset_property_command(
128 ds, {'visible': ds.visible}, {'visible': visible},
129 description="データセットの表示/非表示切替"
130 )
131 if is_batch:
132 self._app.undo_stack.endMacro()
134 def _set_all_datasets_visibility(self, visible):
135 """フォルダに関係なく、全データセットをまとめて表示/非表示にする(Undo 1回分)。"""
136 datasets = list(self._app.project.datasets)
137 if not datasets:
138 return
139 is_batch = len(datasets) > 1
140 if is_batch: 140 ↛ 142line 140 didn't jump to line 142 because the condition on line 140 was always true
141 self._app.undo_stack.beginMacro(f"全データセットの表示/非表示切替 ({len(datasets)}件)")
142 for ds in datasets:
143 self._push_dataset_property_command(
144 ds, {'visible': ds.visible}, {'visible': visible},
145 description="データセットの表示/非表示切替"
146 )
147 if is_batch: 147 ↛ exitline 147 didn't return from function '_set_all_datasets_visibility' because the condition on line 147 was always true
148 self._app.undo_stack.endMacro()
150 def _on_show_all_datasets(self):
151 self._set_all_datasets_visibility(True)
153 def _on_hide_all_datasets(self):
154 self._set_all_datasets_visibility(False)
156 def _on_show_all_in_folder(self):
157 current_item = self._app.ui.dataset_list_widget.currentItem()
158 if current_item is None: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 return
160 self._set_folder_datasets_visibility(current_item, True)
162 def _on_hide_all_in_folder(self):
163 current_item = self._app.ui.dataset_list_widget.currentItem()
164 if current_item is None: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 return
166 self._set_folder_datasets_visibility(current_item, False)
168 def _on_dataset_tree_context_menu(self, pos):
169 menu = QMenu(self._app)
170 populate_dataset_actions_menu(self._app, menu)
171 menu.exec(self._app.ui.dataset_list_widget.viewport().mapToGlobal(pos))
173 def _top_level_selected_items(self, items):
174 """ほかの選択項目の子孫でないものだけを返す(フォルダと中身が両方選ばれていても二重に消さないため)。"""
175 return DatasetOrder.top_level_items(items)
177 def _on_remove_dataset(self):
178 """選択中のデータセットとフォルダ(中身ごと)を Undo できる形で消す。"""
179 selected_items = self._app.ui.dataset_list_widget.selectedItems()
180 if not selected_items:
181 return
183 self._app._remove_dataset_items_with_undo(self._top_level_selected_items(selected_items))
184 # Qt は隣の項目を今の項目にするだけで選ばない。選んでおけば、続けてそのまま操作できる
185 tree = self._app.ui.dataset_list_widget
186 neighbour = tree.currentItem()
187 if neighbour is not None and not neighbour.isSelected():
188 tree.setCurrentItem(neighbour)
190 def _find_dataset_row(self, dataset):
191 return self._app.dataset_order.find_row(dataset)
193 def _on_dataset_items_dropped(self, stayed_in_folder):
194 """ドロップ処理の最中にツリーを触ると Qt の処理とぶつかるので、次のイベントループで合わせる。"""
195 QTimer.singleShot(0, self._app, lambda: self._sync_dataset_order_to_tree(stayed_in_folder))
197 def _on_dataset_rows_moved(self, source_parent, source_start, source_end, dest_parent, dest_row):
198 self._sync_dataset_order_to_tree(source_parent == dest_parent)
200 def _sync_dataset_order_to_tree(self, stayed_in_folder):
201 """
202 ツリーのドラッグ&ドロップ後、project.datasets をツリーの並び(=描画の重なり順)に合わせる。
203 同じフォルダ内の並べ替えだけ Undo でき、フォルダをまたぐ移動はフォルダ構造と同じく Undo の対象外。
204 """
205 reordered = self._app.dataset_order.tree_order()
207 if len(reordered) != len(self._app.project.datasets): 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 logger.warning(
209 "データセットの並べ替え同期に失敗しました (表示数 %d, 実際 %d)。",
210 len(reordered), len(self._app.project.datasets)
211 )
212 return
214 old_order = list(self._app.project.datasets)
215 # Dataset の == は使えないので同一性で比べる
216 if [id(d) for d in reordered] == [id(d) for d in old_order]:
217 return
219 if stayed_in_folder:
220 command = ReorderDatasetsCommand(
221 self._app.project, old_order, reordered,
222 on_applied=self._on_dataset_order_applied,
223 description="データセットの並べ替え"
224 )
225 self._app.undo_stack.push(command)
226 else:
227 self._app.project.datasets = reordered
228 self._app._update_plot()
230 def _on_dataset_order_applied(self):
231 """ドロップ処理の最中にツリーを触ると Qt の処理とぶつかるので、同期と再描画は次のイベントループに回す。"""
232 QTimer.singleShot(0, self._app, self._sync_dataset_list_and_replot)
234 def _sync_dataset_list_and_replot(self):
235 self._app._sync_dataset_list_widget_order()
236 self._app._update_plot()
238 def _refresh_after_dataset_property_change(self, dataset, changed_keys=(), old_values=None, new_values=None):
239 """
240 Undo/Redo でデータセットの属性が変わった後に、ツリーの表示・プロパティ欄・描画を合わせる。
241 描き直すのは関係する軸だけ。on_applied は Undo と Redo のどちらの後かを教えないので、
242 subplot_target の新旧どちらの軸も描き直す。
243 """
244 item = self._app._get_dataset_tree_item(dataset)
245 if item is not None: 245 ↛ 254line 245 didn't jump to line 254 because the condition on line 245 was always true
246 if item.text(0) != dataset.name:
247 item.setText(0, dataset.name)
248 item.setIcon(0, make_dataset_style_icon(dataset))
249 item.setIcon(DATASET_TREE_VISIBILITY_COLUMN, make_dataset_visibility_icon(dataset))
250 apply_dataset_visibility_text_style(item, dataset)
251 if item is self._app.ui.dataset_list_widget.currentItem():
252 self._app.property_panel.update_ui_state()
254 axis_index = dataset.subplot_target
255 if axis_index >= len(self._app.canvas.all_axes) or axis_index >= len(self._app.project.all_plot_settings):
256 self._app._update_plot()
257 return
259 axis_indices_to_refresh = {axis_index}
260 if 'subplot_target' in changed_keys:
261 if old_values and 'subplot_target' in old_values: 261 ↛ 263line 261 didn't jump to line 263 because the condition on line 261 was always true
262 axis_indices_to_refresh.add(old_values['subplot_target'])
263 if new_values and 'subplot_target' in new_values: 263 ↛ 265line 263 didn't jump to line 265 because the condition on line 263 was always true
264 axis_indices_to_refresh.add(new_values['subplot_target'])
265 axis_indices_to_refresh = {
266 i for i in axis_indices_to_refresh
267 if i < len(self._app.canvas.all_axes) and i < len(self._app.project.all_plot_settings)
268 }
270 layout_mode = getattr(self._app.project, 'layout_mode', 'grid')
271 if layout_mode == 'free': 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 rows, cols = 0, 0
273 else:
274 rows = self._app.subplot_rows_spinbox.value()
275 cols = self._app.subplot_cols_spinbox.value()
277 for idx in axis_indices_to_refresh:
278 self._app.canvas.update_single_axis(
279 idx, self._app.project.datasets, self._app.project.all_plot_settings[idx],
280 rows=rows, cols=cols,
281 share_x_axis=getattr(self._app.project, 'share_x_axis', False),
282 share_y_axis=getattr(self._app.project, 'share_y_axis', False),
283 panel_labels_enabled=self._app.project.panel_labels_enabled,
284 )
285 is_secondary_visible = any(sa is not None for sa in self._app.canvas.all_secondary_axes)
286 self._app.tick_direction_y2_label.setVisible(is_secondary_visible)
287 self._app.major_tick_direction_y2_combo.setVisible(is_secondary_visible)
288 self._app.minor_tick_direction_y2_combo.setVisible(is_secondary_visible)
289 self._app.y2_label_text_label.setVisible(is_secondary_visible)
290 self._app.y2_label_text_edit.setVisible(is_secondary_visible)
292 self._app._reapply_editor_row_highlight()
293 self._app._refresh_minimap()
294 if hasattr(self._app, 'export_preview_panel'): 294 ↛ 296line 294 didn't jump to line 296 because the condition on line 294 was always true
295 self._app.export_preview_panel.refresh_preview()
296 self._app._notify_plugins_datasets_changed()
298 def _push_dataset_property_command(self, dataset, old_values: dict, new_values: dict, description: str,
299 skip_if_unchanged=True):
300 """
301 Dataset のプロパティ変更を Undo/Redo 可能なコマンドとして発行する共通ヘルパー。
302 old_values と new_values が同じ (実質的に変更なし) 場合は何もしない。
303 値に DataFrame を含むときは == で比べられないので skip_if_unchanged=False で呼ぶ。
304 """
305 if skip_if_unchanged and old_values == new_values:
306 return
307 command = SetDatasetPropertiesCommand(
308 dataset, old_values, new_values,
309 on_applied=lambda: self._refresh_after_dataset_property_change(
310 dataset, changed_keys=new_values.keys(), old_values=old_values, new_values=new_values
311 ),
312 description=description
313 )
314 self._app.undo_stack.push(command)
316 def _on_duplicate_dataset(self):
317 """選択中のデータセットを、元と同じフォルダに複製する。"""
318 selected_items = [
319 item for item in self._app.ui.dataset_list_widget.selectedItems()
320 if item.data(0, Qt.ItemDataRole.UserRole) is not None
321 ]
322 if not selected_items:
323 return
325 copies = []
326 for item in selected_items:
327 original_dataset = item.data(0, Qt.ItemDataRole.UserRole)
328 # DataFrame まで独立させる
329 new_dataset = copy.deepcopy(original_dataset)
330 # 写すと ID も同じになる。ID で引くもの(ウォーターフォールの段・保存の並び・統計値ラベルなど)が取り違えないように
331 new_dataset.dataset_id = uuid.uuid4().hex
332 new_dataset.name = f"{original_dataset.name} (copy)"
333 copies.append((new_dataset, item.parent()))
335 def add():
336 tree = self._app.ui.dataset_list_widget
337 tree.blockSignals(True)
338 new_items = [self._app.dataset_order.append(new_dataset, folder) for new_dataset, folder in copies]
339 tree.clearSelection()
340 for new_item in new_items:
341 new_item.setSelected(True)
342 tree.setCurrentItem(new_items[-1])
343 tree.blockSignals(False)
344 self._app.property_panel.update_ui_state()
345 self._app._update_plot()
347 self._app._push_dataset_additions(add, [new_dataset for new_dataset, _ in copies], "データセットの複製")
349 def _on_show_data_editor(self):
350 dataset = self._app._get_current_dataset()
351 if dataset is None:
352 return
354 # 同じデータセットのエディタが開いていれば、作り直さず(並べ替え・位置・選択が消えるので)前に出すだけ
355 if self._app.data_editor_dialog is not None and self._app.data_editor_dialog.dataset is dataset:
356 self._app.data_editor_dialog.show()
357 self._app.data_editor_dialog.raise_()
358 self._app.data_editor_dialog.activateWindow()
359 return
361 if self._app.data_editor_dialog:
362 self._app.data_editor_dialog.close()
363 # close() は隠すだけで、親(このタブ)にぶら下がったまま残り続けるので破棄する
364 self._app.data_editor_dialog.deleteLater()
365 del self._app.data_editor_dialog
366 self._app.data_editor_dialog = None
368 self._app.data_editor_dialog = DataEditorDialog(dataset, self._app)
370 self._app.data_editor_dialog.dataChanged.connect(self._app.property_panel.on_data_structure_changed)
372 # エディタで選んだ行をグラフ上で強調する
373 self._app.data_editor_dialog.rowsHighlighted.connect(self._app._on_editor_rows_highlighted)
375 # 開いたままグラフを操作できるよう非モーダル
376 self._app.data_editor_dialog.show()
378 def _on_dataset_tree_item_clicked(self, item, column):
379 """
380 目アイコンの列のクリックで表示/非表示を切り替える。
381 クリックした項目が複数選択に含まれていれば、選択中のすべてをまとめて切り替える。
382 """
383 if column != DATASET_TREE_VISIBILITY_COLUMN:
384 return
385 dataset = item.data(0, Qt.ItemDataRole.UserRole)
386 if dataset is None:
387 return
389 new_value = not dataset.visible
391 # Dataset の == は使えないので、in ではなく同一性で調べる
392 selected_datasets = self._app._get_selected_datasets()
393 is_part_of_multi_selection = len(selected_datasets) > 1 and any(
394 ds is dataset for ds in selected_datasets
395 )
396 targets = selected_datasets if is_part_of_multi_selection else [dataset]
398 is_batch = len(targets) > 1
399 if is_batch:
400 self._app.undo_stack.beginMacro(f"表示/非表示の一括切替 ({len(targets)}件)")
401 for ds in targets:
402 self._push_dataset_property_command(
403 ds,
404 {'visible': ds.visible},
405 {'visible': new_value},
406 description="データセットの表示/非表示切替"
407 )
408 if is_batch:
409 self._app.undo_stack.endMacro()
411 def _connect_dataset_signals(self):
412 self._app.ui.add_dataset_button.clicked.connect(self._on_add_dataset)
413 self._app.new_dataset_button.clicked.connect(self._on_create_new_dataset)
414 self._app.ui.remove_dataset_button.clicked.connect(self._on_remove_dataset)
415 self._app.new_folder_button.clicked.connect(self._on_new_folder)
416 self._app.dataset_search_edit.textChanged.connect(self._on_dataset_search_changed)
417 self._app.ui.dataset_list_widget.currentItemChanged.connect(self._app.property_panel.on_dataset_selected)
418 self._app.ui.dataset_list_widget.itemSelectionChanged.connect(self._app.property_panel.on_dataset_selected)
419 self._app.ui.dataset_list_widget.customContextMenuRequested.connect(self._on_dataset_tree_context_menu)
420 self._app.ui.dataset_list_widget.itemClicked.connect(self._on_dataset_tree_item_clicked)
421 # ドラッグでの並べ替え(描画の重なり順)を project.datasets に合わせる
422 self._app.ui.dataset_list_widget.items_dropped.connect(self._on_dataset_items_dropped)
425 # プロパティの欄は表(gui/dataset_bindings.py)の順につなぐ
426 self._app.property_panel.connect_signals()
428 self._app.fit_curve_button.clicked.connect(self._app.fitting.fit_current_dataset)
429 self._app.find_peaks_button.clicked.connect(self._app.peaks.find_peaks)
430 self._app.multi_peak_fit_button.clicked.connect(self._app.fitting.multi_peak_fit_current_dataset)
432 self._app.duplicate_dataset_button.clicked.connect(self._on_duplicate_dataset)
433 self._app.auto_color_button.clicked.connect(self._app.colors.auto_assign_colors)
434 self._app.manage_palette_action.triggered.connect(self._app.colors.manage_palettes)
435 self._app.colormap_assign_action.triggered.connect(self._app.colors.auto_assign_colors_from_colormap)
436 self._app.view_edit_data_button.clicked.connect(self._on_show_data_editor)
438 self._app.x_col_combo.currentTextChanged.connect(self._app.property_panel.on_plot_column_changed)
439 self._app.y_col_combo.currentTextChanged.connect(self._app.property_panel.on_plot_column_changed)
440 self._app.x_err_col_combo.currentTextChanged.connect(self._app.property_panel.on_error_column_changed)
441 self._app.y_err_col_combo.currentTextChanged.connect(self._app.property_panel.on_error_column_changed)
443 self._app.z_col_combo.currentTextChanged.connect(self._app.property_panel.on_z_column_changed)