Coverage for graphica/gui/mixins/project_io_mixin.py: 94.5%
222 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""プロジェクトの保存と読み込みのメニュー、書式テンプレート、環境設定、設定の書き出しと読み込み。"""
2import json
3import logging
5from graphica.gui import notify
6from graphica.gui import dialog_dirs
7from graphica.gui import app_settings
8from graphica.gui import theme
9from graphica.gui.builders import canvas_area
10from graphica.gui.theme_color import normalize_hex
11from graphica.gui.dialogs import PreferencesDialog
12from graphica.gui.datasets.operations.transfer import STYLE_ATTRS
13from graphica.core.i18n import tr, get_language
15logger = logging.getLogger(__name__)
17AUTOSAVE_INTERVAL_MIN_BOUNDS = (0, 180)
19TEMPLATE_FORMAT_VERSION = 1
21# 書き出すキーは app_settings.EXPORTED_SETTINGS。ウィンドウの状態や最近使ったファイルなど、
22# その環境だけの項目は入れない(別の PC に持ち込んでも意味が無いか害になる)
23SETTINGS_EXPORT_FORMAT_VERSION = 1
25# サブプロットの中身に近いもの。テンプレートでは保存も適用もしない(適用先の値を残す)
26TEMPLATE_EXCLUDED_AXIS_SETTING_KEYS = ('annotations', 'legend_order', 'free_rect')
29def read_style_template(file_path):
30 """書式テンプレートのファイルを読む。"""
31 with open(file_path, 'r', encoding='utf-8') as f:
32 return json.load(f)
35def _apply_dataset_style(dataset, style):
36 for attr, value in style.items():
37 setattr(dataset, attr, value)
40class ProjectIOMixin:
41 def _on_save_project(self):
42 self.manual_save()
44 def _on_save_project_as(self):
45 self.manual_save_as()
47 def _on_load_project(self):
48 self.manual_load()
50 def _on_configure_autosave_interval(self):
51 """0 分で無効。"""
52 current_minutes = (self.autosave_timer.interval() // 60000) if self.autosave_timer.isActive() else 0
53 min_minutes, max_minutes = AUTOSAVE_INTERVAL_MIN_BOUNDS
54 minutes, ok = notify.get_int(
55 self, "オートセーブ間隔の設定",
56 "オートセーブの間隔を分単位で入力してください (0で無効化):",
57 current_minutes, min_minutes, max_minutes
58 )
59 if not ok:
60 return
62 self._apply_autosave_interval(minutes)
64 def _apply_autosave_interval(self, minutes):
65 """メニューと環境設定の両方から呼ばれる。"""
66 app_settings.AUTOSAVE_INTERVAL_MIN.write(self.settings, minutes)
67 if minutes <= 0:
68 self.autosave_timer.stop()
69 self.statusBar().showMessage("オートセーブを無効化しました", 3000)
70 else:
71 self.autosave_timer.start(minutes * 60 * 1000)
72 self.statusBar().showMessage(f"オートセーブ間隔を{minutes}分に設定しました", 3000)
74 self._update_autosave_menu_text()
75 canvas_area.refresh_status_bar(self)
77 def _on_show_preferences(self):
78 current_minutes = (self.autosave_timer.interval() // 60000) if self.autosave_timer.isActive() else 0
79 # 言語だけは、未設定なら今の表示言語を既定にする
80 current_language = app_settings.LANGUAGE.read(self.settings, default=get_language())
81 current_autosave_dir = app_settings.AUTOSAVE_DIR.read(self.settings)
82 current_point_label_max = app_settings.POINT_LABEL_MAX_POINTS.read(self.settings)
83 current_snap_to_grid = app_settings.SNAP_TO_GRID_ENABLED.read(self.settings)
84 current_snap_grid_interval = app_settings.SNAP_GRID_INTERVAL_PX.read(self.settings)
86 # main_window がこの mixin を import しているので、関数の中で import する
87 from graphica.core.plugin_api import get_loaded_plugin_records, get_plugin_registration_errors
88 current_disabled_plugin_names = app_settings.disabled_plugin_names(self.settings)
90 current_default_template = app_settings.DEFAULT_STYLE_TEMPLATE.read(self.settings)
91 current_wheel_zoom_speed = app_settings.WHEEL_ZOOM_SPEED.read(self.settings)
92 dlg = PreferencesDialog(
93 self.canvas.dark_mode, current_minutes,
94 autosave_bounds=AUTOSAVE_INTERVAL_MIN_BOUNDS, parent=self,
95 current_language=current_language, autosave_dir=current_autosave_dir,
96 point_label_max_points=current_point_label_max,
97 snap_to_grid_enabled=current_snap_to_grid,
98 snap_grid_interval_px=current_snap_grid_interval,
99 plugin_records=get_loaded_plugin_records(),
100 plugin_registration_errors=get_plugin_registration_errors(),
101 disabled_plugin_names=current_disabled_plugin_names,
102 default_style_template=current_default_template,
103 wheel_zoom_speed=current_wheel_zoom_speed,
104 theme_color=theme.current_theme_color(),
105 on_check_plugin_updates=self._on_check_plugin_updates,
106 )
107 if dlg.exec() != PreferencesDialog.DialogCode.Accepted:
108 return
110 (new_dark_mode, new_autosave_minutes, new_language,
111 new_autosave_dir, new_point_label_max,
112 new_snap_to_grid, new_snap_grid_interval) = dlg.get_settings()
114 # 次の起動から効く(読み込んだプラグインをその場で入れ替える仕組みは無い)
115 new_disabled_plugin_names = dlg.get_disabled_plugin_names()
116 if new_disabled_plugin_names != current_disabled_plugin_names:
117 app_settings.DISABLED_PLUGINS.write(self.settings, list(new_disabled_plugin_names))
119 if new_autosave_dir != current_autosave_dir:
120 app_settings.AUTOSAVE_DIR.write(self.settings, new_autosave_dir)
121 self._update_autosave_path()
123 # 次に作るタブ・プロジェクトから効く(開いているタブの見た目は変えない)
124 new_default_template = dlg.get_default_style_template()
125 if new_default_template != current_default_template:
126 app_settings.DEFAULT_STYLE_TEMPLATE.write(self.settings, new_default_template)
128 # どのタブもホイールのたびに設定を読むので、書くだけで全部のタブに効く
129 new_wheel_zoom_speed = dlg.get_wheel_zoom_speed()
130 if new_wheel_zoom_speed != current_wheel_zoom_speed: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 app_settings.WHEEL_ZOOM_SPEED.write(self.settings, new_wheel_zoom_speed)
133 new_theme_color = dlg.get_theme_color()
134 if new_theme_color != normalize_hex(theme.current_theme_color()):
135 self.set_theme_color(new_theme_color)
137 # 表示メニューのチェック経由で切り替える(toggled から _on_toggle_dark_mode が適用し、チェックの状態も揃う)
138 if new_dark_mode != self.canvas.dark_mode:
139 self.dark_mode_action.setChecked(new_dark_mode)
141 if new_autosave_minutes != current_minutes: 141 ↛ 144line 141 didn't jump to line 144 because the condition on line 141 was always true
142 self._apply_autosave_interval(new_autosave_minutes)
144 if new_point_label_max != current_point_label_max:
145 app_settings.POINT_LABEL_MAX_POINTS.write(self.settings, new_point_label_max)
146 self.canvas.point_label_max_points = new_point_label_max
147 self._update_plot()
148 self.property_panel.update_point_labels_limit_note()
150 if new_snap_to_grid != current_snap_to_grid:
151 app_settings.SNAP_TO_GRID_ENABLED.write(self.settings, new_snap_to_grid)
152 self.snap_to_grid_enabled = new_snap_to_grid
153 if new_snap_grid_interval != current_snap_grid_interval:
154 app_settings.SNAP_GRID_INTERVAL_PX.write(self.settings, new_snap_grid_interval)
155 self.snap_grid_interval_px = new_snap_grid_interval
157 # 作った画面をその場で訳し直す仕組みは無いので、次の起動からと知らせる
158 if new_language != current_language:
159 app_settings.LANGUAGE.write(self.settings, new_language)
160 notify.information(
161 self, tr("表示言語の変更"),
162 tr("表示言語の変更は、次回起動時に反映されます。")
163 )
165 def _update_autosave_menu_text(self):
166 if not self.autosave_timer.isActive():
167 self.autosave_interval_action.setText(tr("オートセーブ: 無効(&I)..."))
168 else:
169 minutes = self.autosave_timer.interval() // 60000
170 self.autosave_interval_action.setText(tr("オートセーブ: {minutes}分間隔(&I)...").format(minutes=minutes))
172 def _on_save_plot_template(self):
173 """全サブプロットの見た目と全データセットのスタイルを *.graphica-style に保存する(データは含めない)。"""
174 file_path, _ = dialog_dirs.get_save_file_name(
175 self, dialog_dirs.TEMPLATE, "書式テンプレートを保存", "", "Graphica Style Template (*.graphica-style)"
176 )
177 if not file_path:
178 return
179 if not (file_path.endswith('.graphica-style') or file_path.endswith('.json')): 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 file_path += '.graphica-style'
182 # 今の軸の欄の値はまだ all_plot_settings に入っていない
183 if self.project.active_axis_index < len(self.project.all_plot_settings): 183 ↛ 186line 183 didn't jump to line 186 because the condition on line 183 was always true
184 self.project.all_plot_settings[self.project.active_axis_index] = self._gather_settings_from_ui()
186 subplot_styles = [
187 {k: v for k, v in settings.items() if k not in TEMPLATE_EXCLUDED_AXIS_SETTING_KEYS}
188 for settings in self.project.all_plot_settings
189 ]
190 dataset_styles = [
191 {attr: getattr(ds, attr) for attr in STYLE_ATTRS}
192 for ds in self.project.datasets
193 ]
195 template_data = {
196 'format_version': TEMPLATE_FORMAT_VERSION,
197 'subplot_styles': subplot_styles,
198 'dataset_styles': dataset_styles,
199 }
201 try:
202 with open(file_path, 'w', encoding='utf-8') as f:
203 json.dump(template_data, f, indent=4, ensure_ascii=False)
204 except Exception as e:
205 notify.warning(self, "保存エラー", f"テンプレートの保存中にエラーが発生しました:\n{e}")
206 logger.exception("テンプレートの保存中にエラー")
208 def _on_load_plot_template(self):
209 """書式テンプレートを今のプロジェクトに当てる。Undo はできない。
211 新しい形式(format_version あり)は、保存した順にサブプロットとデータセットへ繰り返し当てる(数が違ってもよい)。
212 古い .json(plot_settings だけ)は今の軸だけに当てる。
213 """
214 file_path, _ = dialog_dirs.get_open_file_name(
215 self, dialog_dirs.TEMPLATE, "書式テンプレートを適用", "Graphica Style Template (*.graphica-style *.json)"
216 )
217 if not file_path:
218 return
220 try:
221 if not self._apply_plot_template(read_style_template(file_path)):
222 notify.warning(self, "読込エラー", "有効な書式設定がファイルに含まれていません。")
223 except Exception as e:
224 notify.warning(self, "読込エラー", f"テンプレートの読み込み中にエラーが発生しました:\n{e}")
225 logger.exception("テンプレートの読み込み中にエラー")
227 def _apply_plot_template(self, template_data):
228 """書式テンプレートの中身を今のプロジェクトに当てる。有効な書式が無ければ何もせず False。
230 手動の適用(_on_load_plot_template)と、新しいタブへの既定のテンプレート(_apply_default_style_template)の共通の経路。
231 """
232 if 'format_version' not in template_data:
233 # 古い形式: 今の軸だけ
234 settings = template_data.get('plot_settings', {})
235 if not settings:
236 return False
237 self._apply_settings_to_ui_controls(settings)
238 self._on_axis_setting_changed()
239 return True
241 subplot_styles = template_data.get('subplot_styles') or []
242 if not subplot_styles:
243 return False
245 for i, settings in enumerate(self.project.all_plot_settings):
246 style = subplot_styles[i % len(subplot_styles)]
247 merged = dict(settings)
248 for k, v in style.items():
249 if k not in TEMPLATE_EXCLUDED_AXIS_SETTING_KEYS:
250 merged[k] = v
251 self.project.all_plot_settings[i] = merged
252 if i == self.project.active_axis_index:
253 self._apply_settings_to_ui_controls(merged)
255 dataset_styles = template_data.get('dataset_styles') or []
256 if dataset_styles:
257 for i, dataset in enumerate(self.project.datasets):
258 _apply_dataset_style(dataset, dataset_styles[i % len(dataset_styles)])
260 self._update_plot()
261 return True
263 def _apply_default_style_template(self):
264 """環境設定の既定の書式テンプレートを、この新しいタブに当てる。
266 データセットのスタイルは、このあと読み込むデータセットに順に当てる(_style_imported_dataset)。
267 ファイルが無いか読めなければ、知らせるだけで何も当てない。
268 """
269 self._default_dataset_styles = []
270 self._styled_import_count = 0
271 file_path = app_settings.DEFAULT_STYLE_TEMPLATE.read(self.settings)
272 if not file_path:
273 return
274 try:
275 template_data = read_style_template(file_path)
276 except (OSError, ValueError) as e:
277 logger.warning("既定の書式テンプレートを読めませんでした: %s (%s)", file_path, e)
278 self.statusBar().showMessage(tr("既定の書式テンプレートを読めませんでした: {path}").format(path=file_path), 8000)
279 return
280 if not self._apply_plot_template(template_data): 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 logger.warning("既定の書式テンプレートに有効な書式がありません: %s", file_path)
282 return
283 if 'format_version' in template_data: 283 ↛ exitline 283 didn't return from function '_apply_default_style_template' because the condition on line 283 was always true
284 self._default_dataset_styles = list(template_data.get('dataset_styles') or [])
286 def _style_imported_dataset(self, dataset):
287 """読み込んだデータセットに、今のパレットの次の色を当て、既定の書式テンプレートがあればそのスタイルを
288 読み込んだ順に繰り返し当てる(手動の適用と同じ順。テンプレートの色が優先)。"""
289 # 何もしないと全部同じ既定の色になり、自動配色を押すまで見分けられない
290 color_cycle = self.colors.active_color_cycle()
291 if color_cycle: 291 ↛ 293line 291 didn't jump to line 293 because the condition on line 291 was always true
292 dataset.color = color_cycle[len(self.project.datasets) % len(color_cycle)]
293 styles = getattr(self, '_default_dataset_styles', None)
294 if not styles:
295 return
296 _apply_dataset_style(dataset, styles[self._styled_import_count % len(styles)])
297 self._styled_import_count += 1
299 def _forget_default_style_template(self):
300 """開いたプロジェクトには、そのプロジェクトの書式があるので、既定のテンプレートはもう当てない。"""
301 self._default_dataset_styles = []
303 def _on_export_settings(self):
304 """app_settings.EXPORTED_SETTINGS のキーだけを JSON に書き出す(別の PC や研究室での共有用)。"""
305 file_path, _ = dialog_dirs.get_save_file_name(
306 self, dialog_dirs.OTHER, "設定・スタイルをエクスポート", "graphica_settings.json", "JSON Files (*.json)"
307 )
308 if not file_path:
309 return
310 if not file_path.endswith('.json'):
311 file_path += '.json'
313 exported = {setting.key: setting.read_for_export(self.settings) for setting in app_settings.EXPORTED_SETTINGS}
315 payload = {'format_version': SETTINGS_EXPORT_FORMAT_VERSION, 'settings': exported}
316 try:
317 with open(file_path, 'w', encoding='utf-8') as f:
318 json.dump(payload, f, indent=4, ensure_ascii=False)
319 notify.information(self, "設定・スタイルをエクスポート", f"エクスポートしました:\n{file_path}")
320 except Exception as e:
321 notify.warning(self, "保存エラー", f"設定のエクスポート中にエラーが発生しました:\n{e}")
322 logger.exception("設定のエクスポート中にエラー")
324 def _on_import_settings(self):
325 """書き出した JSON から app_settings.EXPORTED_SETTINGS のキーだけを QSettings に入れる(未知のキーは無視)。
327 言語・ダークモード・パレットなどをその場で当て直すと影響が広いので、次の起動から効くと知らせる。
328 """
329 file_path, _ = dialog_dirs.get_open_file_name(
330 self, dialog_dirs.OTHER, "設定・スタイルをインポート", "JSON Files (*.json)"
331 )
332 if not file_path:
333 return
335 try:
336 with open(file_path, 'r', encoding='utf-8') as f:
337 payload = json.load(f)
338 except Exception as e:
339 notify.warning(self, "設定・スタイルをインポート", f"ファイルの読み込みに失敗しました:\n{e}")
340 logger.exception("設定のインポート中にエラー")
341 return
343 data = payload.get('settings') if isinstance(payload, dict) else None
344 if not isinstance(data, dict):
345 # 手で書いた設定ファイルも読めるよう、settings キーが無くてもよい
346 data = payload if isinstance(payload, dict) else None
347 if not isinstance(data, dict): 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 notify.warning(self, "設定・スタイルをインポート", "対応していないファイル形式です。")
349 return
351 valid_keys = {setting.key for setting in app_settings.EXPORTED_SETTINGS}
352 imported_count = 0
353 for key, value in data.items():
354 if key not in valid_keys:
355 continue
356 self.settings.setValue(key, value)
357 imported_count += 1
359 if imported_count == 0:
360 notify.information(self, "設定・スタイルをインポート", "インポート対象の設定が見つかりませんでした。")
361 return
363 notify.information(
364 self, "設定・スタイルをインポート",
365 f"{imported_count}件の設定を反映しました。\n"
366 "表示言語など一部の設定は、次回起動時に反映されます。"
367 )