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

1"""軸の設定の部品: 画面の欄と設定の辞書の相互変換、サブプロットの行数と列数、フォントと色の選択、その信号の配線。""" 

2import functools 

3import logging 

4from PySide6.QtCore import QTimer 

5from PySide6.QtGui import QFont 

6from PySide6.QtWidgets import QDialog, QFontDialog 

7 

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 

15 

16logger = logging.getLogger(__name__) 

17 

18 

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} 

33 

34 

35@functools.lru_cache(maxsize=256) 

36def _resolve_font_family_for_matplotlib(family_name: str): 

37 """(matplotlib に渡す family, stretch または None, 解決できたか) を返す。 

38 

39 軸の設定を変えるたびに呼ばれるので、findfont の結果をキャッシュする。 

40 """ 

41 import matplotlib.font_manager as fm 

42 

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 

48 

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 

59 

60 return family_name, None, False 

61 

62 

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] 

73 

74 

75class AxisSettingsPanel: 

76 """状態はタブが持ち、この部品はメソッドと信号の配線だけを持つ。PlotterApp の同じ名前は窓口(gui/panels/__init__.py)。""" 

77 

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 ) 

96 

97 def __init__(self, app): 

98 self._app = app 

99 

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 

103 

104 # 欄の値を変える間、変更の通知が連鎖しないように 

105 self._block_all_signals(True) 

106 

107 rows = self._app.subplot_rows_spinbox.value() 

108 cols = self._app.subplot_cols_spinbox.value() 

109 total_plots = rows * cols 

110 

111 current_plot_count = len(self._app.project.all_plot_settings) 

112 

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) 

122 

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] 

125 

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 

131 

132 if self._app.project.active_axis_index >= total_plots: 

133 self._app.project.active_axis_index = 0 

134 

135 self._update_subplot_combos() 

136 self._apply_settings_to_ui_controls(self._app.project.all_plot_settings[self._app.project.active_axis_index]) 

137 

138 self._block_all_signals(False) 

139 

140 self._app._update_plot() 

141 

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

146 

147 def _on_active_axis_changed(self, index): 

148 """切り替える前の欄の値を元の軸に保存してから、新しい軸の設定を欄に読み込む。""" 

149 

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 

152 

153 self._app.project.all_plot_settings[self._app.project.active_axis_index] = self._gather_settings_from_ui() 

154 

155 self._app.project.active_axis_index = index 

156 

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) 

160 

161 def _on_axis_setting_changed(self): 

162 current_settings = self._gather_settings_from_ui() 

163 

164 self._app.project.all_plot_settings[self._app.project.active_axis_index] = current_settings 

165 

166 self._app._update_plot_appearance() 

167 

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

171 

172 self._app.active_axis_combo.blockSignals(True) 

173 self._app.subplot_target_combo.blockSignals(True) 

174 

175 self._app.active_axis_combo.clear() 

176 self._app.subplot_target_combo.clear() 

177 

178 self._app.active_axis_combo.addItems(plot_names) 

179 self._app.subplot_target_combo.addItems(plot_names) 

180 

181 self._app.active_axis_combo.setCurrentIndex(self._app.project.active_axis_index) 

182 

183 # 「描画先」の選択はデータセットを選んだときに property_panel.update_ui_state が決める 

184 self._app.active_axis_combo.blockSignals(False) 

185 self._app.subplot_target_combo.blockSignals(False) 

186 

187 

188 def _seed_min_max_spinboxes(self, min_spinbox, max_spinbox, limits): 

189 """オートスケールを切った瞬間に、いま表示している範囲を最小値・最大値の欄に入れる。 

190 

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) 

202 

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 

208 

209 def _refresh_x_autoscale_enabled_state(self): 

210 """最小値・最大値の欄の有効/無効だけをオートスケールに合わせる。値は書き換えない。 

211 

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 

219 

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 

225 

226 def _on_x_autoscale_changed(self): 

227 """利用者がチェックを変えたとき(設定を戻すときは _refresh_x_autoscale_enabled_state)。""" 

228 is_autoscale = self._refresh_x_autoscale_enabled_state() 

229 

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) 

234 

235 # 欄の値を書き換えたので、描き直すだけでなく設定も集め直す 

236 self._on_axis_setting_changed() 

237 

238 def _on_y_autoscale_changed(self): 

239 is_autoscale = self._refresh_y_autoscale_enabled_state() 

240 

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) 

245 

246 self._on_axis_setting_changed() 

247 

248 def _on_x_tick_mode_changed(self): 

249 self._refresh_x_tick_mode_state() 

250 self._app._update_plot_appearance() 

251 

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) 

255 

256 def _on_y_tick_mode_changed(self): 

257 self._refresh_y_tick_mode_state() 

258 self._app._update_plot_appearance() 

259 

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) 

263 

264 def _on_x_minor_tick_visibility_changed(self): 

265 """補助目盛の表示と対数表示の両方に依存するので、同じスロットで受ける。""" 

266 self._refresh_x_minor_tick_state() 

267 self._app._update_plot_appearance() 

268 

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) 

279 

280 def _on_y_minor_tick_visibility_changed(self): 

281 self._refresh_y_minor_tick_state() 

282 self._app._update_plot_appearance() 

283 

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) 

293 

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

304 

305 def _on_y2_axis_state_changed(self): 

306 self._refresh_y2_axis_state() 

307 

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

318 

319 def _on_legend_visibility_changed(self): 

320 self._refresh_legend_state() 

321 self._app._update_plot_appearance() 

322 

323 def _refresh_legend_state(self): 

324 is_visible = self._app.ui.legend_visible_checkbox.isChecked() 

325 

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) 

332 

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

338 

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 

345 

346 def _on_grid_visibility_changed(self): 

347 self._refresh_grid_state() 

348 self._app._update_plot_appearance() 

349 

350 def _refresh_grid_state(self): 

351 is_visible = self._app.ui.grid_visible_checkbox.isChecked() 

352 

353 self._app.ui.minor_grid_visible_checkbox.setEnabled(is_visible) 

354 

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) 

367 

368 

369 def _warn_if_font_family_unavailable_for_graph(self, font): 

370 """グラフのフォントが matplotlib で見つからなければ知らせる(選択は保存する)。 

371 

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 ) 

384 

385 def _ask_font(self, initial): 

386 """フォントを選ばせる。(選んだか, フォント)。 

387 

388 どの OS でも Qt のダイアログを使う。macOS の OS のフォントパネルは、選んだフォントを Qt へ通知(changeFont:)で 

389 渡すが、それが届かないと OK でも最初のフォントのまま返り、選んでも反映されない。 

390 """ 

391 return QFontDialog.getFont(initial, self._app, "", QFontDialog.FontDialogOption.DontUseNativeDialog) 

392 

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

399 

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

405 

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

413 

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

419 

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

426 

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

432 

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

438 

439 def _open_label_edit_dialog(self, line_edit, dialog_title): 

440 """編集ダイアログの結果を line_edit に setText() する(textChanged からいつもの経路で反映される)。 

441 

442 LABEL_SYMBOL_PALETTE は main_window がこの mixin を import しているので、ここで遅れて import する。 

443 """ 

444 from graphica.gui.main_window import LABEL_SYMBOL_PALETTE 

445 

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

449 

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 

453 

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) 

461 

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) 

466 

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

473 

474 def _on_legend_drag_release(self, _event): 

475 """凡例のドラッグは matplotlib 側の button_release_event で確定し、こちらが先に呼ばれうるので、処理の後で読む。""" 

476 QTimer.singleShot(0, self._app, self._store_dragged_legend_positions) 

477 

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 

497 

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 

511 

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

518 

519 def _font_props_to_dict(self, qfont: QFont) -> dict: 

520 """QFont を JSON に保存できる辞書にする。 

521 

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 

532 

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 

542 

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} 

552 

553 def _apply_settings_to_ui_controls(self, settings: dict): 

554 """設定の辞書を画面の欄に戻す。無いキーは AXIS_SETTING_DEFAULTS の既定値になる。""" 

555 try: 

556 # 欄に値を入れる間は、変更の通知で設定を書き換えないようにする 

557 self._block_all_signals(True) 

558 

559 self._app._axis_binder.restore(settings, axis_setting) 

560 

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

574 

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) 

581 

582 def _block_all_signals(self, block: bool): 

583 self._app._axis_binder.block_signals(block) 

584 

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) 

588 

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) 

591 

592 self._app.active_axis_combo.currentIndexChanged.connect(self._on_active_axis_changed) 

593 

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) 

597 

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) 

602 

603 def _connect_axis_setting_signals(self): 

604 # 値を持つ欄は表(gui/axis_bindings.py)の順につなぐ。タイトルと軸ラベルの編集ボタンは _build_label_editors でつなぐ 

605 self._app._axis_binder.connect() 

606 

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)