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
« 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
13logger = logging.getLogger(__name__)
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
27def _masked_row_background():
28 """マスク済み行の背景色を、現在のテーマトークンから解決する。"""
29 return QColor(theme.current_tokens()["surface_2"])
32def _nan_cell_background():
33 """欠損値のセルの背景。マスクした行(行全体の印)とは別の色にする。"""
34 return QColor(theme.current_tokens()["warning_soft"])
37class DataEditorDialog(QDialog):
38 """dataset.df の表示と編集。編集は Undo できる(列の計算など一部を除く)。"""
40 # データが変わったことを PlotterApp に知らせる
41 dataChanged = Signal()
43 # 選んだ行の df.index ラベル(空なら選択なし)。グラフ側で強調するため
44 rowsHighlighted = Signal(list)
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)
53 self.dataset = dataset
55 # 並べ替えは表示用のコピーに対して行い、dataset.df は変えない
56 self.view_df = self.dataset.df.copy()
57 self.sort_state = (None, True) # (並べ替え中の列名, 昇順か)
59 # 隠した列の名前(表示だけで dataset.df は変えない)
60 self._hidden_columns = set()
62 # 開いたままにして、「次を検索」を前回の続きから探す
63 self._find_replace_dialog = None
64 self._last_search_query = None
65 self._last_search_index = -1
67 self.undo_stack = QUndoStack(self)
68 # コマンドは GUI を知らないので、表の描き直しと通知はここでする
69 self.undo_stack.indexChanged.connect(self._on_undo_stack_changed)
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()
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として保存...")
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)
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)
140 main_layout = QVBoxLayout(self)
142 menu_bar = QMenuBar(self)
143 edit_menu = menu_bar.addMenu("編集")
145 undo_action = self.undo_stack.createUndoAction(self, "元に戻す")
146 undo_action.setShortcut(QKeySequence.StandardKey.Undo)
148 redo_action = self.undo_stack.createRedoAction(self, "やり直し")
149 redo_action.setShortcut(QKeySequence.StandardKey.Redo)
151 edit_menu.addAction(undo_action)
152 edit_menu.addAction(redo_action)
154 main_layout.setMenuBar(menu_bar)
157 main_layout.addLayout(button_layout)
158 main_layout.addWidget(self.table_widget)
160 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
161 button_box.rejected.connect(self.reject)
162 main_layout.addWidget(button_box)
164 self.table_widget.cellChanged.connect(self._on_cell_changed)
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)
179 def _populate_table(self):
180 """view_df を表に入れる。NaN / NaT は空欄で出す。"""
181 df = self.view_df
183 # 入れている間に cellChanged が出ないように
184 self.table_widget.blockSignals(True)
186 self.table_widget.setRowCount(len(df))
187 self.table_widget.setColumnCount(len(df.columns))
188 self.table_widget.setHorizontalHeaderLabels(df.columns)
190 # 行の見出しは df.index のラベル(並べ替えてもどの行か分かる)
191 self.table_widget.setVerticalHeaderLabels([str(i) for i in df.index])
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]
198 is_nan = pd.isna(value)
199 item_text = "" if is_nan else str(value)
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)
211 self.table_widget.resizeColumnsToContents()
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)
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)
228 self.table_widget.blockSignals(False)
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
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
244 self.sort_state = (col_name, ascending)
245 self._populate_table()
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
259 command = RenameColumnCommand(self.dataset, old_name, new_name)
260 self.undo_stack.push(command)
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)]
267 def _on_table_selection_changed(self):
268 self.rowsHighlighted.emit(self.get_selected_master_indices())
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])
277 self.table_widget.blockSignals(True)
278 self.table_widget.clearSelection()
279 self.table_widget.selectRow(row)
280 self.table_widget.blockSignals(False)
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)
286 def closeEvent(self, event):
287 self.rowsHighlighted.emit([])
288 super().closeEvent(event)
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]
296 old_value = self.dataset.df.loc[original_index, col_name]
298 new_value_str = self.table_widget.item(row, column).text()
300 new_value = None
302 if new_value_str == "":
303 new_value = np.nan
304 else:
305 original_dtype = self.dataset.df[col_name].dtype
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
326 # NaN 同士は != で比べられない
327 is_nan_old = pd.isna(old_value)
328 is_nan_new = pd.isna(new_value)
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)
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)
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)
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()
364 def _on_undo_stack_changed(self, index):
365 self._reset_view()
366 self.dataChanged.emit()
368 def _on_add_row(self):
369 command = AddRowCommand(self.dataset)
370 self.undo_stack.push(command)
372 def _on_delete_rows(self):
373 selected_items = self.table_widget.selectedItems()
374 if not selected_items: return
376 view_rows = sorted(list(set(item.row() for item in selected_items)))
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
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 ]
390 if not valid_indices_to_delete:
391 notify.warning(self, "削除エラー", "削除対象のデータがマスターに見つかりませんでした。")
392 return
394 # Undo のため、消す前に写しを取る
395 deleted_data = self.dataset.df.loc[valid_indices_to_delete].copy()
397 command = DeleteRowsCommand(self.dataset, valid_indices_to_delete, deleted_data)
398 self.undo_stack.push(command)
400 def _on_toggle_mask_rows(self):
401 """選んだ行のマスクを反転する(行は消さない。Undo できる)。"""
402 selected_items = self.table_widget.selectedItems()
403 if not selected_items:
404 return
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
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)
424 command = SetMaskedRowsCommand(self.dataset, old_masked, new_masked)
425 self.undo_stack.push(command)
427 def _on_add_column(self):
428 col_name, ok = notify.get_text(self, "列の追加", "新しい列名を入力してください:")
430 if ok and col_name:
431 if col_name in self.dataset.df.columns:
432 notify.warning(self, "エラー", f"列名 '{col_name}' は既に存在します。")
433 return
435 command = AddColumnCommand(self.dataset, col_name)
436 self.undo_stack.push(command)
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
444 col_name = self.view_df.columns[current_col_index]
446 # 描画に使っている列は消させない
447 if (col_name == self.dataset.x_col_name or
448 col_name == self.dataset.y_col_name):
450 notify.warning(self, "削除不可",
451 f"列 '{col_name}' は現在プロットに使用されているため削除できません。")
452 return
454 deleted_column_data = self.dataset.df[col_name].copy()
456 command = DeleteColumnCommand(self.dataset, col_name, deleted_column_data)
457 self.undo_stack.push(command)
460 def _on_calculate_column(self):
461 """列の計算。Undo できない。"""
463 dialog = ColumnCalculatorDialog(self.dataset.df.columns.tolist(), self)
465 if dialog.exec() == QDialog.DialogCode.Accepted:
466 output_col, formula = dialog.get_formula()
468 if not output_col or not formula:
469 notify.warning(self, "入力エラー", "出力列または計算式が空です。")
470 return
472 try:
473 self.dataset.df[output_col] = safe_eval_column_formula(self.dataset.df, formula)
474 self.dataset.invalidate_visible_df_cache()
476 logger.info("計算完了: %s = %s", output_col, formula)
478 self._reset_view()
480 self.dataChanged.emit()
482 except Exception as e:
483 logger.exception("計算エラー")
484 notify.critical(self, "計算エラー",
485 f"計算式の実行に失敗しました:\n\n{e}\n\n"
486 "列名 (A, B など) や関数 (log(A) など) が正しいか確認してください。")
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
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
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}"
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
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)
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)
529 self.dataset.df[mean_col_name] = mean
530 self.dataset.df[error_col_name] = error
531 self.dataset.invalidate_visible_df_cache()
533 logger.info(
534 "誤差自動計算完了: %s, %s (元列: %s, 統計量: %s)",
535 mean_col_name, error_col_name, selected_cols, stat_type
536 )
538 self._reset_view()
539 self.dataChanged.emit()
541 except Exception as e:
542 logger.exception("誤差自動計算エラー")
543 notify.critical(self, "計算エラー", f"誤差の計算に失敗しました:\n{e}")
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
551 mode = dialog.get_mode()
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)
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)
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)
621 self._reset_view()
622 self.dataChanged.emit()
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)
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()
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
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
658 if self._last_search_query != query:
659 self._last_search_index = -1
660 self._last_search_query = query
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
680 dialog.set_status("見つかりませんでした")
682 def _on_replace_all(self):
683 """一致する全セルを置き換え、1つの Undo にまとめる。
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
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)
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))
709 if not matches:
710 note = "(数値/真偽値/日付列は置換対象外です)" if skipped_non_string else ""
711 dialog.set_status(f"置換対象が見つかりませんでした{note}")
712 return
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()
719 note = "(数値/真偽値/日付列は置換対象外です)" if skipped_non_string else ""
720 dialog.set_status(f"{len(matches)}件を置換しました{note}")
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)
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"
744 file_path, _ = dialog_dirs.get_save_file_name(
745 self, dialog_dirs.TABLE, "CSVとして保存", suggested_name, "CSV Files (*.csv);;All Files (*)"
746 )
748 if not file_path:
749 return
751 try:
752 # utf-8-sig でないと Excel で開いたとき日本語が化ける
753 self.dataset.df.to_csv(file_path, index=False, encoding='utf-8-sig')
755 notify.information(self, "保存完了", f"データをCSVファイルとして保存しました:\n{file_path}")
757 except Exception as e:
758 logger.exception("CSV保存エラー")
759 notify.warning(self, "保存エラー", f"CSVファイルの保存中にエラーが発生しました:\n{e}")