graphica-plot 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. graphica/Graphica.ico +0 -0
  2. graphica/__init__.py +1 -0
  3. graphica/__main__.py +111 -0
  4. graphica/assets/__init__.py +0 -0
  5. graphica/assets/icons/__init__.py +0 -0
  6. graphica/assets/icons/arrow-right.svg +21 -0
  7. graphica/assets/icons/bold.svg +20 -0
  8. graphica/assets/icons/calculator.svg +26 -0
  9. graphica/assets/icons/chart-histogram.svg +24 -0
  10. graphica/assets/icons/chart-line.svg +20 -0
  11. graphica/assets/icons/chevron-down.svg +19 -0
  12. graphica/assets/icons/chevron-right.svg +19 -0
  13. graphica/assets/icons/color-swatch.svg +22 -0
  14. graphica/assets/icons/column-insert-right.svg +21 -0
  15. graphica/assets/icons/column-remove.svg +21 -0
  16. graphica/assets/icons/copy.svg +20 -0
  17. graphica/assets/icons/download.svg +21 -0
  18. graphica/assets/icons/edit.svg +21 -0
  19. graphica/assets/icons/eye-off.svg +21 -0
  20. graphica/assets/icons/eye.svg +20 -0
  21. graphica/assets/icons/file-plus.svg +22 -0
  22. graphica/assets/icons/folder-plus.svg +21 -0
  23. graphica/assets/icons/folder.svg +19 -0
  24. graphica/assets/icons/highlight.svg +22 -0
  25. graphica/assets/icons/history.svg +20 -0
  26. graphica/assets/icons/italic.svg +21 -0
  27. graphica/assets/icons/layout-grid.svg +22 -0
  28. graphica/assets/icons/math-function.svg +22 -0
  29. graphica/assets/icons/message-2.svg +21 -0
  30. graphica/assets/icons/mountain.svg +20 -0
  31. graphica/assets/icons/palette.svg +22 -0
  32. graphica/assets/icons/pointer.svg +19 -0
  33. graphica/assets/icons/refresh.svg +20 -0
  34. graphica/assets/icons/row-insert-bottom.svg +21 -0
  35. graphica/assets/icons/row-remove.svg +21 -0
  36. graphica/assets/icons/search.svg +20 -0
  37. graphica/assets/icons/select-all.svg +35 -0
  38. graphica/assets/icons/subscript.svg +20 -0
  39. graphica/assets/icons/superscript.svg +20 -0
  40. graphica/assets/icons/table.svg +21 -0
  41. graphica/assets/icons/trash.svg +23 -0
  42. graphica/assets/icons/typography.svg +23 -0
  43. graphica/assets/icons/x.svg +20 -0
  44. graphica/core/__init__.py +0 -0
  45. graphica/core/analysis.py +1278 -0
  46. graphica/core/app_paths.py +35 -0
  47. graphica/core/axis_settings.py +128 -0
  48. graphica/core/caption_export.py +47 -0
  49. graphica/core/color_palettes.py +56 -0
  50. graphica/core/commands.py +226 -0
  51. graphica/core/cvd_simulation.py +49 -0
  52. graphica/core/dataset.py +443 -0
  53. graphica/core/diagnostics.py +105 -0
  54. graphica/core/excel_utils.py +58 -0
  55. graphica/core/fit_models.py +379 -0
  56. graphica/core/grid_data.py +128 -0
  57. graphica/core/i18n.py +44 -0
  58. graphica/core/json_utils.py +27 -0
  59. graphica/core/label_utils.py +21 -0
  60. graphica/core/methods_text.py +115 -0
  61. graphica/core/named_colors.py +126 -0
  62. graphica/core/plugin_api.py +515 -0
  63. graphica/core/plugin_context.py +167 -0
  64. graphica/core/plugin_install.py +82 -0
  65. graphica/core/plugin_manifest.py +67 -0
  66. graphica/core/plugin_testing.py +213 -0
  67. graphica/core/plugin_types.py +118 -0
  68. graphica/core/provenance.py +19 -0
  69. graphica/core/report_export.py +51 -0
  70. graphica/core/safe_eval.py +194 -0
  71. graphica/core/script_export.py +272 -0
  72. graphica/core/translations_en.py +530 -0
  73. graphica/core/unit_conversion.py +51 -0
  74. graphica/core/update_check.py +43 -0
  75. graphica/core/version.py +14 -0
  76. graphica/gui/__init__.py +0 -0
  77. graphica/gui/app_settings.py +119 -0
  78. graphica/gui/axis_bindings.py +173 -0
  79. graphica/gui/binding.py +135 -0
  80. graphica/gui/builders/__init__.py +1 -0
  81. graphica/gui/builders/axis_panel.py +431 -0
  82. graphica/gui/builders/canvas_area.py +176 -0
  83. graphica/gui/builders/common.py +120 -0
  84. graphica/gui/builders/dataset_panel.py +433 -0
  85. graphica/gui/builders/property_sections.py +208 -0
  86. graphica/gui/canvas.py +519 -0
  87. graphica/gui/color_history.py +47 -0
  88. graphica/gui/color_picker_widget.py +198 -0
  89. graphica/gui/crash_handler.py +77 -0
  90. graphica/gui/cvd_preview.py +23 -0
  91. graphica/gui/data_editor.py +761 -0
  92. graphica/gui/data_import_flow.py +355 -0
  93. graphica/gui/dataset_bindings.py +107 -0
  94. graphica/gui/dataset_style_icon.py +80 -0
  95. graphica/gui/datasets/__init__.py +1 -0
  96. graphica/gui/datasets/actions_menu.py +114 -0
  97. graphica/gui/datasets/colors.py +218 -0
  98. graphica/gui/datasets/fitting.py +70 -0
  99. graphica/gui/datasets/host.py +162 -0
  100. graphica/gui/datasets/operations/__init__.py +1 -0
  101. graphica/gui/datasets/operations/fitting.py +468 -0
  102. graphica/gui/datasets/operations/peaks.py +109 -0
  103. graphica/gui/datasets/operations/processing.py +659 -0
  104. graphica/gui/datasets/operations/runner.py +148 -0
  105. graphica/gui/datasets/operations/transfer.py +227 -0
  106. graphica/gui/datasets/order.py +310 -0
  107. graphica/gui/datasets/overlays.py +74 -0
  108. graphica/gui/datasets/peaks.py +21 -0
  109. graphica/gui/datasets/plugin_runs.py +99 -0
  110. graphica/gui/datasets/processing.py +68 -0
  111. graphica/gui/datasets/property_panel.py +538 -0
  112. graphica/gui/datasets/transfer.py +36 -0
  113. graphica/gui/detached_canvas_window.py +22 -0
  114. graphica/gui/dialogs/__init__.py +137 -0
  115. graphica/gui/dialogs/analysis.py +1322 -0
  116. graphica/gui/dialogs/app.py +1015 -0
  117. graphica/gui/dialogs/appearance.py +851 -0
  118. graphica/gui/dialogs/data_edit.py +577 -0
  119. graphica/gui/dialogs/data_import.py +679 -0
  120. graphica/gui/dialogs/export.py +433 -0
  121. graphica/gui/dock_layout.py +176 -0
  122. graphica/gui/export_preview_panel.py +321 -0
  123. graphica/gui/export_settings.py +16 -0
  124. graphica/gui/file_association.py +97 -0
  125. graphica/gui/icon_utils.py +44 -0
  126. graphica/gui/main_app_window.py +210 -0
  127. graphica/gui/main_window.py +1151 -0
  128. graphica/gui/mathtext_preview.py +168 -0
  129. graphica/gui/menu_bar.py +320 -0
  130. graphica/gui/minimap_widget.py +151 -0
  131. graphica/gui/mixins/__init__.py +0 -0
  132. graphica/gui/mixins/export_mixin.py +520 -0
  133. graphica/gui/mixins/help_mixin.py +127 -0
  134. graphica/gui/mixins/project_io_mixin.py +289 -0
  135. graphica/gui/mixins/quick_access_mixin.py +194 -0
  136. graphica/gui/mixins/ui_setup_mixin.py +206 -0
  137. graphica/gui/notify.py +61 -0
  138. graphica/gui/panels/__init__.py +43 -0
  139. graphica/gui/panels/axis_settings.py +605 -0
  140. graphica/gui/panels/dataset_tree.py +424 -0
  141. graphica/gui/plot_type_drawers.py +131 -0
  142. graphica/gui/plugin_context.py +119 -0
  143. graphica/gui/project_files.py +361 -0
  144. graphica/gui/provenance_panel.py +65 -0
  145. graphica/gui/rendering/__init__.py +1 -0
  146. graphica/gui/rendering/annotations.py +177 -0
  147. graphica/gui/rendering/appearance.py +368 -0
  148. graphica/gui/rendering/common.py +316 -0
  149. graphica/gui/rendering/data_1d.py +376 -0
  150. graphica/gui/rendering/data_2d.py +66 -0
  151. graphica/gui/residual_panel.py +62 -0
  152. graphica/gui/resources.py +16 -0
  153. graphica/gui/single_instance.py +141 -0
  154. graphica/gui/splash.py +83 -0
  155. graphica/gui/task_runner.py +41 -0
  156. graphica/gui/theme.py +864 -0
  157. graphica/gui/tools/__init__.py +56 -0
  158. graphica/gui/tools/annotation.py +275 -0
  159. graphica/gui/tools/cursor.py +259 -0
  160. graphica/gui/tools/layout_edit.py +285 -0
  161. graphica/gui/tools/manager.py +78 -0
  162. graphica/gui/tools/peak_placement.py +132 -0
  163. graphica/gui/tools/range_select.py +191 -0
  164. graphica/gui/tools/region_highlight.py +222 -0
  165. graphica/gui/tools/slice_extraction.py +200 -0
  166. graphica/gui/widget_translation.py +46 -0
  167. graphica/gui/workers.py +280 -0
  168. graphica/models/__init__.py +0 -0
  169. graphica/models/project.py +197 -0
  170. graphica/plugin/__init__.py +20 -0
  171. graphica/plugin/testing.py +57 -0
  172. graphica/sample_data/__init__.py +0 -0
  173. graphica/sample_data/cooling_curve_sample.csv +42 -0
  174. graphica/ui_main_window.py +581 -0
  175. graphica_plot-2.0.0.dist-info/METADATA +317 -0
  176. graphica_plot-2.0.0.dist-info/RECORD +181 -0
  177. graphica_plot-2.0.0.dist-info/WHEEL +5 -0
  178. graphica_plot-2.0.0.dist-info/entry_points.txt +2 -0
  179. graphica_plot-2.0.0.dist-info/licenses/LICENSE +30 -0
  180. graphica_plot-2.0.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +91 -0
  181. graphica_plot-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,289 @@
1
+ """プロジェクトの保存と読み込みのメニュー、書式テンプレート、環境設定、設定の書き出しと読み込み。"""
2
+ import json
3
+ import logging
4
+
5
+ from graphica.gui import notify
6
+ from graphica.gui import app_settings
7
+ from graphica.gui.dialogs import PreferencesDialog
8
+ from graphica.gui.datasets.operations.transfer import STYLE_ATTRS
9
+ from graphica.core.i18n import tr, get_language
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ AUTOSAVE_INTERVAL_MIN_BOUNDS = (0, 180)
14
+
15
+ TEMPLATE_FORMAT_VERSION = 1
16
+
17
+ # 書き出すキーは app_settings.EXPORTED_SETTINGS。ウィンドウの状態や最近使ったファイルなど、
18
+ # その環境だけの項目は入れない(別の PC に持ち込んでも意味が無いか害になる)
19
+ SETTINGS_EXPORT_FORMAT_VERSION = 1
20
+
21
+ # サブプロットの中身に近いもの。テンプレートでは保存も適用もしない(適用先の値を残す)
22
+ TEMPLATE_EXCLUDED_AXIS_SETTING_KEYS = ('annotations', 'legend_order', 'free_rect')
23
+
24
+
25
+ class ProjectIOMixin:
26
+ def _on_save_project(self):
27
+ self.manual_save()
28
+
29
+ def _on_save_project_as(self):
30
+ self.manual_save_as()
31
+
32
+ def _on_load_project(self):
33
+ self.manual_load()
34
+
35
+ def _on_configure_autosave_interval(self):
36
+ """0 分で無効。"""
37
+ current_minutes = (self.autosave_timer.interval() // 60000) if self.autosave_timer.isActive() else 0
38
+ min_minutes, max_minutes = AUTOSAVE_INTERVAL_MIN_BOUNDS
39
+ minutes, ok = notify.get_int(
40
+ self, "オートセーブ間隔の設定",
41
+ "オートセーブの間隔を分単位で入力してください (0で無効化):",
42
+ current_minutes, min_minutes, max_minutes
43
+ )
44
+ if not ok:
45
+ return
46
+
47
+ self._apply_autosave_interval(minutes)
48
+
49
+ def _apply_autosave_interval(self, minutes):
50
+ """メニューと環境設定の両方から呼ばれる。"""
51
+ app_settings.AUTOSAVE_INTERVAL_MIN.write(self.settings, minutes)
52
+ if minutes <= 0:
53
+ self.autosave_timer.stop()
54
+ self.statusBar().showMessage("オートセーブを無効化しました", 3000)
55
+ else:
56
+ self.autosave_timer.start(minutes * 60 * 1000)
57
+ self.statusBar().showMessage(f"オートセーブ間隔を{minutes}分に設定しました", 3000)
58
+
59
+ self._update_autosave_menu_text()
60
+
61
+ def _on_show_preferences(self):
62
+ current_minutes = (self.autosave_timer.interval() // 60000) if self.autosave_timer.isActive() else 0
63
+ # 言語だけは、未設定なら今の表示言語を既定にする
64
+ current_language = app_settings.LANGUAGE.read(self.settings, default=get_language())
65
+ current_autosave_dir = app_settings.AUTOSAVE_DIR.read(self.settings)
66
+ current_point_label_max = app_settings.POINT_LABEL_MAX_POINTS.read(self.settings)
67
+ current_snap_to_grid = app_settings.SNAP_TO_GRID_ENABLED.read(self.settings)
68
+ current_snap_grid_interval = app_settings.SNAP_GRID_INTERVAL_PX.read(self.settings)
69
+
70
+ # main_window がこの mixin を import しているので、関数の中で import する
71
+ from graphica.core.plugin_api import get_loaded_plugin_records, get_plugin_registration_errors
72
+ current_disabled_plugin_names = app_settings.disabled_plugin_names(self.settings)
73
+
74
+ dlg = PreferencesDialog(
75
+ self.canvas.dark_mode, current_minutes,
76
+ autosave_bounds=AUTOSAVE_INTERVAL_MIN_BOUNDS, parent=self,
77
+ current_language=current_language, autosave_dir=current_autosave_dir,
78
+ point_label_max_points=current_point_label_max,
79
+ snap_to_grid_enabled=current_snap_to_grid,
80
+ snap_grid_interval_px=current_snap_grid_interval,
81
+ plugin_records=get_loaded_plugin_records(),
82
+ plugin_registration_errors=get_plugin_registration_errors(),
83
+ disabled_plugin_names=current_disabled_plugin_names,
84
+ )
85
+ if dlg.exec() != PreferencesDialog.DialogCode.Accepted:
86
+ return
87
+
88
+ (new_dark_mode, new_autosave_minutes, new_language,
89
+ new_autosave_dir, new_point_label_max,
90
+ new_snap_to_grid, new_snap_grid_interval) = dlg.get_settings()
91
+
92
+ # 次の起動から効く(読み込んだプラグインをその場で入れ替える仕組みは無い)
93
+ new_disabled_plugin_names = dlg.get_disabled_plugin_names()
94
+ if new_disabled_plugin_names != current_disabled_plugin_names:
95
+ app_settings.DISABLED_PLUGINS.write(self.settings, list(new_disabled_plugin_names))
96
+
97
+ if new_autosave_dir != current_autosave_dir:
98
+ app_settings.AUTOSAVE_DIR.write(self.settings, new_autosave_dir)
99
+ self._update_autosave_path()
100
+
101
+ # 表示メニューのチェック経由で切り替える(toggled から _on_toggle_dark_mode が適用し、チェックの状態も揃う)
102
+ if new_dark_mode != self.canvas.dark_mode:
103
+ self.dark_mode_action.setChecked(new_dark_mode)
104
+
105
+ if new_autosave_minutes != current_minutes:
106
+ self._apply_autosave_interval(new_autosave_minutes)
107
+
108
+ if new_point_label_max != current_point_label_max:
109
+ app_settings.POINT_LABEL_MAX_POINTS.write(self.settings, new_point_label_max)
110
+ self.canvas.point_label_max_points = new_point_label_max
111
+ self._update_plot()
112
+ self.property_panel.update_point_labels_limit_note()
113
+
114
+ if new_snap_to_grid != current_snap_to_grid:
115
+ app_settings.SNAP_TO_GRID_ENABLED.write(self.settings, new_snap_to_grid)
116
+ self.snap_to_grid_enabled = new_snap_to_grid
117
+ if new_snap_grid_interval != current_snap_grid_interval:
118
+ app_settings.SNAP_GRID_INTERVAL_PX.write(self.settings, new_snap_grid_interval)
119
+ self.snap_grid_interval_px = new_snap_grid_interval
120
+
121
+ # 作った画面をその場で訳し直す仕組みは無いので、次の起動からと知らせる
122
+ if new_language != current_language:
123
+ app_settings.LANGUAGE.write(self.settings, new_language)
124
+ notify.information(
125
+ self, tr("表示言語の変更"),
126
+ tr("表示言語の変更は、次回起動時に反映されます。")
127
+ )
128
+
129
+ def _update_autosave_menu_text(self):
130
+ if not self.autosave_timer.isActive():
131
+ self.autosave_interval_action.setText(tr("オートセーブ: 無効(&I)..."))
132
+ else:
133
+ minutes = self.autosave_timer.interval() // 60000
134
+ self.autosave_interval_action.setText(tr("オートセーブ: {minutes}分間隔(&I)...").format(minutes=minutes))
135
+
136
+ def _on_save_plot_template(self):
137
+ """全サブプロットの見た目と全データセットのスタイルを *.graphica-style に保存する(データは含めない)。"""
138
+ file_path, _ = notify.get_save_file_name(
139
+ self, "書式テンプレートを保存", "", "Graphica Style Template (*.graphica-style)"
140
+ )
141
+ if not file_path:
142
+ return
143
+ if not (file_path.endswith('.graphica-style') or file_path.endswith('.json')):
144
+ file_path += '.graphica-style'
145
+
146
+ # 今の軸の欄の値はまだ all_plot_settings に入っていない
147
+ if self.project.active_axis_index < len(self.project.all_plot_settings):
148
+ self.project.all_plot_settings[self.project.active_axis_index] = self._gather_settings_from_ui()
149
+
150
+ subplot_styles = [
151
+ {k: v for k, v in settings.items() if k not in TEMPLATE_EXCLUDED_AXIS_SETTING_KEYS}
152
+ for settings in self.project.all_plot_settings
153
+ ]
154
+ dataset_styles = [
155
+ {attr: getattr(ds, attr) for attr in STYLE_ATTRS}
156
+ for ds in self.project.datasets
157
+ ]
158
+
159
+ template_data = {
160
+ 'format_version': TEMPLATE_FORMAT_VERSION,
161
+ 'subplot_styles': subplot_styles,
162
+ 'dataset_styles': dataset_styles,
163
+ }
164
+
165
+ try:
166
+ with open(file_path, 'w', encoding='utf-8') as f:
167
+ json.dump(template_data, f, indent=4, ensure_ascii=False)
168
+ except Exception as e:
169
+ notify.warning(self, "保存エラー", f"テンプレートの保存中にエラーが発生しました:\n{e}")
170
+ logger.exception("テンプレートの保存中にエラー")
171
+
172
+ def _on_load_plot_template(self):
173
+ """書式テンプレートを今のプロジェクトに当てる。Undo はできない。
174
+
175
+ 新しい形式(format_version あり)は、保存した順にサブプロットとデータセットへ繰り返し当てる(数が違ってもよい)。
176
+ 古い .json(plot_settings だけ)は今の軸だけに当てる。
177
+ """
178
+ file_path, _ = notify.get_open_file_name(
179
+ self, "書式テンプレートを適用", "", "Graphica Style Template (*.graphica-style *.json)"
180
+ )
181
+ if not file_path:
182
+ return
183
+
184
+ try:
185
+ with open(file_path, 'r', encoding='utf-8') as f:
186
+ template_data = json.load(f)
187
+
188
+ if 'format_version' in template_data:
189
+ subplot_styles = template_data.get('subplot_styles') or []
190
+ if not subplot_styles:
191
+ notify.warning(self, "読込エラー", "有効な書式設定がファイルに含まれていません。")
192
+ return
193
+
194
+ for i, settings in enumerate(self.project.all_plot_settings):
195
+ style = subplot_styles[i % len(subplot_styles)]
196
+ merged = dict(settings)
197
+ for k, v in style.items():
198
+ if k not in TEMPLATE_EXCLUDED_AXIS_SETTING_KEYS:
199
+ merged[k] = v
200
+ self.project.all_plot_settings[i] = merged
201
+ if i == self.project.active_axis_index:
202
+ self._apply_settings_to_ui_controls(merged)
203
+
204
+ dataset_styles = template_data.get('dataset_styles') or []
205
+ if dataset_styles:
206
+ for i, dataset in enumerate(self.project.datasets):
207
+ style = dataset_styles[i % len(dataset_styles)]
208
+ for attr, value in style.items():
209
+ setattr(dataset, attr, value)
210
+
211
+ self._update_plot()
212
+ else:
213
+ # 古い形式: 今の軸だけ
214
+ settings = template_data.get('plot_settings', {})
215
+ if not settings:
216
+ notify.warning(self, "読込エラー", "有効な書式設定がファイルに含まれていません。")
217
+ return
218
+ self._apply_settings_to_ui_controls(settings)
219
+ self._on_axis_setting_changed()
220
+
221
+ except Exception as e:
222
+ notify.warning(self, "読込エラー", f"テンプレートの読み込み中にエラーが発生しました:\n{e}")
223
+ logger.exception("テンプレートの読み込み中にエラー")
224
+
225
+ def _on_export_settings(self):
226
+ """app_settings.EXPORTED_SETTINGS のキーだけを JSON に書き出す(別の PC や研究室での共有用)。"""
227
+ file_path, _ = notify.get_save_file_name(
228
+ self, "設定・スタイルをエクスポート", "graphica_settings.json", "JSON Files (*.json)"
229
+ )
230
+ if not file_path:
231
+ return
232
+ if not file_path.endswith('.json'):
233
+ file_path += '.json'
234
+
235
+ exported = {setting.key: setting.read_for_export(self.settings) for setting in app_settings.EXPORTED_SETTINGS}
236
+
237
+ payload = {'format_version': SETTINGS_EXPORT_FORMAT_VERSION, 'settings': exported}
238
+ try:
239
+ with open(file_path, 'w', encoding='utf-8') as f:
240
+ json.dump(payload, f, indent=4, ensure_ascii=False)
241
+ notify.information(self, "設定・スタイルをエクスポート", f"エクスポートしました:\n{file_path}")
242
+ except Exception as e:
243
+ notify.warning(self, "保存エラー", f"設定のエクスポート中にエラーが発生しました:\n{e}")
244
+ logger.exception("設定のエクスポート中にエラー")
245
+
246
+ def _on_import_settings(self):
247
+ """書き出した JSON から app_settings.EXPORTED_SETTINGS のキーだけを QSettings に入れる(未知のキーは無視)。
248
+
249
+ 言語・ダークモード・パレットなどをその場で当て直すと影響が広いので、次の起動から効くと知らせる。
250
+ """
251
+ file_path, _ = notify.get_open_file_name(
252
+ self, "設定・スタイルをインポート", "", "JSON Files (*.json)"
253
+ )
254
+ if not file_path:
255
+ return
256
+
257
+ try:
258
+ with open(file_path, 'r', encoding='utf-8') as f:
259
+ payload = json.load(f)
260
+ except Exception as e:
261
+ notify.warning(self, "設定・スタイルをインポート", f"ファイルの読み込みに失敗しました:\n{e}")
262
+ logger.exception("設定のインポート中にエラー")
263
+ return
264
+
265
+ data = payload.get('settings') if isinstance(payload, dict) else None
266
+ if not isinstance(data, dict):
267
+ # 手で書いた設定ファイルも読めるよう、settings キーが無くてもよい
268
+ data = payload if isinstance(payload, dict) else None
269
+ if not isinstance(data, dict):
270
+ notify.warning(self, "設定・スタイルをインポート", "対応していないファイル形式です。")
271
+ return
272
+
273
+ valid_keys = {setting.key for setting in app_settings.EXPORTED_SETTINGS}
274
+ imported_count = 0
275
+ for key, value in data.items():
276
+ if key not in valid_keys:
277
+ continue
278
+ self.settings.setValue(key, value)
279
+ imported_count += 1
280
+
281
+ if imported_count == 0:
282
+ notify.information(self, "設定・スタイルをインポート", "インポート対象の設定が見つかりませんでした。")
283
+ return
284
+
285
+ notify.information(
286
+ self, "設定・スタイルをインポート",
287
+ f"{imported_count}件の設定を反映しました。\n"
288
+ "表示言語など一部の設定は、次回起動時に反映されます。"
289
+ )
@@ -0,0 +1,194 @@
1
+ """クイックアクセス: よく使うメニュー項目をツールバーにピン留めする。
2
+
3
+ ツールバーにはメニューの QAction そのものを addAction する(写しを作ると状態がずれる)。
4
+ ピン留めは QSettings の "quick_access_pinned_actions" に、メニューのパスを " > " でつないだ文字列で保存する
5
+ (QAction は起動のたびに作り直されるので、表示文字のパスを識別子にする)。見つからないものは黙って飛ばす。
6
+ ツールバーはタブごとに作り、共有するのは保存した識別子だけ。
7
+ """
8
+ import logging
9
+
10
+ from PySide6.QtCore import Qt
11
+ from PySide6.QtGui import QAction
12
+ from PySide6.QtWidgets import QToolBar, QMenu
13
+
14
+ from graphica.core.i18n import tr
15
+ from graphica.gui import app_settings
16
+ from graphica.gui.dialogs import QuickAccessManagerDialog
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+
22
+ # _migrate_pinned_quick_access_ids() が末尾の項目名を取り出すのにも使う
23
+ SEPARATOR = " > "
24
+
25
+
26
+ def quick_access_action_identifier(path):
27
+ return SEPARATOR.join(path)
28
+
29
+
30
+ class QuickAccessMixin:
31
+ def _create_quick_access_toolbar(self):
32
+ """空のツールバーと、表示メニューの切り替えを作る。
33
+
34
+ ピン留めを戻すのは _restore_quick_access_actions()(プラグインのメニューを含めて全部できた後でないと見つからない)。
35
+ """
36
+ self._quick_access_actions = {} # 識別子 -> QAction(回収されないよう持つ)
37
+
38
+ toolbar = QToolBar(tr("クイックアクセス"), self)
39
+ # saveState() が見分けられるように
40
+ toolbar.setObjectName("quick_access_toolbar")
41
+ # 上に固定するので、移動用のつまみは出さない(QSS では隠せない)
42
+ toolbar.setMovable(False)
43
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar)
44
+ self.quick_access_toolbar = toolbar
45
+
46
+ # ピン留めが0件でも管理ダイアログに行けるよう、先頭に置く
47
+ self.quick_access_manage_action = QAction(tr("クイックアクセスの管理..."), self)
48
+ self.quick_access_manage_action.triggered.connect(self._on_manage_quick_access)
49
+ toolbar.addAction(self.quick_access_manage_action)
50
+ toolbar.addSeparator()
51
+
52
+ toggle_action = toolbar.toggleViewAction()
53
+ toggle_action.setText(tr("クイックアクセスツールバー"))
54
+ self._view_menu.addAction(toggle_action)
55
+
56
+
57
+ def _quick_access_pinnable_menus(self):
58
+ menus = [self._file_menu, self._edit_menu, self._view_menu, self._help_menu]
59
+ plugin_menu = getattr(self, '_plugin_menu', None)
60
+ if plugin_menu is not None:
61
+ menus.append(plugin_menu)
62
+ return menus
63
+
64
+ def _install_quick_access_context_menus(self):
65
+ """各メニューの項目の右クリックでピン留めと解除をできるようにする(全部のメニューができた後に1回)。"""
66
+ for menu in self._quick_access_pinnable_menus():
67
+ menu.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
68
+ menu.customContextMenuRequested.connect(
69
+ lambda pos, m=menu: self._on_quick_access_menu_context_menu(m, pos)
70
+ )
71
+
72
+ def _resolve_quick_access_menu_target(self, menu, pos):
73
+ """pos の項目の (識別子, パス, QAction)。区切り線やサブメニューなら None。UI を開かないのでテストできる。"""
74
+ action = menu.actionAt(pos)
75
+ if action is None or action.isSeparator() or action.menu() is not None:
76
+ return None
77
+ text = action.text().replace('&', '').strip()
78
+ if not text:
79
+ return None
80
+ path = [menu.title().replace('&', ''), text]
81
+ ident = quick_access_action_identifier(path)
82
+ return ident, path, action
83
+
84
+ def _on_quick_access_menu_context_menu(self, menu, pos):
85
+ target = self._resolve_quick_access_menu_target(menu, pos)
86
+ if target is None:
87
+ return
88
+ ident, path, action = target
89
+
90
+ popup = QMenu(self)
91
+ if self.is_quick_access_pinned(ident):
92
+ remove_action = popup.addAction(tr("クイックアクセスから削除"))
93
+ remove_action.triggered.connect(lambda: self.unpin_quick_access_action(ident))
94
+ else:
95
+ add_action = popup.addAction(tr("クイックアクセスに追加"))
96
+ add_action.triggered.connect(lambda: self.pin_quick_access_action(ident, action))
97
+ popup.exec(menu.mapToGlobal(pos))
98
+
99
+
100
+ def _get_pinned_quick_access_ids(self):
101
+ """ピン留めした順の識別子。"""
102
+ return app_settings.as_list_keeping_empty_string(app_settings.QUICK_ACCESS_PINNED_ACTIONS.read(self.settings))
103
+
104
+ def _set_pinned_quick_access_ids(self, ids):
105
+ app_settings.QUICK_ACCESS_PINNED_ACTIONS.write(self.settings, ids)
106
+
107
+ def _migrate_pinned_quick_access_ids(self, ids, available):
108
+ """メニューの組み替えでパスが変わったピン留めを、新しい識別子に読み替える。((識別子のリスト, 変えたか))
109
+
110
+ 一致しない識別子は黙って飛ばされ、ピンが知らないうちに消えるので読み替える。末尾の項目名が一致する候補が
111
+ 今のメニューにちょうど1つあるときだけ読み替え、複数あれば触らない。項目名自体が変わったものは読み替えない。
112
+ """
113
+ by_leaf = {}
114
+ for ident in available:
115
+ leaf = ident.rsplit(SEPARATOR, 1)[-1]
116
+ by_leaf.setdefault(leaf, []).append(ident)
117
+
118
+ migrated, changed = [], False
119
+ for ident in ids:
120
+ if ident in available:
121
+ migrated.append(ident)
122
+ continue
123
+ candidates = by_leaf.get(ident.rsplit(SEPARATOR, 1)[-1], [])
124
+ if len(candidates) == 1:
125
+ logger.info(
126
+ "クイックアクセスのピン留めを移動先へ読み替えました: %r -> %r",
127
+ ident, candidates[0],
128
+ )
129
+ migrated.append(candidates[0])
130
+ changed = True
131
+ else:
132
+ migrated.append(ident)
133
+ return migrated, changed
134
+
135
+ def _restore_quick_access_actions(self):
136
+ """保存した識別子のうち、今のメニューにあるものをツールバーに戻す(起動時に1回)。"""
137
+ ids = self._get_pinned_quick_access_ids()
138
+ if not ids:
139
+ return
140
+ available = {
141
+ quick_access_action_identifier(path): action
142
+ for path, action in self._collect_menu_actions()
143
+ }
144
+ ids, changed = self._migrate_pinned_quick_access_ids(ids, available)
145
+ if changed:
146
+ # 書き戻して、次からは読み替えずに済むように
147
+ self._set_pinned_quick_access_ids(ids)
148
+ for ident in ids:
149
+ action = available.get(ident)
150
+ if action is not None:
151
+ self._add_action_to_quick_access_toolbar(ident, action)
152
+
153
+
154
+ def _add_action_to_quick_access_toolbar(self, ident, action):
155
+ if ident in self._quick_access_actions:
156
+ return
157
+ self.quick_access_toolbar.addAction(action)
158
+ self._quick_access_actions[ident] = action
159
+
160
+ def pin_quick_access_action(self, ident, action):
161
+ self._add_action_to_quick_access_toolbar(ident, action)
162
+ ids = self._get_pinned_quick_access_ids()
163
+ if ident not in ids:
164
+ ids.append(ident)
165
+ self._set_pinned_quick_access_ids(ids)
166
+
167
+ def unpin_quick_access_action(self, ident):
168
+ action = self._quick_access_actions.pop(ident, None)
169
+ if action is not None:
170
+ self.quick_access_toolbar.removeAction(action)
171
+ ids = self._get_pinned_quick_access_ids()
172
+ if ident in ids:
173
+ ids.remove(ident)
174
+ self._set_pinned_quick_access_ids(ids)
175
+
176
+ def is_quick_access_pinned(self, ident):
177
+ return ident in self._quick_access_actions
178
+
179
+
180
+ def _on_manage_quick_access(self):
181
+ def toggle(ident, path, checked):
182
+ if checked:
183
+ # 一覧を作ったときの QAction は使わず、実行する直前にパスで取り直す
184
+ for p, action in self._collect_menu_actions():
185
+ if p == path:
186
+ self.pin_quick_access_action(ident, action)
187
+ break
188
+ else:
189
+ self.unpin_quick_access_action(ident)
190
+
191
+ dialog = QuickAccessManagerDialog(
192
+ self._collect_menu_actions, self.is_quick_access_pinned, toggle, self
193
+ )
194
+ dialog.exec()