Coverage for graphica/gui/main_window.py: 96.6%

775 statements  

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

1import os 

2import re 

3from contextlib import contextmanager 

4import types 

5import sys 

6import logging 

7import numpy as np 

8 

9logger = logging.getLogger(__name__) 

10 

11# 入力途中の "1e" や "1e-" も通す 

12_SCIENTIFIC_INPUT_RE = re.compile(r'^[+-]?(\d+\.?\d*|\.\d+)?([eE][+-]?\d*)?$') 

13 

14 

15def _scientific_text_from_value(self, value): 

16 """末尾のゼロを落とし、必要なら指数表記にする(.16g は float64 の有効桁数)。""" 

17 return f'{value:.16g}' 

18 

19 

20def _scientific_validate(self, text, pos): 

21 """QDoubleSpinBox の validate() は "1e-" のような入力途中を弾き、指数表記を打ち込めないので上書きする。""" 

22 if text == '' or text in ('-', '+'): 

23 return (QValidator.State.Intermediate, text, pos) 

24 if _SCIENTIFIC_INPUT_RE.match(text): 24 ↛ 30line 24 didn't jump to line 30 because the condition on line 24 was always true

25 try: 

26 float(text) 

27 return (QValidator.State.Acceptable, text, pos) 

28 except ValueError: 

29 return (QValidator.State.Intermediate, text, pos) 

30 return (QValidator.State.Invalid, text, pos) 

31 

32 

33def _enable_scientific_notation_input(spin_box, minimum, maximum, single_step=0.1): 

34 spin_box.setDecimals(SPIN_BOX_MAX_DECIMALS) 

35 spin_box.setRange(minimum, maximum) 

36 spin_box.textFromValue = types.MethodType(_scientific_text_from_value, spin_box) 

37 spin_box.validate = types.MethodType(_scientific_validate, spin_box) 

38 spin_box.setSingleStep(single_step) 

39 

40 

41def _strip_trailing_colon_from_labels(widget): 

42 """ラベル末尾の「:」を取る。生成物の ui_main_window.py に焼き込まれていて、.ui が無く作り直せない。 

43 

44 動的に足したラベルも含むよう、画面を組み立て終えてから呼ぶ。 

45 """ 

46 for label in widget.findChildren(QLabel): 

47 text = label.text() 

48 if text.endswith(':'): 

49 label.setText(text[:-1]) 

50 

51DEFAULT_WINDOW_WIDTH = 1280 

52DEFAULT_WINDOW_HEIGHT = 800 

53CONTROL_DOCK_WIDTH = 340 # 右のインスペクタの幅(見本どおり)。軸の設定の長い行は名前を上に折り返して収める 

54PROPERTY_LABEL_WIDTH = 92 # プロパティの名前の列(見本どおり)。長い名前は折り返す 

55SPIN_BOX_MAX_DECIMALS = 16 

56 

57 

58DEFAULT_DETACHED_CANVAS_WIDTH = 900 

59DEFAULT_DETACHED_CANVAS_HEIGHT = 700 

60 

61 

62 

63 

64 

65AUTOSAVE_FILENAME = "autosave.gra" 

66 

67 

68from graphica.gui import notify 

69 

70 

71 

72 

73from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, 

74 QAbstractSpinBox, QLabel, QTreeWidgetItem) 

75from PySide6.QtGui import QFont, QIcon, QValidator, QUndoStack 

76from PySide6.QtCore import Qt, QTimer, Signal 

77from graphica.models.project import ProjectModel 

78from graphica.core.version import APP_NAME, __version__ 

79from graphica.core.i18n import tr, set_language 

80from graphica.core.plugin_api import load_plugins_once 

81from graphica.core.plugin_types import PluginExecutionError 

82from graphica.gui import app_settings, log_axis_notes 

83from graphica.gui.app_settings import disabled_plugin_names 

84from graphica.gui.datasets.colors import ColorController 

85from graphica.gui.datasets.transfer import TransferController 

86from graphica.gui.datasets.overlays import OverlayController 

87from graphica.gui.datasets.plugin_runs import PluginRunController 

88from graphica.gui.datasets.property_panel import DatasetPropertyPanel 

89from graphica.gui.datasets.fitting import FittingController 

90from graphica.gui.datasets.host import DatasetHost 

91from graphica.gui.datasets.order import DatasetOrder 

92from graphica.gui.panels import create_panels, expose_panel_names 

93from graphica.gui.widget_translation import translate_widget_texts 

94from graphica.gui.tools import MOUSE_MODES, ToolManager, create_tools, expose_tool_names 

95from graphica.gui.tools.view_navigation import ViewNavigationTool 

96from graphica.gui import data_import_flow 

97# テストがこのモジュールの属性として引くので残す(使うコードは data_import_flow と project_files に移した) 

98import pandas as pd # noqa: F401 

99from PySide6.QtWidgets import QMessageBox # noqa: F401 

100from graphica.core.app_paths import get_app_data_dir # noqa: F401 

101from graphica.gui import project_files 

102from graphica.gui import plugin_update_flow 

103from graphica.gui.data_import_flow import ( # noqa: F401 

104 SUPPORTED_DATA_FILE_EXTENSIONS, find_unevaluated_formula_cells) 

105from graphica.gui.project_files import ( # noqa: F401 

106 AUTOSAVE_GENERATIONS, MAX_RECENT_FILES, UNSAVED_CHANGES_PROMPT_ENV, _unsaved_changes_prompt_enabled) 

107from graphica.gui.builders import axis_panel 

108from graphica.gui.builders import canvas_area 

109from graphica.gui.builders import dataset_panel 

110from graphica.gui.builders import property_sections 

111from graphica.gui import dock_layout 

112from graphica.gui import ui_mode 

113from graphica.gui.shell.side_pages import populate_side_pages 

114from graphica.gui.shell.tab_shell import build_tab_shell 

115from graphica.gui.builders.common import ( # noqa: F401 

116 DATASET_TREE_VISIBILITY_COLUMN_WIDTH, DOCK_LAYOUT_VERSION, DATASET_PROPERTY_SECTIONS, PLOT_TYPE_COMBO_MIN_CHARS, COLORMAP_CHOICES, TOOLBAR_ICON_SIZE, _svg_icon, _DatasetTreeSelectionDelegate, _ClickableMathPreviewLabel, _insert_form_row_after, 

117 disable_wheel_tab_switching) 

118from graphica.gui.resources import resource_path 

119from graphica.gui.axis_bindings import AXIS_BINDINGS, AXIS_BUTTONS 

120from graphica.gui.binding import Binder 

121from graphica.gui.datasets.peaks import PeakController 

122from graphica.gui.datasets.processing import ProcessingController 

123from graphica.gui.plugin_context import TabPluginContext 

124from graphica.core.app_paths import get_user_plugins_dir 

125 

126 

127from graphica.ui_main_window import Ui_MainWindow 

128 

129from graphica.core.dataset import COLOR_BY_COLUMN_PLOT_TYPE 

130from graphica.core.commands import AddDatasetCommand, DeferRedrawCommand, RemoveDatasetCommand 

131from graphica.gui.detached_canvas_window import DetachedCanvasWindow 

132from graphica.gui import theme 

133from graphica.gui.theme_color import theme_color_or_default 

134from graphica.gui.theme import apply_form_spacing 

135from graphica.gui.dialogs import (WelcomeDialog) 

136 

137 

138# 文字装飾パネルの記号。(表示, mathtext のマクロ名)。引数の要るマクロ(\sqrt{...})は 

139# 「$\macro$」で挿入できないので入れない(平方根は \surd)。 

140LABEL_SYMBOL_PALETTE = [ 

141 ("α", "alpha"), ("β", "beta"), ("γ", "gamma"), ("δ", "delta"), 

142 ("ε", "epsilon"), ("μ", "mu"), ("π", "pi"), ("ρ", "rho"), 

143 ("Σ", "Sigma"), ("σ", "sigma"), ("τ", "tau"), ("Ω", "Omega"), 

144 ("ω", "omega"), ("Δ", "Delta"), ("θ", "theta"), ("φ", "phi"), 

145 ("×", "times"), ("÷", "div"), ("±", "pm"), ("∓", "mp"), 

146 ("≈", "approx"), ("≠", "neq"), ("≤", "leq"), ("≥", "geq"), 

147 ("∞", "infty"), ("→", "rightarrow"), ("←", "leftarrow"), ("∂", "partial"), 

148 ("∇", "nabla"), ("∫", "int"), ("∝", "propto"), ("°", "degree"), 

149 # マイナス記号(U+2212)は mathtext で包まず、文字のまま入れる 

150 ("−", None), 

151] 

152 

153 

154 

155 

156 

157 

158from graphica.gui.dataset_style_icon import ( 

159 make_dataset_style_icon, make_dataset_visibility_icon, apply_dataset_visibility_text_style, 

160 DATASET_TREE_VISIBILITY_COLUMN, 

161) 

162from graphica.gui.mathtext_preview import JP_CAPABLE_FONT_FAMILIES 

163from graphica.gui.color_history import load_recent_colors_into_picker 

164 

165# グラフの既定フォント。UI のフォント("Yu Gothic UI" など)は matplotlib が解決できず文字化けする。 

166# "Yu Gothic" は Windows だけにあるので、OS ごとの候補を並べたリストを QFont と matplotlib の両方に渡す 

167# (無いフォント名は飛ばされる)。 

168PLOT_DEFAULT_FONT_FAMILIES = JP_CAPABLE_FONT_FAMILIES 

169 

170 

171def _make_default_plot_font(): 

172 """QFont(str) は1つの名前しか取らないので、setFamilies() で候補を全部入れる。""" 

173 font = QFont() 

174 font.setFamilies(PLOT_DEFAULT_FONT_FAMILIES) 

175 return font 

176 

177from graphica.gui.mixins.ui_setup_mixin import UISetupMixin 

178from graphica.gui.mixins.export_mixin import ExportMixin 

179from graphica.gui.mixins.project_io_mixin import ProjectIOMixin 

180from graphica.gui.mixins.help_mixin import HelpMixin 

181from graphica.gui.mixins.quick_access_mixin import QuickAccessMixin 

182 

183 

184def is_frozen(): 

185 return hasattr(sys, '_MEIPASS') 

186 

187 

188def plugin_search_paths(): 

189 """プラグインの探索先(優先順)。同梱の plugins と、利用者のフォルダ(%LOCALAPPDATA%\\Graphica\\plugins)。 

190 

191 同梱のフォルダはソースから動かすときだけあるので、あるときだけ加える 

192 (探索先は起動時に作られるので、無いものを渡すと site-packages の中に作ろうとする)。 

193 """ 

194 paths = [] 

195 bundled = resource_path("plugins") 

196 if not is_frozen() and os.path.isdir(bundled): 

197 paths.append(bundled) 

198 paths.append(get_user_plugins_dir()) 

199 return paths 

200 

201 

202 

203 

204 

205 

206 

207# 1つのタブ。機能ごとの mixin の役割は CLAUDE.md の「PlotterApp mixin composition」 

208class PlotterApp(QMainWindow, UISetupMixin, 

209 ExportMixin, ProjectIOMixin, HelpMixin, QuickAccessMixin): 

210 # 保存・読み込みのたびに出す。MainAppWindow がタブ名を更新する 

211 project_state_changed = Signal() 

212 

213 MOUSE_MODES = MOUSE_MODES 

214 

215 def _deactivate_other_mouse_modes(self, active_name): 

216 return self.mouse_tool_manager.deactivate_others(active_name) 

217 

218 def _active_mouse_mode(self): 

219 return self.mouse_tool_manager.active_name() 

220 

221 def __init__(self, run_startup_checks=True, tab_id=None): 

222 """ 

223 画面を組み立てる。組み立ての各段は後の段が前の段で作った部品を使うので、呼ぶ順番は変えない。 

224 

225 Args: 

226 run_startup_checks (bool): オートセーブからの復元確認・初回の案内・ドック配置の復元と保存・ 

227 clean_exit の管理をするか。アプリ全体で1回だけ意味を持つので、2つ目以降のタブでは False。 

228 tab_id (int, optional): 2つ目以降のタブの番号。オートセーブのファイル名が重ならないようにする。 

229 None なら autosave.gra。 

230 """ 

231 super().__init__() 

232 self._run_startup_checks = run_startup_checks 

233 self.tab_id = tab_id 

234 # 保存先のフォルダは self.settings を作った後に _update_autosave_path で決める 

235 self._autosave_base_filename = ( 

236 AUTOSAVE_FILENAME if not tab_id 

237 else f"autosave_tab{tab_id}{os.path.splitext(AUTOSAVE_FILENAME)[1]}" 

238 ) 

239 self._autosave_filename = self._autosave_base_filename 

240 

241 self._load_designer_ui() 

242 self._init_state() 

243 self._setup_window() 

244 self._build_canvas_and_toolbar() 

245 self._setup_status_bar() 

246 self._build_dataset_color_picker() 

247 self._build_dataset_list_buttons() 

248 self._build_dataset_style_controls() 

249 self._build_legend_location_control() 

250 self._build_fit_info_and_stats() 

251 self._build_secondary_y_controls() 

252 self._build_tick_grid_and_colorbar_controls() 

253 self._build_tick_format_controls() 

254 self._build_label_editors() 

255 self._add_subplot_target_row() 

256 self._arrange_property_docks() 

257 self._build_tab_shell() 

258 self._rebuild_label_tab() 

259 self._connect_and_initialize() 

260 # 英語表示のとき、組み立てた画面の文字をまとめて訳す(Designer の部品にも効かせる) 

261 translate_widget_texts(self) 

262 # 単独で作ったタブ(テストなど)はアドバンスドの見た目。MainAppWindow に入れると、そのモードで当て直す 

263 ui_mode.apply_ui_mode(self, ui_mode.ADVANCED) 

264 

265 def _load_designer_ui(self): 

266 self.ui = Ui_MainWindow() 

267 self.ui.setupUi(self) 

268 

269 # ui_main_window.py は生成物なので、Designer に無い種類はここで足す 

270 self.ui.plot_type_combo.addItem("Area") 

271 self.ui.plot_type_combo.addItem("Bar") 

272 self.ui.plot_type_combo.addItem("Step") 

273 self.ui.plot_type_combo.addItem("Density Scatter") 

274 self.ui.plot_type_combo.addItem(COLOR_BY_COLUMN_PLOT_TYPE) 

275 

276 # フォルダ分けのため、Designer の QListWidget を同じ位置の QTreeWidget に差し替える 

277 self._replace_dataset_list_with_tree() 

278 

279 self.project = ProjectModel() 

280 # 新しい変更の経路が project.notify_changed() だけで描き直せるように(既存の箇所は _update_plot を直接呼ぶ) 

281 self.project.changed.connect(self._update_plot) 

282 self.settings = app_settings.open_settings() 

283 self._update_autosave_path() 

284 load_recent_colors_into_picker(self.settings) 

285 

286 # 以降に作るメニューやボタンの tr() に効くので、画面を組み立てる前に 

287 set_language(app_settings.LANGUAGE.read(self.settings)) 

288 

289 # 起動時に False にし、正常に閉じたときだけ closeEvent で True に戻す。次の起動で False なら異常終了とみなし、 

290 # オートセーブからの復元を勧める。アプリ全体で1つの値なので、2つ目以降のタブは触らない 

291 if self._run_startup_checks: 

292 self._had_clean_exit = app_settings.CLEAN_EXIT.read(self.settings) 

293 app_settings.CLEAN_EXIT.write(self.settings, False) 

294 else: 

295 self._had_clean_exit = True 

296 

297 self.setWindowTitle(f"{APP_NAME} {__version__}") 

298 icon_path = resource_path("Graphica.ico") 

299 self.setWindowIcon(QIcon(icon_path)) 

300 self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.ui.control_dock_widget) 

301 

302 def _init_state(self): 

303 # 軸の設定とデータセットの一覧の部品(状態はタブが持つ。gui/panels/) 

304 self.panels = create_panels(self) 

305 # 非モーダルのダイアログとバックグラウンドの処理は、回収されないよう参照を持っておく 

306 self.data_editor_dialog = None 

307 self.help_dialog = None 

308 self.calc_help_dialog = None 

309 self._data_load_task_runner = None 

310 self._batch_export_task_runner = None 

311 self._update_check_task_runner = None 

312 self._update_download_runner = None 

313 self._plugin_update_runner = None 

314 # 複数ファイルをまとめて読み込むときの待ち行列と進捗 

315 self._data_load_queue = [] 

316 self._data_load_queue_total = 0 

317 self._data_load_queue_done = 0 

318 # フォルダからの一括取り込みで、ファイル名から列を取り出す正規表現。待ち行列を使い切ったら None に戻す 

319 self._batch_import_filename_regex = None 

320 # 上書き保存先。None なら manual_save() は「名前を付けて保存」になる。 

321 # タブ名もこの値から作る(ProjectModel.current_filepath はオートセーブでも変わる)。 

322 self._current_project_path = None 

323 self._restored_unsaved = False # オートセーブから復元し、まだ保存していない 

324 # 直前に保存/読み込みした時点の内容のハッシュ(未保存の変更の判定)。None はファイルと対応していない 

325 self._saved_content_fingerprint = None 

326 

327 # データエディタのセル編集の Undo とは別 

328 self.undo_stack = QUndoStack(self) 

329 

330 # データセットに対する操作を機能ごとに分けたクラス。窓口(DatasetHost)は本体に使うときに触れるので、 

331 # 画面を組み立てる前に作っておける(組み立ての途中からも使われる)。 

332 self.dataset_order = DatasetOrder( 

333 self.project, lambda: self.ui.dataset_list_widget, self._add_dataset_list_item) 

334 self._dataset_host = DatasetHost(self) 

335 self.peaks = PeakController(self._dataset_host) 

336 self.fitting = FittingController(self._dataset_host) 

337 self.processing = ProcessingController(self._dataset_host) 

338 self.colors = ColorController(self._dataset_host) 

339 self.transfer = TransferController(self._dataset_host) 

340 self.overlays = OverlayController(self._dataset_host) 

341 self.plugin_runs = PluginRunController(self._dataset_host) 

342 self.property_panel = DatasetPropertyPanel(self) 

343 # 軸の設定の欄と値の対応(集める・戻す・止める・つなぐ) 

344 self._axis_binder = Binder(self, AXIS_BINDINGS, also_blocked=AXIS_BUTTONS) 

345 

346 # メニューを作るときに参照するので、ここで用意する(0分なら止めたまま) 

347 self.autosave_timer = QTimer(self) 

348 self.autosave_timer.timeout.connect(self.auto_save) 

349 saved_interval_min = app_settings.AUTOSAVE_INTERVAL_MIN.read(self.settings) 

350 if saved_interval_min > 0: 350 ↛ 353line 350 didn't jump to line 353 because the condition on line 350 was always true

351 self.autosave_timer.start(saved_interval_min * 60 * 1000) 

352 

353 self.all_axes = [] 

354 self.all_secondary_axes = [] 

355 self.project.all_plot_settings = [] 

356 self.project.active_axis_index = 0 

357 

358 # deferred_redraw() の入れ子の深さと、その間に頼まれた描き直し((light, full_resolution) か None) 

359 self._redraw_defer_depth = 0 

360 self._deferred_redraw_request = None 

361 

362 # マウス操作の各モード(状態は各ツールが持つ。gui/tools/) 

363 self.mouse_tools = create_tools(self) 

364 self.mouse_tool_manager = ToolManager(self) 

365 self.view_navigation = ViewNavigationTool(self) 

366 

367 

368 # 最初の軸の設定の既定値になる。フォントは QFont()(UI のフォント)にしない: 

369 # matplotlib は "Yu Gothic UI" のような UI 用のフォントを解決できず、文字化けする 

370 self._tick_font = _make_default_plot_font() 

371 self._tick_color = '#000000' 

372 self._tick_width = 0.8 

373 self._axis_label_font = _make_default_plot_font() 

374 self._axis_label_color = '#000000' 

375 self._spine_width = 1.0 

376 self._spine_color = '#000000' 

377 self._legend_font = _make_default_plot_font() 

378 self._legend_color = '#000000' 

379 

380 def _setup_window(self): 

381 self.resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) 

382 self.ui.control_dock_widget.setFixedWidth(CONTROL_DOCK_WIDTH) 

383 

384 # 余った縦の空きはキャンバスに回す(指定しないとデータセット一覧と均等に分けられ、一覧の下が空く) 

385 self.ui.gridLayout_2.setRowStretch(1, 1) # キャンバス 

386 self.ui.gridLayout_2.setRowStretch(2, 0) # データセット一覧 

387 self.ui.gridLayout_2.setRowStretch(3, 0) # ボタンの行 

388 disable_wheel_tab_switching(self.ui.axis_tab_widget) 

389 

390 def _build_canvas_and_toolbar(self): 

391 return canvas_area.build_canvas_and_toolbar(self) 

392 

393 def _setup_status_bar(self): 

394 return canvas_area.setup_status_bar(self) 

395 

396 def _build_dataset_color_picker(self): 

397 return dataset_panel.build_dataset_color_picker(self) 

398 

399 def _build_dataset_list_buttons(self): 

400 return dataset_panel.build_dataset_list_buttons(self) 

401 

402 def _build_dataset_style_controls(self): 

403 return dataset_panel.build_dataset_style_controls(self) 

404 

405 def _build_legend_location_control(self): 

406 return axis_panel.build_legend_location_control(self) 

407 

408 def _build_fit_info_and_stats(self): 

409 return dataset_panel.build_fit_info_and_stats(self) 

410 

411 def _build_secondary_y_controls(self): 

412 return dataset_panel.build_secondary_y_controls(self) 

413 

414 def _build_tick_grid_and_colorbar_controls(self): 

415 return axis_panel.build_tick_grid_and_colorbar_controls(self) 

416 

417 def _build_tick_format_controls(self): 

418 return axis_panel.build_tick_format_controls(self) 

419 

420 def _build_label_editors(self): 

421 return axis_panel.build_label_editors(self) 

422 

423 def _add_subplot_target_row(self): 

424 return dataset_panel.add_subplot_target_row(self) 

425 

426 def _arrange_property_docks(self): 

427 return dock_layout.arrange_property_docks(self) 

428 

429 def _build_tab_shell(self): 

430 return build_tab_shell(self) 

431 

432 def _rebuild_label_tab(self): 

433 return axis_panel.rebuild_label_tab(self) 

434 

435 def _connect_and_initialize(self): 

436 self.canvas.mpl_connect('motion_notify_event', self._on_mouse_move) 

437 

438 # グラフの要素のクリックでの選択は、どのモードでも常に有効 

439 self.canvas.mpl_connect('pick_event', self._on_element_pick) 

440 

441 # 表示範囲の操作(軸の上のホイール・ドラッグ、矩形ズーム、ダブルクリックでのリセットなど)は常に有効 

442 self.view_navigation.connect() 

443 # 対数軸で表示されない値の説明は、どの経路で描き直しても最新にする 

444 self.canvas.mpl_connect('draw_event', lambda event: log_axis_notes.update_log_axis_notes(self, event)) 

445 # 凡例をドラッグした位置を設定へ保存する 

446 self.canvas.mpl_connect('button_release_event', self._on_legend_drag_release) 

447 

448 # プラグインの登録はメニューを作る(_create_menu_bar)より前に。読み込みはアプリ全体で1回 

449 # (load_plugins_once)、メニューへの追加はタブごとに _create_menu_bar が行う 

450 self.plugin_api = load_plugins_once( 

451 plugin_search_paths(), disabled_names=disabled_plugin_names(self.settings) 

452 ) 

453 

454 # プラグインへの窓口は (このタブ, プラグイン) ごとに1つ。 

455 self._plugin_contexts = {} 

456 

457 # プラグインのパネル。左端の帯の「プラグイン」のページに入れる(GUI 刷新。以前はドック)。メニューに 

458 # 開く項目を足すのは _create_menu_bar() なので、それより前に作る。1つ失敗しても他のパネルとタブの起動は続ける。 

459 self._plugin_panels = {} 

460 for panel in self.plugin_api.get_panels(): 

461 try: 

462 widget = panel.widget_factory(self.plugin_context(panel.plugin_name)) 

463 if not isinstance(widget, QWidget): 

464 raise TypeError(f"QWidgetを返しませんでした(型: {type(widget).__name__})。") 

465 except Exception as e: 

466 logger.warning( 

467 "[plugin:%s] パネル '%s' の構築に失敗しました: %s", 

468 panel.plugin_name, panel.name, e, 

469 ) 

470 continue 

471 widget.setObjectName(f"PluginPanel_{panel.name}") 

472 self.plugin_page.add_panel(panel.name, widget) 

473 self._plugin_panels[panel.name] = widget 

474 

475 # フォーカスのあるドックの枠を強調する(プラグインのドックも含め、全部に効く) 

476 theme.install_dock_focus_highlight(self) 

477 

478 # プラグインの plot_type を種類の欄の最後に足す(描き方は canvas._draw_plot_type が探す) 

479 for plot_type in self.plugin_api.get_plot_types(): 

480 self.ui.plot_type_combo.addItem(plot_type.type_name) 

481 

482 self._connect_signals() 

483 

484 self._create_menu_bar() 

485 populate_side_pages(self) 

486 canvas_area.finish_card(self) 

487 

488 # クイックアクセスのピン留めを戻す。プラグインのものを含めて全メニューができた後であること 

489 self._restore_quick_access_actions() 

490 self._install_quick_access_context_menus() 

491 

492 # 画面の既定の状態を最初の軸の設定にする 

493 default_settings = self._gather_settings_from_ui() 

494 self.project.all_plot_settings.append(default_settings) 

495 self._set_initial_ui_state() 

496 # 生成物の ui_main_window.py のラベルには末尾に「:」が付いている。.ui が無く作り直せないので、ここで取る 

497 _strip_trailing_colon_from_labels(self) 

498 

499 spacing_value = 6 

500 if self.ui.formLayout_3: self.ui.formLayout_3.setSpacing(spacing_value) 

501 

502 # 入力欄の左端を揃える(プロパティ欄の7節どうし、X/Y 軸のタブどうし)。ラベルが全部できた後で 

503 property_sections.align_form_label_columns( 

504 self, [self._prop_form(key) for key, _title in DATASET_PROPERTY_SECTIONS], 

505 width=PROPERTY_LABEL_WIDTH) 

506 self._align_form_label_columns([self.ui.formLayout, self.ui.formLayout_2]) 

507 

508 

509 # 軸の範囲と目盛りの間隔は、指数表記でも入力できるようにする 

510 for spin_box in [self.ui.x_min_spinbox, self.ui.x_max_spinbox, 

511 self.ui.y_min_spinbox, self.ui.y_max_spinbox]: 

512 _enable_scientific_notation_input(spin_box, minimum=-np.inf, maximum=np.inf) 

513 

514 for spin_box in [self.ui.x_major_tick_interval_spinbox, self.ui.y_major_tick_interval_spinbox, 

515 self.ui.x_minor_tick_interval_spinbox, self.ui.y_minor_tick_interval_spinbox]: 

516 _enable_scientific_notation_input(spin_box, minimum=0, maximum=np.inf) 

517 

518 # ウォーターフォールのオフセットもデータの単位なので、大きい値やマイナス・指数表記を入れられるように 

519 for spin_box in [self.waterfall_offset_x_spinbox, self.waterfall_offset_y_spinbox]: 

520 _enable_scientific_notation_input(spin_box, minimum=-np.inf, maximum=np.inf) 

521 

522 # 打ち込んだ数値は Enter か欄を離れたときに反映する(▲▼・矢印キー・ホイールはすぐ)。1文字ごとに反映すると、 

523 # 反映で欄が書き直されて("0" → "0.00")小数を打てず、大きなデータでは打つたびに描き直しで止まる 

524 for spin_box in self.ui.control_dock_widget.findChildren(QAbstractSpinBox): 

525 spin_box.setKeyboardTracking(False) 

526 

527 self._update_plot() 

528 # 環境設定の既定の書式テンプレート(新しいタブはどれも空のプロジェクトから始まる) 

529 self._apply_default_style_template() 

530 

531 # ドックの配置は前回の状態を戻す(最初のタブだけ。窓の大きさと位置は MainAppWindow が扱う)。 

532 # 既定の配置の版(DOCK_LAYOUT_VERSION)が保存時と違えば戻さない(戻すと新しい既定が既存の利用者に届かない)。 

533 # 戻すのはイベントループが一巡してタブが最終の大きさになってから。ここで戻すと仮の大きさで 

534 # スプリッターの位置が決まり、見た目とクリックの位置がずれる。 

535 

536 # タブに結び付けておく(先にタブが閉じられたら呼ばない。閉じたタブのドックを触ると落ちる) 

537 QTimer.singleShot(0, self, self._restore_dock_layout) 

538 

539 # キャンバスを切り離したまま閉じていれば同じ状態に戻す。ダークモードなどと同じ軽い設定なので、どのタブでも戻す 

540 if app_settings.CANVAS_WAS_DETACHED.read(self.settings): 

541 QTimer.singleShot(0, self, lambda: self._detach_canvas(restore_geometry=True)) 

542 

543 self.setAcceptDrops(True) 

544 

545 # 起動時の確認は最初のタブだけ。窓が表示される前だとダイアログが変な位置に出るので、一巡してから 

546 if self._run_startup_checks: 

547 QTimer.singleShot(0, self, self._check_autosave_recovery) 

548 # 初回の案内は復元の確認の後(データに関わる確認を先に) 

549 QTimer.singleShot(0, self, self._check_first_launch) 

550 # 新しい版の確認は上の2つの邪魔をしないよう少し遅らせる。公開 API への匿名の GET 1回だけで、 

551 # 新しい版があるときだけ知らせ、失敗しても何も出さない 

552 QTimer.singleShot(1500, self, self._start_startup_update_check) 

553 # プラグインの新しい版の確認は1日1回(入れたプラグインが無ければ通信しない) 

554 QTimer.singleShot(3000, self, lambda: plugin_update_flow.start_startup_check(self)) 

555 

556 # 項目の間の余白を広げる。フォームが全部できた最後に 

557 apply_form_spacing(self) 

558 

559 def _restore_dock_layout(self): 

560 return dock_layout.restore_dock_layout(self) 

561 

562 def closeEvent(self, event): 

563 # 読み込み中の QThread を破棄すると Qt がプロセスごと止め、ほかのタブの未保存データも失う。 

564 # 読み込みは中断できないので終わるまで待つ。待つ間に届く完了の通知が閉じかけの窓で動かないよう、先に切る 

565 if self._data_load_task_runner is not None: 

566 try: 

567 self._data_load_task_runner.succeeded.disconnect() 

568 self._data_load_task_runner.failed.disconnect() 

569 except (RuntimeError, TypeError): 

570 pass 

571 self._data_load_task_runner.requestInterruption() 

572 self._data_load_task_runner.wait() 

573 self._data_load_task_runner.deleteLater() 

574 self._data_load_task_runner = None 

575 

576 self.fitting.shutdown() 

577 

578 # 同じ理由 

579 if self._batch_export_task_runner is not None: 

580 try: 

581 self._batch_export_task_runner.succeeded.disconnect() 

582 self._batch_export_task_runner.failed.disconnect() 

583 except (RuntimeError, TypeError): 

584 pass 

585 self._batch_export_task_runner.requestInterruption() 

586 self._batch_export_task_runner.wait() 

587 self._batch_export_task_runner.deleteLater() 

588 self._batch_export_task_runner = None 

589 

590 # 同じ理由 

591 if self._update_check_task_runner is not None: 

592 try: 

593 self._update_check_task_runner.succeeded.disconnect() 

594 self._update_check_task_runner.failed.disconnect() 

595 except (RuntimeError, TypeError): 

596 pass 

597 self._update_check_task_runner.requestInterruption() 

598 self._update_check_task_runner.wait() 

599 self._update_check_task_runner.deleteLater() 

600 self._update_check_task_runner = None 

601 

602 # 同じ理由(ダウンロードは止めると途中のファイルを消して戻る) 

603 if self._update_download_runner is not None: 603 ↛ 604line 603 didn't jump to line 604 because the condition on line 603 was never true

604 try: 

605 self._update_download_runner.succeeded.disconnect() 

606 self._update_download_runner.failed.disconnect() 

607 except (RuntimeError, TypeError): 

608 pass 

609 self._update_download_runner.requestInterruption() 

610 self._update_download_runner.wait() 

611 self._update_download_runner.deleteLater() 

612 self._update_download_runner = None 

613 

614 plugin_update_flow.stop(self) 

615 

616 if self._run_startup_checks: 

617 app_settings.CLEAN_EXIT.write(self.settings, True) 

618 app_settings.WINDOW_STATE.write(self.settings, self.saveState()) 

619 app_settings.DOCK_LAYOUT_VERSION.write(self.settings, DOCK_LAYOUT_VERSION) 

620 

621 # 切り離したまま閉じたら次の起動で同じ状態に戻す。軽い設定なので最初のタブに限らない 

622 if self.canvas_detached and self._canvas_detach_window is not None: 

623 app_settings.CANVAS_DETACHED_GEOMETRY.write(self.settings, self._canvas_detach_window.saveGeometry()) 

624 app_settings.CANVAS_WAS_DETACHED.write(self.settings, self.canvas_detached) 

625 

626 # 切り離した窓が残らないよう片付ける。_reattach_canvas を通すと、上で保存した「切り離していた」を 

627 # 消してしまうので通さない。窓と一緒に破棄されないよう、先にキャンバスの親を外す 

628 if self._canvas_detach_window is not None: 

629 self._canvas_detach_window.closed.disconnect(self._on_detach_window_closed) 

630 self._canvas_detach_window.takeCentralWidget() 

631 self.canvas.setParent(None) 

632 self._canvas_detach_window.close() 

633 self._canvas_detach_window.deleteLater() 

634 self._canvas_detach_window = None 

635 

636 super().closeEvent(event) 

637 

638 def _check_autosave_recovery(self): 

639 return project_files.check_autosave_recovery(self) 

640 

641 def _check_first_launch(self): 

642 if app_settings.HAS_SHOWN_WELCOME.read(self.settings): 

643 return 

644 app_settings.HAS_SHOWN_WELCOME.write(self.settings, True) 

645 self._show_welcome_dialog() 

646 

647 def _on_show_startup_screen(self): 

648 self._show_welcome_dialog() 

649 

650 def _show_welcome_dialog(self, last_tab_closed=False): 

651 dialog = WelcomeDialog(self, recent_files=self._get_recent_files(), last_tab_closed=last_tab_closed) 

652 dialog.exec() 

653 if dialog.load_sample_requested: 

654 self._load_sample_data() 

655 elif dialog.selected_recent_file: 

656 self._on_open_recent_file(dialog.selected_recent_file) 

657 elif dialog.load_template_requested: 

658 self._on_load_plot_template() 

659 elif dialog.open_project_requested: 

660 self._on_load_project() 

661 

662 def _on_show_autosave_history(self): 

663 return project_files.on_show_autosave_history(self) 

664 

665 def _load_sample_data(self): 

666 sample_path = resource_path(os.path.join("sample_data", "cooling_curve_sample.csv")) 

667 if not os.path.exists(sample_path): 

668 notify.warning(self, "サンプルデータ", "サンプルデータファイルが見つかりませんでした。") 

669 return 

670 self.load_data(sample_path) 

671 

672 def _update_autosave_path(self): 

673 return project_files.update_autosave_path(self) 

674 

675 def _rotate_autosave_generations(self): 

676 return project_files.rotate_autosave_generations(self) 

677 

678 def _sync_project_from_ui(self): 

679 return project_files.sync_project_from_ui(self) 

680 

681 def _remember_saved_content(self): 

682 return project_files.remember_saved_content(self) 

683 

684 # DatasetHost と TabPluginContext が使う窓口。中は非公開の名前を呼ぶだけ(テストの差し替えが届くよう、呼ぶときに引く) 

685 def current_dataset(self): 

686 return self._get_current_dataset() 

687 

688 def selected_datasets(self): 

689 return self._get_selected_datasets() 

690 

691 def dataset_tree_item(self, dataset): 

692 return self._get_dataset_tree_item(dataset) 

693 

694 def target_folder_for_new_dataset(self): 

695 return self._get_target_folder_for_new_dataset() 

696 

697 def add_dataset(self, dataset, parent_folder=None, select=True): 

698 return self._add_dataset(dataset, parent_folder, select=select) 

699 

700 def add_dataset_with_undo(self, dataset, parent_folder=None, description="データセットの追加"): 

701 return self._add_dataset_with_undo(dataset, parent_folder=parent_folder, description=description) 

702 

703 def push_dataset_additions(self, add, datasets, description): 

704 return self._push_dataset_additions(add, datasets, description) 

705 

706 def remove_dataset_items_with_undo(self, items, description=None): 

707 return self._remove_dataset_items_with_undo(items, description=description) 

708 

709 def push_dataset_property_command(self, dataset, old_values, new_values, description, skip_if_unchanged=True): 

710 return self._push_dataset_property_command(dataset, old_values, new_values, description, 

711 skip_if_unchanged=skip_if_unchanged) 

712 

713 def refresh_after_dataset_property_change(self, dataset, changed_keys=(), old_values=None, new_values=None): 

714 return self._refresh_after_dataset_property_change(dataset, changed_keys=changed_keys, old_values=old_values, 

715 new_values=new_values) 

716 

717 def add_annotation(self, axis_index, annotation, description): 

718 return self._add_annotation(axis_index, annotation, description=description) 

719 

720 def redraw(self): 

721 return self._update_plot() 

722 

723 @contextmanager 

724 def deferred_redraw(self): 

725 """中で頼まれたグラフ全体の描き直しを、抜けるときの1回にまとめる(データセットを1件ずつ足すと件数分描き直すので)。""" 

726 self._begin_deferring_redraw() 

727 try: 

728 yield 

729 finally: 

730 self._end_deferring_redraw() 

731 

732 @contextmanager 

733 def batched_undo_group(self, description): 

734 """Undo 1回分にまとめ、描き直しも最後の1回にする。Undo・Redo のときも1回で済む。""" 

735 self.begin_batched_undo_group(description) 

736 try: 

737 yield 

738 finally: 

739 self.end_batched_undo_group() 

740 

741 def begin_batched_undo_group(self, description): 

742 self.undo_stack.beginMacro(description) 

743 self.undo_stack.push(DeferRedrawCommand(self._begin_deferring_redraw, self._end_deferring_redraw, opening=True)) 

744 

745 def end_batched_undo_group(self): 

746 self.undo_stack.push(DeferRedrawCommand(self._begin_deferring_redraw, self._end_deferring_redraw, opening=False)) 

747 self.undo_stack.endMacro() 

748 

749 def redraw_appearance(self): 

750 return self._update_plot_appearance() 

751 

752 def pending_peak_guesses(self): 

753 return self._pending_peak_guesses 

754 

755 def clear_pending_peak_guesses(self): 

756 return self._clear_pending_peak_guesses() 

757 

758 def toggle_peak_placement_mode(self, checked): 

759 return self._toggle_peak_placement_mode(checked) 

760 

761 @property 

762 def dataset_host(self): 

763 return self._dataset_host 

764 

765 def show_export_preview(self): 

766 """右のインスペクタの「エクスポート」のタブを開く(表示メニュー)。""" 

767 self.properties_dock_widget.show() 

768 self.inspector.show_page("export") 

769 

770 def show_bottom_page(self, key): 

771 """グラフの下のパネルのタブを開く(表示メニュー)。シンプルでは出ないので、アドバンスドに切り替える。""" 

772 if getattr(self, 'ui_mode', None) != ui_mode.ADVANCED: 

773 self.show_advanced_mode() 

774 self.bottom_panel.show_page(key) 

775 

776 def set_theme_color(self, color): 

777 """テーマカラーを替えて保存する(⚙ のポップアップ・環境設定から)。ウィンドウに入っていれば全部のタブに当てる。""" 

778 main_window = self.window() 

779 if main_window is not self and hasattr(main_window, 'set_theme_color'): 

780 main_window.set_theme_color(color) 

781 return 

782 color = theme_color_or_default(color) 

783 app_settings.THEME_COLOR.write(self.settings, color) 

784 theme.apply_theme(QApplication.instance(), self.canvas.dark_mode, color) 

785 self.refresh_theme_colors() 

786 

787 def show_advanced_mode(self): 

788 """アドバンスドに切り替える(シンプルのプロパティの案内から)。ウィンドウに入っていれば全部のタブに当てて保存する。""" 

789 main_window = self.window() 

790 if main_window is not self and hasattr(main_window, 'set_ui_mode'): 790 ↛ 793line 790 didn't jump to line 793 because the condition on line 790 was always true

791 main_window.set_ui_mode(ui_mode.ADVANCED) 

792 else: 

793 ui_mode.apply_ui_mode(self, ui_mode.ADVANCED) 

794 

795 def show_plugin_panel(self, name): 

796 """左端の帯の「プラグイン」のページを開き、そのパネルを出す。""" 

797 self.activity_rail.select_page("plugins") 

798 self.plugin_page.show_panel(name) 

799 

800 def plugin_context(self, plugin_name): 

801 context = self._plugin_contexts.get(plugin_name) 

802 if context is None: 

803 context = TabPluginContext(self, plugin_name) 

804 self._plugin_contexts[plugin_name] = context 

805 return context 

806 

807 def _notify_plugins_datasets_changed(self): 

808 for context in self._plugin_contexts.values(): 

809 context._notify_datasets_changed() 

810 

811 def _notify_plugins_selection_changed(self): 

812 current = self._get_current_dataset() 

813 for context in self._plugin_contexts.values(): 

814 context._notify_selection_changed(current) 

815 

816 def _run_plugin_menu_action(self, menu_action): 

817 try: 

818 menu_action.callback(self.plugin_context(menu_action.plugin_name)) 

819 except Exception as e: 

820 logger.exception("[plugin:%s] メニュー「%s」の実行に失敗しました", menu_action.plugin_name, menu_action.text) 

821 notify.critical(self, "プラグインのエラー", str(PluginExecutionError( 

822 menu_action.plugin_name, f"「{menu_action.text}」の実行に失敗しました: {e}" 

823 ))) 

824 

825 def document_title(self): 

826 return project_files.document_title(self) 

827 

828 def has_unsaved_changes(self): 

829 return project_files.has_unsaved_changes(self) 

830 

831 def confirm_unsaved_changes(self, action_text): 

832 return project_files.confirm_unsaved_changes(self, action_text) 

833 

834 def auto_save(self): 

835 return project_files.auto_save(self) 

836 

837 def manual_save(self): 

838 return project_files.manual_save(self) 

839 

840 def manual_save_as(self): 

841 return project_files.manual_save_as(self) 

842 

843 def _save_project_to_path(self, filepath): 

844 return project_files.save_project_to_path(self, filepath) 

845 

846 def manual_load(self): 

847 return project_files.manual_load(self) 

848 

849 def _load_project_from_path(self, filepath, add_to_recent=True): 

850 return project_files.load_project_from_path(self, filepath, add_to_recent) 

851 

852 def _reset_zoom(self): 

853 """設定どおりの表示範囲に戻す。 

854 

855 matplotlib の Home ボタンは作り直す前の Axes を覚えているので、描き直しを挟むと効かない。 

856 """ 

857 self.view_navigation.forget_all_views() 

858 self._update_plot() 

859 

860 def _begin_deferring_redraw(self): 

861 self._redraw_defer_depth += 1 

862 

863 def _end_deferring_redraw(self): 

864 self._redraw_defer_depth -= 1 

865 if self._redraw_defer_depth == 0 and self._deferred_redraw_request is not None: 

866 light, full_resolution = self._deferred_redraw_request 

867 self._deferred_redraw_request = None 

868 self._update_plot(light=light, full_resolution=full_resolution) 

869 

870 def _update_plot(self, light=False, full_resolution=False): 

871 """グラフ全体を描き直す。 

872 

873 light=True は Axes の数・配置・所属が変わらないときだけ(既存の Axes の上で描き直す)。 

874 full_resolution=True は表示用の間引きをしない(フル解像度でのエクスポート用)。 

875 """ 

876 if self._redraw_defer_depth: 

877 # まとめた描き直しは、一度でも重い方を頼まれたら重い方にする 

878 previous = self._deferred_redraw_request 

879 self._deferred_redraw_request = ( 

880 light and (previous is None or previous[0]), 

881 full_resolution or (previous is not None and previous[1]), 

882 ) 

883 return 

884 layout_mode = getattr(self.project, 'layout_mode', 'grid') 

885 if layout_mode == 'free': 

886 # 自由配置では行数×列数ではなく、軸の設定の数がサブプロットの数になる 

887 rows, cols = 0, 0 

888 if len(self.project.all_plot_settings) == 0: 

889 return 

890 else: 

891 rows = self.subplot_rows_spinbox.value() 

892 cols = self.subplot_cols_spinbox.value() 

893 if rows * cols == 0: 

894 return 

895 

896 canvas_update_method = ( 

897 self.canvas.update_all_axes_appearance_and_data if light else self.canvas.redraw_all 

898 ) 

899 is_secondary_visible_global = canvas_update_method( 

900 self.project.datasets, rows, cols, self.project.all_plot_settings, layout_mode=layout_mode, 

901 panel_labels_enabled=self.project.panel_labels_enabled, 

902 share_x_axis=getattr(self.project, 'share_x_axis', False), 

903 share_y_axis=getattr(self.project, 'share_y_axis', False), 

904 full_resolution=full_resolution, 

905 ) 

906 

907 self.tick_direction_y2_label.setVisible(is_secondary_visible_global) 

908 self.major_tick_direction_y2_combo.setVisible(is_secondary_visible_global) 

909 self.minor_tick_direction_y2_combo.setVisible(is_secondary_visible_global) 

910 self.y2_label_text_label.setVisible(is_secondary_visible_global) 

911 self.y2_label_text_edit.setVisible(is_secondary_visible_global) 

912 

913 self.all_axes = self.canvas.all_axes 

914 self.all_secondary_axes = self.canvas.all_secondary_axes 

915 

916 if hasattr(self, 'export_preview_panel'): 916 ↛ 920line 916 didn't jump to line 920 because the condition on line 916 was always true

917 self.export_preview_panel.refresh_preview() 

918 

919 # 全体の描き直しでハイライトも消えるので、データエディタの選択を戻す 

920 self._reapply_editor_row_highlight() 

921 

922 # ミニマップは別の Figure なので、redraw_all() では描き直されない。 

923 self._refresh_minimap() 

924 self._notify_plugins_datasets_changed() 

925 

926 def _refresh_minimap(self): 

927 """隠れていても描く(次に出したときに最新になっているように)。""" 

928 if not hasattr(self, 'minimap'): 

929 return 

930 self.minimap.refresh(self.project.datasets, self.canvas.dark_mode) 

931 

932 def _on_minimap_range_selected(self, xmin, xmax): 

933 """ミニマップで選んだ範囲を全部の軸の X の範囲にする。Axes は作り直さない(set_xlim と draw_idle で足りる)。""" 

934 for ax in self.canvas.all_axes: 

935 ax.set_xlim(xmin, xmax) 

936 self.view_navigation.remember_x_of_every_subplot() 

937 self.canvas.draw_idle() 

938 

939 def _on_toggle_minimap(self, checked): 

940 self.minimap_visible = checked 

941 self.minimap.setVisible(checked) 

942 self.minimap_separator.setVisible(checked) 

943 app_settings.MINIMAP_VISIBLE.write(self.settings, checked) 

944 

945 def _on_toggle_panel_labels(self, checked): 

946 """QSettings ではなくプロジェクトに保存する。""" 

947 self.project.panel_labels_enabled = checked 

948 self._update_plot(light=True) 

949 

950 # キャンバスの切り離し。self.canvas はあちこちから直接参照されているので、破棄も作り直しもせず親を替えるだけ。 

951 

952 def _on_toggle_canvas_detached(self, checked): 

953 if checked: 

954 self._detach_canvas() 

955 else: 

956 self._reattach_canvas() 

957 

958 def _sync_canvas_detach_action(self): 

959 """メニューのチェックと文言(「切り離す」/「元に戻す」)を状態に合わせる。""" 

960 if not hasattr(self, 'canvas_detach_action'): 

961 return 

962 self.canvas_detach_action.blockSignals(True) 

963 self.canvas_detach_action.setChecked(self.canvas_detached) 

964 self.canvas_detach_action.setText( 

965 tr("キャンバスを元に戻す") if self.canvas_detached else tr("キャンバスを別ウィンドウに切り離す") 

966 ) 

967 self.canvas_detach_action.blockSignals(False) 

968 

969 def _detach_canvas(self, restore_geometry=False): 

970 if self.canvas_detached: 

971 return 

972 

973 self._plot_layout.removeWidget(self.canvas) 

974 

975 title = f"{APP_NAME} - {tr('グラフキャンバス')}" 

976 self._canvas_detach_window = DetachedCanvasWindow(title) 

977 self._canvas_detach_window.setCentralWidget(self.canvas) 

978 self._canvas_detach_window.closed.connect(self._on_detach_window_closed) 

979 

980 saved_geometry = app_settings.CANVAS_DETACHED_GEOMETRY.read(self.settings) if restore_geometry else None 

981 if saved_geometry is not None: 

982 # 先にネイティブのハンドルを作る。窓が実体化する前に restoreGeometry() すると位置がずれる 

983 self._canvas_detach_window.winId() 

984 self._canvas_detach_window.restoreGeometry(saved_geometry) 

985 else: 

986 self._canvas_detach_window.resize(DEFAULT_DETACHED_CANVAS_WIDTH, DEFAULT_DETACHED_CANVAS_HEIGHT) 

987 

988 self._canvas_detach_window.show() 

989 self.canvas.show() 

990 

991 self.canvas_detached = True 

992 self._sync_canvas_detach_action() 

993 app_settings.CANVAS_WAS_DETACHED.write(self.settings, True) 

994 

995 def _reattach_canvas(self): 

996 """「元に戻す」と、切り離した窓を閉じたときの両方から呼ばれる。""" 

997 if not self.canvas_detached: 

998 return 

999 

1000 if self._canvas_detach_window is not None: 1000 ↛ 1008line 1000 didn't jump to line 1008 because the condition on line 1000 was always true

1001 app_settings.CANVAS_DETACHED_GEOMETRY.write(self.settings, self._canvas_detach_window.saveGeometry()) 

1002 self._canvas_detach_window.closed.disconnect(self._on_detach_window_closed) 

1003 self._canvas_detach_window.takeCentralWidget() 

1004 self._canvas_detach_window.close() 

1005 self._canvas_detach_window.deleteLater() 

1006 self._canvas_detach_window = None 

1007 

1008 self.canvas.setParent(self.ui.plot_container) 

1009 self._plot_layout.insertWidget(self._canvas_layout_index, self.canvas) 

1010 self.canvas.show() 

1011 

1012 self.canvas_detached = False 

1013 self._sync_canvas_detach_action() 

1014 app_settings.CANVAS_WAS_DETACHED.write(self.settings, False) 

1015 

1016 def _on_detach_window_closed(self): 

1017 self._reattach_canvas() 

1018 

1019 def _update_plot_appearance(self): 

1020 layout_mode = getattr(self.project, 'layout_mode', 'grid') 

1021 if layout_mode == 'free': 

1022 rows, cols = 0, 0 

1023 else: 

1024 rows = self.subplot_rows_spinbox.value() 

1025 cols = self.subplot_cols_spinbox.value() 

1026 self.canvas.update_appearance_only( 

1027 self.project.all_plot_settings, datasets=self.project.datasets, rows=rows, cols=cols, 

1028 layout_mode=layout_mode, 

1029 share_x_axis=getattr(self.project, 'share_x_axis', False), 

1030 share_y_axis=getattr(self.project, 'share_y_axis', False), 

1031 ) 

1032 

1033 if hasattr(self, 'export_preview_panel'): 1033 ↛ exitline 1033 didn't return from function '_update_plot_appearance' because the condition on line 1033 was always true

1034 self.export_preview_panel.refresh_preview() 

1035 

1036 def _on_editor_rows_highlighted(self, master_indices): 

1037 """データエディタで選んだ行の点をグラフ上で強調する(逆向きは cursor_mixin の _on_pick)。""" 

1038 if self.data_editor_dialog is None: 

1039 return 

1040 self.canvas.set_highlighted_points(self.data_editor_dialog.dataset, master_indices) 

1041 

1042 def _reapply_editor_row_highlight(self): 

1043 if self.data_editor_dialog is None: 

1044 return 

1045 self.canvas.set_highlighted_points( 

1046 self.data_editor_dialog.dataset, self.data_editor_dialog.get_selected_master_indices() 

1047 ) 

1048 

1049 

1050 

1051 def _build_dataset_property_sections(self): 

1052 return property_sections.build_dataset_property_sections(self) 

1053 

1054 def _prop_form(self, section_key): 

1055 return property_sections.prop_form(self, section_key) 

1056 

1057 def _align_form_label_columns(self, forms): 

1058 return property_sections.align_form_label_columns(self, forms) 

1059 

1060 def _set_dataset_property_fields_enabled(self, enabled): 

1061 return property_sections.set_dataset_property_fields_enabled(self, enabled) 

1062 

1063 def _load_collapsed_property_sections(self): 

1064 return property_sections.load_collapsed_property_sections(self) 

1065 

1066 def _on_property_section_toggled(self, section_key, checked): 

1067 return property_sections.on_property_section_toggled(self, section_key, checked) 

1068 

1069 def _update_property_section_visibility(self): 

1070 return property_sections.update_property_section_visibility(self) 

1071 

1072 # データセット一覧(QTreeWidget)。データセットの葉は UserRole に Dataset を、フォルダは None を持つ。 

1073 

1074 def _replace_dataset_list_with_tree(self): 

1075 return dataset_panel.replace_dataset_list_with_tree(self) 

1076 

1077 def _add_dataset_list_item(self, dataset, parent_item=None): 

1078 """UserRole に Dataset そのものを持たせる(並べ替えても、同名があっても対応が崩れない)。""" 

1079 item = QTreeWidgetItem([dataset.name]) 

1080 item.setData(0, Qt.ItemDataRole.UserRole, dataset) 

1081 item.setIcon(0, make_dataset_style_icon(dataset)) 

1082 # 目のアイコンのクリックは dataset_mixin の _on_dataset_tree_item_clicked が列で見分ける 

1083 item.setIcon(DATASET_TREE_VISIBILITY_COLUMN, make_dataset_visibility_icon(dataset)) 

1084 apply_dataset_visibility_text_style(item, dataset) 

1085 # データセット自身はフォルダではないので、ドロップ先にはしない 

1086 item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsDropEnabled) 

1087 if parent_item is not None: 

1088 parent_item.addChild(item) 

1089 else: 

1090 self.ui.dataset_list_widget.addTopLevelItem(item) 

1091 return item 

1092 

1093 def _get_target_folder_for_new_dataset(self): 

1094 """選択中の項目がフォルダならそれを返す(新しいデータセットをその中に入れる)。""" 

1095 return self.dataset_order.target_folder() 

1096 

1097 def _add_dataset(self, dataset, parent_folder=None, select=True): 

1098 new_item = self.dataset_order.append(dataset, parent_folder) 

1099 if select: 

1100 self.ui.dataset_list_widget.setCurrentItem(new_item) 

1101 self._update_plot() 

1102 return new_item 

1103 

1104 def _add_dataset_with_undo(self, dataset, parent_folder=None, description="データセットの追加"): 

1105 """_add_dataset() の Undo できる版(プラグインの処理と解析の結果用)。""" 

1106 def do_add(): 

1107 self._add_dataset(dataset, parent_folder, select=True) 

1108 

1109 def do_remove(): 

1110 self.dataset_order.remove_added(dataset) 

1111 self.property_panel.update_ui_state() 

1112 self._update_plot() 

1113 

1114 command = AddDatasetCommand(do_add, do_remove, description=description) 

1115 self.undo_stack.push(command) 

1116 

1117 def _push_dataset_additions(self, add, datasets, description): 

1118 """add() で足す datasets を、Undo 1回で取り除けるように積む(積むとすぐ add() が走る)。""" 

1119 def remove(): 

1120 for dataset in reversed(datasets): 

1121 self.dataset_order.remove_added(dataset) 

1122 self.property_panel.update_ui_state() 

1123 self._update_plot() 

1124 

1125 self.undo_stack.push(AddDatasetCommand(add, remove, description=description)) 

1126 

1127 def _remove_dataset_items_with_undo(self, top_level_items, description=None): 

1128 """ツリーの項目(データセットかフォルダ)を Undo できるように削除する。 

1129 

1130 top_level_items には最上位の対象だけを渡す(フォルダを渡すと中身も一緒に消える)。 

1131 """ 

1132 steps = self.dataset_order.removal(top_level_items) 

1133 if steps is None: 1133 ↛ 1134line 1133 didn't jump to line 1134 because the condition on line 1133 was never true

1134 return 

1135 remove, restore, removed_datasets = steps 

1136 

1137 if description is None: 

1138 if len(removed_datasets) == 1: 

1139 description = f"「{removed_datasets[0].name}」の削除" 

1140 else: 

1141 description = f"{len(removed_datasets)}件のデータセットの削除" 

1142 

1143 def do_remove(): 

1144 remove() 

1145 self.property_panel.update_ui_state() 

1146 self._update_plot() 

1147 

1148 def do_restore(): 

1149 restore() 

1150 self.property_panel.update_ui_state() 

1151 self._update_plot() 

1152 

1153 self.undo_stack.push( 

1154 RemoveDatasetCommand(do_remove, do_restore, description=description) 

1155 ) 

1156 

1157 def _add_dataset_folder_item(self, name, parent_item=None): 

1158 return self.dataset_order.add_folder(name, parent_item) 

1159 

1160 def _flatten_dataset_tree(self, parent_item=None): 

1161 """データセットの葉を表示順(深さ優先)に返す。""" 

1162 return self.dataset_order.dataset_items(parent_item) 

1163 

1164 def _get_current_dataset(self): 

1165 return self.dataset_order.current_dataset() 

1166 

1167 def _get_selected_datasets(self): 

1168 return self.dataset_order.selected_datasets() 

1169 

1170 def _get_dataset_tree_item(self, dataset): 

1171 return self.dataset_order.item_for(dataset) 

1172 

1173 def _capture_dataset_group_tree(self): 

1174 return self.dataset_order.group_tree() 

1175 

1176 def _rebuild_dataset_tree_widget(self): 

1177 self.dataset_order.rebuild_tree() 

1178 

1179 def _sync_dataset_list_widget_order(self): 

1180 """フォルダの中の並びを project.datasets の順に合わせる(並べ替えの Undo/Redo 用)。""" 

1181 self.dataset_order.sort_tree_to_draw_order() 

1182 

1183 def dragEnterEvent(self, event): 

1184 return data_import_flow.dragEnterEvent(self, event) 

1185 

1186 def dropEvent(self, event): 

1187 return data_import_flow.dropEvent(self, event) 

1188 

1189 def _all_supported_data_file_extensions(self): 

1190 return data_import_flow.all_supported_data_file_extensions(self) 

1191 

1192 def _queue_data_files(self, file_paths): 

1193 return data_import_flow.queue_data_files(self, file_paths) 

1194 

1195 def _process_next_queued_file(self): 

1196 return data_import_flow.process_next_queued_file(self) 

1197 

1198 def load_data(self, file_path, queue_progress=None): 

1199 return data_import_flow.load_data(self, file_path, queue_progress) 

1200 

1201 def _on_data_load_succeeded(self, df, file_path): 

1202 return data_import_flow.on_data_load_succeeded(self, df, file_path) 

1203 

1204 def _import_loaded_dataframe(self, df, file_path): 

1205 return data_import_flow.import_loaded_dataframe(self, df, file_path) 

1206 

1207 @staticmethod 

1208 def _apply_filename_regex_columns(df, file_path, pattern): 

1209 return data_import_flow.apply_filename_regex_columns(df, file_path, pattern) 

1210 

1211 def _on_import_folder(self): 

1212 return data_import_flow.on_import_folder(self) 

1213 

1214 def _on_paste_data_from_clipboard(self): 

1215 return data_import_flow.on_paste_data_from_clipboard(self) 

1216 

1217 def _on_data_load_failed(self, error_message, file_path): 

1218 return data_import_flow.on_data_load_failed(self, error_message, file_path) 

1219 

1220 def _cleanup_data_load_task_runner(self): 

1221 return data_import_flow.cleanup_data_load_task_runner(self) 

1222 

1223 

1224 def _get_recent_files(self): 

1225 return project_files.get_recent_files(self) 

1226 

1227 def _add_recent_file(self, file_path): 

1228 return project_files.add_recent_file(self, file_path) 

1229 

1230 def _update_recent_files_menu(self): 

1231 return project_files.update_recent_files_menu(self) 

1232 

1233 def _on_open_recent_file(self, file_path): 

1234 return project_files.on_open_recent_file(self, file_path) 

1235 

1236 def _on_clear_recent_files(self): 

1237 return project_files.on_clear_recent_files(self) 

1238 

1239 

1240# 各ツールの名前を PlotterApp の属性として出す(gui/tools/__init__.py) 

1241expose_tool_names(PlotterApp) 

1242# 各部品の名前を PlotterApp の属性として出す(gui/panels/__init__.py) 

1243expose_panel_names(PlotterApp)