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

1"""プロジェクトの保存と読み込みのメニュー、書式テンプレート、環境設定、設定の書き出しと読み込み。""" 

2import json 

3import logging 

4 

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 

14 

15logger = logging.getLogger(__name__) 

16 

17AUTOSAVE_INTERVAL_MIN_BOUNDS = (0, 180) 

18 

19TEMPLATE_FORMAT_VERSION = 1 

20 

21# 書き出すキーは app_settings.EXPORTED_SETTINGS。ウィンドウの状態や最近使ったファイルなど、 

22# その環境だけの項目は入れない(別の PC に持ち込んでも意味が無いか害になる) 

23SETTINGS_EXPORT_FORMAT_VERSION = 1 

24 

25# サブプロットの中身に近いもの。テンプレートでは保存も適用もしない(適用先の値を残す) 

26TEMPLATE_EXCLUDED_AXIS_SETTING_KEYS = ('annotations', 'legend_order', 'free_rect') 

27 

28 

29def read_style_template(file_path): 

30 """書式テンプレートのファイルを読む。""" 

31 with open(file_path, 'r', encoding='utf-8') as f: 

32 return json.load(f) 

33 

34 

35def _apply_dataset_style(dataset, style): 

36 for attr, value in style.items(): 

37 setattr(dataset, attr, value) 

38 

39 

40class ProjectIOMixin: 

41 def _on_save_project(self): 

42 self.manual_save() 

43 

44 def _on_save_project_as(self): 

45 self.manual_save_as() 

46 

47 def _on_load_project(self): 

48 self.manual_load() 

49 

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 

61 

62 self._apply_autosave_interval(minutes) 

63 

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) 

73 

74 self._update_autosave_menu_text() 

75 canvas_area.refresh_status_bar(self) 

76 

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) 

85 

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) 

89 

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 

109 

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

113 

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

118 

119 if new_autosave_dir != current_autosave_dir: 

120 app_settings.AUTOSAVE_DIR.write(self.settings, new_autosave_dir) 

121 self._update_autosave_path() 

122 

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) 

127 

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) 

132 

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) 

136 

137 # 表示メニューのチェック経由で切り替える(toggled から _on_toggle_dark_mode が適用し、チェックの状態も揃う) 

138 if new_dark_mode != self.canvas.dark_mode: 

139 self.dark_mode_action.setChecked(new_dark_mode) 

140 

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) 

143 

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

149 

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 

156 

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 ) 

164 

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

171 

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' 

181 

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

185 

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 ] 

194 

195 template_data = { 

196 'format_version': TEMPLATE_FORMAT_VERSION, 

197 'subplot_styles': subplot_styles, 

198 'dataset_styles': dataset_styles, 

199 } 

200 

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("テンプレートの保存中にエラー") 

207 

208 def _on_load_plot_template(self): 

209 """書式テンプレートを今のプロジェクトに当てる。Undo はできない。 

210 

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 

219 

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("テンプレートの読み込み中にエラー") 

226 

227 def _apply_plot_template(self, template_data): 

228 """書式テンプレートの中身を今のプロジェクトに当てる。有効な書式が無ければ何もせず False。 

229 

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 

240 

241 subplot_styles = template_data.get('subplot_styles') or [] 

242 if not subplot_styles: 

243 return False 

244 

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) 

254 

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

259 

260 self._update_plot() 

261 return True 

262 

263 def _apply_default_style_template(self): 

264 """環境設定の既定の書式テンプレートを、この新しいタブに当てる。 

265 

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

285 

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 

298 

299 def _forget_default_style_template(self): 

300 """開いたプロジェクトには、そのプロジェクトの書式があるので、既定のテンプレートはもう当てない。""" 

301 self._default_dataset_styles = [] 

302 

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' 

312 

313 exported = {setting.key: setting.read_for_export(self.settings) for setting in app_settings.EXPORTED_SETTINGS} 

314 

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("設定のエクスポート中にエラー") 

323 

324 def _on_import_settings(self): 

325 """書き出した JSON から app_settings.EXPORTED_SETTINGS のキーだけを QSettings に入れる(未知のキーは無視)。 

326 

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 

334 

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 

342 

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 

350 

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 

358 

359 if imported_count == 0: 

360 notify.information(self, "設定・スタイルをインポート", "インポート対象の設定が見つかりませんでした。") 

361 return 

362 

363 notify.information( 

364 self, "設定・スタイルをインポート", 

365 f"{imported_count}件の設定を反映しました。\n" 

366 "表示言語など一部の設定は、次回起動時に反映されます。" 

367 )