Coverage for graphica/gui/builders/canvas_area.py: 99.1%

198 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""キャンバス・ツールバー・ミニマップ・ステータスバーの組み立て。""" 

2import logging 

3import time 

4from PySide6.QtCore import QSize, Qt, QTimer 

5from PySide6.QtGui import QAction 

6from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QLabel, QToolBar, QToolButton, 

7 QVBoxLayout, QWidget) 

8from graphica.core.axis_settings import axis_setting 

9from graphica.core.i18n import tr 

10from graphica.gui import app_settings, theme 

11from graphica.gui.builders.common import TOOLBAR_ICON_SIZE, _svg_icon 

12from graphica.gui.canvas import MplCanvas 

13from graphica.gui.minimap_widget import MinimapWidget 

14from graphica.gui.tools.cursor import COORDINATE_EMPTY_TEXT 

15from graphica.gui.theme_color import theme_color_or_default 

16 

17logger = logging.getLogger(__name__) 

18 

19 

20def build_canvas_and_toolbar(app): 

21 app.canvas = MplCanvas(app, width=5, height=4, dpi=100) 

22 app.canvas.dark_mode = app_settings.DARK_MODE.read(app.settings) 

23 # アイコンは作るときにテーマの色を焼き込むので、アイコンを作り始める前にテーマを当てる 

24 # (当てないと、ダークモードで起動したときツールバーのアイコンがライト用の色になって見えない) 

25 theme.apply_theme(QApplication.instance(), app.canvas.dark_mode, 

26 theme_color_or_default(app_settings.THEME_COLOR.read(app.settings))) 

27 app.canvas.point_label_max_points = app_settings.POINT_LABEL_MAX_POINTS.read(app.settings) 

28 # 表示範囲の操作はツールバーではなくマウスで行う(gui/tools/view_navigation.py)ので、matplotlib のツールバーは使わない 

29 _create_mode_actions(app) 

30 _build_advanced_toolbar(app) 

31 _build_lite_tool_rail(app) 

32 _build_card(app) 

33 

34 

35# マウスの操作のモード: (属性名, アイコン, 短い名前(ツールバーの文字), 説明(ツールチップ), 切り替えのメソッド)。 

36# 互いに排他にするため、QAction は属性で持つ(gui/tools/manager.py の表から操作する) 

37_MODE_ACTIONS = ( 

38 ("cursor_action", "pointer", "カーソル", "データカーソル", "_toggle_cursor_mode"), 

39 ("annotation_action", "message-2", "注釈", "注釈 (クリック:テキスト / ドラッグ:矢印 / 右クリック:削除)", 

40 "_toggle_annotation_mode"), 

41 ("range_select_action", "select-all", "範囲選択", "範囲選択 (ドラッグした範囲のカレントデータセットをマスク)", 

42 "_toggle_range_select_mode"), 

43 ("peak_placement_action", "mountain", "ピーク", 

44 "ピーク配置 (クリックで多峰分離フィットの初期値を追加、右クリックで削除)", "_toggle_peak_placement_mode"), 

45 ("region_highlight_action", "highlight", "領域", 

46 "領域ハイライト (横ドラッグ:縦帯 / 縦ドラッグ:横帯 / 右クリック:削除)", "_toggle_region_highlight_mode"), 

47 ("slice_extraction_action", "chart-line", "スライス", 

48 "スライス抽出 (2Dマップ上でドラッグした線分に沿って1Dデータを抽出)", "_toggle_slice_extraction_mode"), 

49 ("layout_edit_action", "layout-grid", "配置", 

50 "レイアウト編集 (自由配置レイアウト時のみ: ドラッグでプロットを移動/リサイズ)", "_toggle_layout_edit_mode"), 

51) 

52# テーマを替えたときにアイコンを作り直す QAction(_refresh_custom_svg_icons) 

53TOOL_ACTION_ICONS = tuple((entry[0], entry[1]) for entry in _MODE_ACTIONS) + ( 

54 ("reset_zoom_action", "refresh"), ("fit_tool_action", "math-function"), ("smoothing_tool_action", "chart-line")) 

55# シンプルの縦の道具に出すモード(見本: カーソル・注釈・範囲選択、区切りの下にフィット) 

56LITE_MODE_ACTIONS = ("cursor_action", "annotation_action", "range_select_action") 

57 

58 

59def _create_mode_actions(app): 

60 for attr, icon_name, label, description, toggle in _MODE_ACTIONS: 

61 action = QAction(_svg_icon(icon_name), tr(label), app) 

62 action.setToolTip(tr(description)) 

63 action.setCheckable(True) 

64 action.triggered.connect(getattr(app, toggle)) 

65 setattr(app, attr, action) 

66 # 自由配置レイアウトのときだけ使える 

67 app.layout_edit_action.setEnabled(False) 

68 

69 app.reset_zoom_action = QAction(_svg_icon("refresh"), tr("表示をリセット (拡大/パンを元に戻す)"), app) 

70 app.reset_zoom_action.triggered.connect(app._reset_zoom) 

71 app.fit_tool_action = QAction(_svg_icon("math-function"), tr("フィット"), app) 

72 app.fit_tool_action.setToolTip(tr("曲線フィット...")) 

73 app.fit_tool_action.triggered.connect(lambda _checked=False: app.fitting.fit_current_dataset()) 

74 app.smoothing_tool_action = QAction(_svg_icon("chart-line"), tr("平滑化"), app) 

75 app.smoothing_tool_action.setToolTip(tr("Savitzky-Golayフィルタ") + "...") 

76 app.smoothing_tool_action.triggered.connect(lambda _checked=False: app.processing.savgol_smooth()) 

77 

78 

79def _build_advanced_toolbar(app): 

80 """アドバンスドの、プロットの上の文字付きの道具。モードの7つは1つの塊に見せる(QSS の toolSegment)。""" 

81 toolbar = QToolBar(tr("プロット"), app) 

82 toolbar.setObjectName("plot_toolbar") 

83 toolbar.setIconSize(QSize(TOOLBAR_ICON_SIZE - 3, TOOLBAR_ICON_SIZE - 3)) 

84 toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) 

85 app.plot_toolbar = toolbar 

86 attrs = [entry[0] for entry in _MODE_ACTIONS] 

87 for position, attr in enumerate(attrs): 

88 action = getattr(app, attr) 

89 toolbar.addAction(action) 

90 if position == 0: 

91 place = "first" 

92 elif position == len(attrs) - 1: 

93 place = "last" 

94 else: 

95 place = "middle" 

96 toolbar.widgetForAction(action).setProperty("toolSegment", place) 

97 toolbar.addSeparator() 

98 toolbar.addAction(app.fit_tool_action) 

99 toolbar.addAction(app.smoothing_tool_action) 

100 

101 

102def _build_lite_tool_rail(app): 

103 """シンプルの、グラフの左に浮かせた縦の道具(アイコンだけ)。QAction はアドバンスドのものと同じ。""" 

104 rail = QToolBar(tr("道具"), app) 

105 rail.setObjectName("plot_tool_rail") 

106 rail.setOrientation(Qt.Orientation.Vertical) 

107 rail.setIconSize(QSize(18, 18)) 

108 rail.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) 

109 for attr in LITE_MODE_ACTIONS: 

110 rail.addAction(getattr(app, attr)) 

111 rail.addSeparator() 

112 rail.addAction(app.fit_tool_action) 

113 app.plot_tool_rail = rail 

114 

115 

116def _card_button(icon_name, tooltip): 

117 button = QToolButton() 

118 button.setObjectName("card_button") 

119 button.setToolTip(tooltip) 

120 button.setIcon(_svg_icon(icon_name)) 

121 button.setProperty("iconName", icon_name) 

122 button.setIconSize(QSize(17, 17)) 

123 button.setFixedSize(28, 28) 

124 button.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

125 button.setCursor(Qt.CursorShape.PointingHandCursor) 

126 return button 

127 

128 

129def _build_card(app): 

130 """グラフのカード: タイトルの行、(アドバンスドの道具)、(シンプルの縦の道具 | キャンバスとミニマップ)。""" 

131 app.ui.plot_container.setObjectName("plot_container") 

132 card_layout = QVBoxLayout(app.ui.plot_container) 

133 card_layout.setContentsMargins(14, 10, 14, 10) 

134 card_layout.setSpacing(6) 

135 

136 title_row = QHBoxLayout() 

137 title_row.setSpacing(6) 

138 app.card_title_label = QLabel() 

139 app.card_title_label.setObjectName("card_title") 

140 app.card_series_label = QLabel() 

141 app.card_series_label.setObjectName("card_meta") 

142 app.card_subplot_label = QLabel() 

143 app.card_subplot_label.setObjectName("card_meta") 

144 app.card_reset_button = _card_button("refresh", app.reset_zoom_action.text()) 

145 app.card_reset_button.clicked.connect(lambda _checked=False: app.reset_zoom_action.trigger()) 

146 # ミニマップと切り離しの QAction はメニューと同じもので、メニューを作った後に finish_card() がつなぐ 

147 app.card_minimap_button = _card_button("map", tr("ミニマップ")) 

148 app.card_detach_button = _card_button("external-link", tr("キャンバスを別ウィンドウに切り離す")) 

149 title_row.addWidget(app.card_title_label) 

150 title_row.addWidget(app.card_series_label) 

151 title_row.addStretch(1) 

152 for widget in (app.card_subplot_label, app.card_reset_button, app.card_minimap_button, app.card_detach_button): 

153 title_row.addWidget(widget) 

154 card_layout.addLayout(title_row) 

155 card_layout.addWidget(app.plot_toolbar) 

156 

157 body = QHBoxLayout() 

158 body.setSpacing(10) 

159 body.addWidget(app.plot_tool_rail, 0, Qt.AlignmentFlag.AlignTop) 

160 plot_layout = QVBoxLayout() 

161 plot_layout.setSpacing(6) 

162 plot_layout.addWidget(app.canvas) 

163 body.addLayout(plot_layout, 1) 

164 card_layout.addLayout(body, 1) 

165 

166 # キャンバスを別ウィンドウへ切り離したあと、元の位置に戻せるようにする 

167 # (この後に足すミニマップはキャンバスより後ろなので、この位置は変わらない) 

168 app._plot_layout = plot_layout 

169 app._canvas_layout_index = plot_layout.indexOf(app.canvas) 

170 app._canvas_detach_window = None 

171 app.canvas_detached = False 

172 

173 # グラフの下の小さな全体図。ドラッグで全部の軸の X の範囲を絞る。区切りは見本どおり線を引かず、すき間だけ 

174 app.minimap_separator = QFrame() 

175 app.minimap_separator.setObjectName("canvas_separator") 

176 app.minimap_separator.setFixedHeight(2) 

177 plot_layout.addWidget(app.minimap_separator) 

178 

179 app.minimap = MinimapWidget(app) 

180 app.minimap.range_selected.connect(app._on_minimap_range_selected) 

181 plot_layout.addWidget(app.minimap) 

182 

183 app.minimap_visible = app_settings.MINIMAP_VISIBLE.read(app.settings) 

184 app.minimap.setVisible(app.minimap_visible) 

185 app.minimap_separator.setVisible(app.minimap_visible) 

186 

187 app.canvas.mpl_connect('draw_event', lambda _event: refresh_card_title(app)) 

188 

189 

190def finish_card(app): 

191 """メニューを作った後に呼ぶ: タイトルの行のボタンを、メニューと同じ QAction につなぐ。""" 

192 app.card_minimap_button.setCheckable(True) 

193 app.card_minimap_button.setChecked(app.minimap_action.isChecked()) 

194 app.card_minimap_button.clicked.connect(lambda _checked=False: app.minimap_action.trigger()) 

195 app.minimap_action.toggled.connect(app.card_minimap_button.setChecked) 

196 app.card_detach_button.clicked.connect(lambda _checked=False: app.canvas_detach_action.trigger()) 

197 refresh_card_title(app) 

198 

199 

200def refresh_card_title(app): 

201 """タイトルは選んでいるサブプロットのタイトル(数式の $ は外す)。無ければ「サブプロット N」(決定 8)。""" 

202 settings_list = getattr(app.project, 'all_plot_settings', None) or [] 

203 index = getattr(app.project, 'active_axis_index', 0) or 0 

204 settings = settings_list[index] if 0 <= index < len(settings_list) else {} 

205 title = str(axis_setting(settings, 'title') or '').replace('$', '').strip() 

206 app.card_title_label.setText(title or tr("サブプロット {n}").format(n=index + 1)) 

207 count = sum(1 for ds in app.project.datasets 

208 if getattr(ds, 'visible', True) and getattr(ds, 'subplot_target', 0) == index) 

209 app.card_series_label.setText(tr("{n} 系列").format(n=count)) 

210 total = max(1, len(settings_list)) 

211 app.card_subplot_label.setText(tr("サブプロット {i} / {n}").format(i=index + 1, n=total)) 

212 

213 

214# 自動保存の「N 分前」を書き直す間隔 

215STATUS_REFRESH_MS = 30_000 

216 

217 

218def autosave_status_text(enabled, interval_minutes, seconds_since_save): 

219 """ステータスバーの自動保存の文字。seconds_since_save は、このタブでまだ保存していなければ None。""" 

220 if not enabled: 

221 return tr("自動保存 オフ") 

222 if seconds_since_save is None: 

223 return tr("自動保存 {n} 分ごと").format(n=interval_minutes) 

224 minutes = int(seconds_since_save // 60) 

225 if minutes < 1: 

226 return tr("自動保存 たった今") 

227 return tr("自動保存 {n} 分前").format(n=minutes) 

228 

229 

230def setup_status_bar(app): 

231 """見本のステータスバー: 左に「● 準備完了」と(アドバンスド)データセットと選択の数、右に(アドバンスド)座標と自動保存。 

232 

233 左の2つは通常の部品なので、showMessage() の間は一時的に知らせに置き換わる。 

234 """ 

235 bar = app.ui.statusbar 

236 bar.setSizeGripEnabled(False) 

237 app.status_ready = QWidget() 

238 ready_layout = QHBoxLayout(app.status_ready) 

239 ready_layout.setContentsMargins(4, 0, 8, 0) 

240 ready_layout.setSpacing(6) 

241 dot = QFrame() 

242 dot.setObjectName("status_dot") 

243 dot.setFixedSize(7, 7) 

244 ready_text = QLabel(tr("準備完了")) 

245 ready_text.setObjectName("status_text") 

246 ready_layout.addWidget(dot) 

247 ready_layout.addWidget(ready_text) 

248 bar.addWidget(app.status_ready) 

249 app.status_selection_label = QLabel() 

250 app.status_selection_label.setObjectName("status_text") 

251 bar.addWidget(app.status_selection_label) 

252 

253 app.coordinate_label = QLabel(COORDINATE_EMPTY_TEXT) 

254 app.coordinate_label.setObjectName("status_coordinates") 

255 bar.addPermanentWidget(app.coordinate_label) 

256 app.autosave_status_label = QLabel() 

257 app.autosave_status_label.setObjectName("status_text") 

258 bar.addPermanentWidget(app.autosave_status_label) 

259 

260 app._last_autosave_time = None 

261 app.status_refresh_timer = QTimer(app) 

262 app.status_refresh_timer.timeout.connect(lambda: refresh_status_bar(app)) 

263 app.status_refresh_timer.start(STATUS_REFRESH_MS) 

264 app.canvas.mpl_connect('draw_event', lambda _event: refresh_status_bar(app)) 

265 

266 

267def refresh_status_bar(app): 

268 """データセットと選択の数、自動保存の文字を今の状態に合わせる。""" 

269 if not hasattr(app, 'status_selection_label'): 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 return 

271 total = len(app.project.datasets) 

272 selected = len(app._get_selected_datasets()) if hasattr(app, 'property_panel') else 0 

273 app.status_selection_label.setText(tr("{n} データセット · {m} 選択").format(n=total, m=selected)) 

274 timer = getattr(app, 'autosave_timer', None) 

275 enabled = timer is not None and timer.isActive() 

276 interval = timer.interval() // 60000 if enabled else 0 

277 last = app._last_autosave_time 

278 since = None if last is None else time.monotonic() - last 

279 app.autosave_status_label.setText(autosave_status_text(enabled, interval, since)) 

280