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

1"""自由配置のレイアウト: サブプロットをドラッグか数値で好きな位置と大きさに置く。 

2 

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 

9 

10logger = logging.getLogger(__name__) 

11 

12# 右下の角をつかんでいるとみなす距離 

13RESIZE_HANDLE_TOLERANCE_PX = 12 

14MIN_FREE_RECT_SIZE = 0.05 

15 

16 

17class LayoutEditTool: 

18 """状態はこのツールが持ち、PlotterApp の同じ名前は窓口(gui/tools/__init__.py)。""" 

19 

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 ) 

28 

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 

39 

40 def _on_toggle_free_layout(self, checked): 

41 self._app.project.layout_mode = 'free' if checked else 'grid' 

42 

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) 

51 

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) 

55 

56 if not checked: 

57 self._layout_selected_axis_index = None 

58 self._app.free_layout_position_group.setVisible(False) 

59 

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) 

65 

66 self._app._update_plot() 

67 

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) 

78 

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

84 

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 

94 

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 

99 

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

102 

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

110 

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

115 

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) 

124 

125 self._app.all_axes = self._app.canvas.all_axes 

126 self._app.all_secondary_axes = self._app.canvas.all_secondary_axes 

127 

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

132 

133 def _toggle_layout_edit_mode(self, checked): 

134 self.layout_edit_mode_enabled = checked 

135 

136 if checked: 

137 self._app._deactivate_other_mouse_modes('layout_edit') 

138 

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) 

158 

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 

166 

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 

177 

178 # ドラッグしなくても選択にする(数値の欄に今の矩形を出す) 

179 self._layout_selected_axis_index = axis_index 

180 self._sync_free_layout_position_controls() 

181 

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 } 

195 

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 

201 

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 

206 

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

210 

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 

221 

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

225 

226 self._sync_free_layout_position_controls() 

227 

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 

232 

233 axis_index = self._layout_drag_state['axis_index'] 

234 self._layout_drag_state = None 

235 

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) 

241 

242 self._sync_free_layout_position_controls() 

243 

244 def _sync_free_layout_position_controls(self): 

245 """選んだサブプロットの矩形を数値の欄に出す。自由配置でないか、選択が無ければ欄ごと隠す。 

246 

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 

251 

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 

256 

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) 

269 

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 

277 

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

282 

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

286 

287 self._app.project.all_plot_settings[axis_index]['free_rect'] = ( 

288 new_left, new_bottom, new_width, new_height 

289 )