Coverage for graphica/gui/tools/layout_edit.py: 89.9%
181 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"""自由配置のレイアウト: サブプロットをドラッグか数値で好きな位置と大きさに置く。
3位置は all_plot_settings[i]['free_rect'] に (left, bottom, width, height)(Figure に対する 0〜1)で持つ。
4ドラッグも数値の入力も、ax.set_position() と free_rect への書き込みという同じ経路を通す(食い違わないように)。
5"""
6import logging
7from graphica.core.axis_settings import axis_setting
8from graphica.gui.tools.pointer import legend_at
10logger = logging.getLogger(__name__)
12# 右下の角をつかんでいるとみなす距離
13RESIZE_HANDLE_TOLERANCE_PX = 12
14MIN_FREE_RECT_SIZE = 0.05
17class LayoutEditTool:
18 """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。"""
20 # PlotterApp から同じ名前で読み書きできるもの(テスト・メニュー・ほかの mixin が使う)
21 EXPOSED_NAMES = (
22 '_find_axis_at_point', '_layout_drag_state', '_layout_edit_leave_cid', '_layout_edit_motion_cid',
23 '_layout_edit_press_cid', '_layout_edit_release_cid', '_layout_selected_axis_index', '_on_add_free_subplot',
24 '_on_free_layout_position_spinbox_changed', '_on_layout_motion', '_on_layout_press', '_on_layout_release',
25 '_on_remove_free_subplot', '_on_toggle_free_layout', '_sync_canvas_axes_state_and_side_panels',
26 '_sync_free_layout_position_controls', '_toggle_layout_edit_mode', 'layout_edit_mode_enabled',
27 )
29 def __init__(self, app):
30 self._app = app
31 self.layout_edit_mode_enabled = False
32 self._layout_edit_press_cid = None
33 self._layout_edit_motion_cid = None
34 self._layout_edit_release_cid = None
35 self._layout_edit_leave_cid = None # ドラッグ中に図の外へ出たとき用
36 self._layout_drag_state = None
37 # クリックで選んだ軸(ドラッグとは別)。位置と大きさの数値欄の対象
38 self._layout_selected_axis_index = None
40 def _on_toggle_free_layout(self, checked):
41 self._app.project.layout_mode = 'free' if checked else 'grid'
43 self._app.subplot_rows_spinbox.setEnabled(not checked)
44 self._app.subplot_cols_spinbox.setEnabled(not checked)
45 self._app.add_free_subplot_button.setEnabled(checked)
46 self._app.remove_free_subplot_button.setEnabled(checked)
47 self._app.layout_edit_action.setEnabled(checked)
48 # 自由配置には「同じ行・列」が無いので軸の共有は使えない(値は残し、グリッドに戻せばそのまま効く)
49 self._app.share_x_checkbox.setEnabled(not checked)
50 self._app.share_y_checkbox.setEnabled(not checked)
52 if not checked and self._app.layout_edit_action.isChecked(): 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true
53 self._app.layout_edit_action.setChecked(False)
54 self._toggle_layout_edit_mode(False)
56 if not checked:
57 self._layout_selected_axis_index = None
58 self._app.free_layout_position_group.setVisible(False)
60 if checked:
61 # 矩形をまだ持たないサブプロットには既定の矩形を割り当てる
62 for i, settings in enumerate(self._app.project.all_plot_settings):
63 if not axis_setting(settings, 'free_rect'): 63 ↛ 62line 63 didn't jump to line 62 because the condition on line 63 was always true
64 settings['free_rect'] = self._app.canvas._default_free_rect(i)
66 self._app._update_plot()
68 def _on_add_free_subplot(self):
69 """空の Axes を1つ足すだけなので、全体を描き直さず canvas.add_free_axis() で足りる。"""
70 default_settings = self._app._gather_settings_from_ui()
71 new_settings = default_settings.copy()
72 # 注釈などは引き継がない。浅いコピーなので、差し替えないと全部で共有してしまう
73 new_settings['annotations'] = []
74 new_settings['legend_order'] = []
75 index = len(self._app.project.all_plot_settings)
76 new_settings['free_rect'] = self._app.canvas._default_free_rect(index)
77 self._app.project.all_plot_settings.append(new_settings)
79 self._app._update_subplot_combos()
80 self._app.canvas.add_free_axis(
81 self._app.project.datasets, new_settings, panel_labels_enabled=self._app.project.panel_labels_enabled,
82 )
83 self._sync_canvas_axes_state_and_side_panels()
85 def _on_remove_free_subplot(self):
86 """末尾のサブプロットを消す(最低1つは残す)。ほかの番号は変わらないので、全体を描き直さずに済む。"""
87 if len(self._app.project.all_plot_settings) <= 1: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 return
89 self._app.project.all_plot_settings.pop()
90 new_total = len(self._app.project.all_plot_settings)
91 self._app.view_navigation.forget_view(new_total)
92 if self._app.project.active_axis_index >= new_total: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 self._app.project.active_axis_index = new_total - 1
95 # 消えたサブプロットの系列はどこにも描かれなくなるので、新しい末尾に移す
96 for dataset in self._app.project.datasets:
97 if dataset.subplot_target >= new_total: 97 ↛ 96line 97 didn't jump to line 96 because the condition on line 97 was always true
98 dataset.subplot_target = new_total - 1
100 self._app._update_subplot_combos()
101 self._app._apply_settings_to_ui_controls(self._app.project.all_plot_settings[self._app.project.active_axis_index])
103 self._app.canvas.remove_last_free_axis(self._app.project.datasets)
104 new_last_index = new_total - 1
105 self._app.canvas.update_single_axis(
106 new_last_index, self._app.project.datasets, self._app.project.all_plot_settings[new_last_index],
107 rows=0, cols=0, panel_labels_enabled=self._app.project.panel_labels_enabled,
108 )
109 self._sync_canvas_axes_state_and_side_panels()
111 if (self._layout_selected_axis_index is not None and 111 ↛ 113line 111 didn't jump to line 113 because the condition on line 111 was never true
112 self._layout_selected_axis_index >= len(self._app.project.all_plot_settings)):
113 self._layout_selected_axis_index = None
114 self._sync_free_layout_position_controls()
116 def _sync_canvas_axes_state_and_side_panels(self):
117 """Axes を足したり消したりした後、_update_plot() がしている UI の同期を、全体を描き直さずに行う。"""
118 is_secondary_visible = any(sa is not None for sa in self._app.canvas.all_secondary_axes)
119 self._app.tick_direction_y2_label.setVisible(is_secondary_visible)
120 self._app.major_tick_direction_y2_combo.setVisible(is_secondary_visible)
121 self._app.minor_tick_direction_y2_combo.setVisible(is_secondary_visible)
122 self._app.y2_label_text_label.setVisible(is_secondary_visible)
123 self._app.y2_label_text_edit.setVisible(is_secondary_visible)
125 self._app.all_axes = self._app.canvas.all_axes
126 self._app.all_secondary_axes = self._app.canvas.all_secondary_axes
128 if hasattr(self._app, 'export_preview_panel'): 128 ↛ 130line 128 didn't jump to line 130 because the condition on line 128 was always true
129 self._app.export_preview_panel.refresh_preview()
130 self._app._reapply_editor_row_highlight()
131 self._app._refresh_minimap()
133 def _toggle_layout_edit_mode(self, checked):
134 self.layout_edit_mode_enabled = checked
136 if checked:
137 self._app._deactivate_other_mouse_modes('layout_edit')
139 self._layout_edit_press_cid = self._app.canvas.mpl_connect('button_press_event', self._on_layout_press)
140 self._layout_edit_motion_cid = self._app.canvas.mpl_connect('motion_notify_event', self._on_layout_motion)
141 self._layout_edit_release_cid = self._app.canvas.mpl_connect('button_release_event', self._on_layout_release)
142 # 図の外でボタンを離すと button_release_event が届かず、ボタンを押していないのに動かし続けるので、
143 # 図から出た時点で離したことにする
144 self._layout_edit_leave_cid = self._app.canvas.mpl_connect('figure_leave_event', self._on_layout_release)
145 self._app.statusBar().showMessage(
146 "レイアウト編集モード: プロット内部をドラッグで移動、右下端をドラッグでリサイズします", 5000
147 )
148 else:
149 for cid_attr in ('_layout_edit_press_cid', '_layout_edit_motion_cid', '_layout_edit_release_cid',
150 '_layout_edit_leave_cid'):
151 cid = getattr(self, cid_attr, None)
152 if cid is not None:
153 self._app.canvas.mpl_disconnect(cid)
154 setattr(self, cid_attr, None)
155 self._layout_drag_state = None
156 self._layout_selected_axis_index = None
157 self._app.free_layout_position_group.setVisible(False)
159 def _find_axis_at_point(self, x_px, y_px):
160 """Figure 内のピクセル座標(原点は左下)にある軸の番号。無ければ None。"""
161 for index, ax in enumerate(self._app.canvas.all_axes):
162 bbox = ax.bbox
163 if bbox.x0 <= x_px <= bbox.x1 and bbox.y0 <= y_px <= bbox.y1:
164 return index
165 return None
167 def _on_layout_press(self, event):
168 if not self.layout_edit_mode_enabled or event.x is None or event.y is None: 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 return
170 if legend_at(self._app.canvas, event) is not None:
171 return
172 axis_index = self._find_axis_at_point(event.x, event.y)
173 if axis_index is None:
174 self._layout_selected_axis_index = None
175 self._sync_free_layout_position_controls()
176 return
178 # ドラッグしなくても選択にする(数値の欄に今の矩形を出す)
179 self._layout_selected_axis_index = axis_index
180 self._sync_free_layout_position_controls()
182 ax = self._app.canvas.all_axes[axis_index]
183 bbox = ax.bbox
184 near_corner = (
185 abs(event.x - bbox.x1) <= RESIZE_HANDLE_TOLERANCE_PX and
186 abs(event.y - bbox.y0) <= RESIZE_HANDLE_TOLERANCE_PX
187 )
188 pos = ax.get_position()
189 self._layout_drag_state = {
190 'axis_index': axis_index,
191 'mode': 'resize' if near_corner else 'move',
192 'start_mouse': (event.x, event.y),
193 'start_rect': (pos.x0, pos.y0, pos.width, pos.height),
194 }
196 def _on_layout_motion(self, event):
197 if not self.layout_edit_mode_enabled or self._layout_drag_state is None:
198 return
199 if event.x is None or event.y is None: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 return
202 state = self._layout_drag_state
203 fig_width_px, fig_height_px = self._app.canvas.fig.get_size_inches() * self._app.canvas.fig.dpi
204 if fig_width_px <= 0 or fig_height_px <= 0: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true
205 return
207 delta_x = (event.x - state['start_mouse'][0]) / fig_width_px
208 delta_y = (event.y - state['start_mouse'][1]) / fig_height_px
209 left, bottom, width, height = state['start_rect']
211 if state['mode'] == 'move':
212 new_left = left + delta_x
213 new_bottom = bottom + delta_y
214 new_width, new_height = width, height
215 else:
216 # 右下の角のドラッグは、左上を固定して伸縮する
217 new_width = max(MIN_FREE_RECT_SIZE, width + delta_x)
218 new_height = max(MIN_FREE_RECT_SIZE, height - delta_y)
219 new_left = left
220 new_bottom = bottom + height - new_height
222 ax = self._app.canvas.all_axes[state['axis_index']]
223 ax.set_position([new_left, new_bottom, new_width, new_height])
224 self._app.canvas.draw_idle()
226 self._sync_free_layout_position_controls()
228 def _on_layout_release(self, event):
229 """最終的な位置を設定に保存する。"""
230 if not self.layout_edit_mode_enabled or self._layout_drag_state is None: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 return
233 axis_index = self._layout_drag_state['axis_index']
234 self._layout_drag_state = None
236 if axis_index >= len(self._app.canvas.all_axes) or axis_index >= len(self._app.project.all_plot_settings): 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 return
238 ax = self._app.canvas.all_axes[axis_index]
239 pos = ax.get_position()
240 self._app.project.all_plot_settings[axis_index]['free_rect'] = (pos.x0, pos.y0, pos.width, pos.height)
242 self._sync_free_layout_position_controls()
244 def _sync_free_layout_position_controls(self):
245 """選んだサブプロットの矩形を数値の欄に出す。自由配置でないか、選択が無ければ欄ごと隠す。
247 値を入れる間は通知を止める(止めないと _on_free_layout_position_spinbox_changed が呼ばれて循環する)。
248 """
249 is_free_layout = getattr(self._app.project, 'layout_mode', 'grid') == 'free'
250 axis_index = self._layout_selected_axis_index
252 if (not is_free_layout or axis_index is None or
253 axis_index >= len(self._app.canvas.all_axes)):
254 self._app.free_layout_position_group.setVisible(False)
255 return
257 ax = self._app.canvas.all_axes[axis_index]
258 pos = ax.get_position()
259 for spinbox, value in (
260 (self._app.free_layout_x_spinbox, pos.x0),
261 (self._app.free_layout_y_spinbox, pos.y0),
262 (self._app.free_layout_width_spinbox, pos.width),
263 (self._app.free_layout_height_spinbox, pos.height),
264 ):
265 spinbox.blockSignals(True)
266 spinbox.setValue(value)
267 spinbox.blockSignals(False)
268 self._app.free_layout_position_group.setVisible(True)
270 def _on_free_layout_position_spinbox_changed(self, _value=None):
271 """数値の欄から、ドラッグと同じ経路(set_position → draw_idle → free_rect)で更新する。"""
272 axis_index = self._layout_selected_axis_index
273 if (axis_index is None or 273 ↛ 276line 273 didn't jump to line 276 because the condition on line 273 was never true
274 axis_index >= len(self._app.canvas.all_axes) or
275 axis_index >= len(self._app.project.all_plot_settings)):
276 return
278 new_left = self._app.free_layout_x_spinbox.value()
279 new_bottom = self._app.free_layout_y_spinbox.value()
280 new_width = max(MIN_FREE_RECT_SIZE, self._app.free_layout_width_spinbox.value())
281 new_height = max(MIN_FREE_RECT_SIZE, self._app.free_layout_height_spinbox.value())
283 ax = self._app.canvas.all_axes[axis_index]
284 ax.set_position([new_left, new_bottom, new_width, new_height])
285 self._app.canvas.draw_idle()
287 self._app.project.all_plot_settings[axis_index]['free_rect'] = (
288 new_left, new_bottom, new_width, new_height
289 )