Coverage for graphica/gui/data_editor.py: 94.8%

527 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1import os 

2import logging 

3import re 

4import numpy as np 

5import pandas as pd 

6from scipy import stats as scipy_stats 

7from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton, 

8 QTableWidget, QTableWidgetItem, QMenuBar, 

9 QDialogButtonBox) 

10from PySide6.QtGui import QUndoStack, QKeySequence, QColor 

11from PySide6.QtCore import Signal, Qt 

12 

13logger = logging.getLogger(__name__) 

14 

15from graphica.gui import notify 

16from graphica.gui import dialog_dirs 

17from graphica.core.commands import (EditCellCommand, AddRowCommand, DeleteRowsCommand, 

18 AddColumnCommand, DeleteColumnCommand, SetMaskedRowsCommand, 

19 RenameColumnCommand) 

20from graphica.core.safe_eval import safe_eval_column_formula 

21from graphica.gui.dialogs import (ColumnCalculatorDialog, ReplicateErrorDialog, ColumnStringOpsDialog, 

22 ColumnVisibilityDialog, FindReplaceDialog) 

23from graphica.gui import icon_utils 

24from graphica.gui import theme 

25 

26 

27def _masked_row_background(): 

28 """マスク済み行の背景色を、現在のテーマトークンから解決する。""" 

29 return QColor(theme.current_tokens()["surface_2"]) 

30 

31 

32def _nan_cell_background(): 

33 """欠損値のセルの背景。マスクした行(行全体の印)とは別の色にする。""" 

34 return QColor(theme.current_tokens()["warning_soft"]) 

35 

36 

37class DataEditorDialog(QDialog): 

38 """dataset.df の表示と編集。編集は Undo できる(列の計算など一部を除く)。""" 

39 

40 # データが変わったことを PlotterApp に知らせる 

41 dataChanged = Signal() 

42 

43 # 選んだ行の df.index ラベル(空なら選択なし)。グラフ側で強調するため 

44 rowsHighlighted = Signal(list) 

45 

46 def __init__(self, dataset, parent=None): 

47 super().__init__(parent) 

48 # 独立したウィンドウにして、タスクバーや Alt+Tab から呼び戻せるようにする。親は残すので本体と一緒に閉じる 

49 self.setWindowFlag(Qt.WindowType.Window, True) 

50 self.setWindowTitle(f"データエディタ: {dataset.name}") 

51 self.resize(800, 600) 

52 

53 self.dataset = dataset 

54 

55 # 並べ替えは表示用のコピーに対して行い、dataset.df は変えない 

56 self.view_df = self.dataset.df.copy() 

57 self.sort_state = (None, True) # (並べ替え中の列名, 昇順か) 

58 

59 # 隠した列の名前(表示だけで dataset.df は変えない) 

60 self._hidden_columns = set() 

61 

62 # 開いたままにして、「次を検索」を前回の続きから探す 

63 self._find_replace_dialog = None 

64 self._last_search_query = None 

65 self._last_search_index = -1 

66 

67 self.undo_stack = QUndoStack(self) 

68 # コマンドは GUI を知らないので、表の描き直しと通知はここでする 

69 self.undo_stack.indexChanged.connect(self._on_undo_stack_changed) 

70 

71 self.table_widget = QTableWidget() 

72 self.table_widget.setSortingEnabled(False) # 並べ替えは view_df で自前で行う 

73 self.table_widget.horizontalHeader().setSectionsClickable(True) 

74 self.table_widget.horizontalHeader().sectionClicked.connect(self._on_header_clicked) 

75 self.table_widget.horizontalHeader().sectionDoubleClicked.connect(self._on_header_double_clicked) 

76 # 列の並べ替えも見た目だけ(dataset.df の列順は変えない) 

77 self.table_widget.horizontalHeader().setSectionsMovable(True) 

78 self.table_widget.itemSelectionChanged.connect(self._on_table_selection_changed) 

79 self._populate_table() 

80 

81 button_layout = QHBoxLayout() 

82 self.add_row_button = QPushButton("行を追加") 

83 self.delete_row_button = QPushButton("選択行を削除") 

84 self.mask_rows_button = QPushButton("選択行を除外/解除") 

85 self.add_col_button = QPushButton("列を追加") 

86 self.delete_col_button = QPushButton("選択列を削除") 

87 self.calc_button = QPushButton("列の計算...") 

88 self.replicate_error_button = QPushButton("誤差の自動計算...") 

89 self.string_ops_button = QPushButton("文字列操作...") 

90 self.column_visibility_button = QPushButton("列の表示/非表示...") 

91 self.find_replace_button = QPushButton("検索/置換...") 

92 self.jump_to_row_button = QPushButton("行へ移動...") 

93 self.save_csv_button = QPushButton("CSVとして保存...") 

94 

95 _button_icons = ( 

96 (self.add_row_button, "row-insert-bottom"), 

97 (self.delete_row_button, "row-remove"), 

98 (self.mask_rows_button, "eye-off"), 

99 (self.add_col_button, "column-insert-right"), 

100 (self.delete_col_button, "column-remove"), 

101 (self.calc_button, "calculator"), 

102 (self.replicate_error_button, "math-function"), 

103 (self.string_ops_button, "typography"), 

104 (self.column_visibility_button, "eye"), 

105 (self.find_replace_button, "search"), 

106 (self.jump_to_row_button, "arrow-right"), 

107 (self.save_csv_button, "download"), 

108 ) 

109 self.mask_rows_button.setToolTip( 

110 "行を削除せず、フィット/プロットの対象から除外(または解除)します(非破壊的)" 

111 ) 

112 for button, icon_name in _button_icons: 

113 if not button.toolTip(): 

114 button.setToolTip(button.text()) 

115 button.setText("") 

116 button.setIcon(icon_utils.icon(icon_name, size=18)) 

117 button.setProperty("iconOnly", True) 

118 button.setFixedSize(34, 34) 

119 # フォーカスが残ると :focus の枠が次の操作まで出たままになる 

120 button.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

121 

122 button_layout.addWidget(self.add_row_button) 

123 button_layout.addWidget(self.delete_row_button) 

124 button_layout.addWidget(self.mask_rows_button) 

125 button_layout.addStretch() 

126 button_layout.addWidget(self.add_col_button) 

127 button_layout.addWidget(self.delete_col_button) 

128 button_layout.addStretch() 

129 button_layout.addWidget(self.calc_button) 

130 button_layout.addWidget(self.replicate_error_button) 

131 button_layout.addWidget(self.string_ops_button) 

132 button_layout.addStretch() 

133 button_layout.addWidget(self.column_visibility_button) 

134 button_layout.addWidget(self.find_replace_button) 

135 button_layout.addWidget(self.jump_to_row_button) 

136 button_layout.addStretch() 

137 button_layout.addWidget(self.save_csv_button) 

138 

139 

140 main_layout = QVBoxLayout(self) 

141 

142 menu_bar = QMenuBar(self) 

143 edit_menu = menu_bar.addMenu("編集") 

144 

145 undo_action = self.undo_stack.createUndoAction(self, "元に戻す") 

146 undo_action.setShortcut(QKeySequence.StandardKey.Undo) 

147 

148 redo_action = self.undo_stack.createRedoAction(self, "やり直し") 

149 redo_action.setShortcut(QKeySequence.StandardKey.Redo) 

150 

151 edit_menu.addAction(undo_action) 

152 edit_menu.addAction(redo_action) 

153 

154 main_layout.setMenuBar(menu_bar) 

155 

156 

157 main_layout.addLayout(button_layout) 

158 main_layout.addWidget(self.table_widget) 

159 

160 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) 

161 button_box.rejected.connect(self.reject) 

162 main_layout.addWidget(button_box) 

163 

164 self.table_widget.cellChanged.connect(self._on_cell_changed) 

165 

166 self.add_row_button.clicked.connect(self._on_add_row) 

167 self.delete_row_button.clicked.connect(self._on_delete_rows) 

168 self.mask_rows_button.clicked.connect(self._on_toggle_mask_rows) 

169 self.add_col_button.clicked.connect(self._on_add_column) 

170 self.delete_col_button.clicked.connect(self._on_delete_column) 

171 self.calc_button.clicked.connect(self._on_calculate_column) 

172 self.replicate_error_button.clicked.connect(self._on_calculate_replicate_error) 

173 self.string_ops_button.clicked.connect(self._on_column_string_ops) 

174 self.column_visibility_button.clicked.connect(self._on_toggle_column_visibility) 

175 self.find_replace_button.clicked.connect(self._on_open_find_replace) 

176 self.jump_to_row_button.clicked.connect(self._on_jump_to_row) 

177 self.save_csv_button.clicked.connect(self._on_save_as_csv) 

178 

179 def _populate_table(self): 

180 """view_df を表に入れる。NaN / NaT は空欄で出す。""" 

181 df = self.view_df 

182 

183 # 入れている間に cellChanged が出ないように 

184 self.table_widget.blockSignals(True) 

185 

186 self.table_widget.setRowCount(len(df)) 

187 self.table_widget.setColumnCount(len(df.columns)) 

188 self.table_widget.setHorizontalHeaderLabels(df.columns) 

189 

190 # 行の見出しは df.index のラベル(並べ替えてもどの行か分かる) 

191 self.table_widget.setVerticalHeaderLabels([str(i) for i in df.index]) 

192 

193 for i in range(len(df)): 

194 is_masked = df.index[i] in self.dataset.masked_row_indices 

195 for j in range(len(df.columns)): 

196 value = df.iloc[i, j] 

197 

198 is_nan = pd.isna(value) 

199 item_text = "" if is_nan else str(value) 

200 

201 item = QTableWidgetItem(item_text) 

202 if is_masked: 

203 item.setBackground(_masked_row_background()) 

204 item.setToolTip("この行はフィット/プロットから除外されています") 

205 elif is_nan: 

206 # マスクした行は行全体に色が付いているので重ねない 

207 item.setBackground(_nan_cell_background()) 

208 item.setToolTip("欠損値(NaN)です") 

209 self.table_widget.setItem(i, j, item) 

210 

211 self.table_widget.resizeColumnsToContents() 

212 

213 # 表を作り直すたびに隠す列を当て直す(並べ替え・列の追加・Undo の後も消えないように) 

214 for col_idx, col_name in enumerate(df.columns): 

215 self.table_widget.setColumnHidden(col_idx, col_name in self._hidden_columns) 

216 

217 sort_col, sort_ascending = self.sort_state 

218 header = self.table_widget.horizontalHeader() 

219 if sort_col is not None and sort_col in df.columns: 

220 header.setSortIndicatorShown(True) 

221 header.setSortIndicator( 

222 df.columns.get_loc(sort_col), 

223 Qt.SortOrder.AscendingOrder if sort_ascending else Qt.SortOrder.DescendingOrder 

224 ) 

225 else: 

226 header.setSortIndicatorShown(False) 

227 

228 self.table_widget.blockSignals(False) 

229 

230 def _on_header_clicked(self, logical_index): 

231 """その列で並べ替える(同じ列なら昇順と降順を入れ替える)。表示だけで、データと Undo には影響しない。""" 

232 col_name = self.view_df.columns[logical_index] 

233 current_col, current_ascending = self.sort_state 

234 ascending = (not current_ascending) if current_col == col_name else True 

235 

236 try: 

237 # 安定な並べ替え(同じ値の行の順を保つ) 

238 self.view_df = self.view_df.sort_values(by=col_name, ascending=ascending, kind='mergesort') 

239 except TypeError: 

240 # 型の混ざった列は並べ替えられないことがある 

241 notify.warning(self, "ソートエラー", f"列 '{col_name}' はソートできませんでした。") 

242 return 

243 

244 self.sort_state = (col_name, ascending) 

245 self._populate_table() 

246 

247 def _on_header_double_clicked(self, logical_index): 

248 old_name = self.view_df.columns[logical_index] 

249 new_name, ok = notify.get_text(self, "列名の変更", "新しい列名:", text=str(old_name)) 

250 if not ok: 

251 return 

252 new_name = new_name.strip() 

253 if not new_name or new_name == old_name: 

254 return 

255 if new_name in self.dataset.df.columns: 

256 notify.warning(self, "列名の変更", f"列名 '{new_name}' は既に使用されています。") 

257 return 

258 

259 command = RenameColumnCommand(self.dataset, old_name, new_name) 

260 self.undo_stack.push(command) 

261 

262 def get_selected_master_indices(self): 

263 """選んだ行の dataset.df の index ラベル。view_df は並べ替えてあるので view_df.index を通す。""" 

264 rows = sorted({index.row() for index in self.table_widget.selectionModel().selectedRows()}) 

265 return [self.view_df.index[r] for r in rows if r < len(self.view_df.index)] 

266 

267 def _on_table_selection_changed(self): 

268 self.rowsHighlighted.emit(self.get_selected_master_indices()) 

269 

270 def select_row_by_master_index(self, master_index): 

271 """グラフの点から表の行を選ぶ(逆向きの強調)。通知が往復しないよう itemSelectionChanged を止める。""" 

272 matches = np.where(self.view_df.index == master_index)[0] 

273 if len(matches) == 0: 

274 return 

275 row = int(matches[0]) 

276 

277 self.table_widget.blockSignals(True) 

278 self.table_widget.clearSelection() 

279 self.table_widget.selectRow(row) 

280 self.table_widget.blockSignals(False) 

281 

282 item = self.table_widget.item(row, 0) 

283 if item is not None: 283 ↛ exitline 283 didn't return from function 'select_row_by_master_index' because the condition on line 283 was always true

284 self.table_widget.scrollToItem(item) 

285 

286 def closeEvent(self, event): 

287 self.rowsHighlighted.emit([]) 

288 super().closeEvent(event) 

289 

290 def _on_cell_changed(self, row, column): 

291 try: 

292 # row は表示上の行番号なので、view_df.index で df のラベルにする 

293 original_index = self.view_df.index[row] 

294 col_name = self.view_df.columns[column] 

295 

296 old_value = self.dataset.df.loc[original_index, col_name] 

297 

298 new_value_str = self.table_widget.item(row, column).text() 

299 

300 new_value = None 

301 

302 if new_value_str == "": 

303 new_value = np.nan 

304 else: 

305 original_dtype = self.dataset.df[col_name].dtype 

306 

307 try: 

308 # bool 列は np.bool_("False") が True になるので自前で読む 

309 if np.issubdtype(original_dtype, np.bool_): 

310 normalized = new_value_str.strip().lower() 

311 if normalized in ("true", "1", "yes"): 

312 new_value = True 

313 elif normalized in ("false", "0", "no"): 

314 new_value = False 

315 else: 

316 raise ValueError(f"'{new_value_str}' を真偽値として解釈できません") 

317 else: 

318 new_value = np.dtype(original_dtype).type(new_value_str) 

319 except (ValueError, TypeError): 

320 # 数値と bool の列は NaN にする(文字列のまま入れると列全体が object 型になる) 

321 if np.issubdtype(original_dtype, np.number) or np.issubdtype(original_dtype, np.bool_): 

322 new_value = np.nan 

323 else: 

324 new_value = new_value_str 

325 

326 # NaN 同士は != で比べられない 

327 is_nan_old = pd.isna(old_value) 

328 is_nan_new = pd.isna(new_value) 

329 

330 if (is_nan_old and not is_nan_new) or \ 

331 (not is_nan_old and is_nan_new) or \ 

332 (not is_nan_old and not is_nan_new and old_value != new_value): 

333 command = EditCellCommand(self.dataset, original_index, col_name, old_value, new_value) 

334 self.undo_stack.push(command) 

335 

336 else: 

337 # 変わっていなくても表示を整える("1.0" を "1.0" に編集したときなど) 

338 self.table_widget.blockSignals(True) 

339 item_text = "" if is_nan_old else str(old_value) 

340 self.table_widget.item(row, column).setText(item_text) 

341 self.table_widget.blockSignals(False) 

342 

343 except Exception: 

344 logger.exception("セル編集コマンド作成エラー") 

345 # 元の値を表示し直す。シグナルは必ず戻す(止めたままだと以後の編集が無視される)。 

346 self.table_widget.blockSignals(True) 

347 try: 

348 original_index = self.view_df.index[row] 

349 col_name = self.view_df.columns[column] 

350 original_value = self.dataset.df.loc[original_index, col_name] 

351 item_text = "" if pd.isna(original_value) else str(original_value) 

352 self.table_widget.item(row, column).setText(item_text) 

353 except Exception: 

354 logger.exception("セルの表示を元の値に戻せませんでした") 

355 finally: 

356 self.table_widget.blockSignals(False) 

357 

358 def _reset_view(self): 

359 """view_df を dataset.df から取り直し、並べ替えを戻して描き直す。""" 

360 self.view_df = self.dataset.df.copy() 

361 self.sort_state = (None, True) 

362 self._populate_table() 

363 

364 def _on_undo_stack_changed(self, index): 

365 self._reset_view() 

366 self.dataChanged.emit() 

367 

368 def _on_add_row(self): 

369 command = AddRowCommand(self.dataset) 

370 self.undo_stack.push(command) 

371 

372 def _on_delete_rows(self): 

373 selected_items = self.table_widget.selectedItems() 

374 if not selected_items: return 

375 

376 view_rows = sorted(list(set(item.row() for item in selected_items))) 

377 

378 try: 

379 original_indices_attempt = [self.view_df.index[row] for row in view_rows] 

380 except IndexError: 

381 notify.warning(self, "削除エラー", "行インデックスの取得に失敗しました。") 

382 return 

383 

384 # view_df と dataset.df の index がずれていた場合に備える 

385 valid_indices_to_delete = [ 

386 idx for idx in original_indices_attempt 

387 if idx in self.dataset.df.index 

388 ] 

389 

390 if not valid_indices_to_delete: 

391 notify.warning(self, "削除エラー", "削除対象のデータがマスターに見つかりませんでした。") 

392 return 

393 

394 # Undo のため、消す前に写しを取る 

395 deleted_data = self.dataset.df.loc[valid_indices_to_delete].copy() 

396 

397 command = DeleteRowsCommand(self.dataset, valid_indices_to_delete, deleted_data) 

398 self.undo_stack.push(command) 

399 

400 def _on_toggle_mask_rows(self): 

401 """選んだ行のマスクを反転する(行は消さない。Undo できる)。""" 

402 selected_items = self.table_widget.selectedItems() 

403 if not selected_items: 

404 return 

405 

406 view_rows = sorted(set(item.row() for item in selected_items)) 

407 try: 

408 master_indices = [self.view_df.index[row] for row in view_rows] 

409 except IndexError: 

410 notify.warning(self, "操作エラー", "行インデックスの取得に失敗しました。") 

411 return 

412 master_indices = [idx for idx in master_indices if idx in self.dataset.df.index] 

413 if not master_indices: 

414 return 

415 

416 old_masked = list(self.dataset.masked_row_indices) 

417 new_masked = list(old_masked) 

418 for idx in master_indices: 

419 if idx in new_masked: 

420 new_masked.remove(idx) 

421 else: 

422 new_masked.append(idx) 

423 

424 command = SetMaskedRowsCommand(self.dataset, old_masked, new_masked) 

425 self.undo_stack.push(command) 

426 

427 def _on_add_column(self): 

428 col_name, ok = notify.get_text(self, "列の追加", "新しい列名を入力してください:") 

429 

430 if ok and col_name: 

431 if col_name in self.dataset.df.columns: 

432 notify.warning(self, "エラー", f"列名 '{col_name}' は既に存在します。") 

433 return 

434 

435 command = AddColumnCommand(self.dataset, col_name) 

436 self.undo_stack.push(command) 

437 

438 def _on_delete_column(self): 

439 current_col_index = self.table_widget.currentColumn() 

440 if current_col_index == -1: 

441 notify.warning(self,"エラー", "削除する列が選択されていません。") 

442 return 

443 

444 col_name = self.view_df.columns[current_col_index] 

445 

446 # 描画に使っている列は消させない 

447 if (col_name == self.dataset.x_col_name or 

448 col_name == self.dataset.y_col_name): 

449 

450 notify.warning(self, "削除不可", 

451 f"列 '{col_name}' は現在プロットに使用されているため削除できません。") 

452 return 

453 

454 deleted_column_data = self.dataset.df[col_name].copy() 

455 

456 command = DeleteColumnCommand(self.dataset, col_name, deleted_column_data) 

457 self.undo_stack.push(command) 

458 

459 

460 def _on_calculate_column(self): 

461 """列の計算。Undo できない。""" 

462 

463 dialog = ColumnCalculatorDialog(self.dataset.df.columns.tolist(), self) 

464 

465 if dialog.exec() == QDialog.DialogCode.Accepted: 

466 output_col, formula = dialog.get_formula() 

467 

468 if not output_col or not formula: 

469 notify.warning(self, "入力エラー", "出力列または計算式が空です。") 

470 return 

471 

472 try: 

473 self.dataset.df[output_col] = safe_eval_column_formula(self.dataset.df, formula) 

474 self.dataset.invalidate_visible_df_cache() 

475 

476 logger.info("計算完了: %s = %s", output_col, formula) 

477 

478 self._reset_view() 

479 

480 self.dataChanged.emit() 

481 

482 except Exception as e: 

483 logger.exception("計算エラー") 

484 notify.critical(self, "計算エラー", 

485 f"計算式の実行に失敗しました:\n\n{e}\n\n" 

486 "列名 (A, B など) や関数 (log(A) など) が正しいか確認してください。") 

487 

488 def _on_calculate_replicate_error(self): 

489 """反復測定の列から行ごとの平均と誤差(SD / SEM / 95%CI)を2つの列として足す。Undo できない。""" 

490 dialog = ReplicateErrorDialog(self.dataset.df.columns.tolist(), self) 

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

492 return 

493 

494 selected_cols, stat_type, base_name = dialog.get_settings() 

495 if len(selected_cols) < 2: 

496 notify.warning(self, "入力エラー", "反復測定として扱う列を2つ以上選択してください。") 

497 return 

498 if not base_name: 

499 notify.warning(self, "入力エラー", "出力列名のベースが空です。") 

500 return 

501 

502 error_suffix = {"SD": "SD", "SEM": "SEM", "95%CI": "CI95"}[stat_type] 

503 mean_col_name = f"{base_name}_mean" 

504 error_col_name = f"{base_name}_{error_suffix}" 

505 

506 if mean_col_name in self.dataset.df.columns or error_col_name in self.dataset.df.columns: 

507 notify.warning( 

508 self, "エラー", 

509 f"列名 '{mean_col_name}' または '{error_col_name}' は既に存在します。" 

510 "別のベース名を指定してください。" 

511 ) 

512 return 

513 

514 try: 

515 values = self.dataset.df[selected_cols].apply(pd.to_numeric, errors='coerce') 

516 mean = values.mean(axis=1) 

517 std = values.std(axis=1, ddof=1) 

518 n = values.notna().sum(axis=1) 

519 

520 if stat_type == "SD": 

521 error = std 

522 elif stat_type == "SEM": 

523 error = std / np.sqrt(n) 

524 else: # 95%CI は t 分布で(反復が少ないと正規近似より正確) 

525 dof = (n - 1).clip(lower=1) 

526 t_crit = pd.Series(scipy_stats.t.ppf(0.975, dof), index=dof.index) 

527 error = t_crit * std / np.sqrt(n) 

528 

529 self.dataset.df[mean_col_name] = mean 

530 self.dataset.df[error_col_name] = error 

531 self.dataset.invalidate_visible_df_cache() 

532 

533 logger.info( 

534 "誤差自動計算完了: %s, %s (元列: %s, 統計量: %s)", 

535 mean_col_name, error_col_name, selected_cols, stat_type 

536 ) 

537 

538 self._reset_view() 

539 self.dataChanged.emit() 

540 

541 except Exception as e: 

542 logger.exception("誤差自動計算エラー") 

543 notify.critical(self, "計算エラー", f"誤差の計算に失敗しました:\n{e}") 

544 

545 def _on_column_string_ops(self): 

546 """列の分割・結合・数値の抽出。結果は新しい列に入れる(既存の列は上書きしない)。Undo できない。""" 

547 dialog = ColumnStringOpsDialog(self.dataset.df.columns.tolist(), self) 

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

549 return 

550 

551 mode = dialog.get_mode() 

552 

553 if mode == ColumnStringOpsDialog.MODE_SPLIT: 

554 source_col, delimiter, prefix = dialog.get_split_settings() 

555 if not delimiter: 

556 notify.warning(self, "入力エラー", "区切り文字が空です。") 

557 return 

558 prefix = prefix or source_col 

559 try: 

560 split_result = self.dataset.df[source_col].astype(str).str.split(delimiter, expand=True) 

561 except Exception as e: 

562 logger.exception("列の分割エラー") 

563 notify.critical(self, "列の分割エラー", f"分割に失敗しました:\n{e}") 

564 return 

565 new_names = [] 

566 for i in range(split_result.shape[1]): 

567 name = f"{prefix}_{i + 1}" 

568 while name in self.dataset.df.columns or name in new_names: 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true

569 name = f"{name}_2" 

570 new_names.append(name) 

571 for name, col_idx in zip(new_names, split_result.columns): 

572 self.dataset.df[name] = split_result[col_idx] 

573 self.dataset.invalidate_visible_df_cache() 

574 logger.info("列の分割完了: %s -> %s (区切り文字: %r)", source_col, new_names, delimiter) 

575 

576 elif mode == ColumnStringOpsDialog.MODE_MERGE: 

577 selected_cols, separator, output_col = dialog.get_merge_settings() 

578 if len(selected_cols) < 2: 

579 notify.warning(self, "入力エラー", "結合する列を2つ以上選択してください。") 

580 return 

581 if not output_col: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true

582 notify.warning(self, "入力エラー", "出力列名が空です。") 

583 return 

584 if output_col in self.dataset.df.columns: 

585 notify.warning(self, "入力エラー", f"列名 '{output_col}' は既に存在します。") 

586 return 

587 try: 

588 merged = self.dataset.df[selected_cols[0]].astype(str) 

589 for col in selected_cols[1:]: 

590 merged = merged + separator + self.dataset.df[col].astype(str) 

591 except Exception as e: 

592 logger.exception("列の結合エラー") 

593 notify.critical(self, "列の結合エラー", f"結合に失敗しました:\n{e}") 

594 return 

595 self.dataset.df[output_col] = merged 

596 self.dataset.invalidate_visible_df_cache() 

597 logger.info("列の結合完了: %s -> %s (区切り文字: %r)", selected_cols, output_col, separator) 

598 

599 else: 

600 source_col, pattern, output_col = dialog.get_extract_settings() 

601 if not pattern: 601 ↛ 602line 601 didn't jump to line 602 because the condition on line 601 was never true

602 notify.warning(self, "入力エラー", "正規表現が空です。") 

603 return 

604 if not output_col: 604 ↛ 605line 604 didn't jump to line 605 because the condition on line 604 was never true

605 notify.warning(self, "入力エラー", "出力列名が空です。") 

606 return 

607 if output_col in self.dataset.df.columns: 607 ↛ 608line 607 didn't jump to line 608 because the condition on line 607 was never true

608 notify.warning(self, "入力エラー", f"列名 '{output_col}' は既に存在します。") 

609 return 

610 try: 

611 extracted = self.dataset.df[source_col].astype(str).str.extract(f"({pattern})", expand=False) 

612 values = pd.to_numeric(extracted, errors='coerce') 

613 except Exception as e: 

614 logger.exception("数値抽出エラー") 

615 notify.critical(self, "数値抽出エラー", f"正規表現が不正です:\n{e}") 

616 return 

617 self.dataset.df[output_col] = values 

618 self.dataset.invalidate_visible_df_cache() 

619 logger.info("数値抽出完了: %s -> %s (パターン: %r)", source_col, output_col, pattern) 

620 

621 self._reset_view() 

622 self.dataChanged.emit() 

623 

624 def _on_toggle_column_visibility(self): 

625 """チェックを外した列を隠す(表示だけ)。""" 

626 dialog = ColumnVisibilityDialog(self.view_df.columns.tolist(), self._hidden_columns, self) 

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

628 return 

629 self._hidden_columns = set(dialog.get_hidden_columns()) 

630 for col_idx, col_name in enumerate(self.view_df.columns): 

631 self.table_widget.setColumnHidden(col_idx, col_name in self._hidden_columns) 

632 

633 def _on_open_find_replace(self): 

634 """開いていれば前面に出すだけ。""" 

635 if self._find_replace_dialog is None: 

636 self._find_replace_dialog = FindReplaceDialog(self.dataset.df.columns.tolist(), self) 

637 self._find_replace_dialog.find_next_button.clicked.connect(self._on_find_next) 

638 self._find_replace_dialog.replace_all_button.clicked.connect(self._on_replace_all) 

639 self._find_replace_dialog.show() 

640 self._find_replace_dialog.raise_() 

641 self._find_replace_dialog.activateWindow() 

642 

643 def _on_find_next(self): 

644 """前回見つけた位置の次から、行優先で大文字小文字を区別せず部分一致で探す。検索語が変われば最初から。""" 

645 dialog = self._find_replace_dialog 

646 query = dialog.get_search_text() 

647 if not query: 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true

648 dialog.set_status("検索文字列を入力してください") 

649 return 

650 

651 target_col = dialog.get_target_column() 

652 df = self.view_df 

653 columns = [target_col] if target_col else list(df.columns) 

654 if not columns or len(df) == 0: 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true

655 dialog.set_status("検索対象のデータがありません") 

656 return 

657 

658 if self._last_search_query != query: 

659 self._last_search_index = -1 

660 self._last_search_query = query 

661 

662 cells = [(r, c) for r in range(len(df)) for c in columns] 

663 total = len(cells) 

664 for offset in range(1, total + 1): 

665 idx = (self._last_search_index + offset) % total 

666 row, col_name = cells[idx] 

667 value = df.iloc[row][col_name] 

668 if pd.isna(value): 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true

669 continue 

670 if query.lower() in str(value).lower(): 

671 col_index = df.columns.get_loc(col_name) 

672 self.table_widget.setCurrentCell(row, col_index) 

673 item = self.table_widget.item(row, col_index) 

674 if item is not None: 674 ↛ 676line 674 didn't jump to line 676 because the condition on line 674 was always true

675 self.table_widget.scrollToItem(item) 

676 self._last_search_index = idx 

677 dialog.set_status(f"見つかりました(表示上の{row + 1}行目、列「{col_name}」)") 

678 return 

679 

680 dialog.set_status("見つかりませんでした") 

681 

682 def _on_replace_all(self): 

683 """一致する全セルを置き換え、1つの Undo にまとめる。 

684 

685 置き換えた値は文字列なので、対象は文字列の列だけ(数値などの列は丸ごと object 型になってしまう)。 

686 """ 

687 dialog = self._find_replace_dialog 

688 query = dialog.get_search_text() 

689 replacement = dialog.get_replace_text() 

690 if not query: 

691 dialog.set_status("検索文字列を入力してください") 

692 return 

693 

694 target_col = dialog.get_target_column() 

695 all_columns = [target_col] if target_col else list(self.dataset.df.columns) 

696 columns = [c for c in all_columns if self.dataset.df[c].dtype == object] 

697 skipped_non_string = len(all_columns) - len(columns) 

698 

699 matches = [] # (行ラベル, 列名, 旧値, 新値) 

700 for col_name in columns: 

701 for row_label, value in self.dataset.df[col_name].items(): 

702 if pd.isna(value): 702 ↛ 703line 702 didn't jump to line 703 because the condition on line 702 was never true

703 continue 

704 text = str(value) 

705 new_text = re.sub(re.escape(query), replacement, text, flags=re.IGNORECASE) 

706 if new_text != text: 

707 matches.append((row_label, col_name, value, new_text)) 

708 

709 if not matches: 

710 note = "(数値/真偽値/日付列は置換対象外です)" if skipped_non_string else "" 

711 dialog.set_status(f"置換対象が見つかりませんでした{note}") 

712 return 

713 

714 self.undo_stack.beginMacro(f"検索/置換 ({len(matches)}件)") 

715 for row_label, col_name, old_value, new_value in matches: 

716 self.undo_stack.push(EditCellCommand(self.dataset, row_label, col_name, old_value, new_value)) 

717 self.undo_stack.endMacro() 

718 

719 note = "(数値/真偽値/日付列は置換対象外です)" if skipped_non_string else "" 

720 dialog.set_status(f"{len(matches)}件を置換しました{note}") 

721 

722 def _on_jump_to_row(self): 

723 """表示上の行番号(1始まり)の行を選んで見せる。""" 

724 if len(self.view_df) == 0: 

725 notify.information(self, "行へ移動", "テーブルにデータがありません。") 

726 return 

727 row_number, ok = notify.get_int( 

728 self, "行へ移動", f"移動先の行番号 (1〜{len(self.view_df)}):", 

729 1, 1, len(self.view_df) 

730 ) 

731 if not ok: 

732 return 

733 row_index = row_number - 1 

734 self.table_widget.setCurrentCell(row_index, 0) 

735 item = self.table_widget.item(row_index, 0) 

736 if item is not None: 736 ↛ exitline 736 didn't return from function '_on_jump_to_row' because the condition on line 736 was always true

737 self.table_widget.scrollToItem(item) 

738 

739 def _on_save_as_csv(self): 

740 base_name = os.path.splitext(self.dataset.name)[0] 

741 base_name = base_name.split(' (')[0] 

742 suggested_name = f"{base_name}_edited.csv" 

743 

744 file_path, _ = dialog_dirs.get_save_file_name( 

745 self, dialog_dirs.TABLE, "CSVとして保存", suggested_name, "CSV Files (*.csv);;All Files (*)" 

746 ) 

747 

748 if not file_path: 

749 return 

750 

751 try: 

752 # utf-8-sig でないと Excel で開いたとき日本語が化ける 

753 self.dataset.df.to_csv(file_path, index=False, encoding='utf-8-sig') 

754 

755 notify.information(self, "保存完了", f"データをCSVファイルとして保存しました:\n{file_path}") 

756 

757 except Exception as e: 

758 logger.exception("CSV保存エラー") 

759 notify.warning(self, "保存エラー", f"CSVファイルの保存中にエラーが発生しました:\n{e}")