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

1""" 

2データセットの一覧(ツリー)の管理: 追加・削除・複製、フォルダ、表示/非表示、並べ替え。 

3データセットに対する個々の操作は graphica.gui.datasets の機能ごとのクラスにある。 

4 

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) 

16 

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) 

31 

32logger = logging.getLogger(__name__) 

33 

34 

35class DatasetTreePanel: 

36 """状態はタブが持ち、この部品はメソッドと信号の配線だけを持つ。PlotterApp の同じ名前は窓口(gui/panels/__init__.py)。""" 

37 

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 ) 

48 

49 def __init__(self, app): 

50 self._app = app 

51 

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) 

63 

64 def _on_create_new_dataset(self): 

65 """名前・列・行数を指定した空のデータセットを作り、そのままデータエディタで手入力させる。""" 

66 dialog = NewDatasetDialog(self._app) 

67 if dialog.exec() != QDialog.DialogCode.Accepted: 

68 return 

69 

70 name = dialog.get_dataset_name() 

71 column_names = dialog.get_column_names() 

72 row_count = dialog.get_row_count() 

73 

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] 

77 

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="新規データセットの作成") 

81 

82 self._on_show_data_editor() 

83 

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() 

89 

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 

95 

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 

100 

101 self._app._add_dataset_folder_item(name, parent_item) 

102 

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) 

116 

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() 

133 

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() 

149 

150 def _on_show_all_datasets(self): 

151 self._set_all_datasets_visibility(True) 

152 

153 def _on_hide_all_datasets(self): 

154 self._set_all_datasets_visibility(False) 

155 

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) 

161 

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) 

167 

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)) 

172 

173 def _top_level_selected_items(self, items): 

174 """ほかの選択項目の子孫でないものだけを返す(フォルダと中身が両方選ばれていても二重に消さないため)。""" 

175 return DatasetOrder.top_level_items(items) 

176 

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 

182 

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) 

189 

190 def _find_dataset_row(self, dataset): 

191 return self._app.dataset_order.find_row(dataset) 

192 

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)) 

196 

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) 

199 

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() 

206 

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 

213 

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 

218 

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() 

229 

230 def _on_dataset_order_applied(self): 

231 """ドロップ処理の最中にツリーを触ると Qt の処理とぶつかるので、同期と再描画は次のイベントループに回す。""" 

232 QTimer.singleShot(0, self._app, self._sync_dataset_list_and_replot) 

233 

234 def _sync_dataset_list_and_replot(self): 

235 self._app._sync_dataset_list_widget_order() 

236 self._app._update_plot() 

237 

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() 

253 

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 

258 

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 } 

269 

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() 

276 

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) 

291 

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() 

297 

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) 

315 

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 

324 

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())) 

334 

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() 

346 

347 self._app._push_dataset_additions(add, [new_dataset for new_dataset, _ in copies], "データセットの複製") 

348 

349 def _on_show_data_editor(self): 

350 dataset = self._app._get_current_dataset() 

351 if dataset is None: 

352 return 

353 

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 

360 

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 

367 

368 self._app.data_editor_dialog = DataEditorDialog(dataset, self._app) 

369 

370 self._app.data_editor_dialog.dataChanged.connect(self._app.property_panel.on_data_structure_changed) 

371 

372 # エディタで選んだ行をグラフ上で強調する 

373 self._app.data_editor_dialog.rowsHighlighted.connect(self._app._on_editor_rows_highlighted) 

374 

375 # 開いたままグラフを操作できるよう非モーダル 

376 self._app.data_editor_dialog.show() 

377 

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 

388 

389 new_value = not dataset.visible 

390 

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] 

397 

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() 

410 

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) 

423 

424 

425 # プロパティの欄は表(gui/dataset_bindings.py)の順につなぐ 

426 self._app.property_panel.connect_signals() 

427 

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) 

431 

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) 

437 

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) 

442 

443 self._app.z_col_combo.currentTextChanged.connect(self._app.property_panel.on_z_column_changed)