Coverage for graphica/gui/panels/axis_settings.py: 84.2%
384 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 functools
3import logging
4from PySide6.QtCore import QTimer
5from PySide6.QtGui import QFont
6from PySide6.QtWidgets import QDialog, QFontDialog
8from graphica.core.i18n import tr
9from graphica.gui import notify
10from graphica.core.axis_settings import AXIS_SETTING_DEFAULTS, axis_setting
11from graphica.gui import log_axis_notes, theme
12from graphica.gui.axis_bindings import CARRIED_AXIS_KEYS
13from graphica.gui.color_history import get_color_with_history
14from graphica.gui.dialogs import LegendOrderDialog, LabelEditDialog
16logger = logging.getLogger(__name__)
19# Windows の「Arial Narrow」は matplotlib には family="Arial", stretch="condensed" として登録されていて、
20# 名前のままでは見つからず黙って既定のフォントになる。末尾の語を stretch に読み替えて探し直す。
21_FONT_STRETCH_KEYWORDS = {
22 'narrow': 'condensed',
23 'condensed': 'condensed',
24 'semicondensed': 'semi-condensed',
25 'extracondensed': 'extra-condensed',
26 'ultracondensed': 'ultra-condensed',
27 'wide': 'expanded',
28 'expanded': 'expanded',
29 'semiexpanded': 'semi-expanded',
30 'extraexpanded': 'extra-expanded',
31 'ultraexpanded': 'ultra-expanded',
32}
35@functools.lru_cache(maxsize=256)
36def _resolve_font_family_for_matplotlib(family_name: str):
37 """(matplotlib に渡す family, stretch または None, 解決できたか) を返す。
39 軸の設定を変えるたびに呼ばれるので、findfont の結果をキャッシュする。
40 """
41 import matplotlib.font_manager as fm
43 try:
44 fm.findfont(fm.FontProperties(family=family_name), fallback_to_default=False)
45 return family_name, None, True
46 except ValueError:
47 pass
49 words = family_name.rsplit(None, 1)
50 if len(words) == 2:
51 base, suffix = words
52 stretch = _FONT_STRETCH_KEYWORDS.get(suffix.lower())
53 if stretch is not None:
54 try:
55 fm.findfont(fm.FontProperties(family=base, stretch=stretch), fallback_to_default=False)
56 return base, stretch, True
57 except ValueError:
58 pass
60 return family_name, None, False
63def _order_labels(labels, order):
64 """order の順に並べ、order に無いラベルは元の順のまま末尾に置く。"""
65 if not order:
66 return list(labels)
67 order_index = {name: i for i, name in enumerate(order)}
68 indices = sorted(
69 range(len(labels)),
70 key=lambda i: (0, order_index[labels[i]]) if labels[i] in order_index else (1, i)
71 )
72 return [labels[i] for i in indices]
75class AxisSettingsPanel:
76 """状態はタブが持ち、この部品はメソッドと信号の配線だけを持つ。PlotterApp の同じ名前は窓口(gui/panels/__init__.py)。"""
78 # PlotterApp から同じ名前で使えるもの(テスト・メニュー・ほかの部品が使う)
79 EXPOSED_NAMES = (
80 '_on_y2_autoscale_changed', '_on_y2_axis_state_changed', '_refresh_y2_axis_state',
81 '_apply_settings_to_ui_controls', '_block_all_signals', '_connect_axis_setting_signals',
82 '_connect_layout_signals', '_current_active_axis', '_font_props_to_dict', '_gather_settings_from_ui',
83 '_grid_linestyle_code', '_grid_linestyle_index', '_on_active_axis_changed', '_on_axis_setting_changed',
84 '_on_change_axis_label_color', '_on_change_axis_label_font', '_on_change_legend_color',
85 '_on_change_legend_font', '_on_change_spine_color', '_on_change_tick_color', '_on_change_tick_font',
86 '_on_edit_legend_order', '_on_grid_visibility_changed', '_on_layout_changed', '_on_legend_drag_release',
87 '_on_legend_loc_changed', '_on_legend_visibility_changed', '_on_share_axis_changed',
88 '_on_x_autoscale_changed', '_on_x_minor_tick_visibility_changed', '_on_x_tick_mode_changed',
89 '_on_y_autoscale_changed', '_on_y_minor_tick_visibility_changed', '_on_y_tick_mode_changed',
90 '_open_label_edit_dialog', '_refresh_all_label_previews', '_refresh_grid_state', '_refresh_label_preview',
91 '_refresh_legend_state', '_refresh_x_autoscale_enabled_state', '_refresh_x_minor_tick_state',
92 '_refresh_x_tick_mode_state', '_refresh_y_autoscale_enabled_state', '_refresh_y_minor_tick_state',
93 '_refresh_y_tick_mode_state', '_seed_min_max_spinboxes', '_store_dragged_legend_positions',
94 '_update_subplot_combos', '_warn_if_font_family_unavailable_for_graph',
95 )
97 def __init__(self, app):
98 self._app = app
100 def _on_layout_changed(self):
101 if getattr(self._app.project, 'layout_mode', 'grid') == 'free': 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 return
104 # 欄の値を変える間、変更の通知が連鎖しないように
105 self._block_all_signals(True)
107 rows = self._app.subplot_rows_spinbox.value()
108 cols = self._app.subplot_cols_spinbox.value()
109 total_plots = rows * cols
111 current_plot_count = len(self._app.project.all_plot_settings)
113 if total_plots > current_plot_count:
114 # 増えた分は今の軸の設定をもとにする
115 default_settings = self._gather_settings_from_ui()
116 for _ in range(total_plots - current_plot_count):
117 new_settings = default_settings.copy()
118 # 注釈は引き継がない。浅いコピーなので、空のリストにしないと全部が同じリストを共有する
119 new_settings['annotations'] = []
120 new_settings['legend_order'] = []
121 self._app.project.all_plot_settings.append(new_settings)
123 elif total_plots < current_plot_count: 123 ↛ 132line 123 didn't jump to line 132 because the condition on line 123 was always true
124 self._app.project.all_plot_settings = self._app.project.all_plot_settings[:total_plots]
126 # 無くなったサブプロットを描画先にしていたデータセットは、どこにも描かれず
127 # エクスポートにも入らないのに気づけないので、最後のサブプロットに移す
128 for dataset in self._app.project.datasets:
129 if dataset.subplot_target >= total_plots: 129 ↛ 128line 129 didn't jump to line 128 because the condition on line 129 was always true
130 dataset.subplot_target = total_plots - 1
132 if self._app.project.active_axis_index >= total_plots:
133 self._app.project.active_axis_index = 0
135 self._update_subplot_combos()
136 self._apply_settings_to_ui_controls(self._app.project.all_plot_settings[self._app.project.active_axis_index])
138 self._block_all_signals(False)
140 self._app._update_plot()
142 def _on_share_axis_changed(self):
143 self._app.project.share_x_axis = self._app.share_x_checkbox.isChecked()
144 self._app.project.share_y_axis = self._app.share_y_checkbox.isChecked()
145 self._app._update_plot()
147 def _on_active_axis_changed(self, index):
148 """切り替える前の欄の値を元の軸に保存してから、新しい軸の設定を欄に読み込む。"""
150 if index == -1 or index >= len(self._app.project.all_plot_settings): 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 return
153 self._app.project.all_plot_settings[self._app.project.active_axis_index] = self._gather_settings_from_ui()
155 self._app.project.active_axis_index = index
157 settings_to_load = self._app.project.all_plot_settings[self._app.project.active_axis_index]
158 self._apply_settings_to_ui_controls(settings_to_load)
159 log_axis_notes.update_log_axis_notes(self._app)
161 def _on_axis_setting_changed(self):
162 current_settings = self._gather_settings_from_ui()
164 self._app.project.all_plot_settings[self._app.project.active_axis_index] = current_settings
166 self._app._update_plot_appearance()
168 def _update_subplot_combos(self):
169 total_plots = len(self._app.project.all_plot_settings)
170 plot_names = [tr("プロット {number}").format(number=i + 1) for i in range(total_plots)]
172 self._app.active_axis_combo.blockSignals(True)
173 self._app.subplot_target_combo.blockSignals(True)
175 self._app.active_axis_combo.clear()
176 self._app.subplot_target_combo.clear()
178 self._app.active_axis_combo.addItems(plot_names)
179 self._app.subplot_target_combo.addItems(plot_names)
181 self._app.active_axis_combo.setCurrentIndex(self._app.project.active_axis_index)
183 # 「描画先」の選択はデータセットを選んだときに property_panel.update_ui_state が決める
184 self._app.active_axis_combo.blockSignals(False)
185 self._app.subplot_target_combo.blockSignals(False)
188 def _seed_min_max_spinboxes(self, min_spinbox, max_spinbox, limits):
189 """オートスケールを切った瞬間に、いま表示している範囲を最小値・最大値の欄に入れる。
191 欄が初期値 0/0 のままだと、最小値を先に変えたとき「最小 >= 最大」で反映されない。
192 """
193 if limits is None: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 return
195 min_lim, max_lim = limits
196 min_spinbox.blockSignals(True)
197 max_spinbox.blockSignals(True)
198 min_spinbox.setValue(min_lim)
199 max_spinbox.setValue(max_lim)
200 min_spinbox.blockSignals(False)
201 max_spinbox.blockSignals(False)
203 def _current_active_axis(self):
204 axis_index = self._app.project.active_axis_index
205 if 0 <= axis_index < len(self._app.canvas.all_axes):
206 return self._app.canvas.all_axes[axis_index]
207 return None
209 def _refresh_x_autoscale_enabled_state(self):
210 """最小値・最大値の欄の有効/無効だけをオートスケールに合わせる。値は書き換えない。
212 保存した設定を戻すときはこちらを使う。_on_x_autoscale_changed() は表示中の範囲を欄に入れるので、
213 保存されていた範囲が上書きされる。
214 """
215 is_autoscale = self._app.ui.x_autoscale_checkbox.isChecked()
216 self._app.ui.x_min_spinbox.setEnabled(not is_autoscale)
217 self._app.ui.x_max_spinbox.setEnabled(not is_autoscale)
218 return is_autoscale
220 def _refresh_y_autoscale_enabled_state(self):
221 is_autoscale = self._app.ui.y_autoscale_checkbox.isChecked()
222 self._app.ui.y_min_spinbox.setEnabled(not is_autoscale)
223 self._app.ui.y_max_spinbox.setEnabled(not is_autoscale)
224 return is_autoscale
226 def _on_x_autoscale_changed(self):
227 """利用者がチェックを変えたとき(設定を戻すときは _refresh_x_autoscale_enabled_state)。"""
228 is_autoscale = self._refresh_x_autoscale_enabled_state()
230 if not is_autoscale:
231 axis = self._current_active_axis()
232 self._seed_min_max_spinboxes(
233 self._app.ui.x_min_spinbox, self._app.ui.x_max_spinbox, axis.get_xlim() if axis is not None else None)
235 # 欄の値を書き換えたので、描き直すだけでなく設定も集め直す
236 self._on_axis_setting_changed()
238 def _on_y_autoscale_changed(self):
239 is_autoscale = self._refresh_y_autoscale_enabled_state()
241 if not is_autoscale:
242 axis = self._current_active_axis()
243 self._seed_min_max_spinboxes(
244 self._app.ui.y_min_spinbox, self._app.ui.y_max_spinbox, axis.get_ylim() if axis is not None else None)
246 self._on_axis_setting_changed()
248 def _on_x_tick_mode_changed(self):
249 self._refresh_x_tick_mode_state()
250 self._app._update_plot_appearance()
252 def _refresh_x_tick_mode_state(self):
253 is_fixed_interval = (self._app.ui.x_major_tick_mode_combo.currentIndex() == 1)
254 self._app.ui.x_major_tick_interval_spinbox.setEnabled(is_fixed_interval)
256 def _on_y_tick_mode_changed(self):
257 self._refresh_y_tick_mode_state()
258 self._app._update_plot_appearance()
260 def _refresh_y_tick_mode_state(self):
261 is_fixed_interval = (self._app.ui.y_major_tick_mode_combo.currentIndex() == 1)
262 self._app.ui.y_major_tick_interval_spinbox.setEnabled(is_fixed_interval)
264 def _on_x_minor_tick_visibility_changed(self):
265 """補助目盛の表示と対数表示の両方に依存するので、同じスロットで受ける。"""
266 self._refresh_x_minor_tick_state()
267 self._app._update_plot_appearance()
269 def _refresh_x_minor_tick_state(self):
270 is_visible = self._app.ui.x_minor_ticks_visible_checkbox.isChecked()
271 is_log = self._app.ui.x_log_checkbox.isChecked()
272 # 対数軸は MultipleLocator ではなく LogLocator なので間隔は使わない
273 self._app.ui.x_minor_tick_interval_spinbox.setEnabled(is_visible and not is_log)
274 self._app.x_log_minor_subs_label.setVisible(is_log)
275 self._app.x_log_minor_subs_combo.setVisible(is_log)
276 self._app.x_log_minor_labels_checkbox.setVisible(is_log)
277 self._app.x_log_minor_subs_combo.setEnabled(is_log and is_visible)
278 self._app.x_log_minor_labels_checkbox.setEnabled(is_log and is_visible)
280 def _on_y_minor_tick_visibility_changed(self):
281 self._refresh_y_minor_tick_state()
282 self._app._update_plot_appearance()
284 def _refresh_y_minor_tick_state(self):
285 is_visible = self._app.ui.y_minor_ticks_visible_checkbox.isChecked()
286 is_log = self._app.ui.y_log_checkbox.isChecked()
287 self._app.ui.y_minor_tick_interval_spinbox.setEnabled(is_visible and not is_log)
288 self._app.y_log_minor_subs_label.setVisible(is_log)
289 self._app.y_log_minor_subs_combo.setVisible(is_log)
290 self._app.y_log_minor_labels_checkbox.setVisible(is_log)
291 self._app.y_log_minor_subs_combo.setEnabled(is_log and is_visible)
292 self._app.y_log_minor_labels_checkbox.setEnabled(is_log and is_visible)
294 def _refresh_y2_axis_state(self):
295 """第 2 Y 軸の欄の有効/無効を、自動スケール・目盛りの方式・補助目盛り・対数に合わせる。値は書き換えない。"""
296 app = self._app
297 is_autoscale = app.y2_autoscale_checkbox.isChecked()
298 app.y2_min_spinbox.setEnabled(not is_autoscale)
299 app.y2_max_spinbox.setEnabled(not is_autoscale)
300 app.y2_major_tick_interval_spinbox.setEnabled(app.y2_major_tick_mode_combo.currentIndex() == 1)
301 # 対数軸の補助目盛りは間隔ではなく対数の刻みで打つ
302 app.y2_minor_tick_interval_spinbox.setEnabled(
303 app.y2_minor_ticks_visible_checkbox.isChecked() and not app.y2_log_checkbox.isChecked())
305 def _on_y2_axis_state_changed(self):
306 self._refresh_y2_axis_state()
308 def _on_y2_autoscale_changed(self):
309 """自動スケールを切った瞬間に、いまの第 2 Y 軸の範囲を最小値・最大値の欄に入れる(主の Y 軸と同じ)。"""
310 self._refresh_y2_axis_state()
311 if not self._app.y2_autoscale_checkbox.isChecked(): 311 ↛ 317line 311 didn't jump to line 317 because the condition on line 311 was always true
312 axis_index = self._app.project.active_axis_index
313 secondary_axes = getattr(self._app.canvas, 'all_secondary_axes', [])
314 secondary = secondary_axes[axis_index] if 0 <= axis_index < len(secondary_axes) else None
315 self._seed_min_max_spinboxes(self._app.y2_min_spinbox, self._app.y2_max_spinbox,
316 secondary.get_ylim() if secondary is not None else None)
317 self._on_axis_setting_changed()
319 def _on_legend_visibility_changed(self):
320 self._refresh_legend_state()
321 self._app._update_plot_appearance()
323 def _refresh_legend_state(self):
324 is_visible = self._app.ui.legend_visible_checkbox.isChecked()
326 self._app.legend_loc_label.setEnabled(is_visible)
327 self._app.legend_loc_combo.setEnabled(is_visible)
328 self._app.legend_font_label.setEnabled(is_visible)
329 self._app.legend_font_button.setEnabled(is_visible)
330 self._app.legend_color_label.setEnabled(is_visible)
331 self._app.legend_color_button.setEnabled(is_visible)
333 def _grid_linestyle_code(self, combo_index: int) -> str:
334 choices = self._app.grid_linestyle_choices
335 if 0 <= combo_index < len(choices): 335 ↛ 337line 335 didn't jump to line 337 because the condition on line 335 was always true
336 return choices[combo_index][1]
337 return '-'
339 def _grid_linestyle_index(self, linestyle_code: str) -> int:
340 """未知の値は 0(実線)。"""
341 for i, (_label, code) in enumerate(self._app.grid_linestyle_choices): 341 ↛ 344line 341 didn't jump to line 344 because the loop on line 341 didn't complete
342 if code == linestyle_code:
343 return i
344 return 0
346 def _on_grid_visibility_changed(self):
347 self._refresh_grid_state()
348 self._app._update_plot_appearance()
350 def _refresh_grid_state(self):
351 is_visible = self._app.ui.grid_visible_checkbox.isChecked()
353 self._app.ui.minor_grid_visible_checkbox.setEnabled(is_visible)
355 # 主のグリッドの欄はグリッドの表示に、補助の欄はさらに補助グリッドの表示にも従う
356 is_minor_visible = is_visible and self._app.ui.minor_grid_visible_checkbox.isChecked()
357 for widget in (
358 self._app.x_major_grid_linestyle_combo, self._app.x_major_grid_width_spinbox, self._app.x_major_grid_alpha_spinbox,
359 self._app.y_major_grid_linestyle_combo, self._app.y_major_grid_width_spinbox, self._app.y_major_grid_alpha_spinbox,
360 ):
361 widget.setEnabled(is_visible)
362 for widget in (
363 self._app.x_minor_grid_linestyle_combo, self._app.x_minor_grid_width_spinbox, self._app.x_minor_grid_alpha_spinbox,
364 self._app.y_minor_grid_linestyle_combo, self._app.y_minor_grid_width_spinbox, self._app.y_minor_grid_alpha_spinbox,
365 ):
366 widget.setEnabled(is_minor_visible)
369 def _warn_if_font_family_unavailable_for_graph(self, font):
370 """グラフのフォントが matplotlib で見つからなければ知らせる(選択は保存する)。
372 QFontDialog は OS のフォント一覧を出すが、matplotlib は別の一覧で探し、見つからないと黙って既定のフォントになる。
373 """
374 family = font.family()
375 _resolved_name, _stretch, resolved = _resolve_font_family_for_matplotlib(family)
376 if not resolved:
377 notify.warning(
378 self._app, "フォントが見つかりません",
379 f"フォント「{family}」はグラフの描画エンジン(matplotlib)には認識されず、"
380 "代わりに既定のフォントで表示されます。\n\n"
381 "OS側のフォント一覧には表示されていても、グラフの描画には使えない"
382 "フォントがあります。別のフォントをお試しください。"
383 )
385 def _ask_font(self, initial):
386 """フォントを選ばせる。(選んだか, フォント)。
388 どの OS でも Qt のダイアログを使う。macOS の OS のフォントパネルは、選んだフォントを Qt へ通知(changeFont:)で
389 渡すが、それが届かないと OK でも最初のフォントのまま返り、選んでも反映されない。
390 """
391 return QFontDialog.getFont(initial, self._app, "", QFontDialog.FontDialogOption.DontUseNativeDialog)
393 def _on_change_tick_font(self):
394 ok, font = self._ask_font(self._app._tick_font)
395 if ok:
396 self._warn_if_font_family_unavailable_for_graph(font)
397 self._app._tick_font = font
398 self._on_axis_setting_changed()
400 def _on_change_tick_color(self):
401 color = get_color_with_history(self._app.settings, self._app)
402 if color.isValid():
403 self._app._tick_color = color.name()
404 self._on_axis_setting_changed()
406 def _on_change_axis_label_font(self):
407 ok, font = self._ask_font(self._app._axis_label_font)
408 if ok:
409 self._warn_if_font_family_unavailable_for_graph(font)
410 self._app._axis_label_font = font
411 # _update_plot_appearance() だけでは all_plot_settings に保存されない
412 self._on_axis_setting_changed()
414 def _on_change_axis_label_color(self):
415 color = get_color_with_history(self._app.settings, self._app)
416 if color.isValid():
417 self._app._axis_label_color = color.name()
418 self._on_axis_setting_changed()
420 def _on_change_legend_font(self):
421 ok, font = self._ask_font(self._app._legend_font)
422 if ok:
423 self._warn_if_font_family_unavailable_for_graph(font)
424 self._app._legend_font = font
425 self._on_axis_setting_changed()
427 def _on_change_legend_color(self):
428 color = get_color_with_history(self._app.settings, self._app)
429 if color.isValid():
430 self._app._legend_color = color.name()
431 self._on_axis_setting_changed()
433 def _on_change_spine_color(self):
434 color = get_color_with_history(self._app.settings, self._app)
435 if color.isValid():
436 self._app._spine_color = color.name()
437 self._on_axis_setting_changed()
439 def _open_label_edit_dialog(self, line_edit, dialog_title):
440 """編集ダイアログの結果を line_edit に setText() する(textChanged からいつもの経路で反映される)。
442 LABEL_SYMBOL_PALETTE は main_window がこの mixin を import しているので、ここで遅れて import する。
443 """
444 from graphica.gui.main_window import LABEL_SYMBOL_PALETTE
446 dialog = LabelEditDialog(line_edit.text(), dialog_title, LABEL_SYMBOL_PALETTE, parent=self._app)
447 if dialog.exec() == QDialog.DialogCode.Accepted:
448 line_edit.setText(dialog.get_text())
450 def _refresh_label_preview(self, preview_label, text, placeholder):
451 """プレビューを matplotlib で描いた見た目にする。空なら placeholder を text_muted の色で出す。"""
452 from graphica.gui.mathtext_preview import render_mathtext_to_pixmap
454 tokens = theme.current_tokens()
455 if text:
456 pixmap = render_mathtext_to_pixmap(text, color=tokens["text_primary"])
457 else:
458 pixmap = render_mathtext_to_pixmap(placeholder, color=tokens["text_muted"])
459 # 欄の幅が決まるたびに収まるよう縮める(setPixmap だと、この時点の幅がまだ確定していないとはみ出す)
460 preview_label.set_natural_pixmap(pixmap)
462 def _refresh_all_label_previews(self):
463 """文字色がテーマに従うので、ダークモードの切り替えで描き直す。"""
464 for preview_label, line_edit, placeholder in self._app._label_preview_widgets:
465 self._refresh_label_preview(preview_label, line_edit.text(), placeholder)
467 def _on_legend_loc_changed(self, *_args):
468 """ドラッグした位置(legend_position)が残っていると選んだ位置が効かないので消す。"""
469 axis_index = self._app.project.active_axis_index
470 if axis_index < len(self._app.project.all_plot_settings): 470 ↛ 472line 470 didn't jump to line 472 because the condition on line 470 was always true
471 self._app.project.all_plot_settings[axis_index].pop('legend_position', None)
472 self._on_axis_setting_changed()
474 def _on_legend_drag_release(self, _event):
475 """凡例のドラッグは matplotlib 側の button_release_event で確定し、こちらが先に呼ばれうるので、処理の後で読む。"""
476 QTimer.singleShot(0, self._app, self._store_dragged_legend_positions)
478 def _store_dragged_legend_positions(self):
479 """凡例がドラッグで置かれていれば、その位置を legend_position に保存する(しないと次の描画で戻る)。どれか変えたら True。"""
480 changed = False
481 settings_list = self._app.project.all_plot_settings
482 secondary_axes = getattr(self._app.canvas, 'all_secondary_axes', [])
483 for index, ax in enumerate(getattr(self._app.canvas, 'all_axes', [])):
484 if index >= len(settings_list): 484 ↛ 485line 484 didn't jump to line 485 because the condition on line 484 was never true
485 break
486 legend = ax.get_legend()
487 if legend is None and index < len(secondary_axes) and secondary_axes[index] is not None: 487 ↛ 488line 487 didn't jump to line 488 because the condition on line 487 was never true
488 legend = secondary_axes[index].get_legend()
489 loc = getattr(legend, '_loc', None) if legend is not None else None
490 if not isinstance(loc, tuple) or len(loc) != 2:
491 continue
492 position = [round(float(loc[0]), 4), round(float(loc[1]), 4)]
493 if axis_setting(settings_list[index], 'legend_position') != position: 493 ↛ 483line 493 didn't jump to line 483 because the condition on line 493 was always true
494 settings_list[index]['legend_position'] = position
495 changed = True
496 return changed
498 def _on_edit_legend_order(self):
499 """今の軸の凡例の並びをダイアログで決め、legend_order に保存する。"""
500 axis_index = self._app.project.active_axis_index
501 if axis_index >= len(self._app.canvas.all_axes):
502 return
503 ax = self._app.canvas.all_axes[axis_index]
504 _, labels = ax.get_legend_handles_labels()
505 if axis_index < len(self._app.canvas.all_secondary_axes) and self._app.canvas.all_secondary_axes[axis_index] is not None:
506 _, secondary_labels = self._app.canvas.all_secondary_axes[axis_index].get_legend_handles_labels()
507 labels = labels + secondary_labels
508 if not labels:
509 notify.information(self._app, "凡例の順序", "この軸には凡例に表示するデータセットがありません。")
510 return
512 current_order = axis_setting(self._app.project.all_plot_settings[axis_index], 'legend_order') or []
513 dialog = LegendOrderDialog(_order_labels(labels, current_order), self._app)
514 if dialog.exec() != QDialog.DialogCode.Accepted:
515 return
516 self._app.project.all_plot_settings[axis_index]['legend_order'] = dialog.get_order()
517 self._app._update_plot()
519 def _font_props_to_dict(self, qfont: QFont) -> dict:
520 """QFont を JSON に保存できる辞書にする。
522 'family' は families() の候補リストのまま保存する(.family() の先頭1つにすると、macOS に無い
523 "Yu Gothic" だけが残って日本語が化ける)。各候補は _resolve_font_family_for_matplotlib で解決する。
524 """
525 resolved_families = []
526 stretch = None
527 for name in qfont.families():
528 resolved_name, resolved_stretch, _ok = _resolve_font_family_for_matplotlib(name)
529 resolved_families.append(resolved_name)
530 if stretch is None and resolved_stretch is not None:
531 stretch = resolved_stretch
533 result = {
534 'family': resolved_families,
535 'size': qfont.pointSize(),
536 'weight': 'bold' if qfont.bold() else 'normal',
537 'style': 'italic' if qfont.italic() else 'normal'
538 }
539 if stretch is not None:
540 result['stretch'] = stretch
541 return result
543 def _gather_settings_from_ui(self) -> dict:
544 """今の軸の設定を、画面の欄から集めた辞書(保存形式そのもの)で返す。キーは AXIS_SETTING_DEFAULTS と同じ順。"""
545 values = self._app._axis_binder.gather()
546 # 欄を持たず操作から直接書き込まれるもの。辞書は丸ごと入れ替わるので、引き継がないと消える。
547 settings_list = self._app.project.all_plot_settings
548 current = settings_list[self._app.project.active_axis_index] if self._app.project.active_axis_index < len(settings_list) else {}
549 for key in CARRIED_AXIS_KEYS:
550 values[key] = axis_setting(current, key)
551 return {key: values[key] for key in AXIS_SETTING_DEFAULTS}
553 def _apply_settings_to_ui_controls(self, settings: dict):
554 """設定の辞書を画面の欄に戻す。無いキーは AXIS_SETTING_DEFAULTS の既定値になる。"""
555 try:
556 # 欄に値を入れる間は、変更の通知で設定を書き換えないようにする
557 self._block_all_signals(True)
559 self._app._axis_binder.restore(settings, axis_setting)
561 # 復元では _on_x_autoscale_changed(今の表示範囲を欄に入れる)を呼ばない。
562 # 呼ぶと、オートスケールを切って保存した範囲が今の表示範囲で上書きされる。
563 self._refresh_x_autoscale_enabled_state()
564 self._refresh_y_autoscale_enabled_state()
565 self._refresh_x_tick_mode_state()
566 self._refresh_y_tick_mode_state()
567 self._refresh_x_minor_tick_state()
568 self._refresh_y_minor_tick_state()
569 self._refresh_legend_state()
570 self._refresh_grid_state()
571 self._refresh_y2_axis_state()
572 # 欄の状態をそろえてから 1 回だけ描く(どれも同じ設定で描くので、欄ごとに描いても結果は同じ)
573 self._app._update_plot_appearance()
575 except Exception as e:
576 notify.warning(self._app, "設定適用エラー", f"設定の適用中にエラーが発生しました:\n{e}")
577 logger.exception("設定の適用中にエラー")
578 finally:
579 # 途中で失敗しても必ず戻す
580 self._block_all_signals(False)
582 def _block_all_signals(self, block: bool):
583 self._app._axis_binder.block_signals(block)
585 def _connect_layout_signals(self):
586 self._app.subplot_rows_spinbox.valueChanged.connect(self._on_layout_changed)
587 self._app.subplot_cols_spinbox.valueChanged.connect(self._on_layout_changed)
589 self._app.share_x_checkbox.toggled.connect(self._on_share_axis_changed)
590 self._app.share_y_checkbox.toggled.connect(self._on_share_axis_changed)
592 self._app.active_axis_combo.currentIndexChanged.connect(self._on_active_axis_changed)
594 self._app.free_layout_checkbox.toggled.connect(self._app._on_toggle_free_layout)
595 self._app.add_free_subplot_button.clicked.connect(self._app._on_add_free_subplot)
596 self._app.remove_free_subplot_button.clicked.connect(self._app._on_remove_free_subplot)
598 self._app.free_layout_x_spinbox.valueChanged.connect(self._app._on_free_layout_position_spinbox_changed)
599 self._app.free_layout_y_spinbox.valueChanged.connect(self._app._on_free_layout_position_spinbox_changed)
600 self._app.free_layout_width_spinbox.valueChanged.connect(self._app._on_free_layout_position_spinbox_changed)
601 self._app.free_layout_height_spinbox.valueChanged.connect(self._app._on_free_layout_position_spinbox_changed)
603 def _connect_axis_setting_signals(self):
604 # 値を持つ欄は表(gui/axis_bindings.py)の順につなぐ。タイトルと軸ラベルの編集ボタンは _build_label_editors でつなぐ
605 self._app._axis_binder.connect()
607 self._app.ui.tick_font_button.clicked.connect(self._on_change_tick_font)
608 self._app.ui.tick_color_button.clicked.connect(self._on_change_tick_color)
609 self._app.ui.axis_label_font_button.clicked.connect(self._on_change_axis_label_font)
610 self._app.ui.axis_label_color_button.clicked.connect(self._on_change_axis_label_color)
611 self._app.legend_font_button.clicked.connect(self._on_change_legend_font)
612 self._app.legend_color_button.clicked.connect(self._on_change_legend_color)
613 self._app.legend_order_button.clicked.connect(self._on_edit_legend_order)
614 self._app.ui.spine_color_button.clicked.connect(self._on_change_spine_color)